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
28 changes: 26 additions & 2 deletions src/app/managers/summary-aggregation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,9 @@ export class SummaryAggregator {
private subagentStates: Map<string, SubagentState> = new Map();
private subagentOrder: string[] = [];
private subagentCardIdBySessionId: Map<string, string> = 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<string>();
private pendingSubagentCardIdsByParent: Map<string, string[]> = new Map();
private pendingChildSessionIdsByParent: Map<string, string[]> = new Map();
private fallbackSubagentCardIdsByParent: Map<string, string[]> = new Map();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -970,6 +985,7 @@ export class SummaryAggregator {
this.subagentOrder.push(cardId);
if (sessionId) {
this.subagentCardIdBySessionId.set(sessionId, cardId);
this.applyPendingBackgroundIntent(sessionId, state);
}
return state;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/bot/events/handlers/tool-activity-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
97 changes: 97 additions & 0 deletions tests/app/managers/summary-aggregation-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,103 @@ describe("summary/aggregator", () => {
expect(onSubagent.mock.calls).toHaveLength(callsAfterStop);
});

function emitTaskPart(state: Record<string, unknown>): 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);
Expand Down
Loading