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
8 changes: 6 additions & 2 deletions packages/zcode-tui/src/background-task-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/zcode-tui/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface StreamEvent {
field?: "text" | "reasoning" | "input" | "output";
messageId?: string;
partId?: string;
sessionId?: string;
turnId?: string;
eventId?: string;
inputSource?: string;
Expand Down Expand Up @@ -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))
Expand Down
8 changes: 6 additions & 2 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions scripts/smoke-tui-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions test/background-task-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
28 changes: 28 additions & 0 deletions test/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions test/fixtures/tui-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,76 @@ async function emitSessionEvent(
await submissionListener?.(event);
}

async function emitChildSessionActivity(options: PromptCallOptions): Promise<void> {
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,
Expand Down Expand Up @@ -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",
Expand Down