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.
This commit is contained in:
hadan8977
2026-11-02 10:55:15 +00:00
committed by paseo-bot[bot]
parent 835bc49487
commit 8ff35abdeb
2 changed files with 60 additions and 1 deletions
@@ -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"] });
@@ -1200,6 +1200,8 @@ class PluginAgentSession implements AgentSession {
{ session: ProviderRuntimeSession; unsubscribe: () => void }
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
// Outcome of each child's latest finished turn, so a plain close does not report it as completed.
private readonly childTurnOutcomes = new Map<string, "completed" | "canceled" | "failed">();
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"),
},
});
}