diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8040363 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# Project Context + +## 0. Meta-Protocol Principles + +- `Constraint-Driven Evolution`: Add structure when the bridge gains real operator or runtime constraints +- `Single Source of Truth`: Keep durable rules in `AGENTS.md`, open work in `BACKLOG.md`, completed delivery in `CHANGELOG.md`, and deeper technical detail in `/docs` +- `Boundary Clarity`: Separate Telegram transport concerns, pi integration concerns, rendering behavior, and release/documentation state +- `Runtime Safety`: Prefer queue and rendering behavior that fails predictably over clever behavior that can desynchronize the Telegram bridge from pi session state + +## 1. Concept + +`pi-telegram` is a pi extension that turns a Telegram DM into a session-local frontend for pi, including text/file forwarding, streaming previews, queued follow-ups, model controls, and outbound attachment delivery. + +## 2. Identity & Naming Contract + +- `Telegram turn`: One unit of Telegram input processed by pi; this may represent one message or a coalesced media group +- `Queued Telegram turn`: A Telegram turn accepted by the bridge but not yet active in pi +- `Active Telegram turn`: The Telegram turn currently bound to the running pi agent loop +- `Preview`: The transient streamed response shown through Telegram drafts or editable messages before the final reply lands +- `Scoped models`: The subset of models exposed to Telegram model selection when pi settings or CLI flags limit the available list + +## 3. Project Topology + +- `/index.ts`: Main extension runtime, Telegram API integration, queueing, rendering, previews, menus, and tool wiring +- `/docs/README.md`: Documentation index for technical project docs +- `/docs/architecture.md`: Runtime and subsystem overview for the bridge +- `/README.md`: User-facing project entry point, install guide, and fork summary +- `/AGENTS.md`: Durable engineering and runtime conventions +- `/BACKLOG.md`: Canonical open work +- `/CHANGELOG.md`: Completed delivery history + +## 4. Core Entities + +- `TelegramConfig`: Persisted bot/session pairing state +- `PendingTelegramTurn` / `ActiveTelegramTurn`: Queue and active-turn state for Telegram-originated work +- `TelegramPreviewState`: Streaming preview state for drafts or editable Telegram messages +- `TelegramModelMenuState`: Inline menu state for status/model/thinking controls +- `QueuedAttachment`: Outbound files staged for delivery through `telegram_attach` + +## 5. Architectural Decisions + +- The extension ships as a single runtime file for simple pi packaging, so logical sections inside `index.ts` are the primary module boundaries +- The bridge is session-local and intentionally pairs with a single allowed Telegram user per config +- Telegram queue state is tracked locally and must stay aligned with pi agent lifecycle hooks; dispatch must respect active turns, pending dispatch, compaction, and pi pending-message state +- In-flight `/model` switching is supported only for Telegram-owned active turns and is implemented as set-model plus synthetic continuation turn plus abort; if a tool call is active, the abort is delayed until that tool finishes instead of interrupting the tool mid-flight +- Telegram replies render through Telegram HTML, not raw Markdown; real code blocks must stay literal and escaped +- `telegram_attach` is the canonical outbound file-delivery path for Telegram-originated requests + +## 6. Engineering Conventions + +- Treat queue handling, compaction interaction, and lifecycle-hook state transitions as regression-prone areas; validate them after changing dispatch logic +- Treat Markdown rendering as Telegram-specific output work, not generic Markdown rendering; preserve literal code content and avoid HTML chunk splits that break tags +- Keep comments and user-facing docs in English unless the surrounding file already follows another convention +- Prefer targeted edits inside `index.ts` over broad rewrites unless a section boundary is being intentionally restructured + +## 7. Operational Conventions + +- When Telegram-visible behavior changes, sync `README.md` and the relevant `/docs` entry in the same pass +- When durable runtime constraints or repeat bug patterns emerge, record them here instead of burying them in changelog prose +- When fork identity changes, keep `README.md`, package metadata, and docs aligned so the published package does not point back at stale upstream coordinates + +## 8. Integration Protocols + +- Telegram API methods currently used include polling, message editing, draft streaming, callback queries, reactions, file download, and media upload endpoints +- pi integration depends on lifecycle hooks such as `before_agent_start`, `agent_start`, `message_start`, `message_update`, and `agent_end` +- Status/model/thinking controls are driven through Telegram inline keyboards and callback queries +- Inbound files may become pi image inputs; outbound files must flow through `telegram_attach` + +## 9. Pre-Task Preparation Protocol + +- Read `README.md` for current user-facing behavior and fork positioning +- Read `BACKLOG.md` before changing runtime behavior or documentation so open work stays truthful +- Read `/docs/architecture.md` before restructuring queue, preview, rendering, or command-handling logic +- Inspect the relevant `index.ts` section before editing because most bridge behavior is stateful and cross-linked + +## 10. Task Completion Protocol + +- Run the smallest meaningful validation for the touched area; `npm test` is the default regression suite once rendering or queue logic changes +- For rendering changes, ensure regressions still cover nested lists, code blocks, underscore-heavy text, and long-message chunking +- For queue/dispatch changes, validate abort, compaction, pending-dispatch, and pi pending-message guard behavior +- Sync `README.md`, `CHANGELOG.md`, `BACKLOG.md`, and `/docs` whenever user-visible behavior or real open-work state changes diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..8c6a45a --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,5 @@ +# Project Backlog + +## Open Backlog + +- No open backlog items right now diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a62b7fe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Current + +- `[Controls]` Expanded Telegram session controls with a richer `/status` view, inline model selection, and thinking-level controls. Impact: more bridge configuration can be managed directly from Telegram. +- `[Queue]` Upgraded Telegram turn queueing with previews, reaction-driven prioritization/removal, media-group handling, aborted-turn history preservation, and safer dispatch gating. Impact: follow-up handling is more transparent and less prone to lifecycle races. +- `[Rendering]` Added Telegram-oriented Markdown rendering and hardened reply streaming/chunking behavior. Impact: formatted replies render more reliably while preserving literal code blocks. +- `[Runtime]` Hardened attachment delivery, polling/runtime behavior, and Telegram session integration. Impact: the bridge is more robust as a daily Telegram frontend for pi. +- `[Metadata]` Updated package repository metadata to point at the `llblab/pi-telegram` fork. Impact: published package links no longer send users to stale upstream coordinates. +- `[Validation]` Added lightweight regression tests for Telegram Markdown rendering and queue/compaction dispatch guards. Impact: key renderer and queue invariants now have repeatable automated coverage. +- `[Model Switching]` Enabled `/model` during an active Telegram-owned run by applying the new model and continuing on the new model automatically, delaying the abort until the current tool finishes when needed. Impact: Telegram can now approximate pi's manual stop-switch-continue workflow with fewer mid-tool aborts. diff --git a/README.md b/README.md index 9959b33..f2553a6 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,19 @@ Telegram DM bridge for pi. This repository is a fork of the original [`badlogic/pi-telegram`](https://github.com/badlogic/pi-telegram). It started from upstream commit [`cb34008460b6c1ca036d92322f69d87f626be0fc`](https://github.com/badlogic/pi-telegram/commit/cb34008460b6c1ca036d92322f69d87f626be0fc) and has since diverged substantially. +## Start Here + +- [Project Context](./AGENTS.md) +- [Open Backlog](./BACKLOG.md) +- [Changelog](./CHANGELOG.md) +- [Documentation](./docs/README.md) + ## What Changed In This Fork Compared to upstream commit `cb34008`, this fork significantly extends and hardens the extension. - Better Telegram control UI, including an improved `/status` view with inline buttons for model and thinking selection -- Interactive model selection improvements, including scoped model lists and thinking-level control for reasoning-capable models +- Interactive model selection improvements, including scoped model lists, thinking-level control for reasoning-capable models, and in-flight restart on a newly selected model for active Telegram-owned runs - Queueing and interaction upgrades, including queue previews, reaction-based prioritization/removal, media-group handling, and safer dispatch behavior - Markdown and reply rendering improvements, with richer formatting support and multiple fixes for incorrect Telegram rendering and chunking edge cases - Streaming, attachment, and delivery workflow hardening, including more robust preview updates and file handling @@ -97,7 +104,7 @@ Chat with your bot in Telegram DMs. Additional fork-specific controls: - `/status` now has a richer view with inline buttons for model and thinking controls -- `/model` opens the interactive model selector +- `/model` opens the interactive model selector and can restart the active Telegram-owned run on the newly selected model, waiting for the current tool call to finish when needed - `/compact` starts session compaction when pi and the Telegram queue are idle - Queue reactions: `👍` prioritizes a waiting turn, `👎` removes it diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..5da3b78 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,9 @@ +# Documentation Index + +Living index of project documentation in `/docs`. + +## Documents + +| Document | Description | +| --- | --- | +| [architecture.md](./architecture.md) | Overview of the Telegram bridge runtime, queueing model, rendering pipeline, and interactive controls | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a919d4c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,110 @@ +# Telegram Bridge Architecture + +## Overview + +`pi-telegram` is a session-local pi extension that binds one Telegram DM to one running pi session. The bridge owns four main responsibilities: + +- Poll Telegram updates and enforce single-user pairing +- Translate Telegram messages and media into pi inputs +- Stream and deliver pi responses back to Telegram +- Manage Telegram-specific controls such as queue reactions, `/status`, `/model`, and `/compact` + +## Runtime Structure + +The implementation currently lives in `index.ts` and is organized by logical sections rather than physical modules. + +Main runtime areas: + +- Telegram API types and local bridge state +- Generic utilities and Markdown/rendering helpers +- Message delivery, previews, and attachment sending +- Interactive model/status menu state and callback handling +- Queue management for pending and active Telegram turns +- Polling loop and pi lifecycle-hook integration + +## Message And Queue Flow + +### Inbound Path + +1. Telegram updates are polled through `getUpdates` +2. The bridge filters to the paired private user +3. Media groups are coalesced into a single Telegram turn when needed +4. Files are downloaded into `~/.pi/agent/tmp/telegram` +5. A `PendingTelegramTurn` is created and queued locally +6. The queue dispatcher sends the turn into pi only when dispatch is safe + +### Queue Safety Model + +The bridge keeps its own Telegram queue and does not rely only on pi's internal pending-message state. + +Dispatch is gated by: + +- No active Telegram turn +- No pending Telegram dispatch already sent to pi +- No compaction in progress +- `ctx.isIdle()` being true +- `ctx.hasPendingMessages()` being false + +This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. + +### Abort Behavior + +When `/stop` aborts an active Telegram turn, queued follow-up Telegram messages can be preserved as prior-user history for the next turn. This keeps later Telegram input from being silently dropped after an interrupted run. + +## Rendering Model + +Telegram replies are rendered as Telegram HTML rather than raw Markdown. + +Key rules: + +- Rich text should render cleanly in Telegram chats +- Real code blocks must remain literal and escaped +- Long replies must be split below Telegram's 4096-character limit +- Chunking should avoid breaking HTML structure where possible +- Preview rendering is intentionally simpler than final rich rendering + +The renderer is a Telegram-specific formatter, not a general Markdown engine, so rendering changes should be treated as regression-prone. + +## Streaming And Delivery + +During generation, the bridge streams previews back to Telegram. + +Preferred order: + +1. Try `sendMessageDraft` +2. Fall back to `sendMessage` plus `editMessageText` +3. Replace the preview with the final rendered reply when generation ends + +Outbound files are sent only after the active Telegram turn completes and must be staged through the `telegram_attach` tool. + +## Interactive Controls + +The bridge exposes Telegram-side session controls in addition to regular chat forwarding. + +Current operator controls include: + +- `/status` for model, usage, cost, and context visibility +- Inline status buttons for model and thinking adjustments +- `/model` for interactive model selection, including in-flight restart of the active Telegram-owned run on a newly selected model +- `/compact` for Telegram-triggered pi session compaction when the bridge is idle +- Queue reactions using `👍` and `👎` + +## In-Flight Model Switching + +When `/model` is used during an active Telegram-owned run, the bridge can emulate the interactive pi workflow of stopping, switching model, and continuing. + +The current implementation does this by: + +1. Applying the newly selected model immediately +2. Queuing or staging a synthetic Telegram continuation turn +3. Aborting the active Telegram turn immediately, or delaying the abort until the current tool finishes when a tool call is in flight +4. Dispatching the continuation turn after the abort completes + +This behavior is intentionally limited to runs currently owned by the Telegram bridge. If pi is busy with non-Telegram work, the bridge still refuses the switch instead of hijacking unrelated session activity. + +## Related + +- [README.md](../README.md) +- [Project Context](../AGENTS.md) +- [Project Backlog](../BACKLOG.md) +- [Changelog](../CHANGELOG.md) diff --git a/index.ts b/index.ts index 63ac922..37fbb05 100644 --- a/index.ts +++ b/index.ts @@ -233,6 +233,20 @@ interface TelegramUsageStats { totalCost: number; } +interface TelegramDispatchGuardState { + compactionInProgress: boolean; + hasActiveTelegramTurn: boolean; + hasPendingTelegramDispatch: boolean; + isIdle: boolean; + hasPendingMessages: boolean; +} + +interface TelegramInFlightModelSwitchState { + isIdle: boolean; + hasActiveTelegramTurn: boolean; + hasAbortHandler: boolean; +} + type TelegramReplyMarkup = { inline_keyboard: Array>; }; @@ -322,7 +336,9 @@ function modelsMatch( return !!a && !!b && a.provider === b.provider && a.id === b.id; } -function getCanonicalModelId(model: Model): string { +function getCanonicalModelId( + model: Pick, "provider" | "id">, +): string { return `${model.provider}/${model.id}`; } @@ -1291,6 +1307,34 @@ function renderTelegramMessage( })); } +function canDispatchTelegramTurnState( + state: TelegramDispatchGuardState, +): boolean { + return ( + !state.compactionInProgress && + !state.hasActiveTelegramTurn && + !state.hasPendingTelegramDispatch && + state.isIdle && + !state.hasPendingMessages + ); +} + +function canRestartTelegramTurnForModelSwitch( + state: TelegramInFlightModelSwitchState, +): boolean { + return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler; +} + +function buildTelegramModelSwitchContinuationText< + TModel extends Pick, "provider" | "id">, +>(model: TModel, thinkingLevel?: ThinkingLevel): string { + const modelLabel = getCanonicalModelId(model); + const thinkingSuffix = thinkingLevel + ? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.` + : ""; + return `${TELEGRAM_PREFIX} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`; +} + // --- Persistence --- async function readConfig(): Promise { @@ -1314,14 +1358,25 @@ async function writeConfig(config: TelegramConfig): Promise { // --- Extension Runtime --- +export const __telegramTestUtils = { + MAX_MESSAGE_LENGTH, + renderTelegramMessage, + canDispatchTelegramTurnState, + canRestartTelegramTurnForModelSwitch, + buildTelegramModelSwitchContinuationText, +}; + export default function (pi: ExtensionAPI) { let config: TelegramConfig = {}; let pollingController: AbortController | undefined; let pollingPromise: Promise | undefined; let queuedTelegramTurns: PendingTelegramTurn[] = []; let nextQueuedTelegramTurnOrder = 0; + let nextSyntheticTelegramTurnOrder = -1; let nextPriorityReactionOrder = 0; let activeTelegramTurn: ActiveTelegramTurn | undefined; + let activeTelegramToolExecutions = 0; + let pendingTelegramModelSwitch: ScopedTelegramModel | undefined; let telegramTurnDispatchPending = false; let typingInterval: ReturnType | undefined; let currentAbort: (() => void) | undefined; @@ -1343,13 +1398,13 @@ export default function (pi: ExtensionAPI) { } function canDispatchQueuedTelegramTurn(ctx: ExtensionContext): boolean { - return ( - !compactionInProgress && - !activeTelegramTurn && - !telegramTurnDispatchPending && - ctx.isIdle() && - !ctx.hasPendingMessages() - ); + return canDispatchTelegramTurnState({ + compactionInProgress, + hasActiveTelegramTurn: !!activeTelegramTurn, + hasPendingTelegramDispatch: telegramTurnDispatchPending, + isIdle: ctx.isIdle(), + hasPendingMessages: ctx.hasPendingMessages(), + }); } function dispatchNextQueuedTelegramTurn(ctx: ExtensionContext): void { @@ -1419,7 +1474,11 @@ export default function (pi: ExtensionAPI) { ); return; } - if (activeTelegramTurn || telegramTurnDispatchPending || queuedTelegramTurns.length > 0) { + if ( + activeTelegramTurn || + telegramTurnDispatchPending || + queuedTelegramTurns.length > 0 + ) { const queued = theme.fg( "muted", formatQueuedTelegramTurnsStatus(queuedTelegramTurns), @@ -2133,18 +2192,86 @@ export default function (pi: ExtensionAPI) { modelMenus.set(messageId, state); } + function canOfferInFlightTelegramModelSwitch(ctx: ExtensionContext): boolean { + return canRestartTelegramTurnForModelSwitch({ + isIdle: ctx.isIdle(), + hasActiveTelegramTurn: !!activeTelegramTurn, + hasAbortHandler: !!currentAbort, + }); + } + + function createTelegramModelSwitchContinuationTurn( + turn: ActiveTelegramTurn, + selection: ScopedTelegramModel, + ): PendingTelegramTurn { + const statusLabel = truncateTelegramQueueSummary( + `continue on ${selection.model.id}`, + 4, + 32, + ); + return { + chatId: turn.chatId, + replyToMessageId: turn.replyToMessageId, + sourceMessageIds: [], + queueOrder: nextSyntheticTelegramTurnOrder--, + priorityReactionOrder: -1, + queuedAttachments: [], + content: [ + { + type: "text", + text: buildTelegramModelSwitchContinuationText( + selection.model, + selection.thinkingLevel, + ), + }, + ], + historyText: `Continue interrupted Telegram request on ${getCanonicalModelId(selection.model)}`, + statusSummary: `↻ ${statusLabel || "continue"}`, + }; + } + + function queueTelegramModelSwitchContinuation( + turn: ActiveTelegramTurn, + selection: ScopedTelegramModel, + ctx: ExtensionContext, + ): void { + queuedTelegramTurns.push( + createTelegramModelSwitchContinuationTurn(turn, selection), + ); + reorderQueuedTelegramTurns(ctx); + } + + function triggerPendingTelegramModelSwitchAbort( + ctx: ExtensionContext, + ): boolean { + if ( + !pendingTelegramModelSwitch || + !activeTelegramTurn || + !currentAbort || + activeTelegramToolExecutions > 0 + ) { + return false; + } + const selection = pendingTelegramModelSwitch; + pendingTelegramModelSwitch = undefined; + queueTelegramModelSwitchContinuation(activeTelegramTurn, selection, ctx); + currentAbort(); + return true; + } + async function openModelMenu( chatId: number, replyToMessageId: number, ctx: ExtensionContext, ): Promise { - const isIdle = await ensureIdleOrNotify( - ctx, - chatId, - replyToMessageId, - "Cannot switch model while pi is busy. Send /stop first.", - ); - if (!isIdle) return; + if (!ctx.isIdle() && !canOfferInFlightTelegramModelSwitch(ctx)) { + await sendTextReply( + chatId, + replyToMessageId, + "Cannot switch model while pi is busy. Send /stop first.", + ); + return; + } const state = await getModelMenuState(chatId, ctx); if (state.allModels.length === 0) { await sendTextReply( @@ -2277,10 +2404,6 @@ export default function (pi: ExtensionAPI) { ); return true; } - if (!ctx.isIdle()) { - await answerCallbackQuery(query.id, "Pi is busy. Send /stop first."); - return true; - } const activeModel = getCurrentTelegramModel(ctx); if (modelsMatch(selection.model, activeModel)) { if ( @@ -2293,6 +2416,42 @@ export default function (pi: ExtensionAPI) { await answerCallbackQuery(query.id, `Model: ${selection.model.id}`); return true; } + if (!ctx.isIdle()) { + if (!activeTelegramTurn || !currentAbort) { + await answerCallbackQuery(query.id, "Pi is busy. Send /stop first."); + return true; + } + try { + const changed = await pi.setModel(selection.model); + if (changed === false) { + await answerCallbackQuery(query.id, "Model is not available."); + return true; + } + currentTelegramModel = selection.model; + if (selection.thinkingLevel) { + pi.setThinkingLevel(selection.thinkingLevel); + } + await showStatusMessage(state, ctx); + if (activeTelegramToolExecutions > 0) { + pendingTelegramModelSwitch = selection; + await answerCallbackQuery( + query.id, + `Switched to ${selection.model.id}. Restarting after the current tool finishes…`, + ); + return true; + } + queueTelegramModelSwitchContinuation(activeTelegramTurn, selection, ctx); + currentAbort(); + await answerCallbackQuery( + query.id, + `Switching to ${selection.model.id} and continuing…`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await answerCallbackQuery(query.id, message); + } + return true; + } try { const changed = await pi.setModel(selection.model); if (changed === false) { @@ -2811,6 +2970,7 @@ export default function (pi: ExtensionAPI) { ctx: ExtensionContext, ): Promise { if (currentAbort) { + pendingTelegramModelSwitch = undefined; if (queuedTelegramTurns.length > 0) { preserveQueuedTurnsAsHistory = true; } @@ -2875,7 +3035,8 @@ export default function (pi: ExtensionAPI) { } catch (error) { compactionInProgress = false; updateStatus(ctx); - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); await sendTextReply( message.chat.id, message.message_id, @@ -3265,6 +3426,9 @@ export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { config = await readConfig(); currentTelegramModel = ctx.model; + activeTelegramToolExecutions = 0; + pendingTelegramModelSwitch = undefined; + nextSyntheticTelegramTurnOrder = -1; telegramTurnDispatchPending = false; compactionInProgress = false; await mkdir(TEMP_DIR, { recursive: true }); @@ -3274,8 +3438,11 @@ export default function (pi: ExtensionAPI) { pi.on("session_shutdown", async (_event, _ctx) => { queuedTelegramTurns = []; nextQueuedTelegramTurnOrder = 0; + nextSyntheticTelegramTurnOrder = -1; nextPriorityReactionOrder = 0; currentTelegramModel = undefined; + activeTelegramToolExecutions = 0; + pendingTelegramModelSwitch = undefined; telegramTurnDispatchPending = false; compactionInProgress = false; for (const state of mediaGroups.values()) { @@ -3307,6 +3474,8 @@ export default function (pi: ExtensionAPI) { pi.on("agent_start", async (_event, ctx) => { currentAbort = () => ctx.abort(); + activeTelegramToolExecutions = 0; + pendingTelegramModelSwitch = undefined; if (!activeTelegramTurn && telegramTurnDispatchPending) { const nextTurn = queuedTelegramTurns.shift(); telegramTurnDispatchPending = false; @@ -3319,6 +3488,17 @@ export default function (pi: ExtensionAPI) { updateStatus(ctx); }); + pi.on("tool_execution_start", async (_event, _ctx) => { + if (!activeTelegramTurn) return; + activeTelegramToolExecutions += 1; + }); + + pi.on("tool_execution_end", async (_event, ctx) => { + if (!activeTelegramTurn) return; + activeTelegramToolExecutions = Math.max(0, activeTelegramToolExecutions - 1); + triggerPendingTelegramModelSwitchAbort(ctx); + }); + pi.on("message_start", async (event, _ctx) => { if (!activeTelegramTurn || !isAssistantMessage(event.message)) return; if ( @@ -3350,16 +3530,20 @@ export default function (pi: ExtensionAPI) { currentAbort = undefined; stopTypingLoop(); activeTelegramTurn = undefined; + activeTelegramToolExecutions = 0; + pendingTelegramModelSwitch = undefined; telegramTurnDispatchPending = false; updateStatus(ctx); if (!turn) { dispatchNextQueuedTelegramTurn(ctx); return; } - const assistant = extractAssistantText(event.messages); if (assistant.stopReason === "aborted") { await clearPreview(turn.chatId); + if (!preserveQueuedTurnsAsHistory) { + dispatchNextQueuedTelegramTurn(ctx); + } return; } if (assistant.stopReason === "error") { @@ -3372,7 +3556,6 @@ export default function (pi: ExtensionAPI) { ); return; } - const finalText = assistant.text; if (previewState) { previewState.pendingText = finalText ?? previewState.pendingText; @@ -3393,9 +3576,7 @@ export default function (pi: ExtensionAPI) { ); } } - await sendQueuedAttachments(turn); - if (!preserveQueuedTurnsAsHistory) { dispatchNextQueuedTelegramTurn(ctx); } diff --git a/package.json b/package.json index 9d8d937..3843ee6 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,14 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/badlogic/pi-telegram.git" + "url": "https://github.com/llblab/pi-telegram.git" }, - "homepage": "https://github.com/badlogic/pi-telegram", + "homepage": "https://github.com/llblab/pi-telegram", "bugs": { - "url": "https://github.com/badlogic/pi-telegram/issues" + "url": "https://github.com/llblab/pi-telegram/issues" + }, + "scripts": { + "test": "node --experimental-strip-types --test tests/*.test.ts" }, "pi": { "extensions": [ diff --git a/tests/telegram-queue.test.ts b/tests/telegram-queue.test.ts new file mode 100644 index 0000000..fa6b65a --- /dev/null +++ b/tests/telegram-queue.test.ts @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { __telegramTestUtils } from "../index.ts"; + +test("Dispatch is allowed only when every guard is clear", () => { + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: false, + isIdle: true, + hasPendingMessages: false, + }), + true, + ); +}); + +test("Dispatch is blocked during compaction", () => { + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: true, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: false, + isIdle: true, + hasPendingMessages: false, + }), + false, + ); +}); + +test("Dispatch is blocked while a Telegram turn is active or pending", () => { + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: true, + hasPendingTelegramDispatch: false, + isIdle: true, + hasPendingMessages: false, + }), + false, + ); + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: true, + isIdle: true, + hasPendingMessages: false, + }), + false, + ); +}); + +test("Dispatch is blocked when pi is busy or has pending messages", () => { + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: false, + isIdle: false, + hasPendingMessages: false, + }), + false, + ); + assert.equal( + __telegramTestUtils.canDispatchTelegramTurnState({ + compactionInProgress: false, + hasActiveTelegramTurn: false, + hasPendingTelegramDispatch: false, + isIdle: true, + hasPendingMessages: true, + }), + false, + ); +}); + +test("In-flight model switch is allowed only for active Telegram turns with abort support", () => { + assert.equal( + __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ + isIdle: false, + hasActiveTelegramTurn: true, + hasAbortHandler: true, + }), + true, + ); + assert.equal( + __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ + isIdle: true, + hasActiveTelegramTurn: true, + hasAbortHandler: true, + }), + false, + ); + assert.equal( + __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ + isIdle: false, + hasActiveTelegramTurn: false, + hasAbortHandler: true, + }), + false, + ); + assert.equal( + __telegramTestUtils.canRestartTelegramTurnForModelSwitch({ + isIdle: false, + hasActiveTelegramTurn: true, + hasAbortHandler: false, + }), + false, + ); +}); + +test("Continuation prompt stays Telegram-scoped and resume-oriented", () => { + const text = __telegramTestUtils.buildTelegramModelSwitchContinuationText( + { provider: "openai", id: "gpt-5", name: "GPT-5" }, + "high", + ); + assert.match(text, /^\[telegram\]/); + assert.match(text, /Continue the interrupted previous Telegram request/); + assert.match(text, /openai\/gpt-5/); + assert.match(text, /thinking level \(high\)/); +}); diff --git a/tests/telegram-rendering.test.ts b/tests/telegram-rendering.test.ts new file mode 100644 index 0000000..8562a5d --- /dev/null +++ b/tests/telegram-rendering.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { __telegramTestUtils } from "../index.ts"; + +test("Nested lists stay out of code blocks", () => { + const chunks = __telegramTestUtils.renderTelegramMessage( + "- Level 1\n - Level 2\n - Level 3 with **bold** text", + { mode: "markdown" }, + ); + assert.ok(chunks.length > 0); + assert.equal( + chunks.some((chunk) => chunk.text.includes("
")),
+    false,
+  );
+  assert.equal(
+    chunks.some((chunk) =>
+      chunk.text.includes("• Level 3 with bold text"),
+    ),
+    true,
+  );
+});
+
+test("Fenced code blocks preserve literal markdown", () => {
+  const chunks = __telegramTestUtils.renderTelegramMessage(
+    '~~~ts\nconst value = "**raw**";\n~~~',
+    { mode: "markdown" },
+  );
+  assert.equal(chunks.length, 1);
+  assert.match(chunks[0]?.text ?? "", /
/);
+  assert.match(chunks[0]?.text ?? "", /\*\*raw\*\*/);
+});
+
+test("Underscores inside words do not become italic", () => {
+  const chunks = __telegramTestUtils.renderTelegramMessage(
+    "Path: foo_bar_baz.txt and **bold**",
+    { mode: "markdown" },
+  );
+  assert.equal(chunks.length, 1);
+  assert.equal((chunks[0]?.text ?? "").includes("bar"), false);
+  assert.match(chunks[0]?.text ?? "", /bold<\/b>/);
+});
+
+test("Long markdown replies stay chunked below Telegram limits", () => {
+  const markdown = Array.from(
+    { length: 600 },
+    (_, index) => `- item **${index}**`,
+  ).join("\n");
+  const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
+    mode: "markdown",
+  });
+  assert.ok(chunks.length > 1);
+  for (const chunk of chunks) {
+    assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
+    assert.equal(
+      (chunk.text.match(//g) ?? []).length,
+      (chunk.text.match(/<\/b>/g) ?? []).length,
+    );
+  }
+});