From 2674b34df96a49f54301a7fb0dec7f2b0088072e Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 6 Sep 2026 21:52:01 +0800 Subject: [PATCH] fix(tui): isolate background subagent session events --- .../zcode-tui/src/background-task-events.ts | 8 ++- packages/zcode-tui/src/events.ts | 6 ++ packages/zcode-tui/src/index.ts | 8 ++- scripts/smoke-tui-features.ts | 7 +- test/background-task-events.test.ts | 35 +++++++++ test/events.test.ts | 28 ++++++++ test/fixtures/tui-features.ts | 71 +++++++++++++++++++ 7 files changed, 157 insertions(+), 6 deletions(-) diff --git a/packages/zcode-tui/src/background-task-events.ts b/packages/zcode-tui/src/background-task-events.ts index afb5320..794c7f6 100644 --- a/packages/zcode-tui/src/background-task-events.ts +++ b/packages/zcode-tui/src/background-task-events.ts @@ -36,10 +36,14 @@ const maximumEntryCharacters = 20_000; const maximumScopedTurnIds = 256; const maximumScopedToolCallIds = 512; -function autonomousInputSource(source: string | undefined): source is HandoffTurn["source"] { +function handoffInputSource(source: string | undefined): source is HandoffTurn["source"] { return source === "background_task" || source === "subagent_message"; } +function autonomousInputSource(source: string | undefined): boolean { + return source === "subagent" || handoffInputSource(source); +} + function taskIdFor(event: StreamEvent): string | undefined { return event.taskId ?? event.agentId; } @@ -169,7 +173,7 @@ export class BackgroundTaskEventStore { const started = event.type === "turn_started" || event.type === "turn.started"; if (started && event.turnId - && autonomousInputSource(event.inputSource)) { + && handoffInputSource(event.inputSource)) { const pending = event.inputSource === "background_task" ? this.pendingBackgroundTasks : this.pendingSubagentMessages; diff --git a/packages/zcode-tui/src/events.ts b/packages/zcode-tui/src/events.ts index 572694e..63dfe3a 100644 --- a/packages/zcode-tui/src/events.ts +++ b/packages/zcode-tui/src/events.ts @@ -8,6 +8,7 @@ export interface StreamEvent { field?: "text" | "reasoning" | "input" | "output"; messageId?: string; partId?: string; + sessionId?: string; turnId?: string; eventId?: string; inputSource?: string; @@ -123,6 +124,11 @@ export function normalizeEvent(value: unknown): StreamEvent | null { ?? asString(body.assistantMessageId) ?? part?.messageId, partId: asString(body.partId) ?? asString(body.partID) ?? part?.partId, + sessionId: asString(value.sessionId) ?? asString(value.sessionID) + ?? asString(params?.sessionId) ?? asString(params?.sessionID) + ?? asString(payload?.sessionId) ?? asString(payload?.sessionID) + ?? asString(body.sessionId) ?? asString(body.sessionID) + ?? part?.sessionId, turnId: envelopeString("turnId") ?? envelopeString("turnID"), eventId: asString(value.eventId) ?? (params && asString(params.eventId)) diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index b231832..84e2a8e 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -2144,7 +2144,7 @@ class ZCodeTui { this.debugEvent("session", value); if (turnEpoch !== undefined && turnEpoch !== this.activeTurnEpoch) return; const event = normalizeEvent(value); - if (!event) return; + if (!event || this.isForeignSessionEvent(event)) return; const taskScoped = this.backgroundTaskEvents.isTaskScoped(event); this.applyBackgroundTaskEvent(event); if (!taskScoped && event.kind && toolLifecycleEventKinds.has(event.kind)) this.turnHadWorkActivity = true; @@ -2319,10 +2319,14 @@ class ZCodeTui { private onSessionEvent(value: unknown): void { this.debugEvent("session-subscription", value); const event = normalizeEvent(value); - if (!event) return; + if (!event || this.isForeignSessionEvent(event)) return; this.applyBackgroundTaskEvent(event); } + private isForeignSessionEvent(event: StreamEvent): boolean { + return Boolean(this.sessionId && event.sessionId && event.sessionId !== this.sessionId); + } + private isBackgroundCoordinatorReasoning(event: StreamEvent): boolean { if (!event.messageId || !this.backgroundCoordinatorMessageIds.has(event.messageId)) return false; return event.kind === "reasoning_start" diff --git a/scripts/smoke-tui-features.ts b/scripts/smoke-tui-features.ts index 29dc9e7..2023b84 100644 --- a/scripts/smoke-tui-features.ts +++ b/scripts/smoke-tui-features.ts @@ -295,15 +295,18 @@ 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/i.test(foregroundBeforeTaskCenter)) { + 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)) { 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)) { + throw new Error("Session event filtering hid the parent TaskOutput result."); + } if (/Turn cancelled\./i.test(foregroundBeforeTaskCenter)) { throw new Error("Esc cancelled the foreground submission while a background handoff was active."); } const expandedForegroundStart = await sendAndWait("\x0f", "expanded foreground tool transcript", /source text/i); const expandedForeground = plainText(output.slice(expandedForegroundStart)); - if (/Coordinator dispatching background research|background-research|Inspect nested rendering|Nested rendering inspected/i.test(expandedForeground)) { + if (/Coordinator dispatching background research|background-research|Inspect nested rendering|Nested rendering inspected|RAW_CHILD_/i.test(expandedForeground)) { throw new Error("Expanding foreground tools exposed a background Agent tree."); } await sendAndWait("/diff\r", "diff source picker", /Select current workspace changes or a completed turn/i); diff --git a/test/background-task-events.test.ts b/test/background-task-events.test.ts index 74ed345..d58ed82 100644 --- a/test/background-task-events.test.ts +++ b/test/background-task-events.test.ts @@ -10,6 +10,41 @@ function event(value: unknown) { } describe("background task event store", () => { + test("scopes subagent execution without starting a foreground result handoff", () => { + const store = new BackgroundTaskEventStore(); + const started = event({ + type: "turn_started", + turnId: "child-turn", + payload: { inputSource: "subagent" } + }); + store.handle(started); + + expect(store.isTaskScoped(started)).toBe(true); + expect(store.hasActiveHandoffs()).toBe(false); + expect(store.isTaskScoped(event({ + type: "model.streaming", + turnId: "child-turn", + payload: { kind: "text_delta", delta: "Child final answer." } + }))).toBe(true); + expect(store.isTaskScoped(event({ + type: "tool_call_started", + turnId: "child-turn", + payload: { toolCallId: "child-bash", toolName: "Bash" } + }))).toBe(true); + expect(store.isTaskScoped(event({ + type: "model.streaming", + payload: { inputSource: "subagent", kind: "text_delta", delta: "Unscoped child text." } + }))).toBe(true); + expect(store.isTaskScoped(event({ + type: "model.streaming", + turnId: "parent-turn", + payload: { kind: "text_delta", delta: "Parent final answer." } + }))).toBe(false); + expect(store.handle(event({ + type: "turn_complete", turnId: "child-turn", payload: {} + })).handoffSettled).toBe(false); + }); + test("routes autonomous output to its task without treating handoff completion as task completion", () => { const store = new BackgroundTaskEventStore(); const completed = store.handle(event({ diff --git a/test/events.test.ts b/test/events.test.ts index cd1ef8c..554ef59 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -122,6 +122,34 @@ describe("ZCode event adapter", () => { }); }); + test.each([ + { type: "model.streaming", sessionId: "session_child", payload: { kind: "text_delta", delta: "child" } }, + { method: "session/event", params: { type: "model.streaming", sessionID: "session_child", payload: { kind: "text_delta", delta: "child" } } }, + { type: "model.streaming", payload: { sessionId: "session_child", event: { kind: "text_delta", delta: "child" } } }, + { type: "model.streaming", payload: { event: { sessionID: "session_child", kind: "text_delta", delta: "child" } } }, + { type: "part.upserted", payload: { part: { type: "text", sessionId: "session_child", text: "child" } } } + ])("preserves the owning session across event envelopes: %j", (value) => { + expect(normalizeEvent(value)?.sessionId).toBe("session_child"); + }); + + test("keeps parent event ownership separate from child-session metadata", () => { + expect(normalizeEvent({ + type: "part.started", + sessionId: "session_parent", + payload: { + childSessionId: "session_child", + part: { type: "text", sessionId: "session_child", text: "mirrored" } + } + })).toMatchObject({ + sessionId: "session_parent", + childSessionId: "session_child" + }); + expect(normalizeEvent({ + type: "subagent_spawned", + payload: { childSessionId: "session_child" } + })?.sessionId).toBeUndefined(); + }); + test("normalizes raw autonomous turn lifecycle metadata and failures", () => { expect(normalizeEvent({ id: "event_background_start", diff --git a/test/fixtures/tui-features.ts b/test/fixtures/tui-features.ts index f9c0626..e1fbddc 100644 --- a/test/fixtures/tui-features.ts +++ b/test/fixtures/tui-features.ts @@ -119,6 +119,76 @@ async function emitSessionEvent( await submissionListener?.(event); } +async function emitChildSessionActivity(options: PromptCallOptions): Promise { + const childSessionId = "session_feature_child"; + for (const sessionId of [childSessionId, "session_feature_child_two"]) { + await emitSessionEvent({ + type: "model.streaming", + sessionId, + payload: { kind: "text_delta", delta: "RAW_CHILD_TEXT_129", messageId: `${sessionId}_message` } + }, options.onEvent); + } + await emitSessionEvent({ + type: "tool_call_started", + sessionId: childSessionId, + payload: { + toolCallId: "raw_child_bash_129", + toolName: "Bash", + input: { command: "printf RAW_CHILD_TOOL_129" } + } + }, options.onEvent); + await emitSessionEvent({ + type: "tool_call_result", + sessionId: childSessionId, + payload: { toolCallId: "raw_child_bash_129", toolName: "Bash", result: "RAW_CHILD_TOOL_129" } + }, options.onEvent); + await emitSessionEvent({ + type: "part.started", + payload: { + part: { + type: "text", + sessionId: childSessionId, + partId: "raw_child_part_129", + messageId: "raw_child_message_129", + text: "RAW_CHILD_PART_129" + } + } + }, options.onEvent); + await emitSessionEvent({ + type: "background_task_completed", + sessionId: childSessionId, + payload: { taskId: "RAW_CHILD_NESTED_TASK_129", status: "completed" } + }, options.onEvent); + await emitSessionEvent({ + type: "tool_call_started", + sessionId: "feature-session", + payload: { + toolCallId: "call_task_output_129", + toolName: "TaskOutput", + input: { task_id: "agent_feature", block: true } + } + }, options.onEvent); + await emitSessionEvent({ + type: "tool_call_result", + sessionId: "feature-session", + payload: { + toolCallId: "call_task_output_129", + toolName: "TaskOutput", + childSessionId, + result: { + success: true, + display: { + kind: "task_output", + retrievalStatus: "success", + taskStatus: "completed", + output: "Compact child result preserved." + } + } + } + }, options.onEvent); + await Bun.sleep(80); +} + async function emitBackgroundResultTurn( turnId: string, eventPrefix: string, @@ -798,6 +868,7 @@ await runTui({ childSessionId: "session_feature_child", description: "Inspect nested rendering" }); + await emitChildSessionActivity(options); const childPart = { type: "tool", partId: "part_child_fetch",