diff --git a/packages/zcode-tui/src/background-task-events.ts b/packages/zcode-tui/src/background-task-events.ts index 794c7f6..e542c6f 100644 --- a/packages/zcode-tui/src/background-task-events.ts +++ b/packages/zcode-tui/src/background-task-events.ts @@ -23,6 +23,7 @@ export interface TaskEventUpdate { changed: boolean; handoffSettled: boolean; notices: TaskEventNotice[]; + settledTurnId?: string; } interface HandoffTurn { @@ -107,6 +108,7 @@ function toolScope(event: StreamEvent): { export class BackgroundTaskEventStore { private readonly activities = new Map(); private readonly handoffTurns = new Map(); + private readonly handoffTexts = new Map(); private readonly pendingBackgroundTasks = new Set(); private readonly pendingSubagentMessages = new Set(); private readonly scopedTurnIds = new Set(); @@ -125,6 +127,7 @@ export class BackgroundTaskEventStore { const notices: TaskEventNotice[] = []; let changed = false; let handoffSettled = false; + let settledTurnId: string | undefined; const taskId = taskIdFor(event); if (event.type === "background_task_completed" && taskId) { @@ -195,6 +198,7 @@ export class BackgroundTaskEventStore { if (handoff && (completed || failed)) { this.handoffTurns.delete(event.turnId!); handoffSettled = true; + settledTurnId = event.turnId; if (failed) { const detail = event.message ?? "The task finished, but ZCode could not process its result."; for (const id of handoff.taskIds) this.record(id, "error", detail, event.turnId); @@ -210,7 +214,18 @@ export class BackgroundTaskEventStore { } } - return { changed, handoffSettled, notices }; + return { changed, handoffSettled, notices, settledTurnId }; + } + + /** + * The response text a background handoff turn produced for the conversation. + * Handoff turns are task-scoped away from the main transcript while they + * run, so the transcript render needs the accumulated text when they settle. + */ + takeHandoffText(turnId: string): string | undefined { + const text = this.handoffTexts.get(turnId); + this.handoffTexts.delete(turnId); + return text; } entries(taskId: string): readonly TaskActivityEntry[] { @@ -224,6 +239,7 @@ export class BackgroundTaskEventStore { settleActiveHandoffs(): number { const count = this.handoffTurns.size; this.handoffTurns.clear(); + this.handoffTexts.clear(); return count; } @@ -252,6 +268,12 @@ export class BackgroundTaskEventStore { } private appendHandoffDelta(taskId: string, turnId: string, delta: string): void { + const accumulated = bounded((this.handoffTexts.get(turnId) ?? "") + delta); + this.handoffTexts.set(turnId, accumulated); + if (this.handoffTexts.size > 32) { + const oldest = this.handoffTexts.keys().next().value; + if (oldest !== undefined && oldest !== turnId) this.handoffTexts.delete(oldest); + } const entries = this.activities.get(taskId) ?? []; const id = `handoff:${turnId}`; const existing = entries.find((entry) => entry.id === id); diff --git a/packages/zcode-tui/src/background-task-notifications.ts b/packages/zcode-tui/src/background-task-notifications.ts new file mode 100644 index 0000000..94900a4 --- /dev/null +++ b/packages/zcode-tui/src/background-task-notifications.ts @@ -0,0 +1,24 @@ +export interface RestoredNotificationMessage { + role?: string; + parts?: ReadonlyArray<{ type?: string; text?: string }>; +} + +const deliveredTaskIdPattern = /([^<]{1,200})<\/task-id>/gu; + +/** Task ids whose terminal notification already exists in the conversation. */ +export function deliveredBackgroundTaskIds( + messages: ReadonlyArray +): Set { + const delivered = new Set(); + for (const message of messages) { + if (message.role !== "user") continue; + for (const part of message.parts ?? []) { + if (typeof part.text !== "string") continue; + for (const match of part.text.matchAll(deliveredTaskIdPattern)) { + const taskId = match[1]?.trim(); + if (taskId) delivered.add(taskId); + } + } + } + return delivered; +} diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index d6648f0..fa44343 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -52,6 +52,7 @@ import { import { AttachmentBar } from "./attachment-bar.ts"; import { AssistantStream } from "./assistant-stream.ts"; import { BackgroundTaskEventStore } from "./background-task-events.ts"; +import { deliveredBackgroundTaskIds } from "./background-task-notifications.ts"; import { readBackgroundTaskOutput } from "./background-task-output.ts"; import { BoundedToolText, toolTextValue } from "./bounded-tool-text.ts"; import { choose, promptText, type ChoiceItem } from "./choice-dialog.ts"; @@ -698,6 +699,8 @@ class ZCodeTui { private unsubscribeSession?: () => void; private readonly backgroundTaskEvents = new BackgroundTaskEventStore(); private readonly restoredNoticeSeen = new Set(); + private readonly notifiedBackgroundTaskIds = new Set(); + private transcriptNotificationsReady = false; private readonly turnWork = new TurnWorkTracker(); private readonly backgroundCoordinatorMessageIds = new Set(); private workflowPanel?: Record; @@ -2647,10 +2650,25 @@ class ZCodeTui { }); void this.notifications.notify(notice.notification, notice.detail ?? `${notice.title} ยท ${notice.summary}`); } - if (update.handoffSettled) this.drainInputAfterBackgroundHandoff(); + if (update.handoffSettled) { + this.renderSettledHandoffResponse(update.settledTurnId); + this.drainInputAfterBackgroundHandoff(); + } if (update.changed || update.notices.length > 0) this.requestStreamRender(); } + /** + * Turns that a background task handed off to are task-scoped out of the + * main transcript while they run, so without this the response to a + * completed task is only reachable from /tasks and the assistant appears to + * stay silent after "Background task completed". + */ + private renderSettledHandoffResponse(turnId: string | undefined): void { + if (!turnId) return; + const text = this.backgroundTaskEvents.takeHandoffText(turnId); + if (text && text.trim()) this.addAssistantMessage(text); + } + private drainInputAfterBackgroundHandoff(): void { if (this.backgroundDrainScheduled) return; this.backgroundDrainScheduled = true; @@ -3377,6 +3395,8 @@ class ZCodeTui { this.currentToolGroupBlockId = undefined; this.currentToolGroupMessageId = undefined; this.assistantStream.breakSegment(); + for (const taskId of deliveredBackgroundTaskIds(messages)) this.notifiedBackgroundTaskIds.add(taskId); + this.transcriptNotificationsReady = true; if (lastAssistantModel) this.model = lastAssistantModel; } @@ -5507,8 +5527,11 @@ class ZCodeTui { } catch (error) { const message = error instanceof Error ? error.message : String(error); this.addNotice(`Unable to restore session transcript: ${message}`, "warning"); + // Classification of restored notifications degrades to unfiltered; + // the transcript history itself is unavailable either way. + this.transcriptNotificationsReady = true; } - } + } else this.transcriptNotificationsReady = true; await this.restoreSessionModel(); } @@ -5779,7 +5802,14 @@ class ZCodeTui { * model-facing task_status attachment alone does not reach the user. */ private noticeRestoredBackgroundTasks(projection: RuntimeProjectionSnapshot): void { - for (const job of projection.restoredBackgroundTasks ?? []) { + // Classification needs the restored transcript; until it loads, a + // delivered notification is indistinguishable from a silent one. + if (!this.transcriptNotificationsReady) return; + const jobs = projection.restoredBackgroundTasks ?? []; + for (const job of jobs) { + // Terminal notifications already present in the conversation were + // consumed by a prior turn; re-announcing them is pure noise. + if (this.notifiedBackgroundTaskIds.has(job.taskId)) continue; const seenKey = `${projection.sessionId ?? ""}/${job.taskId}`; if (this.restoredNoticeSeen.has(seenKey)) continue; this.restoredNoticeSeen.add(seenKey); diff --git a/scripts/smoke-tui-features.ts b/scripts/smoke-tui-features.ts index 90e6f64..a874dff 100644 --- a/scripts/smoke-tui-features.ts +++ b/scripts/smoke-tui-features.ts @@ -320,7 +320,14 @@ try { /Background result processing was interrupted; starting your queued input\.[\s\S]*Queued input started after interrupting the stuck background handoff\./i ); const foregroundBeforeTaskCenter = plainText(output.slice(featureTurnStart)); - if (/Task-scoped agent handoff completed|Coordinator began processing the failed task result|Background-only reasoning|background_fetch|Coordinator dispatching background research|background-research|Inspect nested rendering|RAW_CHILD_/i.test(foregroundBeforeTaskCenter)) { + // Settled handoff responses are rendered into the foreground transcript on + // purpose; the task's internal activity must stay scoped to /tasks. + for (const response of ["Task-scoped agent handoff completed.", "Coordinator began processing the failed task result."]) { + if (!foregroundBeforeTaskCenter.includes(response)) { + throw new Error(`Settled background handoff response is missing from the foreground transcript: ${response}`); + } + } + if (/Background-only reasoning|background_fetch|Coordinator dispatching background research|background-research|Inspect nested rendering|RAW_CHILD_/i.test(foregroundBeforeTaskCenter)) { throw new Error("Task-scoped background output leaked into the foreground transcript."); } if (!/TaskOutput task agent_feature[\s\S]*Compact child result preserved\./i.test(foregroundBeforeTaskCenter)) { diff --git a/test/background-task-events.test.ts b/test/background-task-events.test.ts index d58ed82..8307dc0 100644 --- a/test/background-task-events.test.ts +++ b/test/background-task-events.test.ts @@ -10,6 +10,64 @@ function event(value: unknown) { } describe("background task event store", () => { + test("exposes the settled handoff turn's response text exactly once", () => { + const store = new BackgroundTaskEventStore(); + store.handle(event({ + id: "text-task-completed", + type: "background_task_completed", + payload: { taskId: "bg-text", status: "completed" } + })); + store.handle(event({ + id: "text-handoff-started", + type: "turn_started", + turnId: "handoff-text-turn", + payload: { inputSource: "background_task" } + })); + store.handle(event({ + id: "text-handoff-delta-1", + type: "model.streaming", + turnId: "handoff-text-turn", + payload: { kind: "text_delta", delta: "Result processed." } + })); + store.handle(event({ + id: "text-handoff-delta-2", + type: "model.streaming", + turnId: "handoff-text-turn", + payload: { kind: "text_delta", delta: " All clear." } + })); + const settled = store.handle(event({ + id: "text-handoff-completed", + type: "turn_complete", + turnId: "handoff-text-turn", + payload: {} + })); + + expect(settled.handoffSettled).toBe(true); + expect(settled.settledTurnId).toBe("handoff-text-turn"); + expect(store.takeHandoffText("handoff-text-turn")).toBe("Result processed. All clear."); + expect(store.takeHandoffText("handoff-text-turn")).toBeUndefined(); + }); + + test("drops pending handoff response text when the user preempts the handoff", () => { + const store = new BackgroundTaskEventStore(); + store.handle(event({ + id: "preempt-handoff-started", + type: "turn_started", + turnId: "preempted-handoff-turn", + payload: { inputSource: "background_task" } + })); + store.handle(event({ + id: "preempt-handoff-delta", + type: "model.streaming", + turnId: "preempted-handoff-turn", + payload: { kind: "text_delta", delta: "Partial response." } + })); + + store.settleActiveHandoffs(); + + expect(store.takeHandoffText("preempted-handoff-turn")).toBeUndefined(); + }); + test("scopes subagent execution without starting a foreground result handoff", () => { const store = new BackgroundTaskEventStore(); const started = event({ diff --git a/test/background-task-notifications.test.ts b/test/background-task-notifications.test.ts new file mode 100644 index 0000000..0a662dd --- /dev/null +++ b/test/background-task-notifications.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; + +import { deliveredBackgroundTaskIds } from "../packages/zcode-tui/src/background-task-notifications.ts"; + +describe("background task notifications", () => { + test("collects delivered task ids from user notification messages only", () => { + const delivered = deliveredBackgroundTaskIds([ + { + role: "user", + parts: [{ + type: "text", + text: "\nbg-1\ncompleted\n" + }] + }, + { role: "assistant", parts: [{ type: "text", text: "bg-9" }] }, + { role: "user", parts: [{ type: "text", text: "a plain user message" }] } + ]); + + expect([...delivered]).toEqual(["bg-1"]); + }); +});