fix(coding-agent): settle tool results before disposal (#9126)

Fixes #9124.

Disposing a runtime during tool execution disconnects persistence before
the interrupted turn settles, leaving a stored assistant tool call
without its result. Await `session.abort()` before shutdown and disposal
so the interrupted tool result and final assistant message are
persisted.

This matches `teardownCurrent()`. `AgentSession.dispose()` remains
synchronous. Like the existing replacement path, the wait has no
timeout; handling tools that ignore cancellation is outside this change.

Related: #9986 concerns results for tool calls skipped during a
mid-session abort. This fix preserves the result of an already-running
tool during runtime shutdown.

Validation on main `f1b2e77f5`:

- The regression fails without the fix and passes with it, checking the
durable message order and `Operation aborted` result immediately after
`dispose()` returns, before awaiting the prompt.
- `npm run check`, offline build, and `./test.sh` pass.
This commit is contained in:
Gangqiang Yao 2026-11-04 17:21:16 +08:00 • committed by GitHub
parent 2cfab7c269
commit 7a53c310a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 83 additions and 0 deletions

View file

@ -402,6 +402,7 @@ export class AgentSessionRuntime {
}
async dispose(): Promise<void> {
await this.session.abort();
await emitSessionShutdownEvent(this.session.extensionRunner, {
type: "session_shutdown",
reason: "quit",

View file

@ -0,0 +1,82 @@
import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import {
AgentSessionRuntime,
type AgentSessionServices,
type CreateAgentSessionRuntimeFactory,
} from "../../../src/core/agent-session-runtime.ts";
import { createHarness, type Harness } from "../harness.ts";
describe("regression #9124: runtime disposal during tool execution", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
async function createRuntime(tool: AgentTool): Promise<{ harness: Harness; runtime: AgentSessionRuntime }> {
const harness = await createHarness({ tools: [tool] });
harnesses.push(harness);
const services: AgentSessionServices = {
cwd: harness.tempDir,
agentDir: harness.tempDir,
modelRuntime: harness.session.modelRuntime,
settingsManager: harness.settingsManager,
resourceLoader: harness.session.resourceLoader,
diagnostics: [],
};
const unusedFactory: CreateAgentSessionRuntimeFactory = async () => {
throw new Error("runtime replacement is not used in this test");
};
return {
harness,
runtime: new AgentSessionRuntime(harness.session, services, unusedFactory),
};
}
it("persists the aborted tool turn before disconnecting the session", async () => {
let markToolStarted!: () => void;
const toolStarted = new Promise<void>((resolve) => {
markToolStarted = resolve;
});
const tool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait until aborted",
parameters: Type.Object({}),
execute: (_toolCallId, _params, signal) =>
new Promise<AgentToolResult<unknown>>((_resolve, reject) => {
markToolStarted();
signal?.addEventListener("abort", () => setTimeout(() => reject(new Error("Operation aborted")), 10), {
once: true,
});
}),
};
const { harness, runtime } = await createRuntime(tool);
harness.setResponses([fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" })]);
const prompt = runtime.session.prompt("run tool");
await toolStarted;
try {
await runtime.dispose();
// #9124: disposal itself must settle persistence; awaiting the prompt must not be necessary.
const messages = harness.sessionManager
.getEntries()
.filter((entry) => entry.type === "message")
.map((entry) => entry.message)
.filter((message) => message.role !== "system");
expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "toolResult", "assistant"]);
expect(messages[2]).toMatchObject({
role: "toolResult",
content: [{ type: "text", text: "Operation aborted" }],
isError: true,
});
} finally {
await prompt;
}
});
});