diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adba83..4a5a29d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - `[Docs]` Added short responsibility header comments to every project `.ts` file. Impact: file boundaries are easier to understand while navigating the growing `/lib` split. - `[Naming]` Renamed extracted domain modules and mirrored regression suites to use repo-scoped bare domain filenames such as `api.ts`, `queue.ts`, and `queue.test.ts` instead of repeating `telegram-*` in every path. Impact: the internal topology is easier to scan and stays aligned with the repository-level Telegram scope. -- `[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. +- `[Controls]` Expanded Telegram session controls with a richer `/status` view, inline model selection, and thinking-level controls, and fixed the callback-selection path so idle model and thinking picks apply immediately instead of only becoming visible after a later Telegram interaction. Impact: more bridge configuration can be managed directly from Telegram with more predictable immediate feedback. - `[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, including narrower monospace Markdown table output without outer side borders, monospace list markers for unordered and ordered lists, and flattened nested quote indentation inside a single Telegram blockquote. Impact: formatted replies render more reliably while preserving literal code blocks and using width more efficiently on narrow Telegram clients. - `[Runtime]` Hardened attachment delivery, polling/runtime behavior, Telegram session integration, preview-finalization and reply-transport routing into the replies domain, lazy Telegram API client routing into the Telegram API domain, turn-building extraction into its own domain, menu/model-resolution plus menu-state, pure menu-page derivation, pure menu render-payload builders, menu-message runtime, callback parsing, callback entry handling, callback mutation helpers, full model-callback planning and execution, and interface-polished callback effect ports into the menu domain, direct execute-from-update routing into the updates domain, model-switch restart glue extraction into the model-switch domain, and tool/command/lifecycle-hook registration extraction into a dedicated registration domain. Impact: the bridge is more robust as a daily Telegram frontend for pi. diff --git a/README.md b/README.md index aedfe15..00f4ca3 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,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, and joins the high-priority control queue when pi is busy -- `/model` opens the interactive model selector, joins the high-priority control queue when pi is busy, and can restart the active Telegram-owned run on the newly selected model, waiting for the current tool call to finish when needed +- `/model` opens the interactive model selector, applies idle selections immediately, joins the high-priority control queue when pi is busy, 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/architecture.md b/docs/architecture.md index e599e3e..8625578 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,7 +122,7 @@ The bridge exposes Telegram-side session controls in addition to regular chat fo Current operator controls include: - `/status` for model, usage, cost, and context visibility, queued as a high-priority control item when needed -- Inline status buttons for model and thinking adjustments +- Inline status buttons for model and thinking adjustments, applying idle selections immediately while still respecting busy-run restart rules - `/model` for interactive model selection, queued as a high-priority control item when needed and supporting 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 `👎` diff --git a/index.ts b/index.ts index 8b791b8..70ca786 100644 --- a/index.ts +++ b/index.ts @@ -1180,7 +1180,10 @@ export default function (pi: ExtensionAPI) { query.data, getCurrentTelegramModel(ctx), { - setThinkingLevel: (level) => pi.setThinkingLevel(level), + setThinkingLevel: (level) => { + pi.setThinkingLevel(level); + updateStatus(ctx); + }, getCurrentThinkingLevel: () => pi.getThinkingLevel(), updateStatusMessage: async () => showStatusMessage(state, ctx), answerCallbackQuery, @@ -1213,10 +1216,15 @@ export default function (pi: ExtensionAPI) { setModel: (model) => pi.setModel(model), setCurrentModel: (model) => { currentTelegramModel = model; + updateStatus(ctx); + }, + setThinkingLevel: (level) => { + pi.setThinkingLevel(level); + updateStatus(ctx); }, - setThinkingLevel: (level) => pi.setThinkingLevel(level), stagePendingModelSwitch: (selection) => { pendingTelegramModelSwitch = selection; + updateStatus(ctx); }, restartInterruptedTelegramTurn: (selection) => { return restartTelegramModelSwitchContinuation({ @@ -1825,8 +1833,9 @@ export default function (pi: ExtensionAPI) { systemPrompt: nextEvent.systemPrompt + suffix, }; }, - onModelSelect: (event) => { + onModelSelect: (event, ctx) => { currentTelegramModel = (event as { model: Model }).model; + updateStatus(ctx); }, onAgentStart: async (_event, ctx) => { currentAbort = () => ctx.abort(); diff --git a/package.json b/package.json index c875f85..0fb1152 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@llblab/pi-telegram", - "version": "0.2.4", + "version": "0.2.5", "private": false, "description": "Better Telegram DM bridge extension for pi", "type": "module", diff --git a/tests/queue.test.ts b/tests/queue.test.ts index dbeb6b0..df55e39 100644 --- a/tests/queue.test.ts +++ b/tests/queue.test.ts @@ -2467,6 +2467,214 @@ test("Extension runtime applies reaction priority and removal before the next di } }); +test("Extension runtime applies idle model picks immediately and refreshes status", async () => { + const agentDir = join(homedir(), ".pi", "agent"); + const configPath = join(agentDir, "telegram.json"); + const previousConfig = await readFile(configPath, "utf8").catch( + () => undefined, + ); + const previousArgv = [...process.argv]; + const handlers = new Map< + string, + (event: unknown, ctx: unknown) => Promise + >(); + const commands = new Map< + string, + { handler: (args: string, ctx: unknown) => Promise } + >(); + const runtimeEvents: string[] = []; + const statusEvents: string[] = []; + const modelA = { + provider: "openai", + id: "gpt-a", + reasoning: true, + } as const; + const modelB = { + provider: "anthropic", + id: "claude-b", + reasoning: true, + } as const; + const setModels: Array = []; + const thinkingLevels: Array = []; + let secondUpdatesResolve: ((value: Response) => void) | undefined; + const secondUpdates = new Promise((resolve) => { + secondUpdatesResolve = resolve; + }); + const pi = { + on: ( + event: string, + handler: (event: unknown, ctx: unknown) => Promise, + ) => { + handlers.set(event, handler); + }, + registerCommand: ( + name: string, + definition: { handler: (args: string, ctx: unknown) => Promise }, + ) => { + commands.set(name, definition); + }, + registerTool: () => {}, + sendUserMessage: () => {}, + getThinkingLevel: () => thinkingLevels.at(-1) ?? "medium", + setModel: async (model: { provider: string; id: string }) => { + setModels.push(`${model.provider}/${model.id}`); + return true; + }, + setThinkingLevel: (level: string) => { + thinkingLevels.push(level); + }, + } as never; + const originalFetch = globalThis.fetch; + let getUpdatesCalls = 0; + let nextMessageId = 100; + const callbackAnswers: string[] = []; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = url.split("/").at(-1) ?? ""; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + if (method === "deleteWebhook") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "getUpdates") { + getUpdatesCalls += 1; + if (getUpdatesCalls === 1) { + return { + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 1, + message: { + message_id: 60, + chat: { id: 99, type: "private" }, + from: { id: 77, is_bot: false, first_name: "Test" }, + text: "/model", + }, + }, + ], + }), + } as Response; + } + if (getUpdatesCalls === 2) return secondUpdates; + throw new DOMException("stop", "AbortError"); + } + if (method === "sendMessage") { + runtimeEvents.push(`send:${String(body?.text ?? "")}`); + return { + json: async () => ({ + ok: true, + result: { message_id: nextMessageId++ }, + }), + } as Response; + } + if (method === "editMessageText") { + runtimeEvents.push(`edit:${String(body?.text ?? "")}`); + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "answerCallbackQuery") { + callbackAnswers.push(String(body?.text ?? "")); + return { json: async () => ({ ok: true, result: true }) } as Response; + } + if (method === "sendChatAction") { + return { json: async () => ({ ok: true, result: true }) } as Response; + } + throw new Error(`Unexpected Telegram API method: ${method}`); + }; + try { + process.argv = [ + previousArgv[0] ?? "node", + previousArgv[1] ?? "index.ts", + "--models=anthropic/claude-b:high", + ]; + await mkdir(agentDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify( + { botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 }, + null, + "\t", + ) + "\n", + "utf8", + ); + telegramExtension(pi); + const ctx = { + hasUI: true, + cwd: process.cwd(), + model: modelA, + signal: undefined, + ui: { + theme: { + fg: (_token: string, text: string) => text, + }, + setStatus: (_slot: string, text: string) => { + statusEvents.push(text); + }, + notify: () => {}, + }, + sessionManager: { + getEntries: () => [], + }, + modelRegistry: { + refresh: () => {}, + getAvailable: () => [modelA, modelB], + isUsingOAuth: () => false, + }, + getContextUsage: () => undefined, + hasPendingMessages: () => false, + isIdle: () => true, + abort: () => {}, + } as never; + await handlers.get("session_start")?.({}, ctx); + await commands.get("telegram-connect")?.handler("", ctx); + await waitForCondition(() => + runtimeEvents.some((event) => event === "send:Choose a model:"), + ); + const statusCountBeforePick = statusEvents.length; + secondUpdatesResolve?.({ + json: async () => ({ + ok: true, + result: [ + { + _: "other", + update_id: 2, + callback_query: { + id: "cb-idle-1", + from: { id: 77, is_bot: false, first_name: "Test" }, + data: "model:pick:0", + message: { + message_id: 100, + chat: { id: 99, type: "private" }, + }, + }, + }, + ], + }), + } as Response); + await waitForCondition(() => setModels.length === 1); + assert.deepEqual(setModels, ["anthropic/claude-b"]); + assert.deepEqual(thinkingLevels, ["high"]); + assert.equal(callbackAnswers.includes("Switched to claude-b"), true); + assert.equal(statusEvents.length > statusCountBeforePick, true); + assert.equal( + runtimeEvents.some((event) => event.startsWith("edit:Context:")), + true, + ); + await handlers.get("session_shutdown")?.({}, ctx); + } finally { + process.argv = previousArgv; + globalThis.fetch = originalFetch; + if (previousConfig === undefined) { + await rm(configPath, { force: true }); + } else { + await writeFile(configPath, previousConfig, "utf8"); + } + } +}); + test("Extension runtime switches model in flight and dispatches a continuation turn after abort", async () => { const agentDir = join(homedir(), ".pi", "agent"); const configPath = join(agentDir, "telegram.json");