From f8b393bad3ea396f6d55bdbf75b074f925b84a93 Mon Sep 17 00:00:00 2001 From: ktkim1 Date: Sun, 18 Oct 2026 17:05:30 +0900 Subject: [PATCH 1/3] fix(tui): surface silent background task completions Two paths let a finished background task disappear silently. A turn handed off from a completed task is task-scoped out of the main transcript, so its response was only reachable from /tasks and the assistant appeared to ignore the completion notice; render the settled handoff response in the transcript instead. And on session resume, restored tasks whose terminal notifications were already delivered in the conversation were re-announced as fresh system events; classify restored tasks against the transcript and skip the ones a prior turn already consumed. Change-Id: Ia18ac9696142b07e0e7a95c4fdba446cec93c76f --- .../zcode-tui/src/background-task-events.ts | 24 +++++++- .../src/background-task-notifications.ts | 24 ++++++++ packages/zcode-tui/src/index.ts | 34 ++++++++++- test/background-task-events.test.ts | 58 +++++++++++++++++++ test/background-task-notifications.test.ts | 21 +++++++ 5 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 packages/zcode-tui/src/background-task-notifications.ts create mode 100644 test/background-task-notifications.test.ts diff --git a/packages/zcode-tui/src/background-task-events.ts b/packages/zcode-tui/src/background-task-events.ts index 794c7f63..e542c6f9 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 00000000..94900a40 --- /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 d6648f07..f6c26408 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,6 +5527,9 @@ 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; } } 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/test/background-task-events.test.ts b/test/background-task-events.test.ts index d58ed826..8307dc0d 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 00000000..0a662dd4 --- /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"]); + }); +}); From b8b709e9eedb1c942bf3dff98235ef6dc0c720dc Mon Sep 17 00:00:00 2001 From: ktkim1 Date: Mon, 19 Oct 2026 11:29:23 +0900 Subject: [PATCH 2/3] test(tui): expect settled handoff responses in the transcript The feature smoke guarded the old contract where every task-scoped string stayed out of the foreground transcript, so the handoff rendering from f8b393b fails CI on its own fixture text. Assert the settled responses appear in the transcript now, while reasoning, tool names, and nested agent trees must still stay scoped to /tasks. Change-Id: I5c6b840901247c352d518842d31b7338240031d0 --- scripts/smoke-tui-features.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-tui-features.ts b/scripts/smoke-tui-features.ts index 90e6f64a..a874dffc 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)) { From 50685c82c075f65ad7ef4713457ef83c214d0c50 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Mon, 19 Oct 2026 16:35:03 +0800 Subject: [PATCH 3/3] fix(tui): surface restored task notices without transcript loader --- packages/zcode-tui/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index f6c26408..fa443436 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -5531,7 +5531,7 @@ class ZCodeTui { // the transcript history itself is unavailable either way. this.transcriptNotificationsReady = true; } - } + } else this.transcriptNotificationsReady = true; await this.restoreSessionModel(); }