tool verbose

This commit is contained in:
wassname
2026-04-19 15:41:15 +08:00
parent e7e3e86550
commit 5aa37b7a99
7 changed files with 515 additions and 76 deletions
+217 -34
View File
@@ -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<any> | 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<string, unknown>;
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<TelegramAssistantDisplayBlock, { type: "text" }> =>
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<string, unknown>).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<string, unknown>;
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<void> {
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<string, unknown>;
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<TelegramModelMenuState> {
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<void> {
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<void> {
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<void> {
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);
}
+39 -3
View File
@@ -62,6 +62,8 @@ export interface TelegramMenuEffectPort {
setCurrentModel: (model: Model<any>) => 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<any> | undefined,
currentThinkingLevel: ThinkingLevel,
traceVisible: boolean,
): TelegramReplyMarkup {
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
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<any> | 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<any> | undefined,
currentThinkingLevel: ThinkingLevel,
traceVisible: boolean,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<void> {
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<any> | undefined,
currentThinkingLevel: ThinkingLevel,
traceVisible: boolean,
deps: TelegramMenuMessageRuntimeDeps,
): Promise<number | undefined> {
const payload = buildTelegramStatusMenuRenderPayload(
statusText,
activeModel,
currentThinkingLevel,
traceVisible,
);
state.mode = payload.nextMode;
return deps.sendInteractiveMessage(
+19 -7
View File
@@ -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(),
+94
View File
@@ -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<TelegramAssistantDisplayBlock, { type: "text" }> => 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 {
+2
View File
@@ -87,6 +87,7 @@ function buildContextSummary(
export function buildStatusHtml(
ctx: ExtensionContext,
activeModel: Model<any> | 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."));
}
+61 -7
View File
@@ -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", () => {
"<b>Status</b>",
modelA as never,
"medium",
true,
);
assert.equal(modelPayload.nextMode, "model");
assert.equal(modelPayload.text, "<b>Choose a model:</b>");
@@ -586,6 +637,7 @@ test("Menu helpers update and send interactive menu messages", async () => {
"<b>Status</b>",
modelA as never,
"medium",
true,
deps,
);
const sentStatusId = await sendTelegramStatusMessage(
@@ -593,6 +645,7 @@ test("Menu helpers update and send interactive menu messages", async () => {
"<b>Status</b>",
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);
});
+83 -25
View File
@@ -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(/<blockquote>/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(/<b>/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(/<a /g) ?? []).length,
(chunk.text.match(/<\/a>/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(/<blockquote>/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(/<blockquote>/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><code/g) ?? []).length,
(chunk.text.match(/<\/code><\/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(/<b>/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><code/g) ?? []).length,
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,