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. 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/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); 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..4ea5256 --- /dev/null +++ b/extensions/relay/notifications/tool-progress.ts @@ -0,0 +1,380 @@ +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 readonly pendingMissingResultsByToolName = new Map(); + private missingSequence = 0; + + reset(): void { + this.records.clear(); + this.missingIdBySemanticKey.clear(); + this.pendingMissingResultsByToolName.clear(); + this.missingSequence = 0; + } + + has(toolCallId: unknown): boolean { + if (typeof toolCallId !== "string" || !toolCallId.trim()) return false; + return this.records.has(toolCallId.trim()); + } + + consumeResultMatch(event: Pick, config: Pick): boolean { + if (this.has(event.toolCallId)) return true; + const label = summarizeToolProgress(event.toolName, undefined, config); + if (!label) return false; + 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 { + if (typeof toolCallId !== "string" || !toolCallId.trim()) return; + 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 { + 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); + 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(); + 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); + 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); + 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; + } + + 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, label: ToolProgressLabel): string { + if (typeof toolCallId === "string" && toolCallId.trim()) return toolCallId.trim(); + const semanticMatch = this.missingIdBySemanticKey.get(label.semanticKey); + 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); + return activeMatch; + } + const generated = `missing-${++this.missingSequence}`; + this.missingIdBySemanticKey.set(label.semanticKey, generated); + return generated; + } + + private finishToolCallId(toolCallId: unknown, label: ToolProgressLabel): string { + if (typeof toolCallId === "string" && toolCallId.trim()) return toolCallId.trim(); + 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 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 { + 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.discardStableId(oldest.toolCallId); + } + } +} + +export function createToolProgressAccumulator(): ToolProgressAccumulator { + return new ToolProgressAccumulator(); +} + +export function summarizeToolProgress( + toolName: unknown, + input: unknown, + config: Pick, +): ToolProgressLabel | undefined { + 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); + return { + toolName: tool, + label, + semanticKey: semanticToolKey(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[] = []; + let omittedRows = false; + for (const row of rows) { + const next = [...parts, row.text].join(" · "); + if (next.length > limit) { + omittedRows = true; + break; + } + parts.push(row.text); + } + if (parts.length === 0) return undefined; + const output = parts.join(" · "); + return omittedRows ? `${output.slice(0, limit - 1).trimEnd()}…` : output; +} + +export function toolProgressRows(snapshot: ToolProgressAccumulatorSnapshot): ToolProgressFormattedRow[] { + 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[] = [ + ...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) { + 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 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 }); + return sanitized.length > maxLabelChars ? `${sanitized.slice(0, maxLabelChars - 1).trimEnd()}…` : sanitized; +} + +function semanticToolKey(label: string): string { + return 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..647980c 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,9 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { let latestTurnImages: LatestTurnImage[] = []; let latestTurnImageFileCandidates: LatestTurnImageFileCandidate[] = []; let progressSequence = 0; + const toolProgress = createToolProgressAccumulator(); + const suppressedToolProgressIds = new Set(); + const suppressedMissingToolProgress = new Map(); let diagnosticsLogger: CommunicationDiagnosticsLogger | undefined; const pendingApprovalResolvers = new Map void>(); @@ -386,6 +390,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 }> { + resetToolProgress(); let telegramStopped = false; let brokerRestarted = false; const discordStopped: string[] = []; @@ -581,12 +586,54 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { } } + function resetToolProgress(): void { + toolProgress.reset(); + suppressedToolProgressIds.clear(); + suppressedMissingToolProgress.clear(); + } + + function missingToolProgressKey(toolName: unknown): string | undefined { + const normalized = String(toolName ?? "").trim().toLowerCase(); + return normalized || undefined; + } + + 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 { activeTurnImages = []; activeTurnImagePathTexts = []; activeTurnCompletedAssistantText = undefined; latestTurnImages = []; latestTurnImageFileCandidates = []; + resetToolProgress(); } function appendAudit(message: string, route?: SessionRoute): void { @@ -638,11 +685,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 +702,24 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { currentRoute.lastActivityAt = Date.now(); } + function recordToolProgressActivity(input: ToolProgressEventInput & { state: "active" | "completed" | "failed" }, config: Pick): void { + if (!currentRoute) return; + 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(config)); + currentRoute.lastActivityAt = Date.now(); + } + + function optionalToolEventInput(event: unknown): unknown { + 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 { const data: BindingEntryData = { version: 1, @@ -1994,6 +2054,7 @@ export default function telegramTunnelExtension(pi: ExtensionAPI): void { pi.on("session_shutdown", async (_event, ctx) => { latestContext = ctx; + resetToolProgress(); closeConnectQrScreen = undefined; if (currentRoute) await notifyRelayLifecycle(ctx, "offline", currentRoute); if (currentRoute && configCache) { @@ -2106,35 +2167,67 @@ 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") { + const progressSuppressed = consumeToolProgressSuppression(event.message.toolCallId, event.message.toolName); + const hasLifecycleProgress = 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"}` }); publishRouteStateSoon(); } } }); + 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 } }); + toolProgress.start({ toolName: event.toolName, toolCallId: event.toolCallId, input }, config); + }); + pi.on("tool_call", async (event, ctx) => { latestContext = ctx; if (!currentRoute) return; + const currentSessionKey = currentRoute.sessionKey; currentRoute.actions.context = ctx; + 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 } }); 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(config); + 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." }); + suppressToolProgress(event, config); return { block: true, reason: "Approval required, but no active remote requester is available." }; } + recordAllowedProgress(config); + return; + } + if (!currentRoute.remoteRequesterActiveTurn) { + recordAllowedProgress(config); 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." }); + suppressToolProgress(event, config); return { block: true, reason: "Approval required, but the remote requester binding is inactive." }; } const grant = await findMatchingApprovalGrant(currentRoute, requester, operation, config); @@ -2142,6 +2235,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(config); return; } const request = createApprovalRequest({ route: currentRoute, requester, operation, timeoutMs: resolved.timeoutMs }); @@ -2153,6 +2247,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, 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())); @@ -2161,25 +2256,43 @@ 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, config); return { block: true, reason: decision.message }; } + 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) { + 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 (isToolProgressSuppressed(event.toolCallId, event.toolName)) { + if (event.isError) { + currentRoute.notification.lastFailure = `Tool ${event.toolName} failed.`; + publishRouteStateSoon(); + } + 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" }, config); 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" }, config); publishRouteStateSoon(); }); - pi.on("agent_end", async (event, ctx) => { latestContext = ctx; if (!currentRoute) return; @@ -2254,6 +2367,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"); + 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 a8e623c..f39a6e1 100644 --- a/openspec/changes/improve-tool-call-progress-reporting/tasks.md +++ b/openspec/changes/improve-tool-call-progress-reporting/tasks.md @@ -1,45 +1,46 @@ ## 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. +- [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 -- [ ] 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..d60d05c 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 results = await pi.emitResults("tool_call", { toolName: "bash", toolCallId: "tc-remote-missing", input: { command: "git push origin main" } }, context); + const lifecycleProgress = { ...registeredRoute!.notification.progressEvent }; + 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", 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); await expect(new TunnelStateStore(config.stateDir).listApprovalAudit()).resolves.toEqual([ expect.objectContaining({ kind: "failed", detail: "No active remote requester for approval target." }), ]); @@ -2902,19 +2908,124 @@ 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); + + 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"); + 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_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); + 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..5c4d528 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,154 @@ 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"); + expect(summarizeToolProgress("bash", { command: "npm test" }, safeConfig)?.semanticKey).toBe("bash:-npm-test"); + }); + + 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("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 }; + + 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("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 }; + + 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); + 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("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(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"); + }); + + 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(); + }); + + 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) { + 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 () => {