From 8ff35abdebc413ba8752fd32fda217e29fff3ade Mon Sep 17 00:00:00 2001 From: hadan8977 Date: Mon, 19 Oct 2026 18:43:43 +0800 Subject: [PATCH] fix(server): keep a plugin child's canceled or failed turn on close A plugin child that ended its turn as canceled or failed and then sent a plain session.closed was upserted as completed. Remember the outcome of the child's latest finished turn and use it when it closes without an error. --- .../src/server/agent/plugin-provider.test.ts | 49 +++++++++++++++++++ .../src/server/agent/plugin-provider.ts | 12 ++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index a9cf3b0ed5..ad087bbdf2 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1294,6 +1294,55 @@ describe("PluginAgentClientRegistry", () => { } }, ); + test("keeps a child's canceled or failed turn when the child closes normally", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + try { + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + const open = harness.inputs.find((input) => input.type === "session.open")!; + const outcomes = [ + ["child-canceled", "canceled"], + ["child-failed", "failed"], + ["child-completed", "completed"], + ] as const; + for (const [sessionId, state] of outcomes) { + harness.emit({ + type: "session.opened", + sessionId, + parentSessionId: open.sessionId, + capabilities: [], + restoration: "parent", + cwd: "/workspace", + }); + harness.emit({ type: "session.turn", sessionId, turnId: "turn", state: "started" }); + harness.emit({ type: "session.turn", sessionId, turnId: "turn", state }); + harness.emit({ type: "session.closed", sessionId }); + } + + const lastStatus = (id: string) => + events + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.id === id + ? [event.event.status] + : [], + ) + .at(-1); + expect(lastStatus("child-canceled")).toBe("canceled"); + expect(lastStatus("child-failed")).toBe("failed"); + expect(lastStatus("child-completed")).toBe("completed"); + } finally { + await session.close(); + registry.replace([]); + } + }); test("gates persistence operations on negotiated provider capabilities", async () => { const harness = createProviderHarness({ capabilities: ["session.persistence"] }); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 2d20056930..95b3f1c3a0 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1200,6 +1200,8 @@ class PluginAgentSession implements AgentSession { { session: ProviderRuntimeSession; unsubscribe: () => void } >(); private readonly childSnapshots = new Map>(); + // Outcome of each child's latest finished turn, so a plain close does not report it as completed. + private readonly childTurnOutcomes = new Map(); private unsubscribe: (() => void) | null = null; private currentTurnId: string | null = null; private closed = false; @@ -1672,6 +1674,11 @@ class PluginAgentSession implements AgentSession { return; } if (event.type === "session.turn") { + if (event.state === "started") { + this.childTurnOutcomes.delete(childId); + } else { + this.childTurnOutcomes.set(childId, event.state); + } this.publish({ type: "provider_subagent", provider: this.provider, @@ -1690,7 +1697,10 @@ class PluginAgentSession implements AgentSession { event: { type: "upsert", id: childId, - status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed", + status: + event.type === "session.runtime_failed" || event.error + ? "failed" + : (this.childTurnOutcomes.get(childId) ?? "completed"), }, }); }