Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/zcode-tui/src/background-task-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface TaskEventUpdate {
changed: boolean;
handoffSettled: boolean;
notices: TaskEventNotice[];
settledTurnId?: string;
}

interface HandoffTurn {
Expand Down Expand Up @@ -107,6 +108,7 @@ function toolScope(event: StreamEvent): {
export class BackgroundTaskEventStore {
private readonly activities = new Map<string, TaskActivityEntry[]>();
private readonly handoffTurns = new Map<string, HandoffTurn>();
private readonly handoffTexts = new Map<string, string>();
private readonly pendingBackgroundTasks = new Set<string>();
private readonly pendingSubagentMessages = new Set<string>();
private readonly scopedTurnIds = new Set<string>();
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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[] {
Expand All @@ -224,6 +239,7 @@ export class BackgroundTaskEventStore {
settleActiveHandoffs(): number {
const count = this.handoffTurns.size;
this.handoffTurns.clear();
this.handoffTexts.clear();
return count;
}

Expand Down Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions packages/zcode-tui/src/background-task-notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface RestoredNotificationMessage {
role?: string;
parts?: ReadonlyArray<{ type?: string; text?: string }>;
}

const deliveredTaskIdPattern = /<task-id>([^<]{1,200})<\/task-id>/gu;

/** Task ids whose terminal notification already exists in the conversation. */
export function deliveredBackgroundTaskIds(
messages: ReadonlyArray<RestoredNotificationMessage>
): Set<string> {
const delivered = new Set<string>();
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;
}
36 changes: 33 additions & 3 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -698,6 +699,8 @@ class ZCodeTui {
private unsubscribeSession?: () => void;
private readonly backgroundTaskEvents = new BackgroundTaskEventStore();
private readonly restoredNoticeSeen = new Set<string>();
private readonly notifiedBackgroundTaskIds = new Set<string>();
private transcriptNotificationsReady = false;
private readonly turnWork = new TurnWorkTracker();
private readonly backgroundCoordinatorMessageIds = new Set<string>();
private workflowPanel?: Record<string, unknown>;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion scripts/smoke-tui-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
58 changes: 58 additions & 0 deletions test/background-task-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions test/background-task-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n</task-notification>"
}]
},
{ role: "assistant", parts: [{ type: "text", text: "<task-id>bg-9</task-id>" }] },
{ role: "user", parts: [{ type: "text", text: "a plain user message" }] }
]);

expect([...delivered]).toEqual(["bg-1"]);
});
});
Loading