From eaa2296a56a06663396a8ec2caf779b133ec94d7 Mon Sep 17 00:00:00 2001 From: Muhammad Rheza Alfin Date: Fri, 2 Oct 2026 22:36:27 +0700 Subject: [PATCH] fix(background-subagent): keep card alive across turn for V1 background subagents - FIX-BGSUB: V1 background subagents retired at turn boundary The gate now accepts running OR completed and input.background OR metadata.background; a background intent keyed by child session id is retained until the card attaches (task part can precede session.created); isBackgroundTool also honors metadata.background. --- .../managers/summary-aggregation-manager.ts | 28 +++++- .../events/handlers/tool-activity-handler.ts | 2 +- .../summary-aggregation-manager.test.ts | 97 +++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/app/managers/summary-aggregation-manager.ts b/src/app/managers/summary-aggregation-manager.ts index 212c05478..5168a845f 100644 --- a/src/app/managers/summary-aggregation-manager.ts +++ b/src/app/managers/summary-aggregation-manager.ts @@ -368,6 +368,9 @@ export class SummaryAggregator { private subagentStates: Map = new Map(); private subagentOrder: string[] = []; private subagentCardIdBySessionId: Map = new Map(); + // Child session ids whose task part announced a background launch, kept until + // the card attaches so a task part that arrives before session.created is honored. + private backgroundChildSessionIds = new Set(); private pendingSubagentCardIdsByParent: Map = new Map(); private pendingChildSessionIdsByParent: Map = new Map(); private fallbackSubagentCardIdsByParent: Map = new Map(); @@ -700,6 +703,7 @@ export class SummaryAggregator { this.subagentStates.clear(); this.subagentOrder = []; this.subagentCardIdBySessionId.clear(); + this.backgroundChildSessionIds.clear(); this.pendingSubagentCardIdsByParent.clear(); this.pendingChildSessionIdsByParent.clear(); this.fallbackSubagentCardIdsByParent.clear(); @@ -815,6 +819,7 @@ export class SummaryAggregator { if (state.sessionId) { this.subagentCardIdBySessionId.delete(state.sessionId); + this.backgroundChildSessionIds.delete(state.sessionId); this.trackedSessionParents.delete(state.sessionId); this.removeFromQueue( this.pendingChildSessionIdsByParent, @@ -863,6 +868,16 @@ export class SummaryAggregator { this.emitSubagentState(); } + /** Applies a background intent recorded before the child's card attached. */ + private applyPendingBackgroundIntent(sessionId: string, state: SubagentState): void { + if (!this.backgroundChildSessionIds.has(sessionId) || state.background) { + return; + } + + state.background = true; + this.emitSubagentState(); + } + private emitSubagentState(): void { if (!this.currentSessionId || !this.onSubagentCallback || this.subagentOrder.length === 0) { return; @@ -970,6 +985,7 @@ export class SummaryAggregator { this.subagentOrder.push(cardId); if (sessionId) { this.subagentCardIdBySessionId.set(sessionId, cardId); + this.applyPendingBackgroundIntent(sessionId, state); } return state; } @@ -1049,6 +1065,7 @@ export class SummaryAggregator { state.updatedAt = Date.now(); this.subagentCardIdBySessionId.set(sessionId, cardId); this.removeFromQueue(this.pendingSubagentCardIdsByParent, state.parentSessionId, cardId); + this.applyPendingBackgroundIntent(sessionId, state); } private findNextSubagentForTaskTool(parentSessionId: string): SubagentState | null { @@ -1646,12 +1663,19 @@ export class SummaryAggregator { const childSessionId = "metadata" in state && state.metadata ? state.metadata.sessionId : undefined; + // The running state is transient and the task part can land before the + // child card exists, so accept a completed state and a background flag + // carried on the metadata (a promoted foreground task) as well. + const wantsBackground = + input?.background === true || + ("metadata" in state && state.metadata?.background === true); if ( "status" in state && - state.status === "running" && - input?.background === true && + (state.status === "running" || state.status === "completed") && + wantsBackground && typeof childSessionId === "string" ) { + this.backgroundChildSessionIds.add(childSessionId); this.markBackgroundSubagent(childSessionId); } } diff --git a/src/bot/events/handlers/tool-activity-handler.ts b/src/bot/events/handlers/tool-activity-handler.ts index 8bb8b9832..84f79c424 100644 --- a/src/bot/events/handlers/tool-activity-handler.ts +++ b/src/bot/events/handlers/tool-activity-handler.ts @@ -74,7 +74,7 @@ function prepareDocumentCaption(caption: string): string { /** Launched in the background: the operation outlives the turn that started it. */ function isBackgroundTool(toolInfo: ToolInfo): boolean { - return toolInfo.input?.background === true; + return toolInfo.input?.background === true || toolInfo.metadata?.background === true; } function getCompactToolActivity(toolInfo: ToolInfo): string { diff --git a/tests/app/managers/summary-aggregation-manager.test.ts b/tests/app/managers/summary-aggregation-manager.test.ts index e9cfa90b9..1b3cf4c55 100644 --- a/tests/app/managers/summary-aggregation-manager.test.ts +++ b/tests/app/managers/summary-aggregation-manager.test.ts @@ -650,6 +650,103 @@ describe("summary/aggregator", () => { expect(onSubagent.mock.calls).toHaveLength(callsAfterStop); }); + function emitTaskPart(state: Record): void { + summaryAggregator.processEvent({ + type: "message.part.updated", + properties: { + part: { + id: "root-task", + sessionID: "root-session", + messageID: "root-message", + type: "tool", + callID: "call-task", + tool: "task", + state, + }, + }, + } as unknown as Event); + } + + it("honors a background task part that arrives before the child session is created", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + summaryAggregator.setSession("root-session"); + + emitTaskPart({ + status: "running", + input: { description: "task description", background: true }, + metadata: { sessionId: "child-session-1" }, + time: { start: Date.now() }, + }); + + summaryAggregator.processEvent({ + type: "session.created", + properties: { + info: { + id: "child-session-1", + parentID: "root-session", + title: "task description (@explore subagent)", + slug: "child", + directory: "D:/repo", + projectID: "p1", + version: "1", + time: { created: Date.now(), updated: Date.now() }, + }, + }, + } as unknown as Event); + + expect(onSubagent.mock.lastCall?.[1][0]).toMatchObject({ background: true }); + + emitIdle("root-session"); + emitChildTool("call-1"); + expect(onSubagent.mock.lastCall?.[1][0]).toMatchObject({ + background: true, + status: "running", + currentToolCallId: "call-1", + }); + }); + + it("marks a promoted task as background when only its metadata carries the flag", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + startSubagent(); + + emitTaskPart({ + status: "running", + input: { description: "task description" }, + metadata: { sessionId: "child-session-1", background: true }, + time: { start: Date.now() }, + }); + + expect(onSubagent.mock.lastCall?.[1][0]).toMatchObject({ background: true }); + + emitIdle("root-session"); + emitChildTool("call-1"); + expect(onSubagent.mock.lastCall?.[1][0]).toMatchObject({ + background: true, + currentToolCallId: "call-1", + }); + }); + + it("still retires a plain foreground task at the turn boundary", () => { + const onSubagent = vi.fn(); + summaryAggregator.setOnSubagent(onSubagent); + startSubagent(); + + emitTaskPart({ + status: "running", + input: { description: "task description" }, + metadata: { sessionId: "child-session-1" }, + time: { start: Date.now() }, + }); + + emitIdle("root-session"); + const callsAfterIdle = onSubagent.mock.calls.length; + + emitChildTool("call-1"); + expect(onSubagent.mock.calls).toHaveLength(callsAfterIdle); + }); + it("re-emits and restarts timing for an identical tool with a new call id", async () => { const onSubagent = vi.fn(); summaryAggregator.setOnSubagent(onSubagent);