From e8eace05d55ea139047900e99fc3e4779bd24286 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sun, 14 Jun 2026 18:56:11 +0200 Subject: [PATCH 01/17] feat(relay): improve tool-call progress reporting --- README.md | 2 +- extensions/relay/notifications/progress.ts | 10 +- .../relay/notifications/tool-progress.ts | 310 ++++++++++++++++++ extensions/relay/runtime/extension-runtime.ts | 65 +++- .../tasks.md | 56 ++-- tests/broker-process.test.ts | 7 +- tests/discord-runtime.test.ts | 11 +- tests/integration.test.ts | 104 +++++- tests/progress.test.ts | 78 +++++ tests/runtime.test.ts | 15 +- tests/slack-runtime.test.ts | 11 +- 11 files changed, 602 insertions(+), 67 deletions(-) create mode 100644 extensions/relay/notifications/tool-progress.ts diff --git a/README.md b/README.md index b0974cd..5717c52 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ Then invite the app to the channel and pair in that channel/thread with `relay p | create delegation task (opt-in shared rooms) | `/delegate ` | `relay delegate ` | `/relay delegate ` | | control delegation task | `/task@ [task-id]` (or `/task [task-id]` in private/other clients) | `relay task [task-id]` | `/relay task [task-id]` | -`quiet`, `normal`, `verbose`, and `completion-only` are valid progress modes. Progress mode controls non-terminal progress noise: quiet suppresses progress updates, completion-only sends final results plus safe compaction notifications, normal sends coalesced milestone progress, and verbose additionally includes safe visible model/tool snapshots at a shorter interval. Progress updates are deduplicated/coalesced, and supported messengers update the same live progress message in place where possible instead of posting every raw Pi stream event. Terminal notifications still deliver the final assistant answer when it fits safe platform limits, splitting by paragraphs within platform limits and falling back to a Markdown document when an adapter supports files and the output is too large for a reasonable chat burst. +`quiet`, `normal`, `verbose`, and `completion-only` are valid progress modes. Progress mode controls non-terminal progress noise: quiet suppresses progress updates, completion-only sends final results plus safe compaction notifications, normal sends coalesced milestone progress, and verbose additionally includes safe visible model/tool snapshots at a shorter interval. Tool progress in normal/verbose modes is summarized as a bounded live card with safe intent labels such as `▶ bash: npm test`, `✓ read: extensions/relay/runtime/extension-runtime.ts`, or `✕ rg: pattern in extensions`, plus aggregate counts like `tools: bash×2 read×4`; it never includes tool output, file contents, replacement text, raw transcripts, or arbitrary custom-tool arguments. Progress updates are deduplicated/coalesced, and supported messengers update the same live progress message in place where possible instead of posting every raw Pi stream event. Terminal notifications still deliver the final assistant answer when it fits safe platform limits, splitting by paragraphs within platform limits and falling back to a Markdown document when an adapter supports files and the output is too large for a reasonable chat burst. Remote `/disconnect` is scoped to the requesting chat/conversation only: it revokes that Telegram, Discord, or Slack binding and suppresses future session output/buttons there, without disconnecting other messengers that remain paired to the same Pi session. Local `/relay disconnect` is broader and disconnects the current session from all paired messenger bindings. diff --git a/extensions/relay/notifications/progress.ts b/extensions/relay/notifications/progress.ts index 407a773..50c5b8b 100644 --- a/extensions/relay/notifications/progress.ts +++ b/extensions/relay/notifications/progress.ts @@ -142,7 +142,13 @@ export function coalesceLiveProgressEntries(entries: ProgressActivityEntry[]): P const existing = milestones.get(key); if (existing) { existing.count = (existing.count ?? 1) + 1; - existing.at = Math.max(existing.at, entry.at); + if (entry.at >= existing.at) { + existing.text = entry.text; + existing.detail = entry.detail; + existing.delivery = entry.delivery; + existing.semanticKey = entry.semanticKey; + existing.at = entry.at; + } continue; } milestones.set(key, { ...entry }); @@ -152,7 +158,7 @@ export function coalesceLiveProgressEntries(entries: ProgressActivityEntry[]): P return ([...milestones.values(), ...latestVolatile] as CountedProgressActivityEntry[]) .sort((left, right) => left.at - right.at) .slice(-5) - .map((entry) => entry.count && entry.count > 1 ? { ...entry, text: `${entry.text} (${entry.count}×)` } : entry); + .map((entry) => entry.count && entry.count > 1 && entry.semanticKey !== "tool-progress" ? { ...entry, text: `${entry.text} (${entry.count}×)` } : entry); } export function formatProgressUpdate(entries: ProgressActivityEntry[], config: Pick, options: { header?: boolean; marker?: string } = {}): string | undefined { diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts new file mode 100644 index 0000000..4fcec7e --- /dev/null +++ b/extensions/relay/notifications/tool-progress.ts @@ -0,0 +1,310 @@ +import type { ProgressActivityEntry, TelegramTunnelConfig } from "../core/types.js"; +import { maxProgressMessageChars, sanitizeProgressText } from "./progress.js"; + +const MAX_TOOL_PROGRESS_RECORDS = 50; + +export type ToolProgressState = "active" | "completed" | "failed"; + +export interface ToolProgressLabel { + toolName: string; + label: string; + semanticKey: string; +} + +export interface ToolProgressRecord extends ToolProgressLabel { + toolCallId: string; + state: ToolProgressState; + startedAt: number; + updatedAt: number; + completedAt?: number; +} + +export interface ToolProgressAggregate { + toolName: string; + count: number; +} + +export interface ToolProgressFormattedRow { + state: ToolProgressState | "aggregate"; + text: string; + at: number; +} + +export interface ToolProgressAccumulatorSnapshot { + records: ToolProgressRecord[]; + aggregates: ToolProgressAggregate[]; +} + +export interface ToolProgressEventInput { + toolName?: unknown; + toolCallId?: unknown; + input?: unknown; + at?: number; +} + +export interface ToolProgressActivityOptions { + id: string; + at?: number; +} + +export class ToolProgressAccumulator { + private readonly records = new Map(); + private readonly missingIdBySemanticKey = new Map(); + private missingSequence = 0; + private revision = 0; + + reset(): void { + this.records.clear(); + this.missingIdBySemanticKey.clear(); + this.missingSequence = 0; + this.revision = 0; + } + + has(toolCallId: unknown): boolean { + if (typeof toolCallId !== "string" || !toolCallId.trim()) return false; + return this.records.has(toolCallId.trim()); + } + + start(event: ToolProgressEventInput, config: Pick): ToolProgressRecord | undefined { + const now = event.at ?? Date.now(); + const label = summarizeToolProgress(event.toolName, event.input, config); + if (!label) return undefined; + const toolCallId = this.stableToolCallId(event.toolCallId, label.semanticKey); + const existing = this.records.get(toolCallId); + const record: ToolProgressRecord = { + ...label, + toolCallId, + state: existing?.state === "failed" || existing?.state === "completed" ? existing.state : "active", + startedAt: existing?.startedAt ?? now, + updatedAt: now, + completedAt: existing?.completedAt, + }; + this.records.set(toolCallId, record); + this.pruneRecords(); + this.revision += 1; + return record; + } + + finish(event: ToolProgressEventInput & { failed?: boolean }, config: Pick): ToolProgressRecord | undefined { + const now = event.at ?? Date.now(); + const fallbackLabel = summarizeToolProgress(event.toolName, event.input, config); + if (!fallbackLabel) return undefined; + const toolCallId = this.finishToolCallId(event.toolCallId, fallbackLabel.semanticKey); + const existing = this.records.get(toolCallId); + const label = existing ?? fallbackLabel; + const record: ToolProgressRecord = { + toolCallId, + toolName: label.toolName, + label: label.label, + semanticKey: label.semanticKey, + state: event.failed ? "failed" : "completed", + startedAt: existing?.startedAt ?? now, + updatedAt: now, + completedAt: now, + }; + this.records.set(toolCallId, record); + this.pruneRecords(); + this.revision += 1; + return record; + } + + snapshot(): ToolProgressAccumulatorSnapshot { + const records = [...this.records.values()].sort((left, right) => left.updatedAt - right.updatedAt); + const aggregateMap = new Map(); + for (const record of records) { + const current = aggregateMap.get(record.toolName) ?? { toolName: record.toolName, count: 0 }; + current.count += 1; + aggregateMap.set(record.toolName, current); + } + return { + records, + aggregates: [...aggregateMap.values()].sort((left, right) => left.toolName.localeCompare(right.toolName)), + }; + } + + activity(options: ToolProgressActivityOptions, config: Pick): ProgressActivityEntry | undefined { + const detail = formatToolProgressCard(this.snapshot(), config); + if (!detail) return undefined; + const at = options.at ?? Date.now(); + return { + id: options.id, + kind: "tool", + text: "Tool progress", + detail, + at, + delivery: "milestone", + semanticKey: "tool-progress", + }; + } + + private stableToolCallId(toolCallId: unknown, semanticKey: string): string { + if (typeof toolCallId === "string" && toolCallId.trim()) return toolCallId.trim(); + const existing = this.missingIdBySemanticKey.get(semanticKey); + if (existing) return existing; + const generated = `missing-${++this.missingSequence}`; + this.missingIdBySemanticKey.set(semanticKey, generated); + return generated; + } + + private finishToolCallId(toolCallId: unknown, semanticKey: string): string { + if (typeof toolCallId === "string" && toolCallId.trim()) return toolCallId.trim(); + const existing = this.missingIdBySemanticKey.get(semanticKey); + if (existing && this.records.get(existing)?.state === "active") { + this.missingIdBySemanticKey.delete(semanticKey); + return existing; + } + return `missing-${++this.missingSequence}`; + } + + private pruneRecords(): void { + while (this.records.size > MAX_TOOL_PROGRESS_RECORDS) { + const oldest = [...this.records.values()].sort((left, right) => left.updatedAt - right.updatedAt)[0]; + if (!oldest) return; + this.records.delete(oldest.toolCallId); + for (const [semanticKey, toolCallId] of this.missingIdBySemanticKey) { + if (toolCallId === oldest.toolCallId) this.missingIdBySemanticKey.delete(semanticKey); + } + } + } +} + +export function createToolProgressAccumulator(): ToolProgressAccumulator { + return new ToolProgressAccumulator(); +} + +export function summarizeToolProgress( + toolName: unknown, + input: unknown, + config: Pick, +): ToolProgressLabel | undefined { + const tool = sanitizeToolName(toolName); + if (!tool) return undefined; + const rawDetail = summarizeToolIntent(tool, input); + const labelText = rawDetail ? `${tool}: ${rawDetail}` : tool; + const label = boundedToolLabel(labelText, config); + if (!label) return undefined; + return { + toolName: tool, + label, + semanticKey: semanticToolKey(tool, label), + }; +} + +export function formatToolProgressCard(snapshot: ToolProgressAccumulatorSnapshot, config: Pick): string | undefined { + if (snapshot.records.length === 0) return undefined; + const rows = toolProgressRows(snapshot); + const limit = maxProgressMessageChars(config); + const parts: string[] = []; + for (const row of rows) { + const next = [...parts, row.text].join(" · "); + if (next.length > limit) break; + parts.push(row.text); + } + if (parts.length === 0) return undefined; + const output = parts.join(" · "); + return output.length > limit ? `${output.slice(0, limit - 1).trimEnd()}…` : output; +} + +export function toolProgressRows(snapshot: ToolProgressAccumulatorSnapshot): ToolProgressFormattedRow[] { + const active = snapshot.records.filter((record) => record.state === "active").sort((left, right) => right.updatedAt - left.updatedAt); + const failed = snapshot.records.filter((record) => record.state === "failed").sort((left, right) => right.updatedAt - left.updatedAt); + const completed = snapshot.records.filter((record) => record.state === "completed").sort((left, right) => right.updatedAt - left.updatedAt); + const rows: ToolProgressFormattedRow[] = [ + ...active.slice(0, 3).map((record) => ({ state: record.state, text: `▶ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), + ...failed.slice(0, 2).map((record) => ({ state: record.state, text: `✕ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), + ...completed.slice(0, Math.max(0, 4 - active.length - failed.length)).map((record) => ({ state: record.state, text: `✓ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), + ]; + const aggregate = snapshot.aggregates.filter((entry) => entry.count > 1); + if (aggregate.length > 0) { + rows.push({ + state: "aggregate", + text: `tools: ${aggregate.map((entry) => `${entry.toolName}×${entry.count}`).join(" ")}`, + at: snapshot.records.at(-1)?.updatedAt ?? Date.now(), + }); + } + return rows; +} + +function summarizeToolIntent(toolName: string, input: unknown): string | undefined { + const args = objectInput(input); + switch (toolName) { + case "bash": + case "shell": + return firstCommandLine(stringField(args, ["command", "cmd", "script"])); + case "read": + return pathWithRange(args, ["path", "file", "filePath", "relativePath"]); + case "edit": + case "write": + return firstStringField(args, ["path", "file", "filePath", "relativePath"]); + case "grep": + case "rg": + case "ripgrep": { + const pattern = firstStringField(args, ["pattern", "query", "search", "regex"]); + const path = firstStringField(args, ["path", "dir", "directory", "cwd", "root"]); + if (pattern && path) return `${pattern} in ${path}`; + return pattern ?? path; + } + case "find": + case "ls": + case "list": + return firstStringField(args, ["path", "dir", "directory", "cwd", "root"]); + default: + return undefined; + } +} + +function objectInput(input: unknown): Record | undefined { + return input && typeof input === "object" && !Array.isArray(input) ? input as Record : undefined; +} + +function stringField(input: Record | undefined, names: string[]): string | undefined { + return firstStringField(input, names); +} + +function firstStringField(input: Record | undefined, names: string[]): string | undefined { + if (!input) return undefined; + for (const name of names) { + const value = input[name]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +function pathWithRange(input: Record | undefined, names: string[]): string | undefined { + const path = firstStringField(input, names); + if (!path || !input) return path; + const offset = numericField(input, ["offset", "line", "start", "startLine"]); + const limit = numericField(input, ["limit", "lines", "end", "endLine"]); + if (offset !== undefined && limit !== undefined) return `${path}:${offset}+${limit}`; + if (offset !== undefined) return `${path}:${offset}`; + return path; +} + +function numericField(input: Record, names: string[]): number | undefined { + for (const name of names) { + const value = input[name]; + if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value); + } + return undefined; +} + +function firstCommandLine(command: string | undefined): string | undefined { + return command?.split(/\r?\n/, 1)[0]?.trim() || undefined; +} + +function sanitizeToolName(toolName: unknown): string | undefined { + const raw = String(toolName ?? "").trim().toLowerCase(); + if (!raw) return undefined; + const sanitized = raw.replace(/[^a-z0-9_.:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48); + return sanitized || "tool"; +} + +function boundedToolLabel(label: string, config: Pick): string { + const maxLabelChars = Math.min(180, Math.max(48, Math.floor(maxProgressMessageChars(config) / 3))); + const sanitized = sanitizeProgressText(label, { ...config, maxProgressMessageChars: maxLabelChars }); + return sanitized.length > maxLabelChars ? `${sanitized.slice(0, maxLabelChars - 1).trimEnd()}…` : sanitized; +} + +function semanticToolKey(toolName: string, label: string): string { + return `${toolName}:${label}`.toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 160); +} diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index 3a1a18b..b97d09a 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -14,6 +14,7 @@ import { extractStructuredAnswerMetadata } from "../core/guided-answer.js"; import type { DiscordRuntime } from "../adapters/discord/runtime.js"; import type { SlackRuntime } from "../adapters/slack/runtime.js"; import { appendRecentActivity, COMPACTION_PROGRESS_COMPLETED_TEXT, COMPACTION_PROGRESS_STARTED_TEXT, createProgressActivity, recentActivityLimit } from "../notifications/progress.js"; +import { createToolProgressAccumulator, type ToolProgressEventInput } from "../notifications/tool-progress.js"; import { authorityOutcomeAllowsDelivery, resolveChannelBindingAuthority, resolveTelegramBindingAuthority } from "../core/binding-authority.js"; import { formatRelayLifecycleNotification, type RelayLifecycleEventKind } from "../notifications/lifecycle.js"; import { formatRelayStatusLine, type RelayStatusLineBindingState, type RelayStatusLineChannel } from "./status-line.js"; @@ -188,6 +189,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { let latestTurnImages: LatestTurnImage[] = []; let latestTurnImageFileCandidates: LatestTurnImageFileCandidate[] = []; let progressSequence = 0; + const toolProgress = createToolProgressAccumulator(); let diagnosticsLogger: CommunicationDiagnosticsLogger | undefined; const pendingApprovalResolvers = new Map void>(); @@ -386,6 +388,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } async function stopAndClearRuntimes(ctx: ExtensionContext, options: { restartBrokerProcess?: boolean } = {}): Promise<{ telegramStopped: boolean; discordStopped: string[]; slackStopped: string[]; brokerRestarted: boolean }> { + toolProgress.reset(); let telegramStopped = false; let brokerRestarted = false; const discordStopped: string[] = []; @@ -587,6 +590,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { activeTurnCompletedAssistantText = undefined; latestTurnImages = []; latestTurnImageFileCandidates = []; + toolProgress.reset(); } function appendAudit(message: string, route?: SessionRoute): void { @@ -638,11 +642,6 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { return images; } - function toolLifecycleSemanticKey(prefix: string, toolCallId: unknown): string { - const stableId = typeof toolCallId === "string" && toolCallId.trim() ? toolCallId.trim() : `missing-${Date.now()}-${progressSequence + 1}`; - return `${prefix}:${stableId}`; - } - function recordProgress(kind: "lifecycle" | "tool" | "assistant" | "status" | "compaction", text: string, detail?: string, options: { delivery?: "milestone" | "volatile"; semanticKey?: string } = {}): void { if (!currentRoute) return; const config = configCache; @@ -660,6 +659,23 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { currentRoute.lastActivityAt = Date.now(); } + function recordToolProgressActivity(input: ToolProgressEventInput & { state: "active" | "completed" | "failed" }): void { + if (!currentRoute) return; + const config = configCache ?? { redactionPatterns: [], maxProgressMessageChars: undefined }; + if (input.state === "active") toolProgress.start(input, config); + else toolProgress.finish({ ...input, failed: input.state === "failed" }, config); + const entry = toolProgress.activity({ id: `${Date.now()}-${++progressSequence}`, at: input.at }, config); + if (!entry) return; + currentRoute.notification.progressEvent = entry; + appendRecentActivity(currentRoute.notification, entry, recentActivityLimit(configCache ?? {})); + currentRoute.lastActivityAt = Date.now(); + } + + function optionalToolEventInput(event: unknown): unknown { + if (!event || typeof event !== "object" || !("input" in event)) return undefined; + return (event as { input?: unknown }).input; + } + function persistBinding(binding: TelegramBindingMetadata | null, revoked = false, route?: SessionRoute): void { const data: BindingEntryData = { version: 1, @@ -1994,6 +2010,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { pi.on("session_shutdown", async (_event, ctx) => { latestContext = ctx; + toolProgress.reset(); closeConnectQrScreen = undefined; if (currentRoute) await notifyRelayLifecycle(ctx, "offline", currentRoute); if (currentRoute && configCache) { @@ -2106,32 +2123,56 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { activeTurnImages.push(...extractImageContent(event.message.content)); const toolText = extractTextContent(event.message.content as never); if (toolText) activeTurnImagePathTexts.push(toolText); - if (currentRoute.notification.lastStatus === "running") { + if (currentRoute.notification.lastStatus === "running" && !toolProgress.has(event.message.toolCallId)) { recordProgress("tool", "Processed tool result", undefined, { delivery: "volatile", semanticKey: `tool-result:${event.message.toolCallId ?? "unknown"}` }); publishRouteStateSoon(); } } }); + pi.on("tool_execution_start", async (event, ctx) => { + latestContext = ctx; + if (!currentRoute) return; + currentRoute.actions.context = ctx; + const input = optionalToolEventInput(event); + recordDiagnostic({ component: "runtime", event: "tool_execution_start", outcome: "received", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), hasInput: input !== undefined } }); + if (currentRoute.notification.lastStatus === "running") { + recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input, state: "active" }); + publishRouteStateSoon(); + } + }); + pi.on("tool_call", async (event, ctx) => { latestContext = ctx; if (!currentRoute) return; currentRoute.actions.context = ctx; + const recordAllowedProgress = () => { + if (currentRoute?.notification.lastStatus !== "running") return; + recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: event.input, state: "active" }); + publishRouteStateSoon(); + }; recordDiagnostic({ component: "runtime", event: "tool_call", outcome: "received", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), hasInput: event.input !== undefined } }); const config = configCache ?? (await ensureConfig(ctx, false).catch(() => undefined)); if (!config) return; const resolved = approvalConfig(config); const operation = classifyApprovalOperation({ toolName: String(event.toolName ?? ""), toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : undefined, input: event.input }, resolved); - if (!operation) return; + if (!operation) { + recordAllowedProgress(); + return; + } const requester = currentRoute.remoteRequester; if (!requester) { if (currentRoute.remoteRequesterActiveTurn) { await appendApprovalAudit(config, { kind: "failed", sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, summary: operation.summary, detail: "No active remote requester for approval target." }); return { block: true, reason: "Approval required, but no active remote requester is available." }; } + recordAllowedProgress(); + return; + } + if (!currentRoute.remoteRequesterActiveTurn) { + recordAllowedProgress(); return; } - if (!currentRoute.remoteRequesterActiveTurn) return; const active = await approvalRequesterBindingIsActive(currentRoute, requester, new TunnelStateStore(config.stateDir)); if (!active) { await appendApprovalAudit(config, { kind: "failed", sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, requester, summary: operation.summary, detail: "Approval requester binding is inactive." }); @@ -2142,6 +2183,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const store = new TunnelStateStore(config.stateDir); await store.markApprovalGrantUsed(grant.grantId); await appendApprovalAudit(config, { kind: "grant-used", grantId: grant.grantId, sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, requester, summary: operation.summary, expiresAt: grant.expiresAt }); + recordAllowedProgress(); return; } const request = createApprovalRequest({ route: currentRoute, requester, operation, timeoutMs: resolved.timeoutMs }); @@ -2163,6 +2205,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } return { block: true, reason: decision.message }; } + recordAllowedProgress(); }); pi.on("tool_execution_end", async (event, ctx) => { @@ -2170,13 +2213,14 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { if (!currentRoute) return; currentRoute.actions.context = ctx; recordDiagnostic({ component: "runtime", event: "tool_execution_end", outcome: event.isError ? "error" : "ok", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), isError: Boolean(event.isError) } }); + if (currentRoute.notification.lastStatus !== "running") return; if (event.isError) { currentRoute.notification.lastFailure = `Tool ${event.toolName} failed.`; - recordProgress("tool", "Tool failed", event.toolName, { delivery: "milestone", semanticKey: toolLifecycleSemanticKey("tool-failed", event.toolCallId) }); + recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: optionalToolEventInput(event), state: "failed" }); publishRouteStateSoon(); return; } - recordProgress("tool", "Tool completed", event.toolName, { delivery: "milestone", semanticKey: toolLifecycleSemanticKey("tool-completed", event.toolCallId) }); + recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: optionalToolEventInput(event), state: "completed" }); publishRouteStateSoon(); }); @@ -2254,6 +2298,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { details: { ...extraction.diagnostics, selectedStatus: status, finalTextSource, usedMessageLifecycleFallback: finalTextSource === "message-end-fallback", abortRequested: Boolean(currentRoute.notification.abortRequested), hadPriorFailure: Boolean(currentRoute.notification.lastFailure) }, }); recordProgress("lifecycle", status === "completed" ? "Pi task completed" : status === "aborted" ? "Pi task aborted" : "Pi task failed"); + toolProgress.reset(); if (status === "failed" && !currentRoute.notification.lastFailure) { currentRoute.notification.lastFailure = "The agent finished without a final assistant response."; } diff --git a/openspec/changes/improve-tool-call-progress-reporting/tasks.md b/openspec/changes/improve-tool-call-progress-reporting/tasks.md index a8e623c..8dd8d20 100644 --- a/openspec/changes/improve-tool-call-progress-reporting/tasks.md +++ b/openspec/changes/improve-tool-call-progress-reporting/tasks.md @@ -1,45 +1,45 @@ ## 1. Tool Summary Model -- [ ] 1.1 Add shared tool-progress types for current-turn tool records, lifecycle state, safe labels, aggregate counts, and formatted rows. -- [ ] 1.2 Implement pure allowlisted summarizers for built-in tools: bash, read, edit, write, grep/rg, find, and ls. -- [ ] 1.3 Ensure unknown/custom tools fall back to conservative sanitized tool-name labels without serializing arbitrary args. -- [ ] 1.4 Apply existing redaction, normalization, and progress length bounds before storing tool labels or semantic keys. -- [ ] 1.5 Add unit tests proving tool labels omit outputs, file contents, replacement text, raw transcripts, pairing codes, destination ids, and configured secret patterns. +- [x] 1.1 Add shared tool-progress types for current-turn tool records, lifecycle state, safe labels, aggregate counts, and formatted rows. +- [x] 1.2 Implement pure allowlisted summarizers for built-in tools: bash, read, edit, write, grep/rg, find, and ls. +- [x] 1.3 Ensure unknown/custom tools fall back to conservative sanitized tool-name labels without serializing arbitrary args. +- [x] 1.4 Apply existing redaction, normalization, and progress length bounds before storing tool labels or semantic keys. +- [x] 1.5 Add unit tests proving tool labels omit outputs, file contents, replacement text, raw transcripts, pairing codes, destination ids, and configured secret patterns. ## 2. Turn-Scoped Accumulator and Formatting -- [ ] 2.1 Implement a current-turn tool progress accumulator keyed by `toolCallId` with active, completed, failed, and count state. -- [ ] 2.2 Reset accumulator state on agent start, terminal end/failure/abort, route unregister, runtime stop/restart, and session changes. -- [ ] 2.3 Implement compact tool-progress card formatting that prioritizes active tools, recent failed/completed tools, and aggregate counts within `maxProgressMessageChars`. -- [ ] 2.4 Preserve existing live-progress coalescing and rate limiting while replacing generic repeated tool milestones with aggregated tool cards. -- [ ] 2.5 Add unit tests for repeated calls, active/recent prioritization, failed tool rows, count formatting, and truncation behavior. +- [x] 2.1 Implement a current-turn tool progress accumulator keyed by `toolCallId` with active, completed, failed, and count state. +- [x] 2.2 Reset accumulator state on agent start, terminal end/failure/abort, route unregister, runtime stop/restart, and session changes. +- [x] 2.3 Implement compact tool-progress card formatting that prioritizes active tools, recent failed/completed tools, and aggregate counts within `maxProgressMessageChars`. +- [x] 2.4 Preserve existing live-progress coalescing and rate limiting while replacing generic repeated tool milestones with aggregated tool cards. +- [x] 2.5 Add unit tests for repeated calls, active/recent prioritization, failed tool rows, count formatting, and truncation behavior. ## 3. Runtime Event Integration -- [ ] 3.1 Wire `tool_call` and/or `tool_execution_start` into the accumulator with safe summarized intent. -- [ ] 3.2 Wire `tool_execution_end` into completion/failure state without including raw result payloads. -- [ ] 3.3 Keep `message_end` tool-result bookkeeping volatile/verbose-only or suppress it when a matching tool lifecycle record exists. -- [ ] 3.4 Preserve approval-gate behavior and authorization boundaries in `tool_call` handlers before adding progress side effects. -- [ ] 3.5 Add integration tests for bash, read, edit/write, search/list, failed tools, duplicate tool events, and missing lifecycle fields. +- [x] 3.1 Wire `tool_call` and/or `tool_execution_start` into the accumulator with safe summarized intent. +- [x] 3.2 Wire `tool_execution_end` into completion/failure state without including raw result payloads. +- [x] 3.3 Keep `message_end` tool-result bookkeeping volatile/verbose-only or suppress it when a matching tool lifecycle record exists. +- [x] 3.4 Preserve approval-gate behavior and authorization boundaries in `tool_call` handlers before adding progress side effects. +- [x] 3.5 Add integration tests for bash, read, edit/write, search/list, failed tools, duplicate tool events, and missing lifecycle fields. ## 4. Adapter and Broker Parity -- [ ] 4.1 Verify Telegram direct runtime edits the improved live tool card in place and falls back safely when edit fails. -- [ ] 4.2 Verify Telegram broker runtime emits equivalent improved tool progress content and does not persist unsafe tool args or outputs. -- [ ] 4.3 Verify Slack and Discord receive the same bounded coalesced tool summaries through snapshot fallback. -- [ ] 4.4 Verify paused, revoked, moved, state-unavailable, and destination-mismatch binding checks still suppress protected progress delivery. -- [ ] 4.5 Add parity tests for Telegram direct, broker, Slack, and Discord progress-mode behavior. +- [x] 4.1 Verify Telegram direct runtime edits the improved live tool card in place and falls back safely when edit fails. +- [x] 4.2 Verify Telegram broker runtime emits equivalent improved tool progress content and does not persist unsafe tool args or outputs. +- [x] 4.3 Verify Slack and Discord receive the same bounded coalesced tool summaries through snapshot fallback. +- [x] 4.4 Verify paused, revoked, moved, state-unavailable, and destination-mismatch binding checks still suppress protected progress delivery. +- [x] 4.5 Add parity tests for Telegram direct, broker, Slack, and Discord progress-mode behavior. ## 5. Progress Mode UX and Documentation -- [ ] 5.1 Verify normal mode receives safe low-noise tool summaries and no repeated generic `Tool completed — ` stream. -- [ ] 5.2 Verify verbose mode can include additional safe tool lifecycle detail while remaining redacted, bounded, and coalesced. -- [ ] 5.3 Verify completion-only suppresses ordinary tool progress while preserving terminal output and allowed compaction notices. -- [ ] 5.4 Verify quiet suppresses all tool progress. -- [ ] 5.5 Update README/help text with examples of improved tool-progress reporting and progress-mode expectations. +- [x] 5.1 Verify normal mode receives safe low-noise tool summaries and no repeated generic `Tool completed — ` stream. +- [x] 5.2 Verify verbose mode can include additional safe tool lifecycle detail while remaining redacted, bounded, and coalesced. +- [x] 5.3 Verify completion-only suppresses ordinary tool progress while preserving terminal output and allowed compaction notices. +- [x] 5.4 Verify quiet suppresses all tool progress. +- [x] 5.5 Update README/help text with examples of improved tool-progress reporting and progress-mode expectations. ## 6. Validation -- [ ] 6.1 Run `npm run typecheck`. -- [ ] 6.2 Run `npm test`. -- [ ] 6.3 Run `openspec validate improve-tool-call-progress-reporting --strict`. +- [x] 6.1 Run `npm run typecheck`. +- [x] 6.2 Run `npm test`. +- [x] 6.3 Run `openspec validate improve-tool-call-progress-reporting --strict`. diff --git a/tests/broker-process.test.ts b/tests/broker-process.test.ts index b095ad5..6f66af8 100644 --- a/tests/broker-process.test.ts +++ b/tests/broker-process.test.ts @@ -1069,7 +1069,7 @@ describe("telegram broker process", () => { sessionLabel: binding.sessionLabel, online: true, busy: true, - notification: { lastStatus: "running", progressEvent: { id: "tool", kind: "tool", text: "Tool completed", detail: "bash", at: Date.now() } }, + notification: { lastStatus: "running", progressEvent: { id: "tool", kind: "tool", text: "Tool progress", detail: "✓ bash: npm test", semanticKey: "tool-progress", at: Date.now() } }, binding, }, }); @@ -1084,7 +1084,7 @@ describe("telegram broker process", () => { sessionLabel: binding.sessionLabel, online: true, busy: true, - notification: { lastStatus: "running", progressEvent: { id: "tool-2", kind: "tool", text: "Tool completed", detail: "read", at: Date.now() + 1 } }, + notification: { lastStatus: "running", progressEvent: { id: "tool-2", kind: "tool", text: "Tool progress", detail: "✓ read: extensions/relay/runtime/extension-runtime.ts · tools: bash×1 read×1", semanticKey: "tool-progress", at: Date.now() + 1 } }, binding, }, }); @@ -1097,7 +1097,8 @@ describe("telegram broker process", () => { const sends = outbox.filter((entry): entry is TestOutboxMessage => entry.method === "sendMessage"); const edits = outbox.filter((entry): entry is TestOutboxEditMessage => entry.method === "editMessageText"); expect(sends).toHaveLength(1); - expect(sends[0]?.text).toContain("Tool completed"); + expect(sends[0]?.text).toContain("Tool progress"); + expect(sends[0]?.text).toContain("npm test"); expect(sends[0]?.text).not.toContain("Pi progress"); expect(sends[0]?.text).not.toContain("Processed tool result"); expect(edits).toHaveLength(1); diff --git a/tests/discord-runtime.test.ts b/tests/discord-runtime.test.ts index fd3deb6..4142156 100644 --- a/tests/discord-runtime.test.ts +++ b/tests/discord-runtime.test.ts @@ -230,14 +230,15 @@ describe("DiscordRuntime", () => { await runtime.start(); - session.notification.progressEvent = { id: "tool-progress", kind: "tool", text: "Running tests", at: Date.now() }; + session.notification.progressEvent = { id: "tool-progress", kind: "tool", text: "Tool progress", detail: "▶ bash: npm test", semanticKey: "tool-progress", at: Date.now() }; await runtime.registerRoute(session); session.notification.progressEvent = { id: "assistant-progress", kind: "assistant", text: "Drafting response", at: Date.now() }; await runtime.registerRoute(session); await vi.waitFor(() => expect(ops.messages).toHaveLength(1)); expect(ops.messages).toHaveLength(1); - expect(ops.messages[0]?.content).toContain("Running tests"); + expect(ops.messages[0]?.content).toContain("Tool progress"); + expect(ops.messages[0]?.content).toContain("npm test"); expect(ops.messages[0]?.content).not.toContain("Pi progress"); expect(ops.messages[0]?.content).not.toContain("Drafting response"); }); @@ -264,17 +265,17 @@ describe("DiscordRuntime", () => { }); await runtime.start(); - session.notification.progressEvent = { id: "discord-live-progress-1", kind: "tool", text: "Compile", at: Date.now() }; + session.notification.progressEvent = { id: "discord-live-progress-1", kind: "tool", text: "Tool progress", detail: "▶ bash: npm test", semanticKey: "tool-progress", at: Date.now() }; await runtime.registerRoute(session); await vi.waitFor(() => expect(ops.messages).toHaveLength(1)); const firstMessageId = ops.messageIds.at(-1); - session.notification.progressEvent = { id: "discord-live-progress-2", kind: "tool", text: "Compile tests", at: Date.now() }; + session.notification.progressEvent = { id: "discord-live-progress-2", kind: "tool", text: "Tool progress", detail: "▶ edit: tests/discord-runtime.test.ts", semanticKey: "tool-progress", at: Date.now() }; await runtime.registerRoute(session); await vi.waitFor(() => expect(ops.edits).toHaveLength(1)); expect(ops.messages).toHaveLength(1); - expect(ops.edits[0]).toMatchObject({ channelId: "dm1", messageId: firstMessageId, content: expect.stringContaining("Compile tests") }); + expect(ops.edits[0]).toMatchObject({ channelId: "dm1", messageId: firstMessageId, content: expect.stringContaining("edit: tests/discord-runtime.test.ts") }); }); it("falls back to a new Discord progress snapshot when edit updates fail", async () => { diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 8611dff..eb37ee6 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -2902,19 +2902,111 @@ describe("PiRelay integration behavior", () => { await pi.emit("tool_execution_end", { toolCallId: "tool-1", toolName: "bash", result: {}, isError: false }, context); expect(route.notification.progressEvent).toMatchObject({ kind: "tool", - text: "Tool completed", - detail: "bash", + text: "Tool progress", + detail: expect.stringContaining("✓ bash"), delivery: "milestone", - semanticKey: "tool-completed:tool-1", + semanticKey: "tool-progress", }); await pi.emit("tool_execution_end", { toolName: "read", result: {}, isError: false }, context); const missingIdKey = route.notification.progressEvent?.semanticKey; - expect(missingIdKey).toMatch(/^tool-completed:missing-/); + expect(missingIdKey).toBe("tool-progress"); expect(missingIdKey).not.toContain("undefined"); await pi.emit("tool_execution_end", { toolName: "read", result: {}, isError: false }, context); - expect(route.notification.progressEvent?.semanticKey).toMatch(/^tool-completed:missing-/); - expect(route.notification.progressEvent?.semanticKey).not.toBe(missingIdKey); + expect(route.notification.progressEvent?.semanticKey).toBe("tool-progress"); + expect(route.notification.progressEvent?.semanticKey).toBe(missingIdKey); + expect(route.notification.progressEvent?.detail).toContain("read×2"); + }); + + it("records safe aggregated tool progress for built-in tool lifecycle events", async () => { + const config = await createRuntimeConfig("pi-tool-progress-summary-"); + await writeFile(config.configPath!, JSON.stringify({ + botToken: config.botToken, + stateDir: config.stateDir, + redactionPatterns: ["SECRET_[A-Z]+", "123456789"], + recentActivityLimit: 30, + })); + vi.stubEnv("TELEGRAM_BOT_TOKEN", config.botToken); + vi.stubEnv("PI_TELEGRAM_TUNNEL_STATE_DIR", config.stateDir); + + const registeredRoutes = new Map(); + const fakeRuntime: TunnelRuntime = { + setup: undefined, + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + ensureSetup: vi.fn(async () => ({ + botId: 123456, + botUsername: "pi_test_bot", + botDisplayName: "Pi Test Bot", + validatedAt: new Date().toISOString(), + })), + registerRoute: vi.fn(async (route: SessionRoute) => { + registeredRoutes.set(route.sessionKey, route); + }), + unregisterRoute: vi.fn(async () => undefined), + getStatus: vi.fn(() => undefined), + sendToBoundChat: vi.fn(async () => undefined), + }; + + vi.doMock("../extensions/relay/adapters/telegram/runtime.js", () => ({ + getOrCreateTunnelRuntime: () => fakeRuntime, + sendSessionNotification: vi.fn(async () => undefined), + })); + + const { default: relayExtension } = await import("../extensions/relay/index.js"); + const pi = createMockPi(); + const { context } = createMockContext("tool-progress-summary"); + relayExtension(pi.api as any); + + await pi.emit("session_start", { reason: "startup" }, context); + const route = [...registeredRoutes.values()][0]!; + await pi.emit("agent_start", {}, context); + + await pi.emit("tool_call", { toolName: "bash", toolCallId: "bash-1", input: { command: "npm test\necho SECRET_TOKEN", output: "raw output" } }, context); + expect(route.notification.progressEvent).toMatchObject({ text: "Tool progress", detail: expect.stringContaining("▶ bash: npm test") }); + expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_TOKEN"); + expect(JSON.stringify(route.notification.progressEvent)).not.toContain("raw output"); + + await pi.emit("tool_execution_end", { toolName: "bash", toolCallId: "bash-1", result: "SECRET_RESULT", isError: false }, context); + expect(route.notification.progressEvent?.detail).toContain("✓ bash: npm test"); + expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_RESULT"); + + await pi.emit("tool_call", { toolName: "read", toolCallId: "read-1", input: { path: "extensions/relay/runtime/extension-runtime.ts", content: "SECRET_FILE" } }, context); + await pi.emit("tool_call", { toolName: "edit", toolCallId: "edit-1", input: { path: "tests/integration.test.ts", oldText: "SECRET_OLD", newText: "SECRET_NEW" } }, context); + await pi.emit("tool_call", { toolName: "write", toolCallId: "write-1", input: { path: "README.md", content: "SECRET_CONTENT" } }, context); + await pi.emit("tool_call", { toolName: "rg", toolCallId: "rg-1", input: { pattern: "SECRET_PATTERN", path: "extensions" } }, context); + await new Promise((resolve) => setTimeout(resolve, 2)); + await pi.emit("tool_call", { toolName: "find", toolCallId: "find-1", input: { path: "src", chatId: "123456789" } }, context); + expect(route.notification.progressEvent?.detail).toContain("find: src"); + await new Promise((resolve) => setTimeout(resolve, 2)); + await pi.emit("tool_call", { toolName: "ls", toolCallId: "ls-1", input: { dir: "docs", transcript: "raw transcript" } }, context); + expect(route.notification.progressEvent?.detail).toContain("ls: docs"); + await new Promise((resolve) => setTimeout(resolve, 2)); + await pi.emit("tool_call", { toolName: "customTool", toolCallId: "custom-1", input: { prompt: "SECRET_PROMPT", destinationId: "123456789" } }, context); + expect(route.notification.progressEvent?.detail).toContain("customtool"); + await pi.emit("tool_execution_end", { toolName: "rg", toolCallId: "rg-1", isError: true, result: "SECRET_ERROR" }, context); + await pi.emit("tool_execution_end", { toolName: "read", isError: false, result: "missing id 1" }, context); + await pi.emit("tool_execution_end", { toolName: "read", isError: false, result: "missing id 2" }, context); + + const serialized = JSON.stringify(route.notification.recentActivity); + expect(serialized).toContain("edit: tests/integration.test.ts"); + expect(serialized).toContain("write: README.md"); + expect(serialized).toContain("rg: [redacted] in extensions"); + expect(serialized).toContain("customtool"); + expect(route.notification.progressEvent?.detail).toContain("read×"); + expect(serialized).not.toContain("SECRET_TOKEN"); + expect(serialized).not.toContain("SECRET_FILE"); + expect(serialized).not.toContain("SECRET_CONTENT"); + expect(serialized).not.toContain("SECRET_PROMPT"); + expect(serialized).not.toContain("SECRET_ERROR"); + expect(serialized).not.toContain("123456789"); + expect(serialized).not.toContain("raw transcript"); + + await pi.emit("agent_end", { messages: [] }, context); + expect(route.notification.lastStatus).toBe("failed"); + const terminalProgress = { ...route.notification.progressEvent }; + await pi.emit("tool_execution_end", { toolName: "bash", toolCallId: "late-tool", result: "late output", isError: false }, context); + expect(route.notification.progressEvent).toEqual(terminalProgress); }); it("does not use stream-only drafts as final-output fallback", async () => { diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 5eab82b..0562036 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -16,6 +16,7 @@ import { } from "../extensions/relay/notifications/progress.js"; import { deliverLiveProgress } from "../extensions/relay/notifications/progress-delivery.js"; import type { LiveProgressDeliveryState } from "../extensions/relay/notifications/progress-delivery.js"; +import { createToolProgressAccumulator, formatToolProgressCard, summarizeToolProgress, toolProgressRows } from "../extensions/relay/notifications/tool-progress.js"; import type { SessionNotificationState, TelegramBindingMetadata, TelegramTunnelConfig } from "../extensions/relay/core/types.js"; const config: Pick = { @@ -93,6 +94,16 @@ describe("progress helpers", () => { expect(progressSemanticKey(toolA)).toBe(progressSemanticKey(toolB)); }); + it("coalesces repeated tool-progress cards to the latest bounded card", () => { + const first = createProgressActivity({ id: "tool-card-1", kind: "tool", text: "Tool progress", detail: "▶ bash: npm test", semanticKey: "tool-progress", at: 1 }, config)!; + const second = createProgressActivity({ id: "tool-card-2", kind: "tool", text: "Tool progress", detail: "✓ bash: npm test · ▶ edit: tests/progress.test.ts", semanticKey: "tool-progress", at: 2 }, config)!; + const coalesced = coalesceLiveProgressEntries([first, second]); + + expect(coalesced).toHaveLength(1); + expect(coalesced[0]?.text).toBe("Tool progress"); + expect(coalesced[0]?.detail).toContain("edit: tests/progress.test.ts"); + }); + it("keeps the newest volatile progress even when entries are out of order", () => { const newer = createProgressActivity({ id: "newer", kind: "assistant", text: "Model update", detail: "new draft", at: 20, delivery: "volatile" }, config)!; const older = createProgressActivity({ id: "older", kind: "assistant", text: "Model update", detail: "old draft", at: 10, delivery: "volatile" }, config)!; @@ -123,6 +134,73 @@ describe("progress helpers", () => { }); }); +describe("tool progress helpers", () => { + it("summarizes allowlisted tool intent without leaking unallowlisted payloads", () => { + const safeConfig = { ...config, redactionPatterns: ["SECRET_[A-Z]+", "123456789"] }; + + expect(summarizeToolProgress("bash", { command: "npm test\necho SECRET_TOKEN", output: "do not show" }, safeConfig)?.label).toBe("bash: npm test"); + expect(summarizeToolProgress("read", { path: "extensions/relay/runtime/extension-runtime.ts", content: "SECRET_FILE" }, safeConfig)?.label).toContain("read: extensions/relay/runtime/extension"); + expect(summarizeToolProgress("edit", { path: "tests/integration.test.ts", oldText: "SECRET_OLD", newText: "SECRET_NEW" }, safeConfig)?.label).toBe("edit: tests/integration.test.ts"); + expect(summarizeToolProgress("write", { path: "README.md", content: "SECRET_CONTENT" }, safeConfig)?.label).toBe("write: README.md"); + expect(summarizeToolProgress("rg", { pattern: "botToken=SECRET_VALUE", path: "extensions" }, safeConfig)?.label).toBe("rg: botToken=[redacted] in extensions"); + expect(summarizeToolProgress("find", { path: "123456789", transcript: "raw transcript" }, safeConfig)?.label).toBe("find: [redacted]"); + expect(summarizeToolProgress("ls", { dir: "src", chatId: "123456789" }, safeConfig)?.label).toBe("ls: src"); + }); + + it("keeps unknown tools conservative", () => { + const label = summarizeToolProgress("custom_secret_tool", { prompt: "SECRET_PROMPT", chatId: "123456789", args: ["raw"] }, { ...config, redactionPatterns: ["SECRET_[A-Z]+", "123456789"] }); + + expect(label).toMatchObject({ toolName: "custom_secret_tool", label: "custom_secret_tool" }); + expect(JSON.stringify(label)).not.toContain("SECRET_PROMPT"); + expect(JSON.stringify(label)).not.toContain("123456789"); + expect(JSON.stringify(label)).not.toContain("raw"); + }); + + it("aggregates active, completed, failed, repeated, and truncated tool progress", () => { + const accumulator = createToolProgressAccumulator(); + const safeConfig = { ...config, maxProgressMessageChars: 160 }; + + accumulator.start({ toolName: "bash", toolCallId: "bash-1", input: { command: "npm test" }, at: 1 }, safeConfig); + accumulator.finish({ toolName: "bash", toolCallId: "bash-1", failed: false, at: 2 }, safeConfig); + accumulator.start({ toolName: "bash", toolCallId: "bash-2", input: { command: "npm run typecheck" }, at: 3 }, safeConfig); + accumulator.start({ toolName: "read", toolCallId: "read-1", input: { path: "extensions/relay/notifications/progress.ts" }, at: 4 }, safeConfig); + accumulator.finish({ toolName: "read", toolCallId: "read-1", failed: true, at: 5 }, safeConfig); + + const snapshot = accumulator.snapshot(); + const rows = toolProgressRows(snapshot); + const card = formatToolProgressCard(snapshot, safeConfig); + const activity = accumulator.activity({ id: "tools", at: 6 }, safeConfig); + + expect(rows.map((row) => row.text).join("\n")).toContain("▶ bash: npm run typecheck"); + expect(rows.map((row) => row.text).join("\n")).toContain("✕ read: extensions/relay/notifications/progress.ts"); + expect(rows.at(-1)?.text).toContain("bash×2"); + expect(card).toContain("bash"); + expect(card!.length).toBeLessThanOrEqual(160); + expect(activity).toMatchObject({ kind: "tool", text: "Tool progress", delivery: "milestone" }); + }); + + it("reuses missing tool-call identity by safe semantic label", () => { + const accumulator = createToolProgressAccumulator(); + accumulator.start({ toolName: "read", input: { path: "README.md" }, at: 1 }, config); + accumulator.finish({ toolName: "read", input: { path: "README.md" }, failed: false, at: 2 }, config); + + expect(accumulator.snapshot().records).toHaveLength(1); + expect(formatToolProgressCard(accumulator.snapshot(), config)).toContain("✓ read: README.md"); + }); + + it("bounds retained current-turn tool records", () => { + const accumulator = createToolProgressAccumulator(); + for (let index = 0; index < 55; index += 1) { + accumulator.start({ toolName: "read", toolCallId: `read-${index}`, input: { path: `file-${index}.ts` }, at: index }, config); + } + + const snapshot = accumulator.snapshot(); + expect(snapshot.records).toHaveLength(50); + expect(snapshot.records[0]?.label).toContain("file-5.ts"); + expect(snapshot.records.at(-1)?.label).toContain("file-54.ts"); + }); +}); + describe("deliverLiveProgress helper", () => { it("suppresses unchanged progress snapshots", async () => { const updates: string[] = []; diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 43f24e0..f27f2a6 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -3068,18 +3068,19 @@ describe("InProcessTunnelRuntime", () => { sendPlainText: async () => undefined, }; - route.notification.progressEvent = createProgressActivity({ id: "edit-1", kind: "tool", text: "Running tests", at: Date.now() }, config); + route.notification.progressEvent = createProgressActivity({ id: "edit-1", kind: "tool", text: "Tool progress", detail: "▶ bash: npm test", semanticKey: "tool-progress", at: Date.now() }, config); (runtime as any).syncProgressDelivery(route); await vi.runOnlyPendingTimersAsync(); await vi.waitFor(() => expect(sends).toHaveLength(1)); - expect(sends[0]).toContain("Running tests"); + expect(sends[0]).toContain("Tool progress"); + expect(sends[0]).toContain("npm test"); - route.notification.progressEvent = createProgressActivity({ id: "edit-2", kind: "tool", text: "Editing files", at: Date.now() + 1 }, config); + route.notification.progressEvent = createProgressActivity({ id: "edit-2", kind: "tool", text: "Tool progress", detail: "▶ edit: tests/runtime.test.ts · tools: bash×1 edit×1", semanticKey: "tool-progress", at: Date.now() + 1 }, config); (runtime as any).syncProgressDelivery(route); await vi.runOnlyPendingTimersAsync(); await vi.waitFor(() => expect(edits).toHaveLength(1)); - expect(edits[0]).toEqual(expect.objectContaining({ messageId: 77, text: expect.stringContaining("Editing files") })); + expect(edits[0]).toEqual(expect.objectContaining({ messageId: 77, text: expect.stringContaining("edit: tests/runtime.test.ts") })); }); it("falls back to a new Telegram progress snapshot when editing fails", async () => { @@ -3116,17 +3117,17 @@ describe("InProcessTunnelRuntime", () => { sendPlainText: async () => undefined, }; - route.notification.progressEvent = createProgressActivity({ id: "fallback-1", kind: "tool", text: "Running tests", at: Date.now() }, config); + route.notification.progressEvent = createProgressActivity({ id: "fallback-1", kind: "tool", text: "Tool progress", detail: "▶ bash: npm test", semanticKey: "tool-progress", at: Date.now() }, config); (runtime as any).syncProgressDelivery(route); await vi.runOnlyPendingTimersAsync(); await vi.waitFor(() => expect(sends).toHaveLength(1)); - route.notification.progressEvent = createProgressActivity({ id: "fallback-2", kind: "tool", text: "Editing files", at: Date.now() + 1 }, config); + route.notification.progressEvent = createProgressActivity({ id: "fallback-2", kind: "tool", text: "Tool progress", detail: "▶ edit: tests/runtime.test.ts", semanticKey: "tool-progress", at: Date.now() + 1 }, config); (runtime as any).syncProgressDelivery(route); await vi.runOnlyPendingTimersAsync(); await vi.waitFor(() => expect(sends).toHaveLength(2)); - expect(sends[1]).toContain("Editing files"); + expect(sends[1]).toContain("edit: tests/runtime.test.ts"); }); it("clears Telegram progress state when queued progress becomes non-deliverable", async () => { diff --git a/tests/slack-runtime.test.ts b/tests/slack-runtime.test.ts index 91b3a15..bdeb678 100644 --- a/tests/slack-runtime.test.ts +++ b/tests/slack-runtime.test.ts @@ -804,11 +804,12 @@ describe("SlackRuntime foundations", () => { }); const runtime = new SlackRuntime(runtimeConfig, { operations }); - testRoute.notification.progressEvent = { id: "progress-1", kind: "tool", text: "Running tests", at: Date.now() }; + testRoute.notification.progressEvent = { id: "progress-1", kind: "tool", text: "Tool progress", detail: "▶ bash: npm test", semanticKey: "tool-progress", at: Date.now() }; await runtime.registerRoute(testRoute); await waitForSlackRuntimeCondition(() => operations.posts.length > 0); - expect(operations.posts.at(-1)).toMatchObject({ channel: "D1", threadTs: "parent-progress", text: expect.stringContaining("Running tests") }); + expect(operations.posts.at(-1)).toMatchObject({ channel: "D1", threadTs: "parent-progress", text: expect.stringContaining("Tool progress") }); + expect(operations.posts.at(-1)?.text).toContain("npm test"); expect(operations.posts.at(-1)?.text).not.toContain("Pi progress"); }); @@ -833,18 +834,18 @@ describe("SlackRuntime foundations", () => { }); const runtime = new SlackRuntime(runtimeConfig, { operations }); - testRoute.notification.progressEvent = { id: "progress-live-1", kind: "tool", text: "Compiling", at: Date.now() }; + testRoute.notification.progressEvent = { id: "progress-live-1", kind: "tool", text: "Tool progress", detail: "▶ bash: npm test", semanticKey: "tool-progress", at: Date.now() }; await runtime.registerRoute(testRoute); await waitForSlackRuntimeCondition(() => operations.posts.length > 0); expect(operations.posts.at(-1)).toMatchObject({ channel: "D1", threadTs: "parent-progress" }); - testRoute.notification.progressEvent = { id: "progress-live-2", kind: "tool", text: "Running tests", at: Date.now() }; + testRoute.notification.progressEvent = { id: "progress-live-2", kind: "tool", text: "Tool progress", detail: "▶ edit: tests/slack-runtime.test.ts", semanticKey: "tool-progress", at: Date.now() }; await runtime.registerRoute(testRoute); await waitForSlackRuntimeCondition(() => operations.updates.length > 0); expect(operations.posts).toHaveLength(1); expect(operations.updates).toHaveLength(1); - expect(operations.updates[0]).toMatchObject({ channel: "D1", ts: "100.1", text: expect.stringContaining("Running tests") }); + expect(operations.updates[0]).toMatchObject({ channel: "D1", ts: "100.1", text: expect.stringContaining("edit: tests/slack-runtime.test.ts") }); }); it("falls back to a new Slack progress snapshot when edit updates fail", async () => { From ad15ebd8c7be0646105e0206293f6cce24f20981 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sun, 14 Jun 2026 21:52:46 +0200 Subject: [PATCH 02/17] chore(relay): remove dead tool-progress revision state --- extensions/relay/notifications/tool-progress.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index 4fcec7e..9cccd96 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -51,13 +51,11 @@ export class ToolProgressAccumulator { private readonly records = new Map(); private readonly missingIdBySemanticKey = new Map(); private missingSequence = 0; - private revision = 0; reset(): void { this.records.clear(); this.missingIdBySemanticKey.clear(); this.missingSequence = 0; - this.revision = 0; } has(toolCallId: unknown): boolean { @@ -81,7 +79,6 @@ export class ToolProgressAccumulator { }; this.records.set(toolCallId, record); this.pruneRecords(); - this.revision += 1; return record; } @@ -104,7 +101,6 @@ export class ToolProgressAccumulator { }; this.records.set(toolCallId, record); this.pruneRecords(); - this.revision += 1; return record; } From 2b47ba12ac571d8d9fa7852193a5d84a8b4b0ce4 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Mon, 15 Jun 2026 12:37:12 +0200 Subject: [PATCH 03/17] fix(relay): guard tool-progress config and route safety --- .../relay/notifications/tool-progress.ts | 15 ++++--- extensions/relay/runtime/extension-runtime.ts | 42 ++++++++++--------- tests/progress.test.ts | 14 +++++++ 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index 9cccd96..7051a8b 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -202,13 +202,16 @@ export function formatToolProgressCard(snapshot: ToolProgressAccumulatorSnapshot } export function toolProgressRows(snapshot: ToolProgressAccumulatorSnapshot): ToolProgressFormattedRow[] { - const active = snapshot.records.filter((record) => record.state === "active").sort((left, right) => right.updatedAt - left.updatedAt); - const failed = snapshot.records.filter((record) => record.state === "failed").sort((left, right) => right.updatedAt - left.updatedAt); - const completed = snapshot.records.filter((record) => record.state === "completed").sort((left, right) => right.updatedAt - left.updatedAt); + const activeRecords = snapshot.records.filter((record) => record.state === "active").sort((left, right) => right.updatedAt - left.updatedAt); + const failedRecords = snapshot.records.filter((record) => record.state === "failed").sort((left, right) => right.updatedAt - left.updatedAt); + const completedRecords = snapshot.records.filter((record) => record.state === "completed").sort((left, right) => right.updatedAt - left.updatedAt); + const activeRows = activeRecords.slice(0, 3); + const failedRows = failedRecords.slice(0, 2); + const completedRows = completedRecords.slice(0, Math.max(0, 4 - activeRows.length - failedRows.length)); const rows: ToolProgressFormattedRow[] = [ - ...active.slice(0, 3).map((record) => ({ state: record.state, text: `▶ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), - ...failed.slice(0, 2).map((record) => ({ state: record.state, text: `✕ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), - ...completed.slice(0, Math.max(0, 4 - active.length - failed.length)).map((record) => ({ state: record.state, text: `✓ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), + ...activeRows.map((record) => ({ state: record.state, text: `▶ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), + ...failedRows.map((record) => ({ state: record.state, text: `✕ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), + ...completedRows.map((record) => ({ state: record.state, text: `✓ ${record.label}`, at: record.updatedAt } satisfies ToolProgressFormattedRow)), ]; const aggregate = snapshot.aggregates.filter((entry) => entry.count > 1); if (aggregate.length > 0) { diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index b97d09a..12493db 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -659,15 +659,14 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { currentRoute.lastActivityAt = Date.now(); } - function recordToolProgressActivity(input: ToolProgressEventInput & { state: "active" | "completed" | "failed" }): void { + function recordToolProgressActivity(input: ToolProgressEventInput & { state: "active" | "completed" | "failed" }, config: Pick): void { if (!currentRoute) return; - const config = configCache ?? { redactionPatterns: [], maxProgressMessageChars: undefined }; if (input.state === "active") toolProgress.start(input, config); else toolProgress.finish({ ...input, failed: input.state === "failed" }, config); const entry = toolProgress.activity({ id: `${Date.now()}-${++progressSequence}`, at: input.at }, config); if (!entry) return; currentRoute.notification.progressEvent = entry; - appendRecentActivity(currentRoute.notification, entry, recentActivityLimit(configCache ?? {})); + appendRecentActivity(currentRoute.notification, entry, recentActivityLimit(config)); currentRoute.lastActivityAt = Date.now(); } @@ -2133,22 +2132,25 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { pi.on("tool_execution_start", async (event, ctx) => { latestContext = ctx; if (!currentRoute) return; + const currentSessionKey = currentRoute.sessionKey; currentRoute.actions.context = ctx; const input = optionalToolEventInput(event); + const config = configCache ?? (await ensureConfig(ctx, false).catch(() => undefined)); + if (!config) return; + if (!currentRoute || currentRoute.sessionKey !== currentSessionKey || currentRoute.notification.lastStatus !== "running") return; recordDiagnostic({ component: "runtime", event: "tool_execution_start", outcome: "received", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), hasInput: input !== undefined } }); - if (currentRoute.notification.lastStatus === "running") { - recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input, state: "active" }); - publishRouteStateSoon(); - } + recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input, state: "active" }, config); + publishRouteStateSoon(); }); pi.on("tool_call", async (event, ctx) => { latestContext = ctx; if (!currentRoute) return; + const currentSessionKey = currentRoute.sessionKey; currentRoute.actions.context = ctx; - const recordAllowedProgress = () => { - if (currentRoute?.notification.lastStatus !== "running") return; - recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: event.input, state: "active" }); + const recordAllowedProgress = (configToUse: Pick) => { + if (!currentRoute || currentRoute.sessionKey !== currentSessionKey || currentRoute.notification.lastStatus !== "running") return; + recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: event.input, state: "active" }, configToUse); publishRouteStateSoon(); }; recordDiagnostic({ component: "runtime", event: "tool_call", outcome: "received", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), hasInput: event.input !== undefined } }); @@ -2157,7 +2159,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const resolved = approvalConfig(config); const operation = classifyApprovalOperation({ toolName: String(event.toolName ?? ""), toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : undefined, input: event.input }, resolved); if (!operation) { - recordAllowedProgress(); + recordAllowedProgress(config); return; } const requester = currentRoute.remoteRequester; @@ -2166,11 +2168,11 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { await appendApprovalAudit(config, { kind: "failed", sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, summary: operation.summary, detail: "No active remote requester for approval target." }); return { block: true, reason: "Approval required, but no active remote requester is available." }; } - recordAllowedProgress(); + recordAllowedProgress(config); return; } if (!currentRoute.remoteRequesterActiveTurn) { - recordAllowedProgress(); + recordAllowedProgress(config); return; } const active = await approvalRequesterBindingIsActive(currentRoute, requester, new TunnelStateStore(config.stateDir)); @@ -2183,7 +2185,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const store = new TunnelStateStore(config.stateDir); await store.markApprovalGrantUsed(grant.grantId); await appendApprovalAudit(config, { kind: "grant-used", grantId: grant.grantId, sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, requester, summary: operation.summary, expiresAt: grant.expiresAt }); - recordAllowedProgress(); + recordAllowedProgress(config); return; } const request = createApprovalRequest({ route: currentRoute, requester, operation, timeoutMs: resolved.timeoutMs }); @@ -2205,25 +2207,27 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } return { block: true, reason: decision.message }; } - recordAllowedProgress(); + recordAllowedProgress(config); }); pi.on("tool_execution_end", async (event, ctx) => { latestContext = ctx; if (!currentRoute) return; + const currentSessionKey = currentRoute.sessionKey; currentRoute.actions.context = ctx; + const config = configCache ?? (await ensureConfig(ctx, false).catch(() => undefined)); + if (!config) return; recordDiagnostic({ component: "runtime", event: "tool_execution_end", outcome: event.isError ? "error" : "ok", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), isError: Boolean(event.isError) } }); - if (currentRoute.notification.lastStatus !== "running") return; + if (!currentRoute || currentRoute.sessionKey !== currentSessionKey || currentRoute.notification.lastStatus !== "running") return; if (event.isError) { currentRoute.notification.lastFailure = `Tool ${event.toolName} failed.`; - recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: optionalToolEventInput(event), state: "failed" }); + recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: optionalToolEventInput(event), state: "failed" }, config); publishRouteStateSoon(); return; } - recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: optionalToolEventInput(event), state: "completed" }); + recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: optionalToolEventInput(event), state: "completed" }, config); publishRouteStateSoon(); }); - pi.on("agent_end", async (event, ctx) => { latestContext = ctx; if (!currentRoute) return; diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 0562036..60e8552 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -179,6 +179,20 @@ describe("tool progress helpers", () => { expect(activity).toMatchObject({ kind: "tool", text: "Tool progress", delivery: "milestone" }); }); + it("keeps completed rows when active rows are capped", () => { + const accumulator = createToolProgressAccumulator(); + const safeConfig = { ...config, maxProgressMessageChars: 200 }; + + accumulator.start({ toolName: "bash", toolCallId: "bash-1", input: { command: "one" }, at: 1 }, safeConfig); + accumulator.start({ toolName: "read", toolCallId: "read-1", input: { path: "alpha" }, at: 2 }, safeConfig); + accumulator.start({ toolName: "edit", toolCallId: "edit-1", input: { path: "beta" }, at: 3 }, safeConfig); + accumulator.start({ toolName: "find", toolCallId: "find-1", input: { path: "gamma" }, at: 4 }, safeConfig); + accumulator.finish({ toolName: "ls", toolCallId: "ls-1", at: 5 }, safeConfig); + + const rows = toolProgressRows(accumulator.snapshot()); + expect(rows.some((row) => row.text.startsWith("✓ ls:") || row.text.startsWith("✓ ls"))).toBe(true); + }); + it("reuses missing tool-call identity by safe semantic label", () => { const accumulator = createToolProgressAccumulator(); accumulator.start({ toolName: "read", input: { path: "README.md" }, at: 1 }, config); From ee7a0aedb082b2ab51a5291ce053888b1b4d224d Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Fri, 19 Jun 2026 23:38:29 +0200 Subject: [PATCH 04/17] fix(relay): include recentActivityLimit in tool progress config typing --- extensions/relay/runtime/extension-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index 12493db..8c99805 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -2148,7 +2148,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { if (!currentRoute) return; const currentSessionKey = currentRoute.sessionKey; currentRoute.actions.context = ctx; - const recordAllowedProgress = (configToUse: Pick) => { + const recordAllowedProgress = (configToUse: Pick) => { if (!currentRoute || currentRoute.sessionKey !== currentSessionKey || currentRoute.notification.lastStatus !== "running") return; recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: event.input, state: "active" }, configToUse); publishRouteStateSoon(); From c5ecef1c4a936b25e5ce1e30c721013d1769564a Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 20 Jun 2026 09:43:53 +0200 Subject: [PATCH 05/17] fix(broker): avoid progress flush race from zero-delay timer --- extensions/relay/broker/process.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/relay/broker/process.js b/extensions/relay/broker/process.js index 8112188..a890f6e 100644 --- a/extensions/relay/broker/process.js +++ b/extensions/relay/broker/process.js @@ -1245,7 +1245,7 @@ function syncProgressDelivery(route) { if (state.timer) return; const interval = progressIntervalMsFor(mode, config); const elapsed = state.lastSentAt ? Date.now() - state.lastSentAt : interval; - const delay = Math.max(0, interval - elapsed); + const delay = Math.max(1, interval - elapsed); state.timer = setTimeout(() => { void flushProgress(route.sessionKey, route.binding.chatId, route.binding.userId, key); }, delay); From 6b7d191485e7faf5194fd81d5bb5289af07695e0 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 20 Jun 2026 09:58:49 +0200 Subject: [PATCH 06/17] fix(relay): keep tool failure signal when config unavailable --- extensions/relay/runtime/extension-runtime.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index 8c99805..76e698d 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -2216,7 +2216,13 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const currentSessionKey = currentRoute.sessionKey; currentRoute.actions.context = ctx; const config = configCache ?? (await ensureConfig(ctx, false).catch(() => undefined)); - if (!config) return; + if (!config) { + if (currentRoute && currentRoute.sessionKey === currentSessionKey && currentRoute.notification.lastStatus === "running" && event.isError) { + currentRoute.notification.lastFailure = `Tool ${event.toolName} failed.`; + publishRouteStateSoon(); + } + return; + } recordDiagnostic({ component: "runtime", event: "tool_execution_end", outcome: event.isError ? "error" : "ok", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), isError: Boolean(event.isError) } }); if (!currentRoute || currentRoute.sessionKey !== currentSessionKey || currentRoute.notification.lastStatus !== "running") return; if (event.isError) { From 849cee9c1dea12bf9305331a34597039b2af92bc Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Fri, 3 Jul 2026 21:16:55 +0200 Subject: [PATCH 07/17] fix(relay): address progress review findings --- extensions/relay/notifications/tool-progress.ts | 12 +++++++++--- tests/progress.test.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index 7051a8b..be0da03 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -173,9 +173,10 @@ export function summarizeToolProgress( input: unknown, config: Pick, ): ToolProgressLabel | undefined { - const tool = sanitizeToolName(toolName); - if (!tool) return undefined; - const rawDetail = summarizeToolIntent(tool, input); + const intentTool = sanitizeToolName(toolName); + const tool = safeToolName(toolName, config); + if (!intentTool || !tool) return undefined; + const rawDetail = summarizeToolIntent(intentTool, input); const labelText = rawDetail ? `${tool}: ${rawDetail}` : tool; const label = boundedToolLabel(labelText, config); if (!label) return undefined; @@ -298,6 +299,11 @@ function sanitizeToolName(toolName: unknown): string | undefined { return sanitized || "tool"; } +function safeToolName(toolName: unknown, config: Pick): string | undefined { + const redacted = sanitizeProgressText(String(toolName ?? ""), config); + return sanitizeToolName(redacted); +} + function boundedToolLabel(label: string, config: Pick): string { const maxLabelChars = Math.min(180, Math.max(48, Math.floor(maxProgressMessageChars(config) / 3))); const sanitized = sanitizeProgressText(label, { ...config, maxProgressMessageChars: maxLabelChars }); diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 60e8552..4928193 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -156,6 +156,22 @@ describe("tool progress helpers", () => { expect(JSON.stringify(label)).not.toContain("raw"); }); + it("redacts aggregate tool names", () => { + const accumulator = createToolProgressAccumulator(); + const safeConfig = { ...config, redactionPatterns: ["SECRET_[A-Z]+"] }; + + accumulator.start({ toolName: "SECRET_TOKEN", toolCallId: "secret-1", at: 1 }, safeConfig); + accumulator.start({ toolName: "SECRET_TOKEN", toolCallId: "secret-2", at: 2 }, safeConfig); + + const snapshot = accumulator.snapshot(); + const rows = toolProgressRows(snapshot); + const rendered = rows.map((row) => row.text).join("\n"); + expect(snapshot.aggregates).toEqual([{ toolName: "redacted", count: 2 }]); + expect(rendered).toContain("redacted×2"); + expect(rendered).not.toContain("SECRET_TOKEN"); + expect(rendered).not.toContain("secret_token"); + }); + it("aggregates active, completed, failed, repeated, and truncated tool progress", () => { const accumulator = createToolProgressAccumulator(); const safeConfig = { ...config, maxProgressMessageChars: 160 }; From 5601ce5bc7c902aa1df95fdc9da3432e8f6e17d5 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 12:09:16 +0200 Subject: [PATCH 08/17] fix(relay): gate tool progress before publishing --- .../relay/notifications/tool-progress.ts | 52 ++++++++++++++----- extensions/relay/runtime/extension-runtime.ts | 43 ++++++++++++--- .../specs/messenger-relay-sessions/spec.md | 5 ++ .../tasks.md | 1 + tests/integration.test.ts | 10 ++++ tests/progress.test.ts | 21 ++++++++ 6 files changed, 112 insertions(+), 20 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index be0da03..fe0c04f 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -63,11 +63,18 @@ export class ToolProgressAccumulator { return this.records.has(toolCallId.trim()); } + discard(toolCallId: unknown): void { + if (typeof toolCallId !== "string" || !toolCallId.trim()) return; + const stableId = toolCallId.trim(); + this.records.delete(stableId); + this.deleteMissingIdentity(stableId); + } + start(event: ToolProgressEventInput, config: Pick): ToolProgressRecord | undefined { const now = event.at ?? Date.now(); const label = summarizeToolProgress(event.toolName, event.input, config); if (!label) return undefined; - const toolCallId = this.stableToolCallId(event.toolCallId, label.semanticKey); + const toolCallId = this.stableToolCallId(event.toolCallId, label); const existing = this.records.get(toolCallId); const record: ToolProgressRecord = { ...label, @@ -86,7 +93,7 @@ export class ToolProgressAccumulator { const now = event.at ?? Date.now(); const fallbackLabel = summarizeToolProgress(event.toolName, event.input, config); if (!fallbackLabel) return undefined; - const toolCallId = this.finishToolCallId(event.toolCallId, fallbackLabel.semanticKey); + const toolCallId = this.finishToolCallId(event.toolCallId, fallbackLabel); const existing = this.records.get(toolCallId); const label = existing ?? fallbackLabel; const record: ToolProgressRecord = { @@ -133,33 +140,52 @@ export class ToolProgressAccumulator { }; } - private stableToolCallId(toolCallId: unknown, semanticKey: string): string { + private stableToolCallId(toolCallId: unknown, label: ToolProgressLabel): string { if (typeof toolCallId === "string" && toolCallId.trim()) return toolCallId.trim(); - const existing = this.missingIdBySemanticKey.get(semanticKey); - if (existing) return existing; + const semanticMatch = this.missingIdBySemanticKey.get(label.semanticKey); + if (semanticMatch) return semanticMatch; + const activeMatch = this.activeMissingToolCallId(label.toolName); + if (activeMatch) { + this.missingIdBySemanticKey.set(label.semanticKey, activeMatch); + return activeMatch; + } const generated = `missing-${++this.missingSequence}`; - this.missingIdBySemanticKey.set(semanticKey, generated); + this.missingIdBySemanticKey.set(label.semanticKey, generated); return generated; } - private finishToolCallId(toolCallId: unknown, semanticKey: string): string { + private finishToolCallId(toolCallId: unknown, label: ToolProgressLabel): string { if (typeof toolCallId === "string" && toolCallId.trim()) return toolCallId.trim(); - const existing = this.missingIdBySemanticKey.get(semanticKey); - if (existing && this.records.get(existing)?.state === "active") { - this.missingIdBySemanticKey.delete(semanticKey); + const semanticMatch = this.missingIdBySemanticKey.get(label.semanticKey); + const existing = semanticMatch && this.records.get(semanticMatch)?.state === "active" + ? semanticMatch + : this.activeMissingToolCallId(label.toolName); + if (existing) { + this.deleteMissingIdentity(existing); return existing; } return `missing-${++this.missingSequence}`; } + private activeMissingToolCallId(toolName: string): string | undefined { + const matches = [...this.records.values()] + .filter((record) => record.toolCallId.startsWith("missing-") && record.toolName === toolName && record.state === "active") + .sort((left, right) => left.startedAt - right.startedAt); + return matches.length === 1 ? matches[0]?.toolCallId : undefined; + } + + private deleteMissingIdentity(toolCallId: string): void { + for (const [semanticKey, candidateId] of this.missingIdBySemanticKey) { + if (candidateId === toolCallId) this.missingIdBySemanticKey.delete(semanticKey); + } + } + private pruneRecords(): void { while (this.records.size > MAX_TOOL_PROGRESS_RECORDS) { const oldest = [...this.records.values()].sort((left, right) => left.updatedAt - right.updatedAt)[0]; if (!oldest) return; this.records.delete(oldest.toolCallId); - for (const [semanticKey, toolCallId] of this.missingIdBySemanticKey) { - if (toolCallId === oldest.toolCallId) this.missingIdBySemanticKey.delete(semanticKey); - } + this.deleteMissingIdentity(oldest.toolCallId); } } } diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index 76e698d..97c0255 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -190,6 +190,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { let latestTurnImageFileCandidates: LatestTurnImageFileCandidate[] = []; let progressSequence = 0; const toolProgress = createToolProgressAccumulator(); + const suppressedToolProgressIds = new Set(); let diagnosticsLogger: CommunicationDiagnosticsLogger | undefined; const pendingApprovalResolvers = new Map void>(); @@ -388,7 +389,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } async function stopAndClearRuntimes(ctx: ExtensionContext, options: { restartBrokerProcess?: boolean } = {}): Promise<{ telegramStopped: boolean; discordStopped: string[]; slackStopped: string[]; brokerRestarted: boolean }> { - toolProgress.reset(); + resetToolProgress(); let telegramStopped = false; let brokerRestarted = false; const discordStopped: string[] = []; @@ -584,13 +585,29 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } } + function resetToolProgress(): void { + toolProgress.reset(); + suppressedToolProgressIds.clear(); + } + + function suppressToolProgress(toolCallId: unknown): void { + if (typeof toolCallId !== "string" || !toolCallId.trim()) return; + const stableId = toolCallId.trim(); + suppressedToolProgressIds.add(stableId); + toolProgress.discard(stableId); + } + + function isToolProgressSuppressed(toolCallId: unknown): boolean { + return typeof toolCallId === "string" && suppressedToolProgressIds.has(toolCallId.trim()); + } + function clearTurnImageCaches(): void { activeTurnImages = []; activeTurnImagePathTexts = []; activeTurnCompletedAssistantText = undefined; latestTurnImages = []; latestTurnImageFileCandidates = []; - toolProgress.reset(); + resetToolProgress(); } function appendAudit(message: string, route?: SessionRoute): void { @@ -2009,7 +2026,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { pi.on("session_shutdown", async (_event, ctx) => { latestContext = ctx; - toolProgress.reset(); + resetToolProgress(); closeConnectQrScreen = undefined; if (currentRoute) await notifyRelayLifecycle(ctx, "offline", currentRoute); if (currentRoute && configCache) { @@ -2122,7 +2139,9 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { activeTurnImages.push(...extractImageContent(event.message.content)); const toolText = extractTextContent(event.message.content as never); if (toolText) activeTurnImagePathTexts.push(toolText); - if (currentRoute.notification.lastStatus === "running" && !toolProgress.has(event.message.toolCallId)) { + const progressSuppressed = isToolProgressSuppressed(event.message.toolCallId); + if (progressSuppressed && typeof event.message.toolCallId === "string") suppressedToolProgressIds.delete(event.message.toolCallId.trim()); + if (currentRoute.notification.lastStatus === "running" && !progressSuppressed && !toolProgress.has(event.message.toolCallId)) { recordProgress("tool", "Processed tool result", undefined, { delivery: "volatile", semanticKey: `tool-result:${event.message.toolCallId ?? "unknown"}` }); publishRouteStateSoon(); } @@ -2139,8 +2158,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { if (!config) return; if (!currentRoute || currentRoute.sessionKey !== currentSessionKey || currentRoute.notification.lastStatus !== "running") return; recordDiagnostic({ component: "runtime", event: "tool_execution_start", outcome: "received", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), hasInput: input !== undefined } }); - recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input, state: "active" }, config); - publishRouteStateSoon(); + toolProgress.start({ toolName: event.toolName, toolCallId: event.toolCallId, input }, config); }); pi.on("tool_call", async (event, ctx) => { @@ -2166,6 +2184,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { if (!requester) { if (currentRoute.remoteRequesterActiveTurn) { await appendApprovalAudit(config, { kind: "failed", sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, summary: operation.summary, detail: "No active remote requester for approval target." }); + suppressToolProgress(event.toolCallId); return { block: true, reason: "Approval required, but no active remote requester is available." }; } recordAllowedProgress(config); @@ -2178,6 +2197,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const active = await approvalRequesterBindingIsActive(currentRoute, requester, new TunnelStateStore(config.stateDir)); if (!active) { await appendApprovalAudit(config, { kind: "failed", sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, requester, summary: operation.summary, detail: "Approval requester binding is inactive." }); + suppressToolProgress(event.toolCallId); return { block: true, reason: "Approval required, but the remote requester binding is inactive." }; } const grant = await findMatchingApprovalGrant(currentRoute, requester, operation, config); @@ -2197,6 +2217,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } catch (error) { await store.updateApprovalRequest(request.approvalId, (current) => ({ ...current, status: "failed", resolvedAt: toIsoNow(), decision: "deny" })); await appendApprovalAudit(config, { kind: "failed", approvalId: request.approvalId, sessionKey: request.sessionKey, sessionLabel: request.sessionLabel, toolName: request.toolName, category: request.category, matcherFingerprint: request.matcherFingerprint, requester: request.requester, summary: request.safeSummary, detail: error instanceof Error ? error.message : String(error) }); + suppressToolProgress(event.toolCallId); return { block: true, reason: "Approval request could not be delivered; operation blocked." }; } const decision = await awaitApprovalDecision(request, Math.max(0, Date.parse(request.expiresAt) - Date.now())); @@ -2205,6 +2226,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { await store.updateApprovalRequest(request.approvalId, (current) => current.status === "pending" ? { ...current, status: "expired", resolvedAt: toIsoNow(), decision: "deny" } : current); await appendApprovalAudit(config, { kind: "expired", approvalId: request.approvalId, sessionKey: request.sessionKey, sessionLabel: request.sessionLabel, toolName: request.toolName, category: request.category, matcherFingerprint: request.matcherFingerprint, requester: request.requester, summary: request.safeSummary, expiresAt: request.expiresAt }); } + suppressToolProgress(event.toolCallId); return { block: true, reason: decision.message }; } recordAllowedProgress(config); @@ -2225,6 +2247,13 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } recordDiagnostic({ component: "runtime", event: "tool_execution_end", outcome: event.isError ? "error" : "ok", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), isError: Boolean(event.isError) } }); if (!currentRoute || currentRoute.sessionKey !== currentSessionKey || currentRoute.notification.lastStatus !== "running") return; + if (isToolProgressSuppressed(event.toolCallId)) { + if (event.isError) { + currentRoute.notification.lastFailure = `Tool ${event.toolName} failed.`; + publishRouteStateSoon(); + } + return; + } if (event.isError) { currentRoute.notification.lastFailure = `Tool ${event.toolName} failed.`; recordToolProgressActivity({ toolName: event.toolName, toolCallId: event.toolCallId, input: optionalToolEventInput(event), state: "failed" }, config); @@ -2308,7 +2337,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { details: { ...extraction.diagnostics, selectedStatus: status, finalTextSource, usedMessageLifecycleFallback: finalTextSource === "message-end-fallback", abortRequested: Boolean(currentRoute.notification.abortRequested), hadPriorFailure: Boolean(currentRoute.notification.lastFailure) }, }); recordProgress("lifecycle", status === "completed" ? "Pi task completed" : status === "aborted" ? "Pi task aborted" : "Pi task failed"); - toolProgress.reset(); + resetToolProgress(); if (status === "failed" && !currentRoute.notification.lastFailure) { currentRoute.notification.lastFailure = "The agent finished without a final assistant response."; } diff --git a/openspec/changes/improve-tool-call-progress-reporting/specs/messenger-relay-sessions/spec.md b/openspec/changes/improve-tool-call-progress-reporting/specs/messenger-relay-sessions/spec.md index 7ac3aa3..e902287 100644 --- a/openspec/changes/improve-tool-call-progress-reporting/specs/messenger-relay-sessions/spec.md +++ b/openspec/changes/improve-tool-call-progress-reporting/specs/messenger-relay-sessions/spec.md @@ -41,6 +41,11 @@ The system SHALL aggregate current-turn tool progress by stable tool-call identi - **THEN** the progress update marks that tool as failed using the safe tool label - **AND** it does not include raw stack traces, command output, or unbounded error payloads in normal mode +#### Scenario: Approval-gated tools are published only after authorization +- **WHEN** a tool execution lifecycle starts before its blockable approval-gate check completes +- **THEN** PiRelay may stage safe local progress state but does not publish the tool as active before the gate allows execution +- **AND** denied, expired, undeliverable, or unauthorized approval requests do not leave active or failed tool-progress rows + #### Scenario: Tool progress resets on turn boundaries - **WHEN** a Pi turn starts, completes, fails, aborts, unregisters, or the runtime restarts - **THEN** current-turn tool progress state is reset or discarded diff --git a/openspec/changes/improve-tool-call-progress-reporting/tasks.md b/openspec/changes/improve-tool-call-progress-reporting/tasks.md index 8dd8d20..f39a6e1 100644 --- a/openspec/changes/improve-tool-call-progress-reporting/tasks.md +++ b/openspec/changes/improve-tool-call-progress-reporting/tasks.md @@ -21,6 +21,7 @@ - [x] 3.3 Keep `message_end` tool-result bookkeeping volatile/verbose-only or suppress it when a matching tool lifecycle record exists. - [x] 3.4 Preserve approval-gate behavior and authorization boundaries in `tool_call` handlers before adding progress side effects. - [x] 3.5 Add integration tests for bash, read, edit/write, search/list, failed tools, duplicate tool events, and missing lifecycle fields. +- [x] 3.6 Stage preflight lifecycle records without publishing before approval, and suppress denied/blocked calls through their end/result events. ## 4. Adapter and Broker Parity diff --git a/tests/integration.test.ts b/tests/integration.test.ts index eb37ee6..a947686 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -651,9 +651,15 @@ describe("PiRelay integration behavior", () => { await pi.emit("agent_start", {}, context); registeredRoute!.remoteRequester = undefined; + const lifecycleProgress = { ...registeredRoute!.notification.progressEvent }; + await pi.emit("tool_execution_start", { toolName: "bash", toolCallId: "tc-remote-missing", args: { command: "git push origin main" } }, context); + expect(registeredRoute!.notification.progressEvent).toEqual(lifecycleProgress); const results = await pi.emitResults("tool_call", { toolName: "bash", toolCallId: "tc-remote-missing", input: { command: "git push origin main" } }, context); + await pi.emit("tool_execution_end", { toolName: "bash", toolCallId: "tc-remote-missing", result: {}, isError: true }, context); + await pi.emit("message_end", { message: { role: "toolResult", toolCallId: "tc-remote-missing", toolName: "bash", content: [], isError: true } }, context); expect(results).toEqual([{ block: true, reason: "Approval required, but no active remote requester is available." }]); + expect(registeredRoute!.notification.progressEvent).toEqual(lifecycleProgress); await expect(new TunnelStateStore(config.stateDir).listApprovalAudit()).resolves.toEqual([ expect.objectContaining({ kind: "failed", detail: "No active remote requester for approval target." }), ]); @@ -2962,6 +2968,10 @@ describe("PiRelay integration behavior", () => { const route = [...registeredRoutes.values()][0]!; await pi.emit("agent_start", {}, context); + const lifecycleProgress = { ...route.notification.progressEvent }; + await pi.emit("tool_execution_start", { toolName: "bash", toolCallId: "bash-1", args: { command: "npm test\necho SECRET_TOKEN", output: "raw output" } }, context); + expect(route.notification.progressEvent).toEqual(lifecycleProgress); + await pi.emit("tool_call", { toolName: "bash", toolCallId: "bash-1", input: { command: "npm test\necho SECRET_TOKEN", output: "raw output" } }, context); expect(route.notification.progressEvent).toMatchObject({ text: "Tool progress", detail: expect.stringContaining("▶ bash: npm test") }); expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_TOKEN"); diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 4928193..ae590f5 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -218,6 +218,27 @@ describe("tool progress helpers", () => { expect(formatToolProgressCard(accumulator.snapshot(), config)).toContain("✓ read: README.md"); }); + it("enriches and completes one missing-id record across lifecycle labels", () => { + const accumulator = createToolProgressAccumulator(); + accumulator.start({ toolName: "bash", at: 1 }, config); + accumulator.start({ toolName: "bash", input: { command: "npm test" }, at: 2 }, config); + accumulator.finish({ toolName: "bash", failed: false, at: 3 }, config); + + expect(accumulator.snapshot().records).toEqual([ + expect.objectContaining({ toolCallId: "missing-1", state: "completed", label: "bash: npm test" }), + ]); + expect(formatToolProgressCard(accumulator.snapshot(), config)).toContain("✓ bash: npm test"); + }); + + it("discards staged progress by explicit tool-call identity", () => { + const accumulator = createToolProgressAccumulator(); + accumulator.start({ toolName: "bash", toolCallId: "blocked-1", at: 1 }, config); + accumulator.discard("blocked-1"); + + expect(accumulator.snapshot().records).toEqual([]); + expect(accumulator.activity({ id: "empty" }, config)).toBeUndefined(); + }); + it("bounds retained current-turn tool records", () => { const accumulator = createToolProgressAccumulator(); for (let index = 0; index < 55; index += 1) { From 52322288d1a0ae9ad4f46a6c999036bcabe66f8b Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 14:56:27 +0200 Subject: [PATCH 09/17] fix(relay): suppress missing-id blocked progress --- .../relay/notifications/tool-progress.ts | 19 +++++-- extensions/relay/runtime/extension-runtime.ts | 53 ++++++++++++++----- tests/integration.test.ts | 8 +-- tests/progress.test.ts | 4 +- 4 files changed, 62 insertions(+), 22 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index fe0c04f..ff78ec7 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -65,9 +65,17 @@ export class ToolProgressAccumulator { discard(toolCallId: unknown): void { if (typeof toolCallId !== "string" || !toolCallId.trim()) return; - const stableId = toolCallId.trim(); - this.records.delete(stableId); - this.deleteMissingIdentity(stableId); + this.discardStableId(toolCallId.trim()); + } + + discardMatching(event: ToolProgressEventInput, config: Pick): void { + const label = summarizeToolProgress(event.toolName, event.input, config); + if (!label) return; + const semanticMatch = this.missingIdBySemanticKey.get(label.semanticKey); + const stableId = semanticMatch && this.records.get(semanticMatch)?.state === "active" + ? semanticMatch + : this.activeMissingToolCallId(label.toolName); + if (stableId) this.discardStableId(stableId); } start(event: ToolProgressEventInput, config: Pick): ToolProgressRecord | undefined { @@ -174,6 +182,11 @@ export class ToolProgressAccumulator { return matches.length === 1 ? matches[0]?.toolCallId : undefined; } + private discardStableId(toolCallId: string): void { + this.records.delete(toolCallId); + this.deleteMissingIdentity(toolCallId); + } + private deleteMissingIdentity(toolCallId: string): void { for (const [semanticKey, candidateId] of this.missingIdBySemanticKey) { if (candidateId === toolCallId) this.missingIdBySemanticKey.delete(semanticKey); diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index 97c0255..dddcf70 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -191,6 +191,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { let progressSequence = 0; const toolProgress = createToolProgressAccumulator(); const suppressedToolProgressIds = new Set(); + const suppressedMissingToolProgress = new Map(); let diagnosticsLogger: CommunicationDiagnosticsLogger | undefined; const pendingApprovalResolvers = new Map void>(); @@ -588,17 +589,42 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { function resetToolProgress(): void { toolProgress.reset(); suppressedToolProgressIds.clear(); + suppressedMissingToolProgress.clear(); } - function suppressToolProgress(toolCallId: unknown): void { - if (typeof toolCallId !== "string" || !toolCallId.trim()) return; - const stableId = toolCallId.trim(); - suppressedToolProgressIds.add(stableId); - toolProgress.discard(stableId); + function missingToolProgressKey(toolName: unknown): string | undefined { + const normalized = String(toolName ?? "").trim().toLowerCase(); + return normalized || undefined; } - function isToolProgressSuppressed(toolCallId: unknown): boolean { - return typeof toolCallId === "string" && suppressedToolProgressIds.has(toolCallId.trim()); + function suppressToolProgress(event: ToolProgressEventInput, config: Pick): void { + if (typeof event.toolCallId === "string" && event.toolCallId.trim()) { + const stableId = event.toolCallId.trim(); + suppressedToolProgressIds.add(stableId); + toolProgress.discard(stableId); + return; + } + const key = missingToolProgressKey(event.toolName); + if (!key) return; + suppressedMissingToolProgress.set(key, (suppressedMissingToolProgress.get(key) ?? 0) + 1); + toolProgress.discardMatching(event, config); + } + + function isToolProgressSuppressed(toolCallId: unknown, toolName: unknown): boolean { + if (typeof toolCallId === "string" && toolCallId.trim()) return suppressedToolProgressIds.has(toolCallId.trim()); + const key = missingToolProgressKey(toolName); + return key !== undefined && (suppressedMissingToolProgress.get(key) ?? 0) > 0; + } + + function consumeToolProgressSuppression(toolCallId: unknown, toolName: unknown): boolean { + if (typeof toolCallId === "string" && toolCallId.trim()) return suppressedToolProgressIds.delete(toolCallId.trim()); + const key = missingToolProgressKey(toolName); + if (!key) return false; + const count = suppressedMissingToolProgress.get(key) ?? 0; + if (count <= 0) return false; + if (count === 1) suppressedMissingToolProgress.delete(key); + else suppressedMissingToolProgress.set(key, count - 1); + return true; } function clearTurnImageCaches(): void { @@ -2139,8 +2165,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { activeTurnImages.push(...extractImageContent(event.message.content)); const toolText = extractTextContent(event.message.content as never); if (toolText) activeTurnImagePathTexts.push(toolText); - const progressSuppressed = isToolProgressSuppressed(event.message.toolCallId); - if (progressSuppressed && typeof event.message.toolCallId === "string") suppressedToolProgressIds.delete(event.message.toolCallId.trim()); + const progressSuppressed = consumeToolProgressSuppression(event.message.toolCallId, event.message.toolName); if (currentRoute.notification.lastStatus === "running" && !progressSuppressed && !toolProgress.has(event.message.toolCallId)) { recordProgress("tool", "Processed tool result", undefined, { delivery: "volatile", semanticKey: `tool-result:${event.message.toolCallId ?? "unknown"}` }); publishRouteStateSoon(); @@ -2184,7 +2209,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { if (!requester) { if (currentRoute.remoteRequesterActiveTurn) { await appendApprovalAudit(config, { kind: "failed", sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, summary: operation.summary, detail: "No active remote requester for approval target." }); - suppressToolProgress(event.toolCallId); + suppressToolProgress(event, config); return { block: true, reason: "Approval required, but no active remote requester is available." }; } recordAllowedProgress(config); @@ -2197,7 +2222,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const active = await approvalRequesterBindingIsActive(currentRoute, requester, new TunnelStateStore(config.stateDir)); if (!active) { await appendApprovalAudit(config, { kind: "failed", sessionKey: currentRoute.sessionKey, sessionLabel: currentRoute.sessionLabel, toolName: operation.toolName, category: operation.category, matcherFingerprint: operation.matcherFingerprint, requester, summary: operation.summary, detail: "Approval requester binding is inactive." }); - suppressToolProgress(event.toolCallId); + suppressToolProgress(event, config); return { block: true, reason: "Approval required, but the remote requester binding is inactive." }; } const grant = await findMatchingApprovalGrant(currentRoute, requester, operation, config); @@ -2217,7 +2242,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } catch (error) { await store.updateApprovalRequest(request.approvalId, (current) => ({ ...current, status: "failed", resolvedAt: toIsoNow(), decision: "deny" })); await appendApprovalAudit(config, { kind: "failed", approvalId: request.approvalId, sessionKey: request.sessionKey, sessionLabel: request.sessionLabel, toolName: request.toolName, category: request.category, matcherFingerprint: request.matcherFingerprint, requester: request.requester, summary: request.safeSummary, detail: error instanceof Error ? error.message : String(error) }); - suppressToolProgress(event.toolCallId); + suppressToolProgress(event, config); return { block: true, reason: "Approval request could not be delivered; operation blocked." }; } const decision = await awaitApprovalDecision(request, Math.max(0, Date.parse(request.expiresAt) - Date.now())); @@ -2226,7 +2251,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { await store.updateApprovalRequest(request.approvalId, (current) => current.status === "pending" ? { ...current, status: "expired", resolvedAt: toIsoNow(), decision: "deny" } : current); await appendApprovalAudit(config, { kind: "expired", approvalId: request.approvalId, sessionKey: request.sessionKey, sessionLabel: request.sessionLabel, toolName: request.toolName, category: request.category, matcherFingerprint: request.matcherFingerprint, requester: request.requester, summary: request.safeSummary, expiresAt: request.expiresAt }); } - suppressToolProgress(event.toolCallId); + suppressToolProgress(event, config); return { block: true, reason: decision.message }; } recordAllowedProgress(config); @@ -2247,7 +2272,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } recordDiagnostic({ component: "runtime", event: "tool_execution_end", outcome: event.isError ? "error" : "ok", ...diagnosticRouteFields(), details: { toolName: String(event.toolName ?? ""), isError: Boolean(event.isError) } }); if (!currentRoute || currentRoute.sessionKey !== currentSessionKey || currentRoute.notification.lastStatus !== "running") return; - if (isToolProgressSuppressed(event.toolCallId)) { + if (isToolProgressSuppressed(event.toolCallId, event.toolName)) { if (event.isError) { currentRoute.notification.lastFailure = `Tool ${event.toolName} failed.`; publishRouteStateSoon(); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index a947686..38a9046 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -652,11 +652,11 @@ describe("PiRelay integration behavior", () => { registeredRoute!.remoteRequester = undefined; const lifecycleProgress = { ...registeredRoute!.notification.progressEvent }; - await pi.emit("tool_execution_start", { toolName: "bash", toolCallId: "tc-remote-missing", args: { command: "git push origin main" } }, context); + await pi.emit("tool_execution_start", { toolName: "bash", args: { command: "git push origin main" } }, context); expect(registeredRoute!.notification.progressEvent).toEqual(lifecycleProgress); - const results = await pi.emitResults("tool_call", { toolName: "bash", toolCallId: "tc-remote-missing", input: { command: "git push origin main" } }, context); - await pi.emit("tool_execution_end", { toolName: "bash", toolCallId: "tc-remote-missing", result: {}, isError: true }, context); - await pi.emit("message_end", { message: { role: "toolResult", toolCallId: "tc-remote-missing", toolName: "bash", content: [], isError: true } }, context); + const results = await pi.emitResults("tool_call", { toolName: "bash", input: { command: "git push origin main" } }, context); + await pi.emit("tool_execution_end", { toolName: "bash", result: {}, isError: true }, context); + await pi.emit("message_end", { message: { role: "toolResult", toolName: "bash", content: [], isError: true } }, context); expect(results).toEqual([{ block: true, reason: "Approval required, but no active remote requester is available." }]); expect(registeredRoute!.notification.progressEvent).toEqual(lifecycleProgress); diff --git a/tests/progress.test.ts b/tests/progress.test.ts index ae590f5..0a28c1e 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -230,10 +230,12 @@ describe("tool progress helpers", () => { expect(formatToolProgressCard(accumulator.snapshot(), config)).toContain("✓ bash: npm test"); }); - it("discards staged progress by explicit tool-call identity", () => { + it("discards staged progress by explicit or missing tool-call identity", () => { const accumulator = createToolProgressAccumulator(); accumulator.start({ toolName: "bash", toolCallId: "blocked-1", at: 1 }, config); accumulator.discard("blocked-1"); + accumulator.start({ toolName: "read", at: 2 }, config); + accumulator.discardMatching({ toolName: "read", input: { path: "README.md" } }, config); expect(accumulator.snapshot().records).toEqual([]); expect(accumulator.activity({ id: "empty" }, config)).toBeUndefined(); From 326b54053266c111cc2537bbbd0c687ab45cab4c Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 15:07:16 +0200 Subject: [PATCH 10/17] fix(relay): avoid duplicated tool semantic keys --- extensions/relay/notifications/tool-progress.ts | 6 +++--- tests/progress.test.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index ff78ec7..625161f 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -222,7 +222,7 @@ export function summarizeToolProgress( return { toolName: tool, label, - semanticKey: semanticToolKey(tool, label), + semanticKey: semanticToolKey(label), }; } @@ -349,6 +349,6 @@ function boundedToolLabel(label: string, config: Pick maxLabelChars ? `${sanitized.slice(0, maxLabelChars - 1).trimEnd()}…` : sanitized; } -function semanticToolKey(toolName: string, label: string): string { - return `${toolName}:${label}`.toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 160); +function semanticToolKey(label: string): string { + return label.toLowerCase().replace(/[^a-z0-9_.:/-]+/g, "-").slice(0, 160); } diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 0a28c1e..db65865 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -145,6 +145,7 @@ describe("tool progress helpers", () => { expect(summarizeToolProgress("rg", { pattern: "botToken=SECRET_VALUE", path: "extensions" }, safeConfig)?.label).toBe("rg: botToken=[redacted] in extensions"); expect(summarizeToolProgress("find", { path: "123456789", transcript: "raw transcript" }, safeConfig)?.label).toBe("find: [redacted]"); expect(summarizeToolProgress("ls", { dir: "src", chatId: "123456789" }, safeConfig)?.label).toBe("ls: src"); + expect(summarizeToolProgress("bash", { command: "npm test" }, safeConfig)?.semanticKey).toBe("bash:-npm-test"); }); it("keeps unknown tools conservative", () => { From 0b7815412a24e8fe01e6a490e6cd88cf1eb74943 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 15:30:47 +0200 Subject: [PATCH 11/17] fix(relay): read lifecycle tool arguments --- extensions/relay/runtime/extension-runtime.ts | 6 ++++-- tests/integration.test.ts | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index dddcf70..15417b7 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -714,8 +714,10 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } function optionalToolEventInput(event: unknown): unknown { - if (!event || typeof event !== "object" || !("input" in event)) return undefined; - return (event as { input?: unknown }).input; + if (!event || typeof event !== "object") return undefined; + if ("input" in event) return (event as { input?: unknown }).input; + if ("args" in event) return (event as { args?: unknown }).args; + return undefined; } function persistBinding(binding: TelegramBindingMetadata | null, revoked = false, route?: SessionRoute): void { diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 38a9046..3f3bffe 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -2981,6 +2981,12 @@ describe("PiRelay integration behavior", () => { expect(route.notification.progressEvent?.detail).toContain("✓ bash: npm test"); expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_RESULT"); + await pi.emit("tool_execution_start", { toolName: "read", toolCallId: "read-lifecycle-only", args: { path: "README.md", content: "SECRET_LIFECYCLE_CONTENT" } }, context); + await pi.emit("tool_execution_end", { toolName: "read", toolCallId: "read-lifecycle-only", result: "SECRET_LIFECYCLE_RESULT", isError: false }, context); + expect(route.notification.progressEvent?.detail).toContain("✓ read: README.md"); + expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_LIFECYCLE_CONTENT"); + expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_LIFECYCLE_RESULT"); + await pi.emit("tool_call", { toolName: "read", toolCallId: "read-1", input: { path: "extensions/relay/runtime/extension-runtime.ts", content: "SECRET_FILE" } }, context); await pi.emit("tool_call", { toolName: "edit", toolCallId: "edit-1", input: { path: "tests/integration.test.ts", oldText: "SECRET_OLD", newText: "SECRET_NEW" } }, context); await pi.emit("tool_call", { toolName: "write", toolCallId: "write-1", input: { path: "README.md", content: "SECRET_CONTENT" } }, context); From b92636a2d3222d1b02bb7adaa982459dedf44eb7 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 16:06:57 +0200 Subject: [PATCH 12/17] fix(relay): match missing-id tool results --- extensions/relay/notifications/tool-progress.ts | 7 +++++++ extensions/relay/runtime/extension-runtime.ts | 5 ++++- tests/integration.test.ts | 7 +++++-- tests/progress.test.ts | 2 ++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index 625161f..97253c8 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -63,6 +63,13 @@ export class ToolProgressAccumulator { return this.records.has(toolCallId.trim()); } + hasMatching(event: Pick, config: Pick): boolean { + if (this.has(event.toolCallId)) return true; + const label = summarizeToolProgress(event.toolName, undefined, config); + if (!label) return false; + return [...this.records.values()].some((record) => record.toolName === label.toolName); + } + discard(toolCallId: unknown): void { if (typeof toolCallId !== "string" || !toolCallId.trim()) return; this.discardStableId(toolCallId.trim()); diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index 15417b7..ba4c4d7 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -2168,7 +2168,10 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { const toolText = extractTextContent(event.message.content as never); if (toolText) activeTurnImagePathTexts.push(toolText); const progressSuppressed = consumeToolProgressSuppression(event.message.toolCallId, event.message.toolName); - if (currentRoute.notification.lastStatus === "running" && !progressSuppressed && !toolProgress.has(event.message.toolCallId)) { + const hasLifecycleProgress = configCache + ? toolProgress.hasMatching({ toolName: event.message.toolName, toolCallId: event.message.toolCallId }, configCache) + : toolProgress.has(event.message.toolCallId); + if (currentRoute.notification.lastStatus === "running" && !progressSuppressed && !hasLifecycleProgress) { recordProgress("tool", "Processed tool result", undefined, { delivery: "volatile", semanticKey: `tool-result:${event.message.toolCallId ?? "unknown"}` }); publishRouteStateSoon(); } diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 3f3bffe..d60d05c 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -2981,11 +2981,14 @@ describe("PiRelay integration behavior", () => { expect(route.notification.progressEvent?.detail).toContain("✓ bash: npm test"); expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_RESULT"); - await pi.emit("tool_execution_start", { toolName: "read", toolCallId: "read-lifecycle-only", args: { path: "README.md", content: "SECRET_LIFECYCLE_CONTENT" } }, context); - await pi.emit("tool_execution_end", { toolName: "read", toolCallId: "read-lifecycle-only", result: "SECRET_LIFECYCLE_RESULT", isError: false }, context); + await pi.emit("tool_execution_start", { toolName: "read", args: { path: "README.md", content: "SECRET_LIFECYCLE_CONTENT" } }, context); + await pi.emit("tool_execution_end", { toolName: "read", result: "SECRET_LIFECYCLE_RESULT", isError: false }, context); expect(route.notification.progressEvent?.detail).toContain("✓ read: README.md"); expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_LIFECYCLE_CONTENT"); expect(JSON.stringify(route.notification.progressEvent)).not.toContain("SECRET_LIFECYCLE_RESULT"); + const lifecycleOnlyProgress = { ...route.notification.progressEvent }; + await pi.emit("message_end", { message: { role: "toolResult", toolName: "read", content: [], isError: false } }, context); + expect(route.notification.progressEvent).toEqual(lifecycleOnlyProgress); await pi.emit("tool_call", { toolName: "read", toolCallId: "read-1", input: { path: "extensions/relay/runtime/extension-runtime.ts", content: "SECRET_FILE" } }, context); await pi.emit("tool_call", { toolName: "edit", toolCallId: "edit-1", input: { path: "tests/integration.test.ts", oldText: "SECRET_OLD", newText: "SECRET_NEW" } }, context); diff --git a/tests/progress.test.ts b/tests/progress.test.ts index db65865..45b1c5f 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -228,6 +228,8 @@ describe("tool progress helpers", () => { expect(accumulator.snapshot().records).toEqual([ expect.objectContaining({ toolCallId: "missing-1", state: "completed", label: "bash: npm test" }), ]); + expect(accumulator.hasMatching({ toolName: "bash" }, config)).toBe(true); + expect(accumulator.hasMatching({ toolName: "read" }, config)).toBe(false); expect(formatToolProgressCard(accumulator.snapshot(), config)).toContain("✓ bash: npm test"); }); From 23b9a9b98a2d7b7f1d4f385dbc54758abe696b41 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 16:12:03 +0200 Subject: [PATCH 13/17] fix(relay): consume missing-id result matches --- .../relay/notifications/tool-progress.ts | 23 +++++++++++++++---- extensions/relay/runtime/extension-runtime.ts | 2 +- tests/progress.test.ts | 5 ++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index 97253c8..6ce25b3 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -50,11 +50,13 @@ export interface ToolProgressActivityOptions { export class ToolProgressAccumulator { private readonly records = new Map(); private readonly missingIdBySemanticKey = new Map(); + private readonly pendingMissingResultsByToolName = new Map(); private missingSequence = 0; reset(): void { this.records.clear(); this.missingIdBySemanticKey.clear(); + this.pendingMissingResultsByToolName.clear(); this.missingSequence = 0; } @@ -63,11 +65,15 @@ export class ToolProgressAccumulator { return this.records.has(toolCallId.trim()); } - hasMatching(event: Pick, config: Pick): boolean { + consumeResultMatch(event: Pick, config: Pick): boolean { if (this.has(event.toolCallId)) return true; const label = summarizeToolProgress(event.toolName, undefined, config); if (!label) return false; - return [...this.records.values()].some((record) => record.toolName === label.toolName); + const pending = this.pendingMissingResultsByToolName.get(label.toolName); + if (!pending || pending.length === 0) return false; + pending.shift(); + if (pending.length === 0) this.pendingMissingResultsByToolName.delete(label.toolName); + return true; } discard(toolCallId: unknown): void { @@ -122,6 +128,11 @@ export class ToolProgressAccumulator { completedAt: now, }; this.records.set(toolCallId, record); + if (!(typeof event.toolCallId === "string" && event.toolCallId.trim())) { + const pending = this.pendingMissingResultsByToolName.get(record.toolName) ?? []; + pending.push(toolCallId); + this.pendingMissingResultsByToolName.set(record.toolName, pending); + } this.pruneRecords(); return record; } @@ -192,6 +203,11 @@ export class ToolProgressAccumulator { private discardStableId(toolCallId: string): void { this.records.delete(toolCallId); this.deleteMissingIdentity(toolCallId); + for (const [toolName, pending] of this.pendingMissingResultsByToolName) { + const remaining = pending.filter((candidateId) => candidateId !== toolCallId); + if (remaining.length === 0) this.pendingMissingResultsByToolName.delete(toolName); + else if (remaining.length !== pending.length) this.pendingMissingResultsByToolName.set(toolName, remaining); + } } private deleteMissingIdentity(toolCallId: string): void { @@ -204,8 +220,7 @@ export class ToolProgressAccumulator { while (this.records.size > MAX_TOOL_PROGRESS_RECORDS) { const oldest = [...this.records.values()].sort((left, right) => left.updatedAt - right.updatedAt)[0]; if (!oldest) return; - this.records.delete(oldest.toolCallId); - this.deleteMissingIdentity(oldest.toolCallId); + this.discardStableId(oldest.toolCallId); } } } diff --git a/extensions/relay/runtime/extension-runtime.ts b/extensions/relay/runtime/extension-runtime.ts index ba4c4d7..647980c 100644 --- a/extensions/relay/runtime/extension-runtime.ts +++ b/extensions/relay/runtime/extension-runtime.ts @@ -2169,7 +2169,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { if (toolText) activeTurnImagePathTexts.push(toolText); const progressSuppressed = consumeToolProgressSuppression(event.message.toolCallId, event.message.toolName); const hasLifecycleProgress = configCache - ? toolProgress.hasMatching({ toolName: event.message.toolName, toolCallId: event.message.toolCallId }, configCache) + ? toolProgress.consumeResultMatch({ toolName: event.message.toolName, toolCallId: event.message.toolCallId }, configCache) : toolProgress.has(event.message.toolCallId); if (currentRoute.notification.lastStatus === "running" && !progressSuppressed && !hasLifecycleProgress) { recordProgress("tool", "Processed tool result", undefined, { delivery: "volatile", semanticKey: `tool-result:${event.message.toolCallId ?? "unknown"}` }); diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 45b1c5f..9103f86 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -228,8 +228,9 @@ describe("tool progress helpers", () => { expect(accumulator.snapshot().records).toEqual([ expect.objectContaining({ toolCallId: "missing-1", state: "completed", label: "bash: npm test" }), ]); - expect(accumulator.hasMatching({ toolName: "bash" }, config)).toBe(true); - expect(accumulator.hasMatching({ toolName: "read" }, config)).toBe(false); + expect(accumulator.consumeResultMatch({ toolName: "bash" }, config)).toBe(true); + expect(accumulator.consumeResultMatch({ toolName: "bash" }, config)).toBe(false); + expect(accumulator.consumeResultMatch({ toolName: "read" }, config)).toBe(false); expect(formatToolProgressCard(accumulator.snapshot(), config)).toContain("✓ bash: npm test"); }); From 56f5135852757e3a8cd420e9d2c62c5c257ebdc9 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 16:14:03 +0200 Subject: [PATCH 14/17] refactor(relay): remove unreachable progress branches --- extensions/relay/notifications/tool-progress.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index 6ce25b3..43daf2e 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -240,7 +240,6 @@ export function summarizeToolProgress( const rawDetail = summarizeToolIntent(intentTool, input); const labelText = rawDetail ? `${tool}: ${rawDetail}` : tool; const label = boundedToolLabel(labelText, config); - if (!label) return undefined; return { toolName: tool, label, @@ -259,8 +258,7 @@ export function formatToolProgressCard(snapshot: ToolProgressAccumulatorSnapshot parts.push(row.text); } if (parts.length === 0) return undefined; - const output = parts.join(" · "); - return output.length > limit ? `${output.slice(0, limit - 1).trimEnd()}…` : output; + return parts.join(" · "); } export function toolProgressRows(snapshot: ToolProgressAccumulatorSnapshot): ToolProgressFormattedRow[] { From 386090c2b121fbfa2959d5eaa457c8a1d395292b Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 16:18:57 +0200 Subject: [PATCH 15/17] fix(relay): mark omitted tool progress rows --- extensions/relay/notifications/tool-progress.ts | 9 +++++++-- tests/progress.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index 43daf2e..47eab5e 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -252,13 +252,18 @@ export function formatToolProgressCard(snapshot: ToolProgressAccumulatorSnapshot const rows = toolProgressRows(snapshot); const limit = maxProgressMessageChars(config); const parts: string[] = []; + let omittedRows = false; for (const row of rows) { const next = [...parts, row.text].join(" · "); - if (next.length > limit) break; + if (next.length > limit) { + omittedRows = true; + break; + } parts.push(row.text); } if (parts.length === 0) return undefined; - return parts.join(" · "); + const output = parts.join(" · "); + return omittedRows ? `${output.slice(0, limit - 1).trimEnd()}…` : output; } export function toolProgressRows(snapshot: ToolProgressAccumulatorSnapshot): ToolProgressFormattedRow[] { diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 9103f86..2ff9eeb 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -196,6 +196,18 @@ describe("tool progress helpers", () => { expect(activity).toMatchObject({ kind: "tool", text: "Tool progress", delivery: "milestone" }); }); + it("marks a bounded card when rows are omitted", () => { + const accumulator = createToolProgressAccumulator(); + const safeConfig = { ...config, maxProgressMessageChars: 120 }; + for (let index = 0; index < 4; index += 1) { + accumulator.start({ toolName: "read", toolCallId: `read-${index}`, input: { path: `extensions/relay/very-long-progress-file-name-${index}.ts` }, at: index }, safeConfig); + } + + const card = formatToolProgressCard(accumulator.snapshot(), safeConfig); + expect(card).toMatch(/…$/u); + expect(card!.length).toBeLessThanOrEqual(120); + }); + it("keeps completed rows when active rows are capped", () => { const accumulator = createToolProgressAccumulator(); const safeConfig = { ...config, maxProgressMessageChars: 200 }; From 5ea490775b0ef1738e9fccc72f7640bf11c75584 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 16:21:56 +0200 Subject: [PATCH 16/17] fix(relay): avoid stale missing-id reuse --- extensions/relay/notifications/tool-progress.ts | 3 ++- tests/progress.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/extensions/relay/notifications/tool-progress.ts b/extensions/relay/notifications/tool-progress.ts index 47eab5e..4ea5256 100644 --- a/extensions/relay/notifications/tool-progress.ts +++ b/extensions/relay/notifications/tool-progress.ts @@ -169,7 +169,8 @@ export class ToolProgressAccumulator { private stableToolCallId(toolCallId: unknown, label: ToolProgressLabel): string { if (typeof toolCallId === "string" && toolCallId.trim()) return toolCallId.trim(); const semanticMatch = this.missingIdBySemanticKey.get(label.semanticKey); - if (semanticMatch) return semanticMatch; + if (semanticMatch && this.records.get(semanticMatch)?.state === "active") return semanticMatch; + if (semanticMatch) this.deleteMissingIdentity(semanticMatch); const activeMatch = this.activeMissingToolCallId(label.toolName); if (activeMatch) { this.missingIdBySemanticKey.set(label.semanticKey, activeMatch); diff --git a/tests/progress.test.ts b/tests/progress.test.ts index 2ff9eeb..5c4d528 100644 --- a/tests/progress.test.ts +++ b/tests/progress.test.ts @@ -257,6 +257,18 @@ describe("tool progress helpers", () => { expect(accumulator.activity({ id: "empty" }, config)).toBeUndefined(); }); + it("does not reuse a stale completed missing-id semantic mapping", () => { + const accumulator = createToolProgressAccumulator(); + accumulator.start({ toolName: "bash", input: { command: "npm test" }, at: 1 }, config); + accumulator.finish({ toolName: "bash", toolCallId: "missing-1", failed: false, at: 2 }, config); + accumulator.start({ toolName: "bash", input: { command: "npm test" }, at: 3 }, config); + + expect(accumulator.snapshot().records).toEqual([ + expect.objectContaining({ toolCallId: "missing-1", state: "completed" }), + expect.objectContaining({ toolCallId: "missing-2", state: "active" }), + ]); + }); + it("bounds retained current-turn tool records", () => { const accumulator = createToolProgressAccumulator(); for (let index = 0; index < 55; index += 1) { From 6447dea5dc4e9eb2c8cfe915e7f5be3faced461f Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Sat, 11 Jul 2026 18:40:42 +0200 Subject: [PATCH 17/17] docs: strengthen lifecycle review guidance --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 60ad77b..be01a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ The legacy Telegram-tunnel extension path has been removed; do not add compatibi - Add unit tests for pure helpers. - Add runtime/integration tests for authorization, prompt routing, persisted state, media handling, and broker parity. - For Telegram UX changes, cover both happy path and ambiguous/error states. +- For event-driven changes, define lifecycle states and safety invariants before implementation. Cover identifiers present and missing, repeated calls, partial or delayed events, authorization denial, and delivery failure where applicable. +- When multiple handlers participate in one lifecycle, test complete event sequences and assert that fallback paths preserve the same authorization, redaction, correlation, and deduplication invariants. - Prefer small targeted tests over brittle transcript snapshots. ## OpenSpec workflow @@ -60,6 +62,14 @@ When implementing an OpenSpec change: 4. Validate the change with `openspec validate --strict`. 5. Archive only after implementation is complete and specs are synced. +## Review feedback workflow + +1. Verify each comment against the current code before changing it. +2. When feedback exposes a shared invariant or lifecycle flaw, audit adjacent handlers and equivalent paths instead of patching only the cited line. +3. Add focused regression tests plus a complete lifecycle test for systemic fixes. +4. Batch related feedback, run the required checks, and perform a structured pre-review before requesting another review. +5. Re-fetch unresolved threads after pushing. Avoid repeated automated review requests after individual micro-fixes; request review once the related batch is complete and validated. + ## Git workflow - Use concise Conventional Commits-style messages.