Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andhadan8977 cd12b2c133 fix(server): clear plugin child outcomes when replaying a branch
Keep the close-status fix compatible with reused-child running updates and discard remembered outcomes when rewind replaces a binding. Cover terminal close states through the agent API and replay.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
2026-11-02 10:58:21 +00:00
hadan8977 8ff35abdeb 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.
2026-11-02 10:55:15 +00:00
2 changed files with 164 additions and 1 deletions
@@ -488,6 +488,58 @@ async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSes
);
}
test("plugin rewind discards child outcomes from the withdrawn branch", async () => {
await withPluginRewindSession(
{
async handleInput(input, emit) {
if (input.type !== "session.open" || input.history !== "replay") return false;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: CAPABILITIES,
restoration: "core",
persistence: input.persistence,
cwd: input.config.cwd,
});
emit({
type: "session.opened",
sessionId: "rewound-child",
parentSessionId: input.sessionId,
capabilities: [],
restoration: "parent",
cwd: input.config.cwd,
});
emit({ type: "session.closed", sessionId: "rewound-child" });
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
},
},
async (session, harness) => {
const open = harness.inputs.find((input) => input.type === "session.open")!;
harness.emit({
type: "session.opened",
sessionId: "rewound-child",
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
harness.emit({
type: "session.turn",
sessionId: "rewound-child",
turnId: "withdrawn-turn",
state: "failed",
});
await session.revertConversation!({ messageId: "user-B" });
const history = await Array.fromAsync(session.streamHistory());
expect(history.findLast((event) => event.type === "provider_subagent")).toMatchObject({
event: { type: "upsert", id: "rewound-child", status: "completed" },
});
},
);
});
test("combined plugin rewind replays the retained native branch", async () => {
await withPluginRewindSession({}, async (session) => {
await session.revertBoth!({ messageId: "user-B" });
@@ -1234,6 +1286,11 @@ describe("PluginAgentClientRegistry", () => {
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome },
]);
harness.emit({ type: "session.closed", sessionId: "reusable-child" });
await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome },
]);
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
await plugins.shutdown();
@@ -1268,6 +1325,7 @@ describe("PluginAgentClientRegistry", () => {
turnId: "second-turn",
state,
});
emit({ type: "session.closed", sessionId: "reusable-child" });
}
},
});
@@ -1294,6 +1352,100 @@ 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.each([
{ turns: [], error: false, expected: "completed" },
{ turns: ["canceled", "started"], error: false, expected: "completed" },
{ turns: ["failed", "started", "completed"], error: false, expected: "completed" },
{ turns: ["canceled"], error: true, expected: "failed" },
] as const)(
"closes a plugin child using its latest turn: $turns, error=$error",
async ({ turns, error, expected }) => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
try {
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const open = harness.inputs.find((input) => input.type === "session.open")!;
harness.emit({
type: "session.opened",
sessionId: "close-control",
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
for (const state of turns) {
harness.emit({ type: "session.turn", sessionId: "close-control", turnId: "turn", state });
}
harness.emit({
type: "session.closed",
sessionId: "close-control",
...(error ? { error: { message: "Provider failed to close" } } : {}),
});
expect(events.at(-1)).toMatchObject({
type: "provider_subagent",
event: { type: "upsert", id: "close-control", status: expected },
});
} finally {
await registry.shutdown();
}
},
);
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"),
},
});
}
@@ -1708,6 +1718,7 @@ class PluginAgentSession implements AgentSession {
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.childTurnOutcomes.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();