From bf9e8f17b2bd08897066eca62141f428f497ab6d Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Fri, 14 Aug 2026 00:26:10 -0700 Subject: [PATCH 1/9] feat(coding-agent): await child batches - Keeps the originating parent prompt active until coordinated child runs terminate - Delivers each frozen batch once through hidden name-sorted context --- .../coding-agent/src/core/agent-session.ts | 120 +++++++++++- .../src/core/subagents/controller.ts | 180 ++++++++++++++++-- .../coding-agent/src/core/subagents/types.ts | 24 +++ .../coding-agent/src/core/tools/subagents.ts | 7 +- .../test/suite/subagent-coordination.test.ts | 99 ++++++++++ 5 files changed, 416 insertions(+), 14 deletions(-) create mode 100644 packages/coding-agent/test/suite/subagent-coordination.test.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5b5431011..3979745c5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -106,7 +106,11 @@ import type { SettingsManager } from "./settings-manager.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; import type { SubagentController } from "./subagents/controller.ts"; -import type { SubagentSummary } from "./subagents/types.ts"; +import type { + SubagentBatchResult, + SubagentDeliveryBatch, + SubagentSummary, +} from "./subagents/types.ts"; import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts"; import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts"; import { createAllToolDefinitions } from "./tools/index.ts"; @@ -183,6 +187,16 @@ export type AgentSessionEvent = } | { type: "summarization_retry_finished" } | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string } + | { type: "subagent_wait_start"; names: readonly string[] } + | { type: "subagent_wait_end"; names: readonly string[]; cancelled: boolean } + | { + type: "subagent_notice"; + name: string; + status: "idle" | "failed" | "interrupted"; + attempt: "initial" | "retry"; + retryAvailable: boolean; + text: string; + } | { type: "bash_execution_update"; id?: string; delta: string }; /** Listener function for agent session events */ @@ -383,6 +397,7 @@ export class AgentSession { private _baseSystemPromptOptions!: BuildSystemPromptOptions; private _systemPromptOverride?: string; private readonly _subagentController: SubagentController | undefined; + private _subagentWaitAbortController: AbortController | undefined; private _disposePromise: Promise | undefined; constructor(config: AgentSessionConfig) { @@ -1248,6 +1263,96 @@ export class AgentSession { } } + private _createSubagentNotice(result: SubagentBatchResult): Extract { + const text = + result.status === "idle" + ? `${result.name} has finished execution.` + : result.status === "interrupted" + ? `${result.name} was interrupted.` + : result.retryAvailable + ? `${result.name} failed and a retry is being planned.` + : `${result.name} failed after one retry.`; + return { + type: "subagent_notice", + name: result.name, + status: result.status, + attempt: result.attempt, + retryAvailable: result.retryAvailable, + text, + }; + } + + private _createSubagentResultMessage(results: readonly SubagentBatchResult[]): CustomMessage { + const agents = results.map((result) => ({ + name: result.name, + status: result.status, + attempt: result.attempt, + retry_available: result.retryAvailable, + ...(result.status === "idle" && result.result !== undefined ? { result: result.result } : {}), + ...(result.status !== "idle" && result.result !== undefined ? { partial_output: result.result } : {}), + ...(result.error === undefined ? {} : { error: result.error }), + })); + const retryTargets = results.filter((result) => result.retryAvailable).map((result) => result.name); + return { + role: "custom", + customType: "subagent_results", + content: JSON.stringify({ + type: "subagent_batch_results", + agents, + ...(retryTargets.length === 0 + ? {} + : { + retry_instruction: + "Analyze each failed child's error and partial output. If a correction can help, call agent_action with action set to message, a corrective message, and wake set to true. Only one coordinated retry is available for each listed child in this parent prompt cycle. You may decline a retry and answer normally.", + retry_targets: retryTargets, + }), + }), + display: false, + details: { names: results.map((result) => result.name) }, + timestamp: Date.now(), + }; + } + + private async _waitForSubagentBatch( + batch: SubagentDeliveryBatch, + ): Promise<{ cancelled: boolean; results: readonly SubagentBatchResult[] }> { + const controller = this._subagentController; + if (!controller) return { cancelled: false, results: Object.freeze([]) }; + + const names = Object.freeze(batch.attempts.map((attempt) => attempt.name)); + const noticed = new Set(); + const emitNotice = (result: SubagentBatchResult): void => { + if (noticed.has(result.name)) return; + noticed.add(result.name); + this._emit(this._createSubagentNotice(result)); + }; + const waitAbortController = new AbortController(); + this._subagentWaitAbortController = waitAbortController; + this._emit({ type: "subagent_wait_start", names }); + + let cancelled = false; + try { + try { + await controller.waitForBatch(batch, { + signal: waitAbortController.signal, + onTerminal: emitNotice, + }); + } catch (error) { + if (!waitAbortController.signal.aborted) throw error; + cancelled = true; + await controller.interruptBatch(batch); + await controller.waitForBatch(batch, { onTerminal: emitNotice }); + } + const results = controller.consumeBatch(batch); + return { cancelled, results }; + } finally { + if (this._subagentWaitAbortController === waitAbortController) { + this._subagentWaitAbortController = undefined; + } + this._emit({ type: "subagent_wait_end", names, cancelled }); + } + } + private async _handlePostAgentRun(): Promise { const msg = this._lastAssistantMessage; this._lastAssistantMessage = undefined; @@ -1273,6 +1378,17 @@ export class AgentSession { return true; } + const batch = this._subagentController?.freezeUnconsumedBatch(); + if (batch) { + const delivery = await this._waitForSubagentBatch(batch); + if (delivery.cancelled) return false; + if (delivery.results.length > 0) { + const message = this._createSubagentResultMessage(delivery.results); + await this.sendCustomMessage(message, { deliverAs: "steer" }); + return true; + } + } + // The agent loop drains both queues before emitting agent_end. Any messages // here were queued by agent_end extension handlers and need a continuation. return this.agent.hasQueuedMessages(); @@ -1443,6 +1559,7 @@ export class AgentSession { } preflightResult?.(true); + this._subagentController?.beginCoordinationCycle(); await this._runAgentPrompt(messages); } @@ -1723,6 +1840,7 @@ export class AgentSession { */ async abort(): Promise { this.abortRetry(); + this._subagentWaitAbortController?.abort(new Error("Subagent coordination cancelled")); this.agent.abort(); await this.waitForIdle(); } diff --git a/packages/coding-agent/src/core/subagents/controller.ts b/packages/coding-agent/src/core/subagents/controller.ts index d2725c9ed..1dc077b69 100644 --- a/packages/coding-agent/src/core/subagents/controller.ts +++ b/packages/coding-agent/src/core/subagents/controller.ts @@ -6,7 +6,10 @@ import type { CaptureSubagentSession, CreateSubagentSession, SubagentActionResult, + SubagentBatchResult, + SubagentBatchWaitOptions, SubagentControllerSnapshot, + SubagentDeliveryBatch, SubagentLifecycleEvent, SubagentLifecycleListener, SubagentPermit, @@ -17,6 +20,7 @@ import type { SubagentSpawnOrigin, SubagentStatus, SubagentSummary, + SubagentTerminalStatus, SubagentWaitOptions, SubagentWaitResult, } from "./types.ts"; @@ -44,6 +48,15 @@ interface TransitionFields { readonly error?: string; } +type DeliveryState = "pending" | "frozen" | "consumed"; + +interface CoordinatedRun { + readonly id: number; + readonly cycleId: number; + readonly attempt: "initial" | "retry"; + delivery: DeliveryState; +} + function normalizeError(error: unknown): string { if (error instanceof Error) return error.message; try { @@ -82,9 +95,11 @@ interface SubagentRecord { interruptionRequested?: boolean; actionTail?: Promise; cleanupFailures?: unknown[]; + coordinatedRun?: CoordinatedRun; + lastRetryCycleId?: number; } -function isTerminalStatus(status: SubagentStatus): boolean { +function isTerminalStatus(status: SubagentStatus): status is SubagentTerminalStatus { return status === "idle" || status === "failed" || status === "interrupted"; } @@ -133,6 +148,9 @@ export class SubagentController { readonly #permitQueue: PermitRequest[] = []; #activePermits = 0; #revision = 0; + #coordinationCycle = 0; + #runSequence = 0; + #batchSequence = 0; #closed = false; #resetting = false; #parent: AgentSession | undefined; @@ -157,14 +175,19 @@ export class SubagentController { if (!createSession) { throw new Error("Subagent session capture is unavailable"); } - this.spawn(name, task, createSession); + this.spawn(name, task, createSession, origin); + }), + createAgentActionTool((request) => { + const parent = this.#getBoundParent(); + return request.action === "message" + ? this.message(request.target, request.message, request.wake, parent.isStreaming) + : this.interrupt(request.target); + }), + createAgentsTool(async (options) => { + const result = await this.queryAgents(options); + this.#consumeTerminalQueryResults(result); + return result; }), - createAgentActionTool((request) => - request.action === "message" - ? this.message(request.target, request.message, request.wake) - : this.interrupt(request.target), - ), - createAgentsTool((options) => this.queryAgents(options)), ]; return definitions.map((definition) => definition as unknown as ToolDefinition); } @@ -177,6 +200,11 @@ export class SubagentController { this.#parent = session; } + beginCoordinationCycle(): void { + this.#assertOpen(); + this.#coordinationCycle += 1; + } + reserve(name: string, task: string): void { this.#assertOpen(); if (!SUBAGENT_NAME_PATTERN.test(name)) { @@ -204,18 +232,21 @@ export class SubagentController { })); } - spawn(name: string, task: string, createSession: CreateSubagentSession): void { + spawn(name: string, task: string, createSession: CreateSubagentSession, origin?: SubagentSpawnOrigin): void { this.reserve(name, task); const record = this.#records.get(name)!; record.createSession = createSession; + if (origin?.type === "agent_tool_call") { + record.coordinatedRun = this.#createCoordinatedRun("initial"); + } this.#scheduleRun(record, task); } - message(name: string, message: string, wake = false): Promise { + message(name: string, message: string, wake = false, coordinated = false): Promise { if (message.trim().length === 0) { return Promise.reject(new Error("Subagent message must be non-empty")); } - return this.#serializeAction(name, () => this.#message(name, message, wake)); + return this.#serializeAction(name, () => this.#message(name, message, wake, coordinated)); } interrupt(name: string): Promise { @@ -271,6 +302,89 @@ export class SubagentController { return Object.freeze(summaries); } + freezeUnconsumedBatch(): SubagentDeliveryBatch | undefined { + if (this.#closed || this.#resetting) return undefined; + const attempts = [...this.#records.values()] + .flatMap((record) => { + const run = record.coordinatedRun; + if (!run || run.delivery !== "pending") return []; + run.delivery = "frozen"; + return [{ name: record.name, runId: run.id }]; + }) + .sort((left, right) => left.name.localeCompare(right.name)); + if (attempts.length === 0) return undefined; + return Object.freeze({ + id: ++this.#batchSequence, + attempts: Object.freeze(attempts.map((attempt) => Object.freeze(attempt))), + }); + } + + async waitForBatch(batch: SubagentDeliveryBatch, options: SubagentBatchWaitOptions = {}): Promise { + const notified = new Set(); + let snapshot = this.snapshot(); + while (true) { + if (options.signal?.aborted) throw options.signal.reason; + const terminal = batch.attempts + .flatMap((attempt) => { + if (notified.has(attempt.name)) return []; + const result = this.#getBatchResult(attempt.name, attempt.runId); + if (!result) return []; + const completedAt = this.#records.get(attempt.name)?.completedAt ?? Number.MAX_SAFE_INTEGER; + return [{ result, completedAt }]; + }) + .sort( + (left, right) => + left.completedAt - right.completedAt || left.result.name.localeCompare(right.result.name), + ); + for (const { result } of terminal) { + notified.add(result.name); + options.onTerminal?.(result); + } + if (notified.size === batch.attempts.length) return; + + const wait = await this.waitForChange(snapshot.revision, { + timeoutMs: MAX_TIMER_MS, + signal: options.signal, + }); + if (wait.reason === "disposed" || wait.reason === "reset") { + throw wait.reason === "disposed" ? this.#closedError() : this.#resetError(); + } + snapshot = wait.reason === "changed" ? wait.snapshot : this.snapshot(); + } + } + + consumeBatch(batch: SubagentDeliveryBatch): readonly SubagentBatchResult[] { + this.#assertOpen(); + const results = batch.attempts.flatMap((attempt) => { + const record = this.#records.get(attempt.name); + const run = record?.coordinatedRun; + if (!record || !run || run.id !== attempt.runId || run.delivery === "consumed") return []; + if (run.delivery !== "frozen") { + throw new Error(`Subagent result is not frozen for delivery: ${attempt.name}`); + } + const result = this.#getBatchResult(attempt.name, attempt.runId); + if (!result) { + throw new Error(`Subagent result is not terminal: ${attempt.name}`); + } + run.delivery = "consumed"; + return [result]; + }); + return Object.freeze(results); + } + + async interruptBatch(batch: SubagentDeliveryBatch): Promise { + const targets = batch.attempts.flatMap((attempt) => { + const record = this.#records.get(attempt.name); + if (!record?.coordinatedRun || record.coordinatedRun.id !== attempt.runId) return []; + return isTerminalStatus(record.status) ? [] : [record.name]; + }); + const results = await Promise.allSettled(targets.map((target) => this.interrupt(target))); + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + if (failures.length > 0) { + throw new AggregateError(failures, "Failed to interrupt coordinated subagents"); + } + } + async queryAgents(options: SubagentQueryOptions): Promise { if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 0) { throw new RangeError("timeoutMs must be a non-negative integer"); @@ -625,6 +739,38 @@ export class SubagentController { return Object.freeze({ timedOut, agents: Object.freeze(agents) }); } + #consumeTerminalQueryResults(result: SubagentQueryResult): void { + for (const agent of result.agents) { + if (!isTerminalStatus(agent.status)) continue; + const run = this.#records.get(agent.name)?.coordinatedRun; + if (run) run.delivery = "consumed"; + } + } + + #createCoordinatedRun(attempt: "initial" | "retry"): CoordinatedRun { + return { + id: ++this.#runSequence, + cycleId: this.#coordinationCycle, + attempt, + delivery: "pending", + }; + } + + #getBatchResult(name: string, runId: number): SubagentBatchResult | undefined { + const record = this.#records.get(name); + const run = record?.coordinatedRun; + if (!record || !run || run.id !== runId || !isTerminalStatus(record.status)) return undefined; + const retryAvailable = record.status === "failed" && record.lastRetryCycleId !== run.cycleId; + return Object.freeze({ + name, + status: record.status, + attempt: run.attempt, + retryAvailable, + ...(record.finalText === undefined ? {} : { result: record.finalText }), + ...(record.error === undefined ? {} : { error: record.error }), + }); + } + #queryConditionMet(agents: readonly SubagentResultView[], until: "any" | "all"): boolean { return until === "any" ? agents.some((agent) => isTerminalStatus(agent.status)) @@ -719,7 +865,7 @@ export class SubagentController { return result; } - async #message(name: string, message: string, wake: boolean): Promise { + async #message(name: string, message: string, wake: boolean, coordinated: boolean): Promise { const record = this.#getRecordForAction(name); while (record.status === "running" && record.session?.isIdle) { await record.run; @@ -733,6 +879,15 @@ export class SubagentController { if (!wake) { throw new Error(`Cannot message ${record.status} subagent without wake: ${name}`); } + if (coordinated && record.status === "failed") { + if (record.lastRetryCycleId === this.#coordinationCycle) { + throw new Error(`Subagent retry limit reached for this parent prompt cycle: ${name}`); + } + record.lastRetryCycleId = this.#coordinationCycle; + record.coordinatedRun = this.#createCoordinatedRun("retry"); + } else { + record.coordinatedRun = undefined; + } this.#scheduleRun(record, message); return { target: name, action: "message", status: "queued", delivery: "prompt" }; } @@ -743,6 +898,7 @@ export class SubagentController { } if (record.status === "idle") { if (wake) { + record.coordinatedRun = undefined; this.#scheduleRun(record, message); return { target: name, action: "message", status: "queued", delivery: "prompt" }; } diff --git a/packages/coding-agent/src/core/subagents/types.ts b/packages/coding-agent/src/core/subagents/types.ts index f7c13051f..2f383d49c 100644 --- a/packages/coding-agent/src/core/subagents/types.ts +++ b/packages/coding-agent/src/core/subagents/types.ts @@ -85,6 +85,30 @@ export interface SubagentQueryResult { readonly agents: readonly SubagentResultView[]; } +export type SubagentTerminalStatus = Extract; + +export interface SubagentDeliveryBatch { + readonly id: number; + readonly attempts: readonly { + readonly name: string; + readonly runId: number; + }[]; +} + +export interface SubagentBatchResult { + readonly name: string; + readonly status: SubagentTerminalStatus; + readonly attempt: "initial" | "retry"; + readonly retryAvailable: boolean; + readonly result?: string; + readonly error?: string; +} + +export interface SubagentBatchWaitOptions { + readonly signal?: AbortSignal; + readonly onTerminal?: (result: SubagentBatchResult) => void; +} + export type SubagentLifecycleEvent = | { readonly type: "subagent_reserved"; diff --git a/packages/coding-agent/src/core/tools/subagents.ts b/packages/coding-agent/src/core/tools/subagents.ts index 479831110..20aad0781 100644 --- a/packages/coding-agent/src/core/tools/subagents.ts +++ b/packages/coding-agent/src/core/tools/subagents.ts @@ -125,7 +125,12 @@ export function createSpawnAgentTool( return { name: "spawn_agent", label: "Spawn Agent", - description: "Start an independent child agent for a focused task and return immediately after acceptance.", + description: + "Start an independent child agent asynchronously. The tool returns after acceptance, but the enclosing parent prompt remains active until coordinated child work is delivered.", + promptGuidelines: [ + "After spawning child agents, you may finish the current response. The session waits for the frozen child batch and automatically sends one hidden, name-sorted result message before the parent prompt settles.", + "If that hidden result message says a retry is available, analyze the error and partial output. Retry only with agent_action using action message and wake true. One coordinated retry is allowed per failed child in the current parent prompt cycle.", + ], parameters: spawnAgentSchema, executionMode: "parallel", execute: async (toolCallId, { name, task }) => { diff --git a/packages/coding-agent/test/suite/subagent-coordination.test.ts b/packages/coding-agent/test/suite/subagent-coordination.test.ts new file mode 100644 index 000000000..999a4162f --- /dev/null +++ b/packages/coding-agent/test/suite/subagent-coordination.test.ts @@ -0,0 +1,99 @@ +import type { Context } from "@earendil-works/diffex-ai"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/diffex-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentSession } from "../../src/core/agent-session.ts"; +import { createAgentSession } from "../../src/core/sdk.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { createTestResourceLoader } from "../utilities.ts"; +import { createHarness, getMessageText, type Harness } from "./harness.ts"; +import { deferred } from "./subagent-harness.ts"; + +async function expectPending(promise: Promise): Promise { + let settled = false; + void promise.finally(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); +} + +function hasTool(context: Context, name: string): boolean { + return context.tools?.some((tool) => tool.name === name) ?? false; +} + +function hasToolResult(context: Context, name: string): boolean { + return context.messages.some((message) => message.role === "toolResult" && message.toolName === name); +} + +describe("automatic subagent coordination", () => { + const roots: AgentSession[] = []; + const harnesses: Harness[] = []; + + afterEach(async () => { + for (const root of roots.splice(0)) await root.dispose(); + for (const harness of harnesses.splice(0)) await harness.cleanup(); + }); + + async function createRoot(): Promise<{ root: AgentSession; harness: Harness }> { + const harness = await createHarness({ settings: { retry: { enabled: false } } }); + harnesses.push(harness); + const { session: root } = await createAgentSession({ + cwd: harness.tempDir, + agentDir: harness.tempDir, + modelRuntime: harness.session.modelRuntime, + settingsManager: harness.settingsManager, + resourceLoader: createTestResourceLoader(), + sessionManager: SessionManager.inMemory(harness.tempDir), + model: harness.getModel(), + tools: ["spawn_agent", "agent_action", "agents"], + subagents: { maxConcurrent: 2 }, + }); + roots.push(root); + return { root, harness }; + } + + it("keeps the parent pending and resumes it with the child result", async () => { + const { root, harness } = await createRoot(); + const releaseChild = deferred(); + const parentFinishedEarly = deferred(); + const parentContexts: Context[] = []; + harness.setResponses( + Array.from({ length: 8 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) { + await releaseChild.promise; + return fauxAssistantMessage("CHILD-RESULT-SENTINEL"); + } + + parentContexts.push(context); + const hiddenResult = context.messages.find( + (message) => message.role === "user" && getMessageText(message).includes("CHILD-RESULT-SENTINEL"), + ); + if (hiddenResult) { + return fauxAssistantMessage("automatic final answer"); + } + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage( + fauxToolCall("spawn_agent", { name: "worker", task: "finish delayed work" }), + { stopReason: "toolUse" }, + ); + } + + parentFinishedEarly.resolve(); + return fauxAssistantMessage("attempted early finish"); + }), + ); + + const prompt = root.prompt("delegate work"); + await parentFinishedEarly.promise; + try { + await expectPending(prompt); + } finally { + releaseChild.resolve(); + } + await prompt; + + expect(root.getLastAssistantText()).toBe("automatic final answer"); + expect(parentContexts).toHaveLength(3); + expect(getMessageText(parentContexts[2]?.messages.at(-1))).toContain("CHILD-RESULT-SENTINEL"); + }); +}); From e2ac04a7e70ded28f8d3cff436ef2dc4776dd764 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Fri, 14 Aug 2026 00:33:08 -0700 Subject: [PATCH 2/9] feat(coding-agent): show child wait state - Keeps cancellation visible and restores queued editor input before interrupting frozen children - Covers result ordering, retry limits, explicit consumption, and SDK-managed runs --- .../coding-agent/src/core/agent-session.ts | 6 +- .../components/status-indicator.ts | 15 +- .../src/modes/interactive/interactive-mode.ts | 29 +- ...ractive-mode-subagent-coordination.test.ts | 113 +++++ .../test/suite/subagent-coordination.test.ts | 436 +++++++++++++++++- 5 files changed, 587 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/test/interactive-mode-subagent-coordination.test.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 3979745c5..87e5b25e3 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -106,11 +106,7 @@ import type { SettingsManager } from "./settings-manager.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; import type { SubagentController } from "./subagents/controller.ts"; -import type { - SubagentBatchResult, - SubagentDeliveryBatch, - SubagentSummary, -} from "./subagents/types.ts"; +import type { SubagentBatchResult, SubagentDeliveryBatch, SubagentSummary } from "./subagents/types.ts"; import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts"; import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts"; import { createAllToolDefinitions } from "./tools/index.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts index 05396803a..8fdfcabf1 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts @@ -4,7 +4,7 @@ import { theme } from "../theme/theme.ts"; import { CountdownTimer } from "./countdown-timer.ts"; import { keyText } from "./keybinding-hints.ts"; -export type StatusIndicatorKind = "working" | "retry" | "compaction" | "branchSummary"; +export type StatusIndicatorKind = "working" | "retry" | "compaction" | "branchSummary" | "subagentWait"; export class StatusIndicator extends Loader { readonly kind: StatusIndicatorKind; @@ -102,6 +102,19 @@ export class BranchSummaryStatusIndicator extends StatusIndicator { } } +export class SubagentWaitStatusIndicator extends StatusIndicator { + constructor(ui: TUI, count: number) { + const label = count === 1 ? "Waiting for subagent" : `Waiting for ${count} subagents`; + super( + "subagentWait", + ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + `${label}... (${keyText("app.interrupt")} to cancel)`, + ); + } +} + export class IdleStatus implements Component { invalidate(): void { // No cached state to invalidate. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5238f1c20..8e5493750 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -156,6 +156,7 @@ import { IdleStatus, RetryStatusIndicator, type StatusIndicator, + SubagentWaitStatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.ts"; import { SubagentsComponent } from "./components/subagents.ts"; @@ -3307,6 +3308,30 @@ export class InteractiveMode { await this.checkShutdownRequested(); break; + case "subagent_wait_start": + if (this.settingsManager.getShowTerminalProgress()) { + this.ui.terminal.setProgress(true); + } + this.showStatusIndicator(new SubagentWaitStatusIndicator(this.ui, event.names.length)); + this.ui.requestRender(); + break; + + case "subagent_notice": + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Markdown(event.text, this.outputPad, 0, this.getMarkdownThemeWithSettings()), + ); + this.ui.requestRender(); + break; + + case "subagent_wait_end": + if (this.settingsManager.getShowTerminalProgress()) { + this.ui.terminal.setProgress(false); + } + this.clearStatusIndicator("subagentWait"); + this.ui.requestRender(); + break; + case "compaction_start": { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); @@ -4236,7 +4261,7 @@ export class InteractiveMode { if (allQueued.length === 0) { this.updatePendingMessagesDisplay(); if (options?.abort) { - this.agent.abort(); + void this.session.abort(); } return 0; } @@ -4246,7 +4271,7 @@ export class InteractiveMode { this.editor.setText(combinedText); this.updatePendingMessagesDisplay(); if (options?.abort) { - this.agent.abort(); + void this.session.abort(); } return allQueued.length; } diff --git a/packages/coding-agent/test/interactive-mode-subagent-coordination.test.ts b/packages/coding-agent/test/interactive-mode-subagent-coordination.test.ts new file mode 100644 index 000000000..c2920a287 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-subagent-coordination.test.ts @@ -0,0 +1,113 @@ +import { Container, Markdown, type TUI } from "@earendil-works/diffex-tui"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { TuiMainScreen } from "../../tui/src/tui-main-screen.ts"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +class ProgressTerminal extends VirtualTerminal { + progressActive = false; + + override setProgress(active: boolean): void { + this.progressActive = active; + } +} + +async function flushTui(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await Promise.resolve(); + await terminal.waitForRender(); +} + +describe("InteractiveMode subagent coordination", () => { + beforeAll(() => { + initTheme("dark"); + }); + + it("renders deterministic notices as assistant content while showing a wait indicator", async () => { + const terminal = new ProgressTerminal(80, 12); + const ui = new TuiMainScreen(terminal); + ui.setClearOnShrink(false); + const chatContainer = new Container(); + const statusContainer = new Container(); + const settingsManager = { + getShowTerminalProgress: () => true, + getCodeBlockIndent: () => 0, + }; + ui.addChild(chatContainer); + ui.addChild(statusContainer); + const context = Object.assign(Object.create(InteractiveMode.prototype), { + runtimeHost: { session: { settingsManager } }, + isInitialized: true, + footer: { invalidate: vi.fn() }, + ui, + chatContainer, + statusContainer, + activeStatusIndicator: undefined, + outputPad: 1, + options: { tuiMode: "regular" }, + }); + const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as ( + this: typeof context, + event: unknown, + ) => Promise; + + ui.start(); + try { + await handleEvent.call(context, { type: "subagent_wait_start", names: ["alpha", "beta"] }); + await flushTui(ui, terminal); + expect(terminal.getViewport().join("\n")).toContain("Waiting for 2 subagents"); + expect(terminal.progressActive).toBe(true); + + await handleEvent.call(context, { + type: "subagent_notice", + name: "beta", + status: "idle", + attempt: "initial", + retryAvailable: false, + text: "beta has finished execution.", + }); + await flushTui(ui, terminal); + expect(terminal.getViewport().join("\n")).toContain("beta has finished execution."); + expect(chatContainer.children.at(-1)).toBeInstanceOf(Markdown); + + await handleEvent.call(context, { + type: "subagent_wait_end", + names: ["alpha", "beta"], + cancelled: false, + }); + await flushTui(ui, terminal); + expect(statusContainer.children).toEqual([]); + expect(terminal.progressActive).toBe(false); + } finally { + ui.stop(); + } + }); + + it("restores queued input and cancels the session on Escape", () => { + const abort = vi.fn().mockResolvedValue(undefined); + const editor = { + getText: vi.fn(() => "draft"), + setText: vi.fn(), + }; + const context = Object.assign(Object.create(InteractiveMode.prototype), { + runtimeHost: { + session: { + clearQueue: vi.fn(() => ({ steering: ["steer"], followUp: ["follow up"] })), + abort, + }, + }, + compactionQueuedMessages: [], + editor, + updatePendingMessagesDisplay: vi.fn(), + }); + const restoreQueuedMessagesToEditor = Reflect.get(InteractiveMode.prototype, "restoreQueuedMessagesToEditor") as ( + this: typeof context, + options: { abort: boolean }, + ) => number; + + expect(restoreQueuedMessagesToEditor.call(context, { abort: true })).toBe(2); + expect(editor.setText).toHaveBeenCalledWith("steer\n\nfollow up\n\ndraft"); + expect(abort).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/coding-agent/test/suite/subagent-coordination.test.ts b/packages/coding-agent/test/suite/subagent-coordination.test.ts index 999a4162f..80e6a66d3 100644 --- a/packages/coding-agent/test/suite/subagent-coordination.test.ts +++ b/packages/coding-agent/test/suite/subagent-coordination.test.ts @@ -1,12 +1,29 @@ -import type { Context } from "@earendil-works/diffex-ai"; +import type { Context, SimpleStreamOptions } from "@earendil-works/diffex-ai"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/diffex-ai"; import { afterEach, describe, expect, it } from "vitest"; import type { AgentSession } from "../../src/core/agent-session.ts"; +import type { ExtensionContext } from "../../src/core/extensions/types.ts"; +import type { CustomMessage } from "../../src/core/messages.ts"; import { createAgentSession } from "../../src/core/sdk.ts"; import { SessionManager } from "../../src/core/session-manager.ts"; import { createTestResourceLoader } from "../utilities.ts"; import { createHarness, getMessageText, type Harness } from "./harness.ts"; -import { deferred } from "./subagent-harness.ts"; +import { deferred, getLastUserText } from "./subagent-harness.ts"; + +interface BatchPayload { + readonly type: "subagent_batch_results"; + readonly agents: readonly { + readonly name: string; + readonly status: "idle" | "failed" | "interrupted"; + readonly attempt: "initial" | "retry"; + readonly retry_available: boolean; + readonly result?: string; + readonly partial_output?: string; + readonly error?: string; + }[]; + readonly retry_instruction?: string; + readonly retry_targets?: readonly string[]; +} async function expectPending(promise: Promise): Promise { let settled = false; @@ -25,6 +42,37 @@ function hasToolResult(context: Context, name: string): boolean { return context.messages.some((message) => message.role === "toolResult" && message.toolName === name); } +function getToolResultCount(context: Context, name: string): number { + return context.messages.filter((message) => message.role === "toolResult" && message.toolName === name).length; +} + +function getBatchPayloads(context: Context): BatchPayload[] { + return context.messages.flatMap((message) => { + if (message.role !== "user") return []; + const text = getMessageText(message); + try { + const payload = JSON.parse(text) as Partial; + return payload.type === "subagent_batch_results" ? [payload as BatchPayload] : []; + } catch { + return []; + } + }); +} + +function getHiddenResultMessages(root: AgentSession): CustomMessage[] { + return root.messages.flatMap((message) => + message.role === "custom" && message.customType === "subagent_results" ? [message] : [], + ); +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error("Timed out waiting for condition"); +} + describe("automatic subagent coordination", () => { const roots: AgentSession[] = []; const harnesses: Harness[] = []; @@ -34,7 +82,7 @@ describe("automatic subagent coordination", () => { for (const harness of harnesses.splice(0)) await harness.cleanup(); }); - async function createRoot(): Promise<{ root: AgentSession; harness: Harness }> { + async function createRoot(maxConcurrent = 2): Promise<{ root: AgentSession; harness: Harness }> { const harness = await createHarness({ settings: { retry: { enabled: false } } }); harnesses.push(harness); const { session: root } = await createAgentSession({ @@ -46,7 +94,7 @@ describe("automatic subagent coordination", () => { sessionManager: SessionManager.inMemory(harness.tempDir), model: harness.getModel(), tools: ["spawn_agent", "agent_action", "agents"], - subagents: { maxConcurrent: 2 }, + subagents: { maxConcurrent }, }); roots.push(root); return { root, harness }; @@ -96,4 +144,384 @@ describe("automatic subagent coordination", () => { expect(parentContexts).toHaveLength(3); expect(getMessageText(parentContexts[2]?.messages.at(-1))).toContain("CHILD-RESULT-SENTINEL"); }); + + it("notices each completion and delivers one name-sorted batch", async () => { + const { root, harness } = await createRoot(); + const releaseAlpha = deferred(); + const releaseBeta = deferred(); + const notices: string[] = []; + const lifecycle: string[] = []; + root.subscribe((event) => { + if (event.type === "subagent_notice") notices.push(event.text); + if (event.type === "subagent_wait_start" || event.type === "subagent_wait_end") lifecycle.push(event.type); + }); + harness.setResponses( + Array.from({ length: 10 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) { + const task = getLastUserText(context); + await (task === "alpha task" ? releaseAlpha.promise : releaseBeta.promise); + return fauxAssistantMessage(`result for ${task}`); + } + const batches = getBatchPayloads(context); + if (batches.length > 0) return fauxAssistantMessage("sorted synthesis"); + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage( + [ + fauxToolCall("spawn_agent", { name: "alpha", task: "alpha task" }), + fauxToolCall("spawn_agent", { name: "beta", task: "beta task" }), + ], + { stopReason: "toolUse" }, + ); + } + return fauxAssistantMessage("attempted batch finish"); + }), + ); + + const prompt = root.prompt("delegate two tasks"); + await waitFor(() => lifecycle.includes("subagent_wait_start")); + releaseBeta.resolve(); + await waitFor(() => notices.length === 1); + expect(notices).toEqual(["beta has finished execution."]); + await expectPending(prompt); + releaseAlpha.resolve(); + await prompt; + + expect(notices).toEqual(["beta has finished execution.", "alpha has finished execution."]); + expect(lifecycle).toEqual(["subagent_wait_start", "subagent_wait_end"]); + const hiddenMessages = getHiddenResultMessages(root); + expect(hiddenMessages).toHaveLength(1); + const payload = JSON.parse( + typeof hiddenMessages[0]?.content === "string" ? hiddenMessages[0].content : "", + ) as BatchPayload; + expect(payload.agents).toEqual([ + expect.objectContaining({ name: "alpha", result: "result for alpha task" }), + expect.objectContaining({ name: "beta", result: "result for beta task" }), + ]); + const persisted = JSON.stringify(root.sessionManager.getEntries()); + expect(persisted).not.toContain("has finished execution."); + expect(JSON.stringify(root.messages)).not.toContain("has finished execution."); + }); + + it("offers one corrective retry and waits for its successful result", async () => { + const { root, harness } = await createRoot(); + const retryStarted = deferred(); + const releaseRetry = deferred(); + const parentFinishedRetryEarly = deferred(); + const notices: string[] = []; + root.subscribe((event) => { + if (event.type === "subagent_notice") notices.push(event.text); + }); + harness.setResponses( + Array.from({ length: 14 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) { + const task = getLastUserText(context); + if (task === "fail once") { + return fauxAssistantMessage("PARTIAL-FAILURE-SENTINEL", { + stopReason: "error", + errorMessage: "recoverable child error", + }); + } + retryStarted.resolve(); + await releaseRetry.promise; + return fauxAssistantMessage("RETRY-SUCCESS-SENTINEL"); + } + + const batches = getBatchPayloads(context); + if (batches.length === 2) return fauxAssistantMessage("retry synthesis"); + if (batches.length === 1 && getToolResultCount(context, "agent_action") === 0) { + return fauxAssistantMessage( + fauxToolCall("agent_action", { + target: "worker", + action: "message", + message: "correct the child error", + wake: true, + }), + { stopReason: "toolUse" }, + ); + } + if (getToolResultCount(context, "agent_action") === 1) { + parentFinishedRetryEarly.resolve(); + return fauxAssistantMessage("attempted retry finish"); + } + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage(fauxToolCall("spawn_agent", { name: "worker", task: "fail once" }), { + stopReason: "toolUse", + }); + } + return fauxAssistantMessage("attempted initial finish"); + }), + ); + + const prompt = root.prompt("recover the child"); + await Promise.all([retryStarted.promise, parentFinishedRetryEarly.promise]); + await expectPending(prompt); + releaseRetry.resolve(); + await prompt; + + expect(root.getLastAssistantText()).toBe("retry synthesis"); + expect(notices).toEqual(["worker failed and a retry is being planned.", "worker has finished execution."]); + const hiddenMessages = getHiddenResultMessages(root); + expect(hiddenMessages).toHaveLength(2); + const failure = JSON.parse( + typeof hiddenMessages[0]?.content === "string" ? hiddenMessages[0].content : "", + ) as BatchPayload; + expect(failure).toMatchObject({ + agents: [ + { + name: "worker", + status: "failed", + attempt: "initial", + retry_available: true, + partial_output: "PARTIAL-FAILURE-SENTINEL", + error: "recoverable child error", + }, + ], + retry_targets: ["worker"], + }); + expect(failure.retry_instruction).toContain("wake set to true"); + }); + + it("rejects a second retry in one cycle and resets the limit for a later prompt", async () => { + const { root, harness } = await createRoot(); + let childAttempts = 0; + harness.setResponses( + Array.from({ length: 16 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) { + childAttempts += 1; + return fauxAssistantMessage(`partial ${childAttempts}`, { + stopReason: "error", + errorMessage: `failure ${childAttempts}`, + }); + } + const batches = getBatchPayloads(context); + const actionResults = getToolResultCount(context, "agent_action"); + if (batches.length === 2 && actionResults === 1) { + return fauxAssistantMessage( + fauxToolCall("agent_action", { + target: "worker", + action: "message", + message: "retry again", + wake: true, + }), + { stopReason: "toolUse" }, + ); + } + if (batches.length === 2 && actionResults === 2) return fauxAssistantMessage("retry exhausted"); + if (batches.length === 1 && actionResults === 0) { + return fauxAssistantMessage( + fauxToolCall("agent_action", { + target: "worker", + action: "message", + message: "first correction", + wake: true, + }), + { stopReason: "toolUse" }, + ); + } + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage(fauxToolCall("spawn_agent", { name: "worker", task: "fail twice" }), { + stopReason: "toolUse", + }); + } + return fauxAssistantMessage("attempted finish"); + }), + ); + + await root.prompt("exhaust this cycle"); + expect(childAttempts).toBe(2); + expect(root.getLastAssistantText()).toBe("retry exhausted"); + const actionResults = root.messages.filter( + (message) => message.role === "toolResult" && message.toolName === "agent_action", + ); + expect(actionResults).toHaveLength(2); + expect(actionResults[1]).toMatchObject({ isError: true }); + expect(getMessageText(actionResults[1])).toContain("Subagent retry limit reached"); + + harness.setResponses( + Array.from({ length: 10 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) return fauxAssistantMessage("later retry succeeded"); + let promptIndex = -1; + for (let index = context.messages.length - 1; index >= 0; index -= 1) { + const message = context.messages[index]; + if (message?.role === "user" && getMessageText(message) === "retry in a new prompt") { + promptIndex = index; + break; + } + } + const recent = promptIndex < 0 ? [] : context.messages.slice(promptIndex + 1); + const recentActionResults = recent.filter( + (message) => message.role === "toolResult" && message.toolName === "agent_action", + ).length; + const recentBatches = recent.filter( + (message) => message.role === "user" && getMessageText(message).includes("later retry succeeded"), + ).length; + if (recentBatches > 0) return fauxAssistantMessage("later cycle synthesis"); + if (recentActionResults === 0) { + return fauxAssistantMessage( + fauxToolCall("agent_action", { + target: "worker", + action: "message", + message: "new cycle correction", + wake: true, + }), + { stopReason: "toolUse" }, + ); + } + return fauxAssistantMessage("attempted later finish"); + }), + ); + await root.prompt("retry in a new prompt"); + expect(root.getLastAssistantText()).toBe("later cycle synthesis"); + }); + + it("settles when the parent declines the offered retry", async () => { + const { root, harness } = await createRoot(); + harness.setResponses( + Array.from({ length: 8 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) { + return fauxAssistantMessage("partial decline", { + stopReason: "error", + errorMessage: "not worth retrying", + }); + } + if (getBatchPayloads(context).length > 0) return fauxAssistantMessage("declined retry"); + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage(fauxToolCall("spawn_agent", { name: "worker", task: "decline" }), { + stopReason: "toolUse", + }); + } + return fauxAssistantMessage("attempted finish"); + }), + ); + + await root.prompt("do not retry if it fails"); + expect(root.getLastAssistantText()).toBe("declined retry"); + expect(getToolResultCount({ messages: root.messages } as Context, "agent_action")).toBe(0); + }); + + it("treats terminal agents tool results as already consumed", async () => { + const { root, harness } = await createRoot(); + harness.setResponses( + Array.from({ length: 8 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) return fauxAssistantMessage("explicit child result"); + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage(fauxToolCall("spawn_agent", { name: "worker", task: "explicit" }), { + stopReason: "toolUse", + }); + } + if (!hasToolResult(context, "agents")) { + return fauxAssistantMessage( + fauxToolCall("agents", { timeout_ms: 1_000, targets: ["worker"], until: "all" }), + { stopReason: "toolUse" }, + ); + } + return fauxAssistantMessage("explicit synthesis"); + }), + ); + const events: string[] = []; + root.subscribe((event) => events.push(event.type)); + + await root.prompt("retrieve explicitly"); + expect(root.getLastAssistantText()).toBe("explicit synthesis"); + expect( + root.messages.some((message) => message.role === "custom" && message.customType === "subagent_results"), + ).toBe(false); + expect(events).not.toContain("subagent_wait_start"); + expect(events).not.toContain("subagent_notice"); + }); + + it("leaves children spawned through direct SDK calls manually managed", async () => { + const { root, harness } = await createRoot(); + const childStarted = deferred(); + const releaseChild = deferred(); + harness.setResponses( + Array.from({ length: 6 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) { + childStarted.resolve(); + await releaseChild.promise; + return fauxAssistantMessage("manual child result"); + } + return fauxAssistantMessage("ordinary parent answer"); + }), + ); + await root + .getToolDefinition("spawn_agent") + ?.execute( + "direct-spawn", + { name: "manual", task: "manual work" }, + undefined, + undefined, + {} as ExtensionContext, + ); + await childStarted.promise; + + await root.prompt("ordinary prompt"); + expect(root.getLastAssistantText()).toBe("ordinary parent answer"); + expect(root.getSubagents()).toEqual([{ name: "manual", status: "running", task: "manual work" }]); + releaseChild.resolve(); + await root + .getToolDefinition("agents") + ?.execute( + "manual-wait", + { timeout_ms: 1_000, targets: ["manual"], until: "all" }, + undefined, + undefined, + {} as ExtensionContext, + ); + }); + + it("interrupts a frozen batch on cancellation without another provider call", async () => { + const { root, harness } = await createRoot(); + const childrenStarted = new Set(); + const notices: string[] = []; + const waitEnded = deferred(); + let parentCalls = 0; + root.subscribe((event) => { + if (event.type === "subagent_notice") notices.push(event.text); + if (event.type === "subagent_wait_end") waitEnded.resolve(event.cancelled); + }); + harness.setResponses( + Array.from({ length: 8 }, () => async (context: Context, options?: SimpleStreamOptions) => { + if (!hasTool(context, "spawn_agent")) { + const name = getLastUserText(context).split(" ")[0] ?? "child"; + childrenStarted.add(name); + return await new Promise>((resolve) => { + options?.signal?.addEventListener( + "abort", + () => resolve(fauxAssistantMessage(`${name} partial`, { stopReason: "aborted" })), + { once: true }, + ); + }); + } + parentCalls += 1; + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage( + [ + fauxToolCall("spawn_agent", { name: "alpha", task: "alpha waits" }), + fauxToolCall("spawn_agent", { name: "beta", task: "beta waits" }), + ], + { stopReason: "toolUse" }, + ); + } + return fauxAssistantMessage("attempted cancellation finish"); + }), + ); + + const prompt = root.prompt("cancel the batch"); + await waitFor(() => childrenStarted.size === 2 && root.isStreaming); + await root.steer("queued steering"); + await root.followUp("queued follow-up"); + const queued = root.clearQueue(); + expect(queued).toEqual({ steering: ["queued steering"], followUp: ["queued follow-up"] }); + await root.abort(); + await prompt; + + expect(await waitEnded.promise).toBe(true); + expect(parentCalls).toBe(2); + expect(notices.sort()).toEqual(["alpha was interrupted.", "beta was interrupted."]); + expect(root.getSubagents().map((agent) => agent.status)).toEqual(["interrupted", "interrupted"]); + expect( + root.messages.some((message) => message.role === "custom" && message.customType === "subagent_results"), + ).toBe(false); + }); }); From c253034f9b9575a6205a7690127299521b476419 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Fri, 14 Aug 2026 00:37:00 -0700 Subject: [PATCH 3/9] fix(coding-agent): keep SDK retries manual - Restricts coordinated retries to children originally spawned by parent tool calls - Covers failed direct SDK children woken during a parent model turn --- .../src/core/subagents/controller.ts | 2 +- .../test/suite/subagent-coordination.test.ts | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/subagents/controller.ts b/packages/coding-agent/src/core/subagents/controller.ts index 1dc077b69..be8be3f37 100644 --- a/packages/coding-agent/src/core/subagents/controller.ts +++ b/packages/coding-agent/src/core/subagents/controller.ts @@ -879,7 +879,7 @@ export class SubagentController { if (!wake) { throw new Error(`Cannot message ${record.status} subagent without wake: ${name}`); } - if (coordinated && record.status === "failed") { + if (coordinated && record.status === "failed" && record.coordinatedRun) { if (record.lastRetryCycleId === this.#coordinationCycle) { throw new Error(`Subagent retry limit reached for this parent prompt cycle: ${name}`); } diff --git a/packages/coding-agent/test/suite/subagent-coordination.test.ts b/packages/coding-agent/test/suite/subagent-coordination.test.ts index 80e6a66d3..83b81db15 100644 --- a/packages/coding-agent/test/suite/subagent-coordination.test.ts +++ b/packages/coding-agent/test/suite/subagent-coordination.test.ts @@ -470,6 +470,69 @@ describe("automatic subagent coordination", () => { ); }); + it("leaves retries of failed direct SDK children manually managed", async () => { + const { root, harness } = await createRoot(); + const retryStarted = deferred(); + const releaseRetry = deferred(); + let childAttempts = 0; + harness.setResponses( + Array.from({ length: 8 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) { + childAttempts += 1; + if (childAttempts === 1) { + return fauxAssistantMessage("manual partial output", { + stopReason: "error", + errorMessage: "manual child failure", + }); + } + retryStarted.resolve(); + await releaseRetry.promise; + return fauxAssistantMessage("manual retry result"); + } + if (!hasToolResult(context, "agent_action")) { + return fauxAssistantMessage( + fauxToolCall("agent_action", { + target: "manual", + action: "message", + message: "retry manually", + wake: true, + }), + { stopReason: "toolUse" }, + ); + } + return fauxAssistantMessage("ordinary parent answer"); + }), + ); + await root + .getToolDefinition("spawn_agent") + ?.execute( + "direct-spawn", + { name: "manual", task: "fail manually" }, + undefined, + undefined, + {} as ExtensionContext, + ); + await waitFor(() => root.getSubagents()[0]?.status === "failed"); + + const prompt = root.prompt("wake the manual child"); + await retryStarted.promise; + await prompt; + expect(root.getLastAssistantText()).toBe("ordinary parent answer"); + expect(root.getSubagents()).toEqual([{ name: "manual", status: "running", task: "fail manually" }]); + expect(getHiddenResultMessages(root)).toHaveLength(0); + + releaseRetry.resolve(); + await root + .getToolDefinition("agents") + ?.execute( + "manual-wait", + { timeout_ms: 1_000, targets: ["manual"], until: "all" }, + undefined, + undefined, + {} as ExtensionContext, + ); + }); + it("interrupts a frozen batch on cancellation without another provider call", async () => { const { root, harness } = await createRoot(); const childrenStarted = new Set(); From f4504859af81489dc3deb81f06e5059e96d5ba88 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Fri, 14 Aug 2026 00:39:04 -0700 Subject: [PATCH 4/9] docs(coding-agent): document child coordination - Describes automatic waiting, hidden delivery, retry limits, and cancellation - Documents safe subagent lifecycle events for SDK and RPC consumers --- packages/coding-agent/docs/rpc.md | 67 ++++++++++++++++++++++++++++- packages/coding-agent/docs/sdk.md | 37 ++++++++++++++-- packages/coding-agent/docs/usage.md | 24 ++++++++++- 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 289e57de6..a95b218de 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -75,6 +75,10 @@ Response: `success: true` means the prompt was accepted, queued, or handled immediately. `success: false` means the prompt was rejected before acceptance. Failures after acceptance are reported through the normal event and message stream, not as a second `response` for the same request id. +Acceptance is not settlement. +If the parent spawns coordinated children, events continue while RPC mode waits for the frozen child batch and automatically resumes the parent with hidden results. +Wait for `agent_settled`, not the `prompt` response or the first `agent_end`, before treating the prompt cycle as complete. + The `images` field is optional. Each image uses `ImageContent` format: `{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}`. #### steer @@ -125,6 +129,8 @@ See [set_follow_up_mode](#set_follow_up_mode) for controlling how follow-up mess Abort the current agent operation. +During a coordinated child wait, this interrupts every unfinished child in the frozen batch, emits terminal notices and a cancelled wait end event, and settles without another parent provider call. + ```json {"type": "abort"} ``` @@ -850,6 +856,9 @@ Events are streamed to stdout as JSON lines during agent operation. Events do no | `tool_execution_update` | Tool execution progress (streaming output) | | `tool_execution_end` | Tool completes | | `queue_update` | Pending steering/follow-up queue changed | +| `subagent_wait_start` | Parent begins waiting for a frozen, name-sorted child batch | +| `subagent_notice` | One child reaches a terminal state; contains deterministic safe metadata only | +| `subagent_wait_end` | Frozen child batch wait ends or is cancelled | | `compaction_start` | Compaction begins | | `compaction_end` | Compaction completes | | `auto_retry_start` | Auto-retry begins (after transient error) | @@ -881,7 +890,8 @@ Emitted when one low-level agent run completes. Contains all messages generated ### agent_settled -Emitted after the full session-level run settles. At this point Diffex will not continue automatically through retry, compaction retry, or queued follow-up messages. +Emitted after the full session-level run settles. +At this point Diffex will not continue automatically through retry, compaction retry, coordinated child delivery, or queued follow-up messages. ```json {"type": "agent_settled"} @@ -1026,6 +1036,61 @@ Emitted whenever the pending steering or follow-up queue changes. } ``` +### subagent_wait_start / subagent_notice / subagent_wait_end + +When a parent assistant run ends with unconsumed children spawned through parent tool calls, RPC mode emits `subagent_wait_start` with the frozen, name-sorted batch: + +```json +{ + "type": "subagent_wait_start", + "names": ["alpha", "beta"] +} +``` + +Each child emits one `subagent_notice` when its current attempt reaches a terminal state: + +```json +{ + "type": "subagent_notice", + "name": "beta", + "status": "idle", + "attempt": "initial", + "retryAvailable": false, + "text": "beta has finished execution." +} +``` + +Notice text is deterministic: + +| Condition | Text | +|-----------|------| +| Successful completion | ` has finished execution.` | +| Initial failure with a retry available | ` failed and a retry is being planned.` | +| Failure after the coordinated retry | ` failed after one retry.` | +| Interruption | ` was interrupted.` | + +Notice events never contain child results, partial output, or errors. +They are not added to agent messages, persisted as session entries, or included in provider context. + +After every child is terminal, RPC mode emits the wait end event: + +```json +{ + "type": "subagent_wait_end", + "names": ["alpha", "beta"], + "cancelled": false +} +``` + +Diffex then supplies one hidden, non-displayed context message to the parent with name-sorted child results, partial output, errors, and retry availability. +That payload is model context, not a result-bearing session event. +The parent continues automatically, and a later `agent_settled` marks completion. + +Terminal child results explicitly returned by the `agents` tool are consumed and excluded from automatic delivery. +An initial failure offers one corrective wake through `agent_action` per child for the current parent prompt cycle. +A second retry in the same cycle is rejected, while a later explicit user prompt starts a fresh cycle. +The parent can decline a retry and settle normally. + ### compaction_start / compaction_end Emitted when compaction runs, whether manual or automatic. diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 17c39e92d..a76f13e97 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -200,7 +200,9 @@ interface PromptOptions { - `true` when the prompt was accepted, queued, or handled immediately - `false` when prompt preflight rejected before acceptance -It fires before `prompt()` resolves. `prompt()` still resolves only after the full accepted run finishes, including retries. Failures after acceptance are reported through the normal event and message stream, not through `preflightResult(false)`. +It fires before `prompt()` resolves. +`prompt()` still resolves only after the full accepted run finishes, including retries and coordinated child-agent batches. +Failures after acceptance are reported through the normal event and message stream, not through `preflightResult(false)`. The `prompt()` method handles prompt templates, extension commands, and message sending: @@ -315,10 +317,19 @@ session.subscribe((event) => { // event.toolResults: tool results from this turn break; - // Session events (queue, compaction, retry) + // Session events (queue, compaction, retry, child coordination) case "queue_update": console.log(event.steering, event.followUp); break; + case "subagent_wait_start": + console.log(`Waiting for: ${event.names.join(", ")}`); + break; + case "subagent_notice": + console.log(event.text); + break; + case "subagent_wait_end": + console.log(`Child wait cancelled: ${event.cancelled}`); + break; case "compaction_start": case "compaction_end": case "auto_retry_start": @@ -331,6 +342,12 @@ session.subscribe((event) => { }); ``` +`subagent_wait_start` identifies the frozen, name-sorted batch that must finish before the parent settles. +`subagent_notice` is emitted as each child terminates and contains only the child name, terminal status, attempt kind, retry availability, and deterministic notice text. +It never contains child results, partial output, or errors. +`subagent_wait_end` closes the wait and reports whether it was cancelled. +These lifecycle events are not added to agent messages, persisted as session entries, or included in provider context. + ## Options Reference ### Directories @@ -514,7 +531,7 @@ const { session } = await createAgentSession({ resourceLoader: loader }); Every root session enables three child-agent coordination tools by default: -- `spawn_agent` starts a child agent asynchronously +- `spawn_agent` starts a child agent asynchronously, while keeping the enclosing parent prompt active until coordinated work finishes - `agent_action` sends, wakes, or interrupts a child agent - `agents` lists child status immediately or waits for controller changes @@ -522,6 +539,20 @@ Root sessions run at most four children concurrently by default. Set `subagents: { maxConcurrent: number }` to configure that limit, or `subagents: false` to disable child agents and omit all three coordination tools. The names `spawn_agent`, `agent_action`, and `agents` are reserved and cannot be supplied through `customTools`. +When the parent finishes an assistant run, the session freezes every unconsumed child attempt spawned through parent model tool calls. +It waits until every child in that batch is idle, failed, or interrupted, then adds one non-displayed context message with name-sorted results, partial output, errors, and retry availability. +The parent model continues automatically from that message, so callers do not need to send another user prompt. +Terminal results returned explicitly by `agents` are marked consumed and are not delivered again. + +An initial child failure offers the parent one coordinated retry for that child during the current explicit parent prompt cycle. +The parent can analyze the delivered error and partial output, then call `agent_action` with `action: "message"` and `wake: true`. +A second retry in the same cycle is rejected, while a later explicit user prompt starts a fresh cycle. +The parent may decline the retry and settle normally. + +Calling `session.abort()` during a coordinated wait interrupts unfinished children in the frozen batch, emits their interrupted notices, and settles without another provider call. +Only children spawned by parent model tool calls use automatic coordination. +Invoking the `spawn_agent` tool definition directly through the SDK leaves that child and its retries manually managed. + Specify which tools to enable: - Built-in tool names: `read`, `bash`, `edit`, `write`, `update_plan`, `grep`, `find`, `ls` diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index fabb0888b..f0b7f5fb2 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -63,6 +63,28 @@ Type `/` in the editor to open command completion. Extensions can register custo `/subagents` runs immediately, including while the parent agent is working or compacting. It displays the current in-memory roster without adding the command or roster to model context. +## Automatic Sub-agent Coordination + +`spawn_agent` starts child work asynchronously, so the parent can launch multiple children without waiting between tool calls. +The enclosing parent prompt remains active after the parent attempts to finish. +Diffex freezes the children started by that prompt, waits until all of them finish, fail, or are interrupted, and displays a deterministic notice as each child terminates. + +After the whole batch is terminal, Diffex gives the parent one hidden, name-sorted context message containing the child results, partial output, errors, and retry availability. +The parent continues automatically and produces its final response without another user prompt. +If the parent already retrieved a terminal result with `agents`, Diffex does not deliver that result again. + +An initial failure gives the parent one opportunity to send a corrective `agent_action` message with `wake: true`. +Only one coordinated retry is allowed per child during the current user prompt. +The parent may decline the retry, and a later explicit user prompt starts a fresh retry cycle. + +While Diffex waits, the status indicator remains active and steering or follow-up input stays queued. +Press Escape to restore queued input to the editor and interrupt unfinished children in the frozen batch. +Diffex displays interrupted notices and settles without making another parent model call. + +Completion notices use assistant styling, but are not conversation messages. +They are not persisted or included in model context. +JSON and RPC modes emit the same notices as structured session events. + ## Message Queue You can submit messages while the agent is still working: @@ -305,6 +327,6 @@ diffex --exclude-tools ask_question Diffex keeps the core small and pushes workflow-specific behavior into extensions, skills, prompt templates, and packages. -It intentionally does not include built-in MCP, sub-agents, permission popups, full plan mode, persistent to-dos, or background bash. The built-in `update_plan` tool is only a lightweight progress checklist. You can build or install richer workflows as extensions or packages, or use external tools such as containers and tmux. +It intentionally does not include built-in MCP, permission popups, full plan mode, persistent to-dos, or background bash. The built-in `update_plan` tool is only a lightweight progress checklist. You can build or install richer workflows as extensions or packages, or use external tools such as containers and tmux. For the full rationale, read the [blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/). From 50bc88845739af0bcb03ca815176636094f6a902 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Fri, 14 Aug 2026 10:20:29 -0700 Subject: [PATCH 5/9] fix(coding-agent): dedupe subagent retry guidance --- packages/coding-agent/src/core/tools/subagents.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/coding-agent/src/core/tools/subagents.ts b/packages/coding-agent/src/core/tools/subagents.ts index 20aad0781..10bef3f6d 100644 --- a/packages/coding-agent/src/core/tools/subagents.ts +++ b/packages/coding-agent/src/core/tools/subagents.ts @@ -129,7 +129,6 @@ export function createSpawnAgentTool( "Start an independent child agent asynchronously. The tool returns after acceptance, but the enclosing parent prompt remains active until coordinated child work is delivered.", promptGuidelines: [ "After spawning child agents, you may finish the current response. The session waits for the frozen child batch and automatically sends one hidden, name-sorted result message before the parent prompt settles.", - "If that hidden result message says a retry is available, analyze the error and partial output. Retry only with agent_action using action message and wake true. One coordinated retry is allowed per failed child in the current parent prompt cycle.", ], parameters: spawnAgentSchema, executionMode: "parallel", From 0804db8a5aaacab2a2c09e0e1e2c8dbf0959ab73 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 10:12:06 -0700 Subject: [PATCH 6/9] fix(coding-agent): include update_plan in subagent tool capture expectation --- packages/coding-agent/test/suite/subagent-registration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/test/suite/subagent-registration.test.ts b/packages/coding-agent/test/suite/subagent-registration.test.ts index fc609df63..1279691e6 100644 --- a/packages/coding-agent/test/suite/subagent-registration.test.ts +++ b/packages/coding-agent/test/suite/subagent-registration.test.ts @@ -440,7 +440,7 @@ describe("root subagent tool registration", () => { expect(captured?.model.id).toBe("faux-1"); expect(captured?.options?.reasoning).toBe("high"); - expect(captured?.toolNames).toEqual(["read", "bash", "edit", "write"]); + expect(captured?.toolNames).toEqual(["read", "bash", "edit", "write", "update_plan"]); }); it("creates an independent empty controller after runtime replacement", async () => { From db721479e21ba549a4b1815c4d80772f5b6f4eb0 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 14:22:21 -0700 Subject: [PATCH 7/9] fix(coding-agent): preserve cancellation before subagent waits --- .../coding-agent/src/core/agent-session.ts | 6 +++ .../test/suite/subagent-coordination.test.ts | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 87e5b25e3..80d045725 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -394,6 +394,7 @@ export class AgentSession { private _systemPromptOverride?: string; private readonly _subagentController: SubagentController | undefined; private _subagentWaitAbortController: AbortController | undefined; + private _agentRunAborted = false; private _disposePromise: Promise | undefined; constructor(config: AgentSessionConfig) { @@ -1246,6 +1247,7 @@ export class AgentSession { // ========================================================================= private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise { + this._agentRunAborted = false; this._isAgentRunActive = true; try { await this.agent.prompt(messages); @@ -1324,6 +1326,9 @@ export class AgentSession { }; const waitAbortController = new AbortController(); this._subagentWaitAbortController = waitAbortController; + if (this._agentRunAborted) { + waitAbortController.abort(new Error("Subagent coordination cancelled")); + } this._emit({ type: "subagent_wait_start", names }); let cancelled = false; @@ -1836,6 +1841,7 @@ export class AgentSession { */ async abort(): Promise { this.abortRetry(); + if (this._isAgentRunActive) this._agentRunAborted = true; this._subagentWaitAbortController?.abort(new Error("Subagent coordination cancelled")); this.agent.abort(); await this.waitForIdle(); diff --git a/packages/coding-agent/test/suite/subagent-coordination.test.ts b/packages/coding-agent/test/suite/subagent-coordination.test.ts index 83b81db15..22403970a 100644 --- a/packages/coding-agent/test/suite/subagent-coordination.test.ts +++ b/packages/coding-agent/test/suite/subagent-coordination.test.ts @@ -533,6 +533,54 @@ describe("automatic subagent coordination", () => { ); }); + it("preserves cancellation before the subagent wait is installed", async () => { + const { root, harness } = await createRoot(); + const childStarted = deferred(); + const abortStarted = deferred(); + const lifecycle: Array<{ type: "start" } | { type: "end"; cancelled: boolean }> = []; + let abortPromise: Promise | undefined; + let parentCalls = 0; + root.subscribe((event) => { + if (event.type === "agent_end" && !abortPromise) { + abortPromise = root.abort(); + abortStarted.resolve(); + } + if (event.type === "subagent_wait_start") lifecycle.push({ type: "start" }); + if (event.type === "subagent_wait_end") lifecycle.push({ type: "end", cancelled: event.cancelled }); + }); + harness.setResponses( + Array.from({ length: 6 }, () => async (context: Context, options?: SimpleStreamOptions) => { + if (!hasTool(context, "spawn_agent")) { + childStarted.resolve(); + return await new Promise>((resolve) => { + options?.signal?.addEventListener( + "abort", + () => resolve(fauxAssistantMessage("child partial", { stopReason: "aborted" })), + { once: true }, + ); + }); + } + parentCalls += 1; + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage(fauxToolCall("spawn_agent", { name: "worker", task: "wait forever" }), { + stopReason: "toolUse", + }); + } + return fauxAssistantMessage("attempted finish before cancellation"); + }), + ); + + const prompt = root.prompt("cancel before post-run coordination"); + await Promise.all([childStarted.promise, abortStarted.promise]); + await prompt; + await abortPromise; + + expect(parentCalls).toBe(2); + expect(lifecycle).toEqual([{ type: "start" }, { type: "end", cancelled: true }]); + expect(root.getSubagents()).toEqual([{ name: "worker", status: "interrupted", task: "wait forever" }]); + expect(getHiddenResultMessages(root)).toHaveLength(0); + }); + it("interrupts a frozen batch on cancellation without another provider call", async () => { const { root, harness } = await createRoot(); const childrenStarted = new Set(); From 032f09c4b27fce561afa3ab0b493dcc23a8f1de5 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 14:24:22 -0700 Subject: [PATCH 8/9] fix(coding-agent): consume matching subagent attempts --- .../src/core/subagents/controller.ts | 6 +- .../coding-agent/src/core/subagents/types.ts | 6 +- .../coding-agent/src/core/tools/subagents.ts | 11 +++- .../test/suite/subagent-agents.test.ts | 60 ++++++++++++++++++- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/core/subagents/controller.ts b/packages/coding-agent/src/core/subagents/controller.ts index be8be3f37..28c6263e0 100644 --- a/packages/coding-agent/src/core/subagents/controller.ts +++ b/packages/coding-agent/src/core/subagents/controller.ts @@ -15,6 +15,7 @@ import type { SubagentPermit, SubagentQueryOptions, SubagentQueryResult, + SubagentQueryResultView, SubagentResultView, SubagentSnapshot, SubagentSpawnOrigin, @@ -723,7 +724,7 @@ export class SubagentController { snapshot: SubagentControllerSnapshot, targets: ReadonlySet, ): SubagentQueryResult { - const agents = snapshot.agents.flatMap((agent): SubagentResultView[] => { + const agents = snapshot.agents.flatMap((agent): SubagentQueryResultView[] => { if (!targets.has(agent.name)) return []; const record = this.#records.get(agent.name); if (!record) return []; @@ -731,6 +732,7 @@ export class SubagentController { Object.freeze({ name: agent.name, status: agent.status, + ...(record.coordinatedRun === undefined ? {} : { runId: record.coordinatedRun.id }), ...(record.finalText === undefined ? {} : { result: record.finalText }), ...(record.error === undefined ? {} : { error: record.error }), }), @@ -743,7 +745,7 @@ export class SubagentController { for (const agent of result.agents) { if (!isTerminalStatus(agent.status)) continue; const run = this.#records.get(agent.name)?.coordinatedRun; - if (run) run.delivery = "consumed"; + if (run && run.id === agent.runId) run.delivery = "consumed"; } } diff --git a/packages/coding-agent/src/core/subagents/types.ts b/packages/coding-agent/src/core/subagents/types.ts index 2f383d49c..ecaa1de90 100644 --- a/packages/coding-agent/src/core/subagents/types.ts +++ b/packages/coding-agent/src/core/subagents/types.ts @@ -80,9 +80,13 @@ export interface SubagentResultView { readonly error?: string; } +export interface SubagentQueryResultView extends SubagentResultView { + readonly runId?: number; +} + export interface SubagentQueryResult { readonly timedOut: boolean; - readonly agents: readonly SubagentResultView[]; + readonly agents: readonly SubagentQueryResultView[]; } export type SubagentTerminalStatus = Extract; diff --git a/packages/coding-agent/src/core/tools/subagents.ts b/packages/coding-agent/src/core/tools/subagents.ts index 10bef3f6d..822f1b993 100644 --- a/packages/coding-agent/src/core/tools/subagents.ts +++ b/packages/coding-agent/src/core/tools/subagents.ts @@ -199,7 +199,16 @@ export function createAgentsTool( }); const details: AgentsDetails = Object.freeze({ timed_out: result.timedOut, - agents: result.agents, + agents: Object.freeze( + result.agents.map((agent) => + Object.freeze({ + name: agent.name, + status: agent.status, + ...(agent.result === undefined ? {} : { result: agent.result }), + ...(agent.error === undefined ? {} : { error: agent.error }), + }), + ), + ), }); return { content: [{ type: "text", text: JSON.stringify(details) }], diff --git a/packages/coding-agent/test/suite/subagent-agents.test.ts b/packages/coding-agent/test/suite/subagent-agents.test.ts index ef4090e52..32df27990 100644 --- a/packages/coding-agent/test/suite/subagent-agents.test.ts +++ b/packages/coding-agent/test/suite/subagent-agents.test.ts @@ -9,7 +9,12 @@ import type { SubagentQueryResult, } from "../../src/core/subagents/types.ts"; import { type AgentsRequest, createAgentsTool } from "../../src/core/tools/subagents.ts"; -import { createSubagentTestHarness, type SubagentTestHarness } from "./subagent-harness.ts"; +import { + createSubagentTestHarness, + deferred, + type SubagentTestHarness, + waitForStatus, +} from "./subagent-harness.ts"; type AgentsTool = ReturnType; @@ -301,6 +306,59 @@ describe("subagent queries", () => { }); }); + it("only consumes the coordinated attempt returned by a racing agents query", async () => { + const { harness, controller: subject, createSession } = await setup(); + const releaseRetry = deferred(); + harness.setResponses([ + fauxAssistantMessage("initial partial", { + stopReason: "error", + errorMessage: "initial failure", + }), + async () => { + await releaseRetry.promise; + return fauxAssistantMessage("retry result"); + }, + ]); + subject.beginCoordinationCycle(); + subject.spawn("worker", "fail once", createSession, { + type: "agent_tool_call", + toolCallId: "spawn-worker", + assistantEntryId: "assistant-entry", + contextEntryId: null, + }); + await waitForStatus(subject, "worker", "failed"); + + const agentsTool = subject.createToolDefinitions().find((tool) => tool.name === "agents") as + | AgentsTool + | undefined; + if (!agentsTool) throw new Error("agents tool is unavailable"); + + const retry = subject.message("worker", "correct the failure", true, true); + const explicitQuery = executeAgents(agentsTool, { timeout_ms: 0 }); + const [, explicitResult] = await Promise.all([retry, explicitQuery]); + expect(explicitResult.details).toEqual({ + timed_out: false, + agents: [ + { name: "worker", status: "failed", result: "initial partial", error: "initial failure" }, + ], + }); + + releaseRetry.resolve(); + await waitForStatus(subject, "worker", "idle"); + const retryBatch = subject.freezeUnconsumedBatch(); + if (!retryBatch) throw new Error("retry batch was consumed by the stale query"); + expect(retryBatch).toMatchObject({ attempts: [{ name: "worker" }] }); + expect(subject.consumeBatch(retryBatch)).toEqual([ + { + name: "worker", + status: "idle", + attempt: "retry", + retryAvailable: false, + result: "retry result", + }, + ]); + }); + it("keeps lifecycle snapshots and events free of results and errors", async () => { const subject = controller(); const events: SubagentLifecycleEvent[] = []; From efb3bf7288fb5af8bf2400d50281236cb0d0b903 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 14:25:31 -0700 Subject: [PATCH 9/9] fix(coding-agent): reset retries for queued prompts --- .../coding-agent/src/core/agent-session.ts | 2 +- .../test/suite/subagent-agents.test.ts | 11 +- .../test/suite/subagent-coordination.test.ts | 115 ++++++++++++++++++ 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 80d045725..a498b85ec 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -694,6 +694,7 @@ export class AgentSession { // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { + this._subagentController?.beginCoordinationCycle(); this._overflowRecoveryAttempted = false; const messageText = contentText(event.message.content, ""); if (messageText) { @@ -1560,7 +1561,6 @@ export class AgentSession { } preflightResult?.(true); - this._subagentController?.beginCoordinationCycle(); await this._runAgentPrompt(messages); } diff --git a/packages/coding-agent/test/suite/subagent-agents.test.ts b/packages/coding-agent/test/suite/subagent-agents.test.ts index 32df27990..baa9f2096 100644 --- a/packages/coding-agent/test/suite/subagent-agents.test.ts +++ b/packages/coding-agent/test/suite/subagent-agents.test.ts @@ -9,12 +9,7 @@ import type { SubagentQueryResult, } from "../../src/core/subagents/types.ts"; import { type AgentsRequest, createAgentsTool } from "../../src/core/tools/subagents.ts"; -import { - createSubagentTestHarness, - deferred, - type SubagentTestHarness, - waitForStatus, -} from "./subagent-harness.ts"; +import { createSubagentTestHarness, deferred, type SubagentTestHarness, waitForStatus } from "./subagent-harness.ts"; type AgentsTool = ReturnType; @@ -338,9 +333,7 @@ describe("subagent queries", () => { const [, explicitResult] = await Promise.all([retry, explicitQuery]); expect(explicitResult.details).toEqual({ timed_out: false, - agents: [ - { name: "worker", status: "failed", result: "initial partial", error: "initial failure" }, - ], + agents: [{ name: "worker", status: "failed", result: "initial partial", error: "initial failure" }], }); releaseRetry.resolve(); diff --git a/packages/coding-agent/test/suite/subagent-coordination.test.ts b/packages/coding-agent/test/suite/subagent-coordination.test.ts index 22403970a..ef3047aa7 100644 --- a/packages/coding-agent/test/suite/subagent-coordination.test.ts +++ b/packages/coding-agent/test/suite/subagent-coordination.test.ts @@ -374,6 +374,121 @@ describe("automatic subagent coordination", () => { expect(root.getLastAssistantText()).toBe("later cycle synthesis"); }); + it.each(["steer", "followUp"] as const)( + "starts a fresh retry cycle when queued %s input is delivered", + async (delivery) => { + const { root, harness } = await createRoot(); + const readyForQueuedInput = deferred(); + const releaseParent = deferred(); + const queuedInput = `new cycle via ${delivery}`; + let childAttempts = 0; + harness.setResponses( + Array.from({ length: 24 }, () => async (context: Context) => { + if (!hasTool(context, "spawn_agent")) { + childAttempts += 1; + if (childAttempts <= 2) { + return fauxAssistantMessage(`partial ${childAttempts}`, { + stopReason: "error", + errorMessage: `failure ${childAttempts}`, + }); + } + return fauxAssistantMessage("queued retry succeeded"); + } + + let queuedInputIndex = -1; + for (let index = context.messages.length - 1; index >= 0; index -= 1) { + const message = context.messages[index]; + if (message?.role === "user" && getMessageText(message) === queuedInput) { + queuedInputIndex = index; + break; + } + } + if (queuedInputIndex >= 0) { + const recentContext = { + ...context, + messages: context.messages.slice(queuedInputIndex + 1), + }; + const recentBatches = getBatchPayloads(recentContext); + if ( + recentBatches.some((batch) => + batch.agents.some((agent) => agent.result === "queued retry succeeded"), + ) + ) { + return fauxAssistantMessage("queued cycle synthesis"); + } + if (getToolResultCount(recentContext, "agent_action") === 0) { + return fauxAssistantMessage( + fauxToolCall("agent_action", { + target: "worker", + action: "message", + message: "correction from queued input", + wake: true, + }), + { stopReason: "toolUse" }, + ); + } + return fauxAssistantMessage("attempted queued finish"); + } + + const batches = getBatchPayloads(context); + const actionResults = getToolResultCount(context, "agent_action"); + if (batches.length === 2 && actionResults === 1) { + return fauxAssistantMessage( + fauxToolCall("agent_action", { + target: "worker", + action: "message", + message: "exhaust this cycle", + wake: true, + }), + { stopReason: "toolUse" }, + ); + } + if (actionResults === 2) { + readyForQueuedInput.resolve(); + await releaseParent.promise; + return fauxAssistantMessage("retry exhausted before queued input"); + } + if (batches.length === 1 && actionResults === 0) { + return fauxAssistantMessage( + fauxToolCall("agent_action", { + target: "worker", + action: "message", + message: "first correction", + wake: true, + }), + { stopReason: "toolUse" }, + ); + } + if (!hasToolResult(context, "spawn_agent")) { + return fauxAssistantMessage(fauxToolCall("spawn_agent", { name: "worker", task: "fail twice" }), { + stopReason: "toolUse", + }); + } + return fauxAssistantMessage("attempted initial finish"); + }), + ); + + const prompt = root.prompt("exhaust retry before queued input"); + await readyForQueuedInput.promise; + if (delivery === "steer") { + await root.steer(queuedInput); + } else { + await root.followUp(queuedInput); + } + releaseParent.resolve(); + await prompt; + + expect(childAttempts).toBe(3); + expect(root.getLastAssistantText()).toBe("queued cycle synthesis"); + const actionResults = root.messages.filter( + (message) => message.role === "toolResult" && message.toolName === "agent_action", + ); + expect(actionResults).toHaveLength(3); + expect(actionResults[1]).toMatchObject({ isError: true }); + expect(actionResults[2]).toMatchObject({ isError: false }); + }, + ); + it("settles when the parent declines the offered retry", async () => { const { root, harness } = await createRoot(); harness.setResponses(