Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions packages/agent-runtime/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5921,6 +5921,100 @@ describe("DesktopAgentRuntime subagents", () => {
await runtime.dispose();
});

it("feeds a report that settled before the parent idled (#226)", async () => {
const runtime = createRuntime({ subagents: [explorer] });
subagentRuns.calls.length = 0;
subagentRuns.instances.length = 0;
subagentRuns.deferred = true;
subagentRuns.resolveRun = undefined;
const tool = taskTool(runtime);
const prompt = vi.fn(async () => undefined);
(runtime as any).agent.prompt = prompt;
(runtime as any).agent.waitForIdle = vi.fn(async () => undefined);

const started = await tool.execute("task-1", {
agent: "explorer",
task: "Find it.",
});
const delegationId = (started.details as any).delegationId as string;

// The explorer finishes while the parent is still on its own line of work…
subagentRuns.resolveRun!({
agentName: "explorer",
status: "completed",
report: "src/app.ts:12 misses the null check.",
turns: 1,
toolCalls: 1,
});
await vi.waitFor(() => {
expect((runtime as any).delegations.get(delegationId).status).toBe(
"completed",
);
});
expect((runtime as any).keepTurnOpenForDelegates()).toBe(true);

// …and the parent then idles without a TaskWait. The settled report is
// "done and unpublished", not "unfinished": it must still reach the parent.
await (runtime as any).resumeAfterDelegations();

expect(prompt).toHaveBeenCalledTimes(1);
const delivered = String(
(prompt.mock.calls as unknown as unknown[][])[0]?.[0] ?? "",
);
expect(delivered).toContain("src/app.ts:12 misses the null check.");
expect((runtime as any).keepTurnOpenForDelegates()).toBe(false);

// Single shot: a later idle does not replay it.
await (runtime as any).resumeAfterDelegations();
expect(prompt).toHaveBeenCalledTimes(1);

subagentRuns.deferred = false;
await runtime.dispose();
});

it("does not replay a report that TaskWait already returned", async () => {
const runtime = createRuntime({ subagents: [explorer] });
subagentRuns.calls.length = 0;
subagentRuns.instances.length = 0;
subagentRuns.deferred = true;
subagentRuns.resolveRun = undefined;
const tool = taskTool(runtime);
const wait = (runtime as any).agent.state.tools.find(
(entry: any) => entry.name === "TaskWait",
);
const prompt = vi.fn(async () => undefined);
(runtime as any).agent.prompt = prompt;
(runtime as any).agent.waitForIdle = vi.fn(async () => undefined);

const started = await tool.execute("task-1", {
agent: "explorer",
task: "Find it.",
});
const delegationId = (started.details as any).delegationId as string;
subagentRuns.resolveRun!({
agentName: "explorer",
status: "completed",
report: "src/app.ts:12 misses the null check.",
turns: 1,
toolCalls: 1,
});
await vi.waitFor(() => {
expect((runtime as any).delegations.get(delegationId).status).toBe(
"completed",
);
});

const result = await wait.execute("wait-1", { delegationIds: [delegationId] });
expect(result.content[0].text).toContain("src/app.ts:12 misses the null check.");
expect((runtime as any).keepTurnOpenForDelegates()).toBe(false);

await (runtime as any).resumeAfterDelegations();
expect(prompt).not.toHaveBeenCalled();

subagentRuns.deferred = false;
await runtime.dispose();
});

it("lists a heartbeat for a running delegate", async () => {
const runtime = createRuntime({ subagents: [explorer] });
subagentRuns.calls.length = 0;
Expand Down
41 changes: 37 additions & 4 deletions packages/agent-runtime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,10 @@ export type DelegationRecord = {
/** `prompt()` / `executeApprovedPlan()` generation that started this run.
* Resume-after-idle only waits for the current turn's delegates (D352). */
startedEpoch: number;
/** The settled report reached the parent's context once: through a
* `TaskWait` result or the resume-after-idle prompt. Auto-delivery is a
* single shot per record. */
reportDelivered: boolean;
};

function delegationSummary(record: DelegationRecord): Record<string, unknown> {
Expand Down Expand Up @@ -3422,6 +3426,7 @@ Delegation rules:
lastActivityAt: startedAt,
lastPhase: "waiting-model",
startedEpoch: this.turnEpoch,
reportDelivered: false,
};
this.delegations.set(delegationId, record);
const scopedTools = this.scopeDelegateTools(tools, definition);
Expand Down Expand Up @@ -3568,6 +3573,24 @@ Delegation rules:
);
}

/**
* Current-turn delegates whose report the parent has not seen yet: still
* running, or settled before the parent idled and never read through
* `TaskWait`. A delegate that finished in a few hundred milliseconds is
* "done and unpublished", not "unfinished"; keying the idle resume on
* running delegates alone dropped such reports (#226). Stopped and
* aborted runs are not auto-delivered.
*/
private pendingCurrentTurnDelegations(): DelegationRecord[] {
return [...this.delegations.values()].filter(
(record) =>
record.startedEpoch === this.turnEpoch &&
!record.reportDelivered &&
record.status !== "stopped" &&
record.status !== "aborted",
);
}

private abortDelegationsFromPreviousTurns(): void {
for (const record of this.runningDelegations()) {
if (record.startedEpoch !== this.turnEpoch) record.abort();
Expand All @@ -3588,7 +3611,8 @@ Delegation rules:
/** D328 keeps the turn open on parent idle, not on a fatal parent error. */
private keepTurnOpenForDelegates(): boolean {
return (
this.runningDelegations().length > 0 &&
(this.runningDelegations().length > 0 ||
this.pendingCurrentTurnDelegations().length > 0) &&
!this.runCancelled &&
!this.turnHadError
);
Expand Down Expand Up @@ -3696,9 +3720,9 @@ Delegation rules:
!this.runCancelled &&
!this.turnHadError &&
epoch === this.turnEpoch &&
this.currentTurnDelegations().length > 0
this.pendingCurrentTurnDelegations().length > 0
) {
const targets = this.currentTurnDelegations();
const targets = this.pendingCurrentTurnDelegations();
this.beginDelegationWait(targets);
await this.waitForDelegations(targets, targets.length, null);
this.endDelegationWait();
Expand All @@ -3711,8 +3735,11 @@ Delegation rules:
if (this.turnHadError) this.terminateParentTurn();
return;
}
const settled = targets.filter((record) => record.status !== "running");
const settled = targets.filter(
(record) => record.status !== "running" && !record.reportDelivered,
);
if (settled.length === 0) return;
for (const record of settled) record.reportDelivered = true;
const results = settled.map((record) => ({
delegationId: record.delegationId,
agent: record.agentName,
Expand Down Expand Up @@ -3827,6 +3854,12 @@ Delegation rules:
} finally {
this.endDelegationWait();
}
// A settled report returned here reached the parent; the idle resume
// must not deliver it a second time. Running delegates keep their
// shot: a timeout or an early `any` convergence has not consumed it.
for (const record of targets) {
if (record.status !== "running") record.reportDelivered = true;
}
const results = targets.map((record) => ({
delegationId: record.delegationId,
agent: record.agentName,
Expand Down
Loading