From 01af3937c56edfe9225cf137d11862f9c585c9fd Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Fri, 9 Oct 2026 19:22:50 +0800 Subject: [PATCH 1/2] fix(subagent): wake an idle parent when a background lane settles Lane notifications were sent with { deliverAs: "steer" } only. A steer reaches the model only while the parent is mid-turn; when the parent is idle, which is exactly when it waits on background lanes, the message is appended and no turn starts. A parent told to "wait for the lanes and report" therefore never saw the results until the user typed again. Done, failed, interrupted, and needs-input events now pass triggerTurn: true. Progress and restart notices still only ride along, so a progress subscription does not wake the parent every 15s per lane. --- .../src/features/subagent/lane-events.ts | 16 +++++++++- .../test/step-subagent-events.test.ts | 29 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/features/subagent/lane-events.ts b/packages/coding-agent/src/features/subagent/lane-events.ts index 3367b621..3bb701b5 100644 --- a/packages/coding-agent/src/features/subagent/lane-events.ts +++ b/packages/coding-agent/src/features/subagent/lane-events.ts @@ -18,6 +18,14 @@ export type BackgroundLaneEvent = | "background_progress" | "background_restarted"; +/** Events that start a parent turn when the parent is idle. */ +const WAKING_EVENTS: ReadonlySet = new Set([ + "background_done", + "background_failed", + "background_interrupted", + "background_needs_input", +]); + /** Minimum interval between background_progress notifications per lane. */ const PROGRESS_NOTIFY_INTERVAL_MS = 15_000; @@ -69,7 +77,13 @@ export function notifyLaneEvent( display: true, details: { agentId: lane.id, event, status: lane.status }, }, - { deliverAs: "steer" }, + // A lane usually settles while the parent sits idle, waiting on it. A + // steer alone only appends the message then, so the model never reads the + // result until the user types again. Terminal and needs-input events wake + // the parent; progress and restart notices ride along with the next turn + // instead of waking it every 15s per lane. While the parent is mid-turn + // both forms steer into that turn. + { deliverAs: "steer", triggerTurn: WAKING_EVENTS.has(event) }, ); } diff --git a/packages/coding-agent/test/step-subagent-events.test.ts b/packages/coding-agent/test/step-subagent-events.test.ts index d1ffa52d..d3e6cc26 100644 --- a/packages/coding-agent/test/step-subagent-events.test.ts +++ b/packages/coding-agent/test/step-subagent-events.test.ts @@ -18,6 +18,7 @@ interface SentMessage { content: string; details?: { agentId?: string; event?: string; status?: string }; deliverAs?: string; + triggerTurn?: boolean; } function textResult(value: string): StepSubagentRunResult { @@ -64,8 +65,8 @@ function createApi(): { api: ExtensionAPI; tools: Map; s setActiveTools: () => {}, getFlag: () => false, appendEntry: () => {}, - sendMessage: (message: SentMessage, options?: { deliverAs?: string }) => { - sent.push({ ...message, deliverAs: options?.deliverAs }); + sendMessage: (message: SentMessage, options?: { deliverAs?: string; triggerTurn?: boolean }) => { + sent.push({ ...message, deliverAs: options?.deliverAs, triggerTurn: options?.triggerTurn }); }, sendUserMessage: () => {}, } as unknown as ExtensionAPI; @@ -163,6 +164,8 @@ test("background lane completion steers an escaped agent-notification", async () const done = sent[0]; expect(done.customType).toBe("agent-notification"); expect(done.deliverAs).toBe("steer"); + // The parent is usually idle while it waits on a lane; the result must wake it. + expect(done.triggerTurn).toBe(true); expect(done.details?.event).toBe("background_done"); expect(done.content).toContain('alias="audit" role="admin"'); expect(done.content).not.toContain('alias="audit" role="admin"'); @@ -473,3 +476,25 @@ test("agent_send validates its target and action inputs", async () => { expect(tools.has("agent_wait")).toBe(false); expect(tools.has("agent_list")).toBe(false); }); + +test("progress notifications ride along without waking an idle parent", async () => { + const { notifyLaneEvent } = await import("../src/features/subagent/lane-events.ts"); + const sent: Array<{ event?: string; triggerTurn?: boolean }> = []; + const pi = { + sendMessage: (message: { details?: { event?: string } }, options?: { triggerTurn?: boolean }) => + sent.push({ event: message.details?.event, triggerTurn: options?.triggerTurn }), + } as unknown as ExtensionAPI; + const lane = { id: "lane", subscribe: "progress", status: "running", details: { results: [] } } as never; + + notifyLaneEvent(pi, lane, "background_progress", "step 1/1"); + notifyLaneEvent(pi, lane, "background_restarted"); + notifyLaneEvent(pi, lane, "background_needs_input", "which file?"); + notifyLaneEvent(pi, lane, "background_failed", "boom"); + + expect(sent).toEqual([ + { event: "background_progress", triggerTurn: false }, + { event: "background_restarted", triggerTurn: false }, + { event: "background_needs_input", triggerTurn: true }, + { event: "background_failed", triggerTurn: true }, + ]); +}); From 3e78f793a0462d8d28da25418704a7c22da5b69f Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Fri, 9 Oct 2026 20:24:31 +0800 Subject: [PATCH 2/2] fix(subagent): keep progress notices steering into a running turn Passing triggerTurn: false made AgentSession defer background_progress and background_restarted to the end of a running turn instead of steering them in. Leave triggerTurn unset for non-waking events so they steer mid-turn and only append while the parent is idle. --- .../src/features/subagent/lane-events.ts | 7 ++++--- .../test/step-subagent-events.test.ts | 18 +++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/features/subagent/lane-events.ts b/packages/coding-agent/src/features/subagent/lane-events.ts index 3bb701b5..c6368a82 100644 --- a/packages/coding-agent/src/features/subagent/lane-events.ts +++ b/packages/coding-agent/src/features/subagent/lane-events.ts @@ -81,9 +81,10 @@ export function notifyLaneEvent( // steer alone only appends the message then, so the model never reads the // result until the user types again. Terminal and needs-input events wake // the parent; progress and restart notices ride along with the next turn - // instead of waking it every 15s per lane. While the parent is mid-turn - // both forms steer into that turn. - { deliverAs: "steer", triggerTurn: WAKING_EVENTS.has(event) }, + // instead of waking it every 15s per lane. They leave triggerTurn unset + // rather than false: an explicit false would defer them to the end of a + // running turn instead of steering into it. + WAKING_EVENTS.has(event) ? { deliverAs: "steer", triggerTurn: true } : { deliverAs: "steer" }, ); } diff --git a/packages/coding-agent/test/step-subagent-events.test.ts b/packages/coding-agent/test/step-subagent-events.test.ts index d3e6cc26..484f824d 100644 --- a/packages/coding-agent/test/step-subagent-events.test.ts +++ b/packages/coding-agent/test/step-subagent-events.test.ts @@ -479,10 +479,12 @@ test("agent_send validates its target and action inputs", async () => { test("progress notifications ride along without waking an idle parent", async () => { const { notifyLaneEvent } = await import("../src/features/subagent/lane-events.ts"); - const sent: Array<{ event?: string; triggerTurn?: boolean }> = []; + const sent: Array<{ event?: string; options?: { deliverAs?: string; triggerTurn?: boolean } }> = []; const pi = { - sendMessage: (message: { details?: { event?: string } }, options?: { triggerTurn?: boolean }) => - sent.push({ event: message.details?.event, triggerTurn: options?.triggerTurn }), + sendMessage: ( + message: { details?: { event?: string } }, + options?: { deliverAs?: string; triggerTurn?: boolean }, + ) => sent.push({ event: message.details?.event, options }), } as unknown as ExtensionAPI; const lane = { id: "lane", subscribe: "progress", status: "running", details: { results: [] } } as never; @@ -491,10 +493,12 @@ test("progress notifications ride along without waking an idle parent", async () notifyLaneEvent(pi, lane, "background_needs_input", "which file?"); notifyLaneEvent(pi, lane, "background_failed", "boom"); + // Non-waking events must leave triggerTurn unset, not false: AgentSession + // defers an explicit false to the end of a running turn instead of steering. expect(sent).toEqual([ - { event: "background_progress", triggerTurn: false }, - { event: "background_restarted", triggerTurn: false }, - { event: "background_needs_input", triggerTurn: true }, - { event: "background_failed", triggerTurn: true }, + { event: "background_progress", options: { deliverAs: "steer" } }, + { event: "background_restarted", options: { deliverAs: "steer" } }, + { event: "background_needs_input", options: { deliverAs: "steer", triggerTurn: true } }, + { event: "background_failed", options: { deliverAs: "steer", triggerTurn: true } }, ]); });