From 5aa37b7a996af86a44234e8e05a634c53f0d3879 Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:41:15 +0800 Subject: [PATCH] tool verbose --- index.ts | 251 ++++++++++++++++++++++++++++++++++------ lib/menu.ts | 42 ++++++- lib/registration.ts | 26 +++-- lib/rendering.ts | 94 +++++++++++++++ lib/status.ts | 2 + tests/menu.test.ts | 68 +++++++++-- tests/rendering.test.ts | 108 +++++++++++++---- 7 files changed, 515 insertions(+), 76 deletions(-) diff --git a/index.ts b/index.ts index 70ca786..fc00904 100644 --- a/index.ts +++ b/index.ts @@ -13,7 +13,6 @@ import type { ExtensionAPI, ExtensionContext, } from "@mariozechner/pi-coding-agent"; -import { SettingsManager } from "@mariozechner/pi-coding-agent"; import { createTelegramApiClient, @@ -82,8 +81,11 @@ import { } from "./lib/registration.ts"; import { MAX_MESSAGE_LENGTH, + buildTelegramAssistantPreviewText, + buildTelegramAssistantTranscriptMarkdown, renderMarkdownPreviewText, renderTelegramMessage, + type TelegramAssistantDisplayBlock, type TelegramRenderMode, } from "./lib/rendering.ts"; import { @@ -399,6 +401,9 @@ export default function (pi: ExtensionAPI) { let compactionInProgress = false; let setupInProgress = false; let previewState: TelegramPreviewState | undefined; + let traceVisible = true; + let activeTelegramTraceBlocks: TelegramAssistantDisplayBlock[] = []; + let activeTelegramMessageBlocks: TelegramAssistantDisplayBlock[] = []; let draftSupport: "unknown" | "supported" | "unsupported" = "unknown"; let nextDraftId = 0; let currentTelegramModel: Model | undefined; @@ -611,17 +616,74 @@ export default function (pi: ExtensionAPI) { return (message as unknown as { role?: string }).role === "assistant"; } - function extractTextContent(content: unknown): string { + function stringifyToolArgs(args: unknown): string | undefined { + if (args === undefined) return undefined; + if (typeof args === "string") return args.trim() || undefined; + const encoded = JSON.stringify(args, null, 2); + return encoded?.trim() || undefined; + } + + function normalizeAssistantDisplayBlock( + block: unknown, + ): TelegramAssistantDisplayBlock | undefined { + if (typeof block !== "object" || block === null || !("type" in block)) { + return undefined; + } + const candidate = block as Record; + if (candidate.type === "text" && typeof candidate.text === "string") { + return { type: "text", text: candidate.text }; + } + if (candidate.type === "thinking") { + const text = + typeof candidate.text === "string" + ? candidate.text + : typeof candidate.thinking === "string" + ? candidate.thinking + : undefined; + if (!text) return undefined; + return { type: "thinking", text }; + } + if (candidate.type === "tool_call" || candidate.type === "tool_use") { + const name = + typeof candidate.name === "string" + ? candidate.name + : typeof candidate.tool === "string" + ? candidate.tool + : undefined; + if (!name) return undefined; + return { + type: "tool_call", + name, + argsText: stringifyToolArgs( + "input" in candidate + ? candidate.input + : "arguments" in candidate + ? candidate.arguments + : "args" in candidate + ? candidate.args + : undefined, + ), + }; + } + return undefined; + } + + function extractAssistantDisplayBlocks( + content: unknown, + ): TelegramAssistantDisplayBlock[] { const blocks = Array.isArray(content) ? content : []; return blocks + .map(normalizeAssistantDisplayBlock) + .filter((block): block is TelegramAssistantDisplayBlock => !!block); + } + + function extractTextContent(content: unknown): string { + return extractAssistantDisplayBlocks(content) .filter( - (block): block is { type: string; text?: string } => - typeof block === "object" && block !== null && "type" in block, + (block): block is Extract => + block.type === "text", ) - .filter( - (block) => block.type === "text" && typeof block.text === "string", - ) - .map((block) => block.text as string) + .map((block) => block.text) .join("") .trim(); } @@ -632,6 +694,69 @@ export default function (pi: ExtensionAPI) { ); } + function getMessageBlocks(message: AgentMessage): TelegramAssistantDisplayBlock[] { + return extractAssistantDisplayBlocks( + (message as unknown as Record).content, + ); + } + + function getActiveTracePreviewBlocks(): TelegramAssistantDisplayBlock[] { + return [...activeTelegramTraceBlocks, ...activeTelegramMessageBlocks]; + } + + function extractAssistantTurn(messages: AgentMessage[]): { + blocks: TelegramAssistantDisplayBlock[]; + text?: string; + stopReason?: string; + errorMessage?: string; + } { + const blocks: TelegramAssistantDisplayBlock[] = []; + let text: string | undefined; + let stopReason: string | undefined; + let errorMessage: string | undefined; + for (const next of messages) { + const message = next as unknown as Record; + if (message.role !== "assistant") continue; + const nextBlocks = extractAssistantDisplayBlocks(message.content); + blocks.push(...nextBlocks); + const nextText = extractTextContent(message.content); + if (nextText) { + text = nextText; + } + stopReason = + typeof message.stopReason === "string" ? message.stopReason : stopReason; + errorMessage = + typeof message.errorMessage === "string" + ? message.errorMessage + : errorMessage; + } + return { blocks, text, stopReason, errorMessage }; + } + + async function refreshOpenStatusMenus(ctx: ExtensionContext): Promise { + for (const state of modelMenus.values()) { + if (state.mode !== "status") continue; + await showStatusMessage(state, ctx); + } + } + + function setTraceVisible(nextTraceVisible: boolean, ctx: ExtensionContext): void { + traceVisible = nextTraceVisible; + if (activeTelegramTurn && previewState) { + previewState.pendingText = buildTelegramAssistantPreviewText( + getActiveTracePreviewBlocks(), + nextTraceVisible, + ); + if (previewState.pendingText.trim().length > 0) { + schedulePreviewFlush(activeTelegramTurn.chatId); + } else { + void clearPreview(activeTelegramTurn.chatId); + } + } + updateStatus(ctx); + void refreshOpenStatusMenus(ctx); + } + function createPreviewState(): TelegramPreviewState { return { mode: draftSupport === "unsupported" ? "message" : "draft", @@ -796,24 +921,13 @@ export default function (pi: ExtensionAPI) { }); } - function extractAssistantText(messages: AgentMessage[]): { + function extractAssistantSummary(messages: AgentMessage[]): { + blocks: TelegramAssistantDisplayBlock[]; text?: string; stopReason?: string; errorMessage?: string; } { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] as unknown as Record; - if (message.role !== "assistant") continue; - const stopReason = - typeof message.stopReason === "string" ? message.stopReason : undefined; - const errorMessage = - typeof message.errorMessage === "string" - ? message.errorMessage - : undefined; - const text = extractTextContent(message.content); - return { text: text || undefined, stopReason, errorMessage }; - } - return {}; + return extractAssistantTurn(messages); } // --- Bridge Setup --- @@ -876,6 +990,10 @@ export default function (pi: ExtensionAPI) { command: "status", description: "Show model, usage, cost, and context status", }, + { + command: "trace", + description: "Toggle thinking and tool-call visibility", + }, { command: "model", description: "Open the interactive model selector" }, { command: "compact", description: "Compact the current pi session" }, { command: "stop", description: "Abort the current pi task" }, @@ -895,6 +1013,7 @@ export default function (pi: ExtensionAPI) { chatId: number, ctx: ExtensionContext, ): Promise { + const { SettingsManager } = await import("@mariozechner/pi-coding-agent"); const settingsManager = SettingsManager.create(ctx.cwd); await settingsManager.reload(); ctx.modelRegistry.refresh(); @@ -981,9 +1100,10 @@ export default function (pi: ExtensionAPI) { ): Promise { await updateTelegramStatusMessage( state, - buildStatusHtml(ctx, getCurrentTelegramModel(ctx)), + buildStatusHtml(ctx, getCurrentTelegramModel(ctx), traceVisible), getCurrentTelegramModel(ctx), pi.getThinkingLevel(), + traceVisible, { editInteractiveMessage, sendInteractiveMessage }, ); } @@ -1003,9 +1123,10 @@ export default function (pi: ExtensionAPI) { const state = await getModelMenuState(chatId, ctx); const messageId = await sendTelegramStatusMessage( state, - buildStatusHtml(ctx, getCurrentTelegramModel(ctx)), + buildStatusHtml(ctx, getCurrentTelegramModel(ctx), traceVisible), getCurrentTelegramModel(ctx), pi.getThinkingLevel(), + traceVisible, { editInteractiveMessage, sendInteractiveMessage }, ); if (messageId === undefined) return; @@ -1165,6 +1286,11 @@ export default function (pi: ExtensionAPI) { updateModelMenuMessage: async () => updateModelMenuMessage(state, ctx), updateThinkingMenuMessage: async () => updateThinkingMenuMessage(state, ctx), + updateStatusMessage: async () => showStatusMessage(state, ctx), + setTraceVisible: (nextTraceVisible) => { + setTraceVisible(nextTraceVisible, ctx); + }, + getTraceVisible: () => traceVisible, answerCallbackQuery, }, ); @@ -1542,13 +1668,26 @@ export default function (pi: ExtensionAPI) { ); } + async function handleTraceCommand( + message: TelegramMessage, + ctx: ExtensionContext, + ): Promise { + const nextTraceVisible = !traceVisible; + setTraceVisible(nextTraceVisible, ctx); + await sendTextReply( + message.chat.id, + message.message_id, + `Trace visibility: ${nextTraceVisible ? "on" : "off"}.`, + ); + } + async function handleHelpCommand( message: TelegramMessage, commandName: string, ctx: ExtensionContext, ): Promise { let helpText = - "Send me a message and I will forward it to pi. Commands: /status, /model, /compact, /stop."; + "Send me a message and I will forward it to pi. Commands: /status, /trace, /model, /compact, /stop."; if (commandName === "start") { try { await registerTelegramBotCommands(); @@ -1576,6 +1715,7 @@ export default function (pi: ExtensionAPI) { stop: () => handleStopCommand(message, ctx), compact: () => handleCompactCommand(message, ctx), status: () => handleStatusCommand(message, ctx), + trace: () => handleTraceCommand(message, ctx), model: () => handleModelCommand(message, ctx), help: () => handleHelpCommand(message, commandName, ctx), start: () => handleHelpCommand(message, commandName, ctx), @@ -1856,6 +1996,8 @@ export default function (pi: ExtensionAPI) { } if (startPlan.activeTurn) { activeTelegramTurn = { ...startPlan.activeTurn }; + activeTelegramTraceBlocks = []; + activeTelegramMessageBlocks = []; previewState = createPreviewState(); startTypingLoop(ctx); } @@ -1880,6 +2022,16 @@ export default function (pi: ExtensionAPI) { onMessageStart: async (event, _ctx) => { const nextEvent = event as { message: AgentMessage }; if (!activeTelegramTurn || !isAssistantMessage(nextEvent.message)) return; + if (traceVisible) { + if (activeTelegramMessageBlocks.length > 0) { + activeTelegramTraceBlocks.push(...activeTelegramMessageBlocks); + activeTelegramMessageBlocks = []; + } + if (!previewState) { + previewState = createPreviewState(); + } + return; + } if ( previewState && (previewState.pendingText.trim().length > 0 || @@ -1903,7 +2055,15 @@ export default function (pi: ExtensionAPI) { if (!previewState) { previewState = createPreviewState(); } - previewState.pendingText = getMessageText(nextEvent.message); + if (traceVisible) { + activeTelegramMessageBlocks = getMessageBlocks(nextEvent.message); + previewState.pendingText = buildTelegramAssistantPreviewText( + getActiveTracePreviewBlocks(), + true, + ); + } else { + previewState.pendingText = getMessageText(nextEvent.message); + } schedulePreviewFlush(activeTelegramTurn.chatId); }, onAgentEnd: async (event, ctx) => { @@ -1911,14 +2071,18 @@ export default function (pi: ExtensionAPI) { currentAbort = undefined; stopTypingLoop(); activeTelegramTurn = undefined; + activeTelegramTraceBlocks = []; + activeTelegramMessageBlocks = []; activeTelegramToolExecutions = 0; pendingTelegramModelSwitch = undefined; telegramTurnDispatchPending = false; updateStatus(ctx); const assistant = turn - ? extractAssistantText((event as { messages: AgentMessage[] }).messages) - : {}; - const finalText = assistant.text; + ? extractAssistantSummary((event as { messages: AgentMessage[] }).messages) + : { blocks: [] }; + const finalText = traceVisible + ? buildTelegramAssistantTranscriptMarkdown(assistant.blocks, true) + : assistant.text; const endPlan = buildTelegramAgentEndPlan({ hasTurn: !!turn, stopReason: assistant.stopReason, @@ -1936,12 +2100,31 @@ export default function (pi: ExtensionAPI) { await clearPreview(turn.chatId); } if (endPlan.shouldSendErrorMessage) { - await sendTextReply( - turn.chatId, - turn.replyToMessageId, + const errorText = assistant.errorMessage || - "Telegram bridge: pi failed while processing the request.", - ); + "Telegram bridge: pi failed while processing the request."; + const errorTranscript = traceVisible && assistant.blocks.length > 0 + ? `${buildTelegramAssistantTranscriptMarkdown(assistant.blocks, true)}\n\n**Error**\n> ${errorText}` + : undefined; + if (errorTranscript) { + if (previewState) { + previewState.pendingText = errorTranscript; + } + const finalized = await finalizeMarkdownPreview( + turn.chatId, + errorTranscript, + ); + if (!finalized) { + await clearPreview(turn.chatId); + await sendMarkdownReply( + turn.chatId, + turn.replyToMessageId, + errorTranscript, + ); + } + } else { + await sendTextReply(turn.chatId, turn.replyToMessageId, errorText); + } if (endPlan.shouldDispatchNext) { dispatchNextQueuedTelegramTurn(ctx); } diff --git a/lib/menu.ts b/lib/menu.ts index 7d17497..737ecce 100644 --- a/lib/menu.ts +++ b/lib/menu.ts @@ -62,6 +62,8 @@ export interface TelegramMenuEffectPort { setCurrentModel: (model: Model) => void; setThinkingLevel: (level: ThinkingLevel) => void; getCurrentThinkingLevel: () => ThinkingLevel; + setTraceVisible: (traceVisible: boolean) => void; + getTraceVisible: () => boolean; stagePendingModelSwitch: (selection: ScopedTelegramModel) => void; restartInterruptedTelegramTurn: ( selection: ScopedTelegramModel, @@ -70,7 +72,12 @@ export interface TelegramMenuEffectPort { export type TelegramStatusMenuCallbackDeps = Pick< TelegramMenuEffectPort, - "updateModelMenuMessage" | "updateThinkingMenuMessage" | "answerCallbackQuery" + | "updateModelMenuMessage" + | "updateThinkingMenuMessage" + | "updateStatusMessage" + | "setTraceVisible" + | "getTraceVisible" + | "answerCallbackQuery" >; export type TelegramThinkingMenuCallbackDeps = Pick< @@ -121,7 +128,7 @@ export interface BuildTelegramModelMenuStateParams { export type TelegramMenuCallbackAction = | { kind: "ignore" } - | { kind: "status"; action: "model" | "thinking" } + | { kind: "status"; action: "model" | "thinking" | "trace" } | { kind: "thinking:set"; level: string } | { kind: "model"; @@ -451,6 +458,9 @@ export function parseTelegramMenuCallbackAction( if (data === "status:thinking") { return { kind: "status", action: "thinking" }; } + if (data === "status:trace") { + return { kind: "status", action: "trace" }; + } if (data?.startsWith("thinking:set:")) { return { kind: "thinking:set", @@ -665,6 +675,16 @@ export async function handleTelegramStatusMenuCallbackAction( await deps.answerCallbackQuery(callbackQueryId); return true; } + if (action.kind === "status" && action.action === "trace") { + const nextTraceVisible = !deps.getTraceVisible(); + deps.setTraceVisible(nextTraceVisible); + await deps.updateStatusMessage(); + await deps.answerCallbackQuery( + callbackQueryId, + `Trace: ${nextTraceVisible ? "on" : "off"}`, + ); + return true; + } if (!(action.kind === "status" && action.action === "thinking")) { return false; } @@ -793,6 +813,7 @@ export function buildThinkingMenuReplyMarkup( export function buildStatusReplyMarkup( activeModel: Model | undefined, currentThinkingLevel: ThinkingLevel, + traceVisible: boolean, ): TelegramReplyMarkup { const rows: Array> = []; rows.push([ @@ -804,6 +825,12 @@ export function buildStatusReplyMarkup( callback_data: "status:model", }, ]); + rows.push([ + { + text: formatStatusButtonLabel("Trace", traceVisible ? "on" : "off"), + callback_data: "status:trace", + }, + ]); if (activeModel?.reasoning) { rows.push([ { @@ -847,12 +874,17 @@ export function buildTelegramStatusMenuRenderPayload( statusText: string, activeModel: Model | undefined, currentThinkingLevel: ThinkingLevel, + traceVisible: boolean, ): TelegramMenuRenderPayload { return { nextMode: "status", text: statusText, mode: "html", - replyMarkup: buildStatusReplyMarkup(activeModel, currentThinkingLevel), + replyMarkup: buildStatusReplyMarkup( + activeModel, + currentThinkingLevel, + traceVisible, + ), }; } @@ -897,12 +929,14 @@ export async function updateTelegramStatusMessage( statusText: string, activeModel: Model | undefined, currentThinkingLevel: ThinkingLevel, + traceVisible: boolean, deps: TelegramMenuMessageRuntimeDeps, ): Promise { const payload = buildTelegramStatusMenuRenderPayload( statusText, activeModel, currentThinkingLevel, + traceVisible, ); state.mode = payload.nextMode; await deps.editInteractiveMessage( @@ -919,12 +953,14 @@ export async function sendTelegramStatusMessage( statusText: string, activeModel: Model | undefined, currentThinkingLevel: ThinkingLevel, + traceVisible: boolean, deps: TelegramMenuMessageRuntimeDeps, ): Promise { const payload = buildTelegramStatusMenuRenderPayload( statusText, activeModel, currentThinkingLevel, + traceVisible, ); state.mode = payload.nextMode; return deps.sendInteractiveMessage( diff --git a/lib/registration.ts b/lib/registration.ts index 285b6b3..5929a8e 100644 --- a/lib/registration.ts +++ b/lib/registration.ts @@ -8,11 +8,28 @@ import type { ExtensionCommandContext, ExtensionContext, } from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; import { queueTelegramAttachments } from "./attachments.ts"; import type { PendingTelegramTurn } from "./queue.ts"; +function buildAttachmentParametersSchema(maxAttachmentsPerTurn: number) { + return { + type: "object", + properties: { + paths: { + type: "array", + items: { + type: "string", + description: "Local file path to attach", + }, + minItems: 1, + maxItems: maxAttachmentsPerTurn, + }, + }, + required: ["paths"], + }; +} + // --- Tool Registration --- export interface TelegramAttachmentToolRegistrationDeps { @@ -34,12 +51,7 @@ export function registerTelegramAttachmentTool( promptGuidelines: [ "When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.", ], - parameters: Type.Object({ - paths: Type.Array( - Type.String({ description: "Local file path to attach" }), - { minItems: 1, maxItems: deps.maxAttachmentsPerTurn }, - ), - }), + parameters: buildAttachmentParametersSchema(deps.maxAttachmentsPerTurn), async execute(_toolCallId, params) { return queueTelegramAttachments({ activeTurn: deps.getActiveTurn(), diff --git a/lib/rendering.ts b/lib/rendering.ts index 834635a..d5ce24c 100644 --- a/lib/rendering.ts +++ b/lib/rendering.ts @@ -5,6 +5,100 @@ export const MAX_MESSAGE_LENGTH = 4096; +export type TelegramAssistantDisplayBlock = + | { type: "text"; text: string } + | { type: "thinking"; text: string } + | { type: "tool_call"; name: string; argsText?: string }; + +function truncateDisplayText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function normalizePreviewInlineText(text: string): string { + return renderMarkdownPreviewText(text).replace(/\s+/g, " ").trim(); +} + +function renderTracePreviewLine(block: TelegramAssistantDisplayBlock): string | undefined { + if (block.type === "text") return undefined; + if (block.type === "thinking") { + const summary = normalizePreviewInlineText(block.text); + if (!summary) return undefined; + return `[thinking] ${truncateDisplayText(summary, 120)}`; + } + const parts = [`[tool] ${block.name}`]; + if (block.argsText?.trim()) { + const summary = normalizePreviewInlineText(block.argsText); + if (summary) parts.push(summary); + } + return truncateDisplayText(parts.join(" "), 160); +} + +function renderMarkdownQuote(text: string): string { + return text + .split(/\r?\n/) + .map((line) => `> ${line.length > 0 ? line : "\u00A0"}`) + .join("\n"); +} + +function renderToolArgsMarkdown(argsText: string): string { + const trimmed = argsText.trim(); + if (trimmed.length === 0) return ""; + if (trimmed.includes("\n") || trimmed.length > 120) { + return `\n\n\`\`\`json\n${trimmed}\n\`\`\``; + } + return ` ${"`"}${trimmed}${"`"}`; +} + +export function buildTelegramAssistantPreviewText( + blocks: TelegramAssistantDisplayBlock[], + traceVisible: boolean, +): string { + const sections: string[] = []; + const text = blocks + .filter((block): block is Extract => block.type === "text") + .map((block) => block.text) + .join("") + .trim(); + if (text) { + sections.push(text); + } + if (traceVisible) { + const traceLines = blocks + .map(renderTracePreviewLine) + .filter((line): line is string => !!line); + if (traceLines.length > 0) { + sections.push(traceLines.join("\n")); + } + } + return sections.join("\n\n").trim(); +} + +export function buildTelegramAssistantTranscriptMarkdown( + blocks: TelegramAssistantDisplayBlock[], + traceVisible: boolean, +): string { + const sections: string[] = []; + for (const block of blocks) { + if (block.type === "text") { + const trimmed = block.text.trim(); + if (trimmed) sections.push(trimmed); + continue; + } + if (!traceVisible) continue; + if (block.type === "thinking") { + const trimmed = block.text.trim(); + if (!trimmed) continue; + sections.push(`**Thinking**\n${renderMarkdownQuote(trimmed)}`); + continue; + } + sections.push( + `**Tool call** ${"`"}${block.name}${"`"}${block.argsText ? renderToolArgsMarkdown(block.argsText) : ""}`, + ); + } + return sections.join("\n\n").trim(); +} + // --- Escaping --- function escapeHtml(text: string): string { diff --git a/lib/status.ts b/lib/status.ts index 7de4cfd..d9e4be7 100644 --- a/lib/status.ts +++ b/lib/status.ts @@ -87,6 +87,7 @@ function buildContextSummary( export function buildStatusHtml( ctx: ExtensionContext, activeModel: Model | undefined, + traceVisible: boolean, ): string { const stats = collectUsageStats(ctx); const usesSubscription = activeModel @@ -102,6 +103,7 @@ export function buildStatusHtml( lines.push(buildStatusRow("Cost", costSummary)); } lines.push(buildStatusRow("Context", buildContextSummary(ctx, activeModel))); + lines.push(buildStatusRow("Trace", traceVisible ? "on" : "off")); if (lines.length === 0) { lines.push(buildStatusRow("Status", "No usage data yet.")); } diff --git a/tests/menu.test.ts b/tests/menu.test.ts index 17f816b..07bb623 100644 --- a/tests/menu.test.ts +++ b/tests/menu.test.ts @@ -111,6 +111,10 @@ test("Menu helpers build model menu state and parse callback actions", () => { kind: "status", action: "model", }); + assert.deepEqual(parseTelegramMenuCallbackAction("status:trace"), { + kind: "status", + action: "trace", + }); assert.deepEqual(parseTelegramMenuCallbackAction("thinking:set:high"), { kind: "thinking:set", level: "high", @@ -443,6 +447,7 @@ test("Menu helpers execute model callback actions across update, switch, and res test("Menu helpers handle status and thinking callback actions", async () => { const events: string[] = []; + let traceVisible = true; const reasoningModel = { provider: "openai", id: "gpt-5", @@ -465,6 +470,41 @@ test("Menu helpers handle status and thinking callback actions", async () => { updateThinkingMenuMessage: async () => { events.push("status:thinking"); }, + updateStatusMessage: async () => { + events.push("status:update"); + }, + setTraceVisible: (nextTraceVisible) => { + traceVisible = nextTraceVisible; + events.push(`trace:${nextTraceVisible ? "on" : "off"}`); + }, + getTraceVisible: () => traceVisible, + answerCallbackQuery: async (_id, text) => { + events.push(`answer:${text ?? ""}`); + }, + }, + ), + true, + ); + assert.equal( + await handleTelegramStatusMenuCallbackAction( + "callback-trace", + "status:trace", + reasoningModel as never, + { + updateModelMenuMessage: async () => { + events.push("unexpected:model"); + }, + updateThinkingMenuMessage: async () => { + events.push("unexpected:thinking"); + }, + updateStatusMessage: async () => { + events.push("status:update"); + }, + setTraceVisible: (nextTraceVisible) => { + traceVisible = nextTraceVisible; + events.push(`trace:${nextTraceVisible ? "on" : "off"}`); + }, + getTraceVisible: () => traceVisible, answerCallbackQuery: async (_id, text) => { events.push(`answer:${text ?? ""}`); }, @@ -504,6 +544,13 @@ test("Menu helpers handle status and thinking callback actions", async () => { updateThinkingMenuMessage: async () => { events.push("unexpected:thinking"); }, + updateStatusMessage: async () => { + events.push("unexpected:status"); + }, + setTraceVisible: () => { + events.push("unexpected:trace"); + }, + getTraceVisible: () => traceVisible, answerCallbackQuery: async (_id, text) => { events.push(`answer:${text ?? ""}`); }, @@ -513,10 +560,13 @@ test("Menu helpers handle status and thinking callback actions", async () => { ); assert.equal(events[0], "status:model"); assert.equal(events[1], "answer:"); - assert.equal(events[2], "set:high"); + assert.equal(events[2], "trace:off"); assert.equal(events[3], "status:update"); - assert.equal(events[4], "answer:Thinking: high"); - assert.equal(events[5], "answer:This model has no reasoning controls."); + assert.equal(events[4], "answer:Trace: off"); + assert.equal(events[5], "set:high"); + assert.equal(events[6], "status:update"); + assert.equal(events[7], "answer:Thinking: high"); + assert.equal(events[8], "answer:This model has no reasoning controls."); }); test("Menu helpers build pure render payloads before transport", () => { @@ -536,6 +586,7 @@ test("Menu helpers build pure render payloads before transport", () => { "Status", modelA as never, "medium", + true, ); assert.equal(modelPayload.nextMode, "model"); assert.equal(modelPayload.text, "Choose a model:"); @@ -586,6 +637,7 @@ test("Menu helpers update and send interactive menu messages", async () => { "Status", modelA as never, "medium", + true, deps, ); const sentStatusId = await sendTelegramStatusMessage( @@ -593,6 +645,7 @@ test("Menu helpers update and send interactive menu messages", async () => { "Status", modelA as never, "medium", + true, deps, ); const sentModelId = await sendTelegramModelMenuMessage(state, modelA as never, deps); @@ -638,8 +691,9 @@ test("Menu helpers build model, thinking, and status UI payloads", () => { thinkingMarkup.inline_keyboard.some((row) => row[0]?.text === "✅ medium"), true, ); - const statusMarkup = buildStatusReplyMarkup(modelA as never, "medium"); - assert.equal(statusMarkup.inline_keyboard.length, 2); - const noReasoningMarkup = buildStatusReplyMarkup(modelB as never, "medium"); - assert.equal(noReasoningMarkup.inline_keyboard.length, 1); + const statusMarkup = buildStatusReplyMarkup(modelA as never, "medium", true); + assert.equal(statusMarkup.inline_keyboard.length, 3); + assert.equal(statusMarkup.inline_keyboard[1]?.[0]?.callback_data, "status:trace"); + const noReasoningMarkup = buildStatusReplyMarkup(modelB as never, "medium", false); + assert.equal(noReasoningMarkup.inline_keyboard.length, 2); }); diff --git a/tests/rendering.test.ts b/tests/rendering.test.ts index 722113a..53c72da 100644 --- a/tests/rendering.test.ts +++ b/tests/rendering.test.ts @@ -6,10 +6,68 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { __telegramTestUtils } from "../index.ts"; +import { + MAX_MESSAGE_LENGTH, + buildTelegramAssistantPreviewText, + buildTelegramAssistantTranscriptMarkdown, + renderTelegramMessage, +} from "../lib/rendering.ts"; + +test("Assistant trace helpers build compact previews and fuller transcripts", () => { + const blocks = [ + { type: "text", text: "Answer intro" }, + { type: "thinking", text: "Need to inspect the config first." }, + { + type: "tool_call", + name: "read_config", + argsText: '{"path":"config.json"}', + }, + { type: "text", text: "\n\nFinal answer." }, + ] as const; + assert.equal( + buildTelegramAssistantPreviewText(blocks as never, true), + [ + "Answer intro\n\nFinal answer.", + '[thinking] Need to inspect the config first.\n[tool] read_config {"path":"config.json"}', + ].join("\n\n"), + ); + assert.equal( + buildTelegramAssistantPreviewText(blocks as never, false), + "Answer intro\n\nFinal answer.", + ); + assert.equal( + buildTelegramAssistantTranscriptMarkdown(blocks as never, true), + [ + "Answer intro", + "**Thinking**\n> Need to inspect the config first.", + '**Tool call** `read_config` `{"path":"config.json"}`', + "Final answer.", + ].join("\n\n"), + ); + assert.equal( + buildTelegramAssistantTranscriptMarkdown(blocks as never, false), + "Answer intro\n\nFinal answer.", + ); +}); + +test("Assistant trace transcript uses code fences for long tool arguments", () => { + const markdown = buildTelegramAssistantTranscriptMarkdown( + [ + { type: "text", text: "Answer" }, + { + type: "tool_call", + name: "write_file", + argsText: '{\n "path": "out/report.md",\n "content": "long body"\n}', + }, + ], + true, + ); + assert.match(markdown, /\*\*Tool call\*\* `write_file`/); + assert.match(markdown, /```json/); +}); test("Nested lists stay out of code blocks", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( + const chunks = renderTelegramMessage( "- Level 1\n - Level 2\n - Level 3 with **bold** text", { mode: "markdown" }, ); @@ -27,7 +85,7 @@ test("Nested lists stay out of code blocks", () => { }); test("Fenced code blocks preserve literal markdown", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( + const chunks = renderTelegramMessage( '~~~ts\nconst value = "**raw**";\n~~~', { mode: "markdown" }, ); @@ -37,7 +95,7 @@ test("Fenced code blocks preserve literal markdown", () => { }); test("Underscores inside words do not become italic", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( + const chunks = renderTelegramMessage( "Path: foo_bar_baz.txt and **bold**", { mode: "markdown" }, ); @@ -47,7 +105,7 @@ test("Underscores inside words do not become italic", () => { }); test("Quoted nested lists stay in blockquote rendering", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( + const chunks = renderTelegramMessage( "> Quoted intro\n> - nested item\n> - deeper item", { mode: "markdown" }, ); @@ -59,7 +117,7 @@ test("Quoted nested lists stay in blockquote rendering", () => { }); test("Numbered lists use monospace numeric markers", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( + const chunks = renderTelegramMessage( "1. first\n 2. second", { mode: "markdown" }, ); @@ -69,7 +127,7 @@ test("Numbered lists use monospace numeric markers", () => { }); test("Nested blockquotes flatten into one Telegram blockquote with indentation", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( + const chunks = renderTelegramMessage( "> outer\n>> inner\n>>> deepest", { mode: "markdown" }, ); @@ -82,7 +140,7 @@ test("Nested blockquotes flatten into one Telegram blockquote with indentation", }); test("Markdown tables render as literal monospace blocks without outer side borders", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( + const chunks = renderTelegramMessage( "| Name | Value |\n| --- | --- |\n| **x** | `y` |", { mode: "markdown" }, ); @@ -96,7 +154,7 @@ test("Markdown tables render as literal monospace blocks without outer side bord }); test("Links, code spans, and underscore-heavy text coexist safely", () => { - const chunks = __telegramTestUtils.renderTelegramMessage( + const chunks = renderTelegramMessage( "See [docs](https://example.com), run `foo_bar()` and keep foo_bar.txt literal", { mode: "markdown" }, ); @@ -114,12 +172,12 @@ test("Long quoted blocks stay chunked with balanced blockquote tags", () => { { length: 500 }, (_, index) => `> quoted **${index}** line`, ).join("\n"); - const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + const chunks = renderTelegramMessage(markdown, { mode: "markdown", }); assert.ok(chunks.length > 1); for (const chunk of chunks) { - assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH); assert.equal( (chunk.text.match(/
/g) ?? []).length, (chunk.text.match(/<\/blockquote>/g) ?? []).length, @@ -132,12 +190,12 @@ test("Long markdown replies stay chunked below Telegram limits", () => { { length: 600 }, (_, index) => `- item **${index}**`, ).join("\n"); - const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + const chunks = renderTelegramMessage(markdown, { mode: "markdown", }); assert.ok(chunks.length > 1); for (const chunk of chunks) { - assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH); assert.equal( (chunk.text.match(//g) ?? []).length, (chunk.text.match(/<\/b>/g) ?? []).length, @@ -151,12 +209,12 @@ test("Long mixed links and code spans stay chunked with balanced inline tags", ( (_, index) => `Paragraph ${index}: see [docs ${index}](https://example.com/${index}), run \`code_${index}()\`, and keep foo_bar_${index}.txt literal`, ).join("\n\n"); - const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + const chunks = renderTelegramMessage(markdown, { mode: "markdown", }); assert.ok(chunks.length > 1); for (const chunk of chunks) { - assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH); assert.equal( (chunk.text.match(//g) ?? []).length, @@ -180,12 +238,12 @@ test("Long multi-block markdown keeps quotes and code fences structurally balanc "```", ].join("\n"); }).join("\n\n"); - const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + const chunks = renderTelegramMessage(markdown, { mode: "markdown", }); assert.ok(chunks.length > 1); for (const chunk of chunks) { - assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH); assert.equal( (chunk.text.match(/
/g) ?? []).length, (chunk.text.match(/<\/blockquote>/g) ?? []).length, @@ -206,12 +264,12 @@ test("Chunked mixed block transitions keep quote and list structure balanced", ( `plain paragraph ${index} with [link](https://example.com/${index})`, ].join("\n"); }).join("\n\n"); - const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + const chunks = renderTelegramMessage(markdown, { mode: "markdown", }); assert.ok(chunks.length > 1); for (const chunk of chunks) { - assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH); assert.equal( (chunk.text.match(/
/g) ?? []).length, (chunk.text.match(/<\/blockquote>/g) ?? []).length, @@ -232,12 +290,12 @@ test("Chunked code fence transitions keep code blocks closed before following pr `After code **${index}** and \`inline_${index}()\``, ].join("\n"); }).join("\n\n"); - const chunks = __telegramTestUtils.renderTelegramMessage(markdown, { + const chunks = renderTelegramMessage(markdown, { mode: "markdown", }); assert.ok(chunks.length > 1); for (const chunk of chunks) { - assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH); + assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH); assert.equal( (chunk.text.match(/
<\/pre>/g) ?? []).length,
@@ -253,12 +311,12 @@ test("Long inline formatting paragraphs stay balanced across chunk boundaries",
   const markdown = Array.from({ length: 500 }, (_, index) => {
     return `Segment ${index} keeps **bold_${index}** with \`code_${index}()\`, [link_${index}](https://example.com/${index}), and foo_bar_${index}.txt literal.`;
   }).join(" ");
-  const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
+  const chunks = renderTelegramMessage(markdown, {
     mode: "markdown",
   });
   assert.ok(chunks.length > 1);
   for (const chunk of chunks) {
-    assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
+    assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
     assert.equal(
       (chunk.text.match(//g) ?? []).length,
       (chunk.text.match(/<\/b>/g) ?? []).length,
@@ -286,12 +344,12 @@ test("Chunked list, code, quote, and prose cycles stay balanced across transitio
       `Plain paragraph ${index} with \`inline_${index}()\``,
     ].join("\n");
   }).join("\n\n");
-  const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
+  const chunks = renderTelegramMessage(markdown, {
     mode: "markdown",
   });
   assert.ok(chunks.length > 1);
   for (const chunk of chunks) {
-    assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
+    assert.ok(chunk.text.length <= MAX_MESSAGE_LENGTH);
     assert.equal(
       (chunk.text.match(/
<\/pre>/g) ?? []).length,