mirror of
https://github.com/wassname/pi-telegram.git
synced 2026-06-27 15:01:00 +08:00
Fix Telegram stop recovery and trace output
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
## Current
|
||||
|
||||
- `[Runtime]` Fixed Telegram slash-command routing so `/stop`, `/status`, `/model`, and other local commands receive the real Telegram message and pi context instead of the wrong argument positions. Added stale-abort recovery so if pi is already idle but the bridge still thinks an aborted Telegram turn is active, the next Telegram message clears the stale local state and dispatch resumes. Impact: Telegram no longer gets permanently wedged while local `!` commands still work.
|
||||
- `[Trace + Shell Output]` Compact trace mode now marks shortened thinking/tool blocks explicitly with a “use /trace for full” notice, full mode keeps the complete final trace, and direct `!` shell replies are delivered through chunked Telegram-safe markdown instead of silently slicing off the tail. Impact: trace and shell output truncation is visible instead of hidden, and verbose output remains available.
|
||||
|
||||
- `[Security]` Removed auto-pair-on-first-DM behavior. The bot now requires `allowedUserId` to be set before polling starts. Configure it via `TELEGRAM_ALLOWED_USER_ID` env var or the updated `/telegram-setup` prompt (which now asks for a numeric user ID after the bot token). The env var takes precedence over the saved config on every session start. Denied senders get an auth error reply; their numeric ID is also logged to the pi TUI as a warning. Breaking change: fresh installs require explicit configuration; existing installs with `allowedUserId` already in `telegram.json` continue to work unchanged.
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ It started from upstream commit [`cb34008460b6c1ca036d92322f69d87f626be0fc`](htt
|
||||
|
||||
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, and trace visibility toggle for thinking/tool-call blocks
|
||||
- Better Telegram control UI, including an improved `/status` view with inline buttons for model and thinking selection, and trace display controls for thinking/tool-call blocks
|
||||
- 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, high-priority control actions, and safer dispatch behavior
|
||||
- Markdown and reply rendering improvements, with richer formatting support, narrow-client-friendly table/list rendering, quote compatibility fixes, and multiple fixes for incorrect Telegram rendering and chunking edge cases
|
||||
@@ -122,7 +122,7 @@ 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, 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
|
||||
- `/trace` toggles visibility of thinking and tool-call blocks in Telegram replies (on by default)
|
||||
- `/trace` cycles Telegram trace display mode: `text` hides trace blocks, `compact` shows shortened trace blocks with an explicit truncation notice, and `full` shows the complete final trace
|
||||
- Queue reactions: `👍` prioritizes a waiting turn, `👎` removes it
|
||||
|
||||
### Send text
|
||||
@@ -166,6 +166,8 @@ or:
|
||||
|
||||
That aborts the active pi turn.
|
||||
|
||||
If pi becomes locally idle but the Telegram bridge still holds stale local state for the aborted turn, the next Telegram message clears that stale state and resumes normal dispatch.
|
||||
|
||||
### Queue follow-ups
|
||||
|
||||
If you send more Telegram messages while pi is busy, they are queued and processed in order.
|
||||
@@ -203,6 +205,10 @@ The extension streams assistant text previews back to Telegram while pi is gener
|
||||
|
||||
It tries Telegram draft streaming first with `sendMessageDraft`. If that is not supported for your bot, it falls back to `sendMessage` plus `editMessageText`.
|
||||
|
||||
Compact trace mode marks shortened thinking/tool blocks explicitly instead of silently cropping them. Full trace mode keeps the complete final trace content.
|
||||
|
||||
Direct `!` shell command replies are delivered in full across Telegram-safe chunks instead of being cut to the first screenful.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one pi session should be connected to the bot at a time
|
||||
|
||||
+11
-3
@@ -103,11 +103,19 @@ Key rules:
|
||||
|
||||
The renderer is a Telegram-specific formatter, not a general Markdown engine, so rendering changes should be treated as regression-prone.
|
||||
|
||||
### Trace Visibility
|
||||
### Trace Display Modes
|
||||
|
||||
When trace visibility is on (default), thinking blocks and tool-call blocks from the assistant are included in both streaming previews and final replies. During streaming, trace blocks appear as compact one-line summaries (e.g. `🧠 Thinking...`, `🔧 tool_name`). In the final transcript, they render as quoted Markdown blocks with more detail.
|
||||
Telegram trace rendering uses three session-local display modes:
|
||||
|
||||
Trace visibility is toggled per session via `/trace` or the inline button on the `/status` menu. The state is stored in `traceVisible` (boolean, default `true`) and flows through the rendering helpers in `/lib/rendering.ts`.
|
||||
- `text`: hide thinking and tool blocks
|
||||
- `compact`: show shortened thinking/tool blocks and mark any truncation explicitly with a “use /trace for full” notice
|
||||
- `full`: show the complete final trace content
|
||||
|
||||
During streaming, trace blocks still appear as compact one-line summaries (e.g. `🧠 Thinking...`, `🔧 tool_name`). Final replies use the selected display mode through `/trace` and the status menu helpers.
|
||||
|
||||
### Abort Recovery
|
||||
|
||||
`/stop` still aborts the active Telegram-owned pi turn, but the bridge now also tracks locally requested aborts. If pi has already returned to an idle/no-pending-message state and the bridge still holds stale active-turn state from that abort, the next Telegram message clears the stale local turn and resumes dispatch instead of staying wedged.
|
||||
|
||||
## Streaming And Delivery
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Stop Recovery And Trace Visibility
|
||||
|
||||
## Goal
|
||||
Fix two Telegram bridge failures: `/stop` must not leave the bridge permanently wedged, and truncated shell or trace output must be visibly marked while full trace mode keeps complete detail.
|
||||
|
||||
## Scope
|
||||
In: abort-recovery behavior for stale Telegram-owned turns, direct `!` shell reply delivery, compact trace truncation signaling, focused regression coverage, and synced user-facing docs.
|
||||
Out: broader queue-policy redesign, non-Telegram pi abort semantics, and new trace UI beyond the existing `/trace` mode cycle.
|
||||
|
||||
## Requirements
|
||||
- R1: A stale aborted Telegram turn cannot block later Telegram prompts forever. Done means: when local Telegram turn state survives after pi is already idle, the next Telegram message recovers the bridge and normal prompt dispatch resumes. VERIFY: targeted runtime regression shows `/stop`, no `agent_end`, then a later Telegram prompt dispatches into pi. If it silently failed, the test would still be stuck waiting for another dispatch.
|
||||
- R2: Direct `!` shell replies do not silently crop output. Done means: long stdout/stderr are delivered through chunked markdown replies instead of a hidden `slice(0, 3900)`. VERIFY: targeted regression inspects the emitted shell reply text and confirms the tail of long output is still present.
|
||||
- R3: Any compact trace truncation is explicit, and full trace mode stays untruncated. Done means: compact thinking/tool blocks include a visible “use /trace for full” notice when shortened, while full-mode tests still see the complete content. VERIFY: rendering tests assert the truncation notice in compact mode and the original long text in full mode.
|
||||
- R4: User-facing docs describe the actual `/trace` behavior and the new recovery/truncation guarantees. Done means: README, architecture doc, and changelog all reflect the shipped behavior. VERIFY: grep/read shows aligned wording in all three docs.
|
||||
|
||||
## Tasks
|
||||
- [x] T1 (R1): Add stale-abort recovery in the Telegram runtime.
|
||||
- steps: track local abort requests, detect stale Telegram abort state when pi is idle, clear stale local state, and resume normal dispatch.
|
||||
- verify: `node --experimental-strip-types --test tests/queue.test.ts`
|
||||
- success: the new stop-recovery regression passes.
|
||||
- likely_fail: local active-turn state is never cleared, so the test times out waiting for the resumed dispatch.
|
||||
- sneaky_fail: recovery clears state too broadly and breaks normal abort completion; existing queue tests would fail around aborted-turn history or model-switch abort behavior.
|
||||
- UAT: "when Telegram gets stuck after `/stop`, my next normal message is processed again instead of being ignored forever."
|
||||
- [x] T2 (R2, R3): Remove hidden cropping and make compact truncation explicit.
|
||||
- steps: route direct shell replies through markdown chunking, replace silent compact truncation with explicit notices, and mark preview truncation clearly when it happens.
|
||||
- verify: `node --experimental-strip-types --test tests/rendering.test.ts tests/replies.test.ts tests/queue.test.ts`
|
||||
- success: new rendering/reply/runtime assertions pass.
|
||||
- likely_fail: shell output is still sliced or compact traces still only show a bare ellipsis.
|
||||
- sneaky_fail: full-mode trace content gets truncated by the new helpers; full-mode assertions catch that.
|
||||
- UAT: "when a tool call or shell command is shortened in compact mode, Telegram explicitly tells me it was truncated and that `/trace` full mode shows the complete content."
|
||||
- [x] T3 (R4): Sync docs for the shipped behavior.
|
||||
- steps: update README, architecture doc, and changelog wording for `/trace`, stale-abort recovery, and explicit truncation markers.
|
||||
- verify: `rg -n "trace|stop|trunc" README.md docs/architecture.md CHANGELOG.md`
|
||||
- success: aligned wording appears in all files.
|
||||
- likely_fail: runtime changes land without doc updates, so the grep output is missing one of the files.
|
||||
- sneaky_fail: docs still describe `/trace` as a simple on/off toggle instead of text/compact/full.
|
||||
- UAT: "when I read the docs, they match what the Telegram bot actually does."
|
||||
|
||||
## Context
|
||||
- The bridge keeps local queue and active-turn state separate from pi core state, so stale local state can wedge Telegram even when pi is already idle.
|
||||
- `!` shell commands bypass the queue and are handled directly in `index.ts`.
|
||||
- `renderBlockMessage()` controls compact/full trace formatting for thinking, tool calls, and tool results.
|
||||
|
||||
## Log
|
||||
- Existing tests cover abort-plus-follow-up history, but they did not cover the stale-local-state path where pi is already idle and Telegram still thinks a turn is active.
|
||||
- The immediate `/stop` failure had a concrete routing bug too: slash commands were passed into `handleTelegramCommand()` with the wrong argument positions, so Telegram local commands could receive the wrong message/ctx objects while direct `!` shell commands still worked.
|
||||
|
||||
## TODO
|
||||
- Consider exposing a clearer inline status indicator when the bridge auto-recovers a stale aborted turn.
|
||||
|
||||
## Errors
|
||||
| Task | Error | Resolution |
|
||||
|------|-------|------------|
|
||||
@@ -293,6 +293,7 @@ const MAX_ATTACHMENTS_PER_TURN = 10;
|
||||
const PREVIEW_THROTTLE_MS = 750;
|
||||
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
|
||||
const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
|
||||
const STALE_ABORT_RECOVERY_GRACE_MS = 1500;
|
||||
const SYSTEM_PROMPT_SUFFIX = `
|
||||
|
||||
Telegram bridge extension is active.
|
||||
@@ -352,10 +353,35 @@ function truncateTelegramButtonLabel(label: string, maxLength = 56): string {
|
||||
: `${label.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
function buildShellCommandReply(options: {
|
||||
shellCmd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}): string {
|
||||
const sections = [
|
||||
`**Shell**\n\`\`\`sh\n${options.shellCmd}\n\`\`\``,
|
||||
`Exit code: \`${options.exitCode}\``,
|
||||
];
|
||||
const stdout = options.stdout.trimEnd();
|
||||
const stderr = options.stderr.trimEnd();
|
||||
if (stdout) {
|
||||
sections.push(`**stdout**\n\`\`\`text\n${stdout}\n\`\`\``);
|
||||
}
|
||||
if (stderr) {
|
||||
sections.push(`**stderr**\n\`\`\`text\n${stderr}\n\`\`\``);
|
||||
}
|
||||
if (!stdout && !stderr) {
|
||||
sections.push("`(no output)`");
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
// --- Extension Runtime ---
|
||||
|
||||
export const __telegramTestUtils = {
|
||||
MAX_MESSAGE_LENGTH,
|
||||
STALE_ABORT_RECOVERY_GRACE_MS,
|
||||
renderTelegramMessage,
|
||||
compareTelegramQueueItems,
|
||||
removeTelegramQueueItemsByMessageIds,
|
||||
@@ -373,6 +399,7 @@ export const __telegramTestUtils = {
|
||||
canRestartTelegramTurnForModelSwitch,
|
||||
restartTelegramModelSwitchContinuation,
|
||||
shouldTriggerPendingTelegramModelSwitchAbort,
|
||||
buildShellCommandReply,
|
||||
buildTelegramModelSwitchContinuationText: (
|
||||
model: Pick<Model<any>, "provider" | "id">,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
@@ -398,6 +425,7 @@ export default function (pi: ExtensionAPI) {
|
||||
let telegramTurnDispatchPending = false;
|
||||
let typingInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let currentAbort: (() => void) | undefined;
|
||||
let abortRequestedAt: number | undefined;
|
||||
let preserveQueuedTurnsAsHistory = false;
|
||||
let compactionInProgress = false;
|
||||
let setupInProgress = false;
|
||||
@@ -427,6 +455,40 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
|
||||
function markTelegramAbortRequested(): void {
|
||||
abortRequestedAt = Date.now();
|
||||
}
|
||||
|
||||
function clearTelegramAbortRequested(): void {
|
||||
abortRequestedAt = undefined;
|
||||
}
|
||||
|
||||
function shouldRecoverStaleTelegramAbort(ctx: ExtensionContext): boolean {
|
||||
if (!activeTelegramTurn || !currentAbort || abortRequestedAt === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - abortRequestedAt >= STALE_ABORT_RECOVERY_GRACE_MS;
|
||||
}
|
||||
|
||||
async function recoverStaleTelegramAbort(ctx: ExtensionContext): Promise<void> {
|
||||
const turn = activeTelegramTurn;
|
||||
if (!turn) return;
|
||||
stopTypingLoop();
|
||||
currentAbort = undefined;
|
||||
activeTelegramTurn = undefined;
|
||||
activeTelegramToolExecutions = 0;
|
||||
pendingTelegramModelSwitch = undefined;
|
||||
telegramTurnDispatchPending = false;
|
||||
pendingNonTextBlocks = [];
|
||||
clearTelegramAbortRequested();
|
||||
await clearPreview(turn.chatId);
|
||||
updateStatus(ctx, "recovered stale aborted Telegram turn");
|
||||
dispatchNextQueuedTelegramTurn(ctx);
|
||||
}
|
||||
|
||||
function executeQueuedTelegramControlItem(
|
||||
item: PendingTelegramControlItem,
|
||||
ctx: ExtensionContext,
|
||||
@@ -1134,7 +1196,7 @@ export default function (pi: ExtensionAPI) {
|
||||
"Cannot open status while pi is busy. Send /stop first.",
|
||||
);
|
||||
if (!isIdle) return;
|
||||
const state = await getModelMenuState(chatId, ctx);
|
||||
const state = await getModelMenuState(chatId, undefined, ctx);
|
||||
const messageId = await sendTelegramStatusMessage(
|
||||
state,
|
||||
buildStatusHtml(ctx, getCurrentTelegramModel(ctx), displayMode !== "text"),
|
||||
@@ -1250,6 +1312,7 @@ export default function (pi: ExtensionAPI) {
|
||||
if (!selection || !turn || !abort) return false;
|
||||
pendingTelegramModelSwitch = undefined;
|
||||
queueTelegramModelSwitchContinuation(turn, selection, ctx);
|
||||
markTelegramAbortRequested();
|
||||
abort();
|
||||
return true;
|
||||
}
|
||||
@@ -1564,6 +1627,7 @@ export default function (pi: ExtensionAPI) {
|
||||
if (queuedTelegramItems.length > 0) {
|
||||
preserveQueuedTurnsAsHistory = true;
|
||||
}
|
||||
markTelegramAbortRequested();
|
||||
currentAbort();
|
||||
updateStatus(ctx);
|
||||
await sendTextReply(
|
||||
@@ -1591,12 +1655,16 @@ export default function (pi: ExtensionAPI) {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await pi.exec("sh", ["-c", shellCmd], { timeout: 30_000 });
|
||||
const output = (result.stdout + result.stderr).trim();
|
||||
const codeTag = result.code !== 0 ? ` (exit ${result.code})` : "";
|
||||
const reply = output
|
||||
? `${output.slice(0, 3900)}${codeTag}`
|
||||
: `(no output)${codeTag}`;
|
||||
await sendTextReply(message.chat.id, message.message_id, reply);
|
||||
await sendMarkdownReply(
|
||||
message.chat.id,
|
||||
message.message_id,
|
||||
buildShellCommandReply({
|
||||
shellCmd,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.code,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await sendTextReply(message.chat.id, message.message_id, `Shell error: ${msg}`);
|
||||
@@ -1692,6 +1760,7 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
async function handleModelCommand(
|
||||
message: TelegramMessage,
|
||||
args: string | undefined,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<void> {
|
||||
enqueueTelegramControlItem(
|
||||
@@ -1701,7 +1770,12 @@ export default function (pi: ExtensionAPI) {
|
||||
"model",
|
||||
"⚡ model",
|
||||
async (controlCtx) => {
|
||||
await openModelMenu(message.chat.id, message.message_id, controlCtx);
|
||||
await openModelMenu(
|
||||
message.chat.id,
|
||||
message.message_id,
|
||||
args,
|
||||
controlCtx,
|
||||
);
|
||||
},
|
||||
),
|
||||
ctx,
|
||||
@@ -1740,6 +1814,7 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
async function handleTelegramCommand(
|
||||
commandName: string | undefined,
|
||||
args: string | undefined,
|
||||
message: TelegramMessage,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<boolean> {
|
||||
@@ -1749,7 +1824,7 @@ export default function (pi: ExtensionAPI) {
|
||||
compact: () => handleCompactCommand(message, ctx),
|
||||
status: () => handleStatusCommand(message, ctx),
|
||||
trace: () => handleTraceCommand(message, ctx),
|
||||
model: () => handleModelCommand(message, ctx),
|
||||
model: () => handleModelCommand(message, args, ctx),
|
||||
help: () => handleHelpCommand(message, commandName, ctx),
|
||||
start: () => handleHelpCommand(message, commandName, ctx),
|
||||
quit: () => handleQuitCommand(message, ctx),
|
||||
@@ -1782,6 +1857,9 @@ export default function (pi: ExtensionAPI) {
|
||||
): Promise<void> {
|
||||
const firstMessage = messages[0];
|
||||
if (!firstMessage) return;
|
||||
if (shouldRecoverStaleTelegramAbort(ctx)) {
|
||||
await recoverStaleTelegramAbort(ctx);
|
||||
}
|
||||
const rawText = extractFirstTelegramMessageText(messages);
|
||||
|
||||
// Handle ! shell commands directly via ctx.exec
|
||||
@@ -1795,7 +1873,12 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
const command = parseTelegramCommand(rawText);
|
||||
const handled = await handleTelegramCommand(command?.name, command?.args, firstMessage, ctx);
|
||||
const handled = await handleTelegramCommand(
|
||||
command?.name,
|
||||
command?.args,
|
||||
firstMessage,
|
||||
ctx,
|
||||
);
|
||||
if (handled) return;
|
||||
|
||||
await enqueueTelegramTurn(messages, ctx);
|
||||
@@ -1984,6 +2067,7 @@ export default function (pi: ExtensionAPI) {
|
||||
telegramTurnDispatchPending =
|
||||
sessionStartState.telegramTurnDispatchPending;
|
||||
compactionInProgress = sessionStartState.compactionInProgress;
|
||||
clearTelegramAbortRequested();
|
||||
await mkdir(TEMP_DIR, { recursive: true });
|
||||
updateStatus(ctx);
|
||||
},
|
||||
@@ -2010,6 +2094,7 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
activeTelegramTurn = undefined;
|
||||
currentAbort = undefined;
|
||||
clearTelegramAbortRequested();
|
||||
preserveQueuedTurnsAsHistory = false;
|
||||
await stopPolling();
|
||||
},
|
||||
@@ -2028,6 +2113,7 @@ export default function (pi: ExtensionAPI) {
|
||||
},
|
||||
onAgentStart: async (_event, ctx) => {
|
||||
currentAbort = () => ctx.abort();
|
||||
clearTelegramAbortRequested();
|
||||
const startPlan = buildTelegramAgentStartPlan({
|
||||
queuedItems: queuedTelegramItems,
|
||||
hasPendingDispatch: telegramTurnDispatchPending,
|
||||
@@ -2110,6 +2196,7 @@ export default function (pi: ExtensionAPI) {
|
||||
onAgentEnd: async (event, ctx) => {
|
||||
const turn = activeTelegramTurn;
|
||||
currentAbort = undefined;
|
||||
clearTelegramAbortRequested();
|
||||
stopTypingLoop();
|
||||
activeTelegramTurn = undefined;
|
||||
activeTelegramToolExecutions = 0;
|
||||
@@ -2154,7 +2241,7 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
// Finalize the streaming text preview (only for normal completions, not abort/empty)
|
||||
if (endPlan.kind === "text") {
|
||||
const finalText = previewState?.pendingText.trim() || assistant.text?.trim();
|
||||
const finalText = assistant.text?.trim() || previewState?.pendingText.trim();
|
||||
if (finalText) {
|
||||
const finalized = await finalizeMarkdownPreview(turn.chatId, finalText);
|
||||
if (!finalized) {
|
||||
|
||||
+31
-7
@@ -13,9 +13,17 @@ export type TelegramAssistantDisplayBlock =
|
||||
| { type: "tool_call"; name: string; argsText?: string }
|
||||
| { type: "tool_result"; text: string; toolName?: string };
|
||||
|
||||
function truncateDisplayText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||
function truncateDisplayText(
|
||||
text: string,
|
||||
maxLength: number,
|
||||
): { text: string; truncated: boolean } {
|
||||
if (text.length <= maxLength) {
|
||||
return { text, truncated: false };
|
||||
}
|
||||
return {
|
||||
text: `${text.slice(0, Math.max(0, maxLength - 1))}…`,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdownQuote(text: string): string {
|
||||
@@ -35,6 +43,7 @@ function renderToolArgsMarkdown(argsText: string): string {
|
||||
}
|
||||
|
||||
const COMPACT_TRUNCATE = 500;
|
||||
const COMPACT_TRUNCATION_NOTICE = "[compact trace truncated; use /trace for full]";
|
||||
|
||||
export function renderBlockMessage(
|
||||
block: TelegramAssistantDisplayBlock,
|
||||
@@ -45,21 +54,36 @@ export function renderBlockMessage(
|
||||
if (block.type === "thinking") {
|
||||
const trimmed = block.text.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const content = mode === "compact" ? truncateDisplayText(trimmed, COMPACT_TRUNCATE) : trimmed;
|
||||
const truncated =
|
||||
mode === "compact"
|
||||
? truncateDisplayText(trimmed, COMPACT_TRUNCATE)
|
||||
: { text: trimmed, truncated: false };
|
||||
const content = truncated.truncated
|
||||
? `${truncated.text}\n${COMPACT_TRUNCATION_NOTICE}`
|
||||
: truncated.text;
|
||||
return `**Thinking**\n${renderMarkdownQuote(content)}`;
|
||||
}
|
||||
|
||||
if (block.type === "tool_call") {
|
||||
const argsText = block.argsText ?? "";
|
||||
const displayArgs = mode === "compact" ? truncateDisplayText(argsText, COMPACT_TRUNCATE) : argsText;
|
||||
return `**Tool call** \`${block.name}\`${displayArgs ? renderToolArgsMarkdown(displayArgs) : ""}`;
|
||||
const truncated =
|
||||
mode === "compact"
|
||||
? truncateDisplayText(argsText, COMPACT_TRUNCATE)
|
||||
: { text: argsText, truncated: false };
|
||||
return `**Tool call** \`${block.name}\`${truncated.text ? renderToolArgsMarkdown(truncated.text) : ""}${truncated.truncated ? `\n\n${COMPACT_TRUNCATION_NOTICE}` : ""}`;
|
||||
}
|
||||
|
||||
if (block.type === "tool_result") {
|
||||
if (mode === "text") return undefined;
|
||||
const trimmed = block.text.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const content = mode === "compact" ? truncateDisplayText(trimmed, COMPACT_TRUNCATE) : trimmed;
|
||||
const truncated =
|
||||
mode === "compact"
|
||||
? truncateDisplayText(trimmed, COMPACT_TRUNCATE)
|
||||
: { text: trimmed, truncated: false };
|
||||
const content = truncated.truncated
|
||||
? `${truncated.text}\n${COMPACT_TRUNCATION_NOTICE}`
|
||||
: truncated.text;
|
||||
const header = block.toolName ? `**Tool result** \`${block.toolName}\`` : "**Tool result**";
|
||||
return `${header}\n${renderMarkdownQuote(content)}`;
|
||||
}
|
||||
|
||||
+18
-3
@@ -53,6 +53,23 @@ export interface TelegramPreviewRuntimeDeps {
|
||||
) => Promise<number | undefined>;
|
||||
}
|
||||
|
||||
const PREVIEW_TRUNCATION_NOTICE = "\n[preview truncated]";
|
||||
|
||||
function truncateTelegramPreviewText(
|
||||
text: string,
|
||||
maxMessageLength: number,
|
||||
): string {
|
||||
if (text.length <= maxMessageLength) return text;
|
||||
if (maxMessageLength <= PREVIEW_TRUNCATION_NOTICE.length + 1) {
|
||||
return text.slice(0, maxMessageLength);
|
||||
}
|
||||
const visibleLength = Math.max(
|
||||
0,
|
||||
maxMessageLength - PREVIEW_TRUNCATION_NOTICE.length - 1,
|
||||
);
|
||||
return `${text.slice(0, visibleLength)}…${PREVIEW_TRUNCATION_NOTICE}`;
|
||||
}
|
||||
|
||||
export function buildTelegramPreviewFlushText(options: {
|
||||
state: TelegramPreviewStateLike;
|
||||
maxMessageLength: number;
|
||||
@@ -63,9 +80,7 @@ export function buildTelegramPreviewFlushText(options: {
|
||||
if (!previewText || previewText === options.state.lastSentText) {
|
||||
return undefined;
|
||||
}
|
||||
return previewText.length > options.maxMessageLength
|
||||
? previewText.slice(0, options.maxMessageLength)
|
||||
: previewText;
|
||||
return truncateTelegramPreviewText(previewText, options.maxMessageLength);
|
||||
}
|
||||
|
||||
export function buildTelegramPreviewFinalText(
|
||||
|
||||
+225
-4
@@ -1097,8 +1097,11 @@ test("Extension runtime polls, pairs, and dispatches an inbound Telegram turn in
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ botToken: "123:abc", lastUpdateId: 0 }, null, "\t") +
|
||||
"\n",
|
||||
JSON.stringify(
|
||||
{ botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
|
||||
null,
|
||||
"\t",
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
telegramExtension(pi);
|
||||
@@ -1122,7 +1125,6 @@ test("Extension runtime polls, pairs, and dispatches an inbound Telegram turn in
|
||||
const dispatchedContent = await dispatched;
|
||||
assert.equal(sentMessages.length, 1);
|
||||
assert.equal(Array.isArray(dispatchedContent), true);
|
||||
assert.equal(apiCalls.includes("sendMessage"), true);
|
||||
assert.equal(apiCalls.includes("sendChatAction"), true);
|
||||
const promptBlocks = dispatchedContent as Array<{
|
||||
type: string;
|
||||
@@ -1292,7 +1294,7 @@ test("Extension runtime finalizes a drafted preview into the final Telegram repl
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.deepEqual(draftTexts, ["Draft preview", "Final answer", ""]);
|
||||
assert.deepEqual(draftTexts, ["Draft preview", ""]);
|
||||
assert.equal(sentTexts.length, 1);
|
||||
assert.match(sentTexts[0] ?? "", /Final <b>answer<\/b>/);
|
||||
await handlers.get("session_shutdown")?.({}, ctx);
|
||||
@@ -1545,6 +1547,225 @@ test("Extension runtime carries queued follow-ups into history after an aborted
|
||||
}
|
||||
});
|
||||
|
||||
test("Extension runtime recovers from a stale aborted Telegram turn on the next message", async () => {
|
||||
const agentDir = join(homedir(), ".pi", "agent");
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
const previousConfig = await readFile(configPath, "utf8").catch(
|
||||
() => undefined,
|
||||
);
|
||||
const handlers = new Map<
|
||||
string,
|
||||
(event: unknown, ctx: unknown) => Promise<unknown>
|
||||
>();
|
||||
const commands = new Map<
|
||||
string,
|
||||
{ handler: (args: string, ctx: unknown) => Promise<void> }
|
||||
>();
|
||||
const sentMessages: Array<string | Array<{ type: string; text?: string }>> =
|
||||
[];
|
||||
let firstDispatchResolved = false;
|
||||
let secondUpdatesResolve: ((value: Response) => void) | undefined;
|
||||
let thirdUpdatesResolve: ((value: Response) => void) | undefined;
|
||||
const secondUpdates = new Promise<Response>((resolve) => {
|
||||
secondUpdatesResolve = resolve;
|
||||
});
|
||||
const thirdUpdates = new Promise<Response>((resolve) => {
|
||||
thirdUpdatesResolve = resolve;
|
||||
});
|
||||
const pi = {
|
||||
on: (
|
||||
event: string,
|
||||
handler: (event: unknown, ctx: unknown) => Promise<unknown>,
|
||||
) => {
|
||||
handlers.set(event, handler);
|
||||
},
|
||||
registerCommand: (
|
||||
name: string,
|
||||
definition: { handler: (args: string, ctx: unknown) => Promise<void> },
|
||||
) => {
|
||||
commands.set(name, definition);
|
||||
},
|
||||
registerTool: () => {},
|
||||
sendUserMessage: (
|
||||
content: string | Array<{ type: string; text?: string }>,
|
||||
) => {
|
||||
sentMessages.push(content);
|
||||
firstDispatchResolved = true;
|
||||
},
|
||||
getThinkingLevel: () => "medium",
|
||||
} as never;
|
||||
const originalFetch = globalThis.fetch;
|
||||
let getUpdatesCalls = 0;
|
||||
const sendTexts: 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<string, unknown>)
|
||||
: 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: 20,
|
||||
chat: { id: 99, type: "private" },
|
||||
from: { id: 77, is_bot: false, first_name: "Test" },
|
||||
text: "first request",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
if (getUpdatesCalls === 2) return secondUpdates;
|
||||
if (getUpdatesCalls === 3) return thirdUpdates;
|
||||
throw new DOMException("stop", "AbortError");
|
||||
}
|
||||
if (method === "sendMessage") {
|
||||
sendTexts.push(String(body?.text ?? ""));
|
||||
return {
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
result: { message_id: 300 + sendTexts.length },
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
if (method === "sendChatAction") {
|
||||
return { json: async () => ({ ok: true, result: true }) } as Response;
|
||||
}
|
||||
throw new Error(`Unexpected Telegram API method: ${method}`);
|
||||
};
|
||||
try {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{ botToken: "123:abc", allowedUserId: 77, lastUpdateId: 0 },
|
||||
null,
|
||||
"\t",
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
telegramExtension(pi);
|
||||
const baseCtx = {
|
||||
hasUI: true,
|
||||
model: undefined,
|
||||
signal: undefined,
|
||||
ui: {
|
||||
theme: {
|
||||
fg: (_token: string, text: string) => text,
|
||||
},
|
||||
setStatus: () => {},
|
||||
notify: () => {},
|
||||
},
|
||||
hasPendingMessages: () => false,
|
||||
};
|
||||
let aborted = false;
|
||||
const idleCtx = {
|
||||
...baseCtx,
|
||||
isIdle: () => true,
|
||||
abort: () => {},
|
||||
} as never;
|
||||
const activeCtx = {
|
||||
...baseCtx,
|
||||
isIdle: () => false,
|
||||
abort: () => {
|
||||
aborted = true;
|
||||
},
|
||||
} as never;
|
||||
await handlers.get("session_start")?.({}, idleCtx);
|
||||
await commands.get("telegram-connect")?.handler("", idleCtx);
|
||||
await waitForCondition(() => firstDispatchResolved);
|
||||
await handlers.get("agent_start")?.({}, activeCtx);
|
||||
secondUpdatesResolve?.({
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
_: "other",
|
||||
update_id: 2,
|
||||
message: {
|
||||
message_id: 21,
|
||||
chat: { id: 99, type: "private" },
|
||||
from: { id: 77, is_bot: false, first_name: "Test" },
|
||||
text: "/stop",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
await waitForCondition(() => aborted);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, __telegramTestUtils.STALE_ABORT_RECOVERY_GRACE_MS + 50),
|
||||
);
|
||||
const dispatchCountBeforeRecovery = sentMessages.length;
|
||||
thirdUpdatesResolve?.({
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
_: "other",
|
||||
update_id: 3,
|
||||
message: {
|
||||
message_id: 22,
|
||||
chat: { id: 99, type: "private" },
|
||||
from: { id: 77, is_bot: false, first_name: "Test" },
|
||||
text: "after stop",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
await waitForCondition(
|
||||
() => sentMessages.length === dispatchCountBeforeRecovery + 1,
|
||||
);
|
||||
const promptBlocks = sentMessages.at(-1) as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
const promptText = promptBlocks[0]?.text ?? "";
|
||||
assert.match(promptText, /^\[telegram\]/);
|
||||
assert.equal(promptText, "[telegram] after stop");
|
||||
assert.equal(
|
||||
promptText.includes("Earlier Telegram messages arrived after an aborted turn"),
|
||||
false,
|
||||
);
|
||||
assert.equal(sendTexts.includes("Aborted current turn."), true);
|
||||
await handlers.get("session_shutdown")?.({}, idleCtx);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (previousConfig === undefined) {
|
||||
await rm(configPath, { force: true });
|
||||
} else {
|
||||
await writeFile(configPath, previousConfig, "utf8");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Shell command replies keep long output tails instead of slicing them away", () => {
|
||||
const reply = __telegramTestUtils.buildShellCommandReply({
|
||||
shellCmd: "printf x",
|
||||
stdout: "x".repeat(5000),
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
});
|
||||
assert.match(reply, /\*\*Shell\*\*/);
|
||||
assert.match(reply, /\*\*stdout\*\*/);
|
||||
assert.ok(reply.includes("x".repeat(64)));
|
||||
assert.ok(reply.endsWith("x".repeat(64) + "\n```"));
|
||||
});
|
||||
|
||||
test("Extension runtime runs queued status control before the next queued prompt after agent end", async () => {
|
||||
const agentDir = join(homedir(), ".pi", "agent");
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
|
||||
+16
-2
@@ -33,7 +33,8 @@ test("renderBlockMessage truncates thinking in compact mode", () => {
|
||||
const compact = renderBlockMessage(block, "compact")!;
|
||||
const full = renderBlockMessage(block, "full")!;
|
||||
assert.ok(compact.length < full.length);
|
||||
assert.ok(compact.includes("…"));
|
||||
assert.match(compact, /\[compact trace truncated; use \/trace for full\]/);
|
||||
assert.ok(full.includes(longText));
|
||||
});
|
||||
|
||||
test("renderBlockMessage renders tool_call block", () => {
|
||||
@@ -70,7 +71,20 @@ test("renderBlockMessage truncates tool_result in compact mode", () => {
|
||||
const compact = renderBlockMessage(block, "compact")!;
|
||||
const full = renderBlockMessage(block, "full")!;
|
||||
assert.ok(compact.length < full.length);
|
||||
assert.ok(compact.includes("…"));
|
||||
assert.match(compact, /\[compact trace truncated; use \/trace for full\]/);
|
||||
assert.ok(full.includes("x".repeat(600)));
|
||||
});
|
||||
|
||||
test("renderBlockMessage marks truncated tool_call args in compact mode", () => {
|
||||
const block = {
|
||||
type: "tool_call" as const,
|
||||
name: "write_file",
|
||||
argsText: "x".repeat(600),
|
||||
};
|
||||
const compact = renderBlockMessage(block, "compact")!;
|
||||
const full = renderBlockMessage(block, "full")!;
|
||||
assert.match(compact, /\[compact trace truncated; use \/trace for full\]/);
|
||||
assert.ok(full.includes("x".repeat(600)));
|
||||
});
|
||||
|
||||
test("Nested lists stay out of code blocks", () => {
|
||||
|
||||
@@ -130,13 +130,13 @@ test("Reply previews truncate long flush text and compute final text fallback",
|
||||
buildTelegramPreviewFlushText({
|
||||
state: {
|
||||
mode: "message",
|
||||
pendingText: "abcdef",
|
||||
pendingText: "abcdefghijklmnopqrstuvwxyz",
|
||||
lastSentText: "",
|
||||
},
|
||||
maxMessageLength: 3,
|
||||
maxMessageLength: 24,
|
||||
renderPreviewText: (markdown) => markdown,
|
||||
}),
|
||||
"abc",
|
||||
"abc…\n[preview truncated]",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramPreviewFinalText({
|
||||
|
||||
Reference in New Issue
Block a user