fix(coding-agent): emit session_shutdown in print mode closes #2576

This commit is contained in:
Mario Zechner
2026-03-24 23:50:47 +01:00
parent a949490f29
commit ebe437081e
4 changed files with 220 additions and 81 deletions

View File

@@ -8,6 +8,7 @@
- Fixed interactive bash execution collapsed previews to recompute visual line wrapping at render time, so previews respect the current terminal width after resizes and split-pane width changes ([#2569](https://github.com/badlogic/pi-mono/issues/2569)) - Fixed interactive bash execution collapsed previews to recompute visual line wrapping at render time, so previews respect the current terminal width after resizes and split-pane width changes ([#2569](https://github.com/badlogic/pi-mono/issues/2569))
- Fixed RPC `get_session_stats` to expose `contextUsage`, so headless clients can read actual current context-window usage instead of deriving it from token totals ([#2550](https://github.com/badlogic/pi-mono/issues/2550)) - Fixed RPC `get_session_stats` to expose `contextUsage`, so headless clients can read actual current context-window usage instead of deriving it from token totals ([#2550](https://github.com/badlogic/pi-mono/issues/2550))
- Fixed `pi update` for git packages to fetch only the tracked target branch with `--no-tags`, reducing unrelated branch and tag noise while preserving force-push-safe updates ([#2548](https://github.com/badlogic/pi-mono/issues/2548)) - Fixed `pi update` for git packages to fetch only the tracked target branch with `--no-tags`, reducing unrelated branch and tag noise while preserving force-push-safe updates ([#2548](https://github.com/badlogic/pi-mono/issues/2548))
- Fixed print and JSON modes to emit `session_shutdown` before exit, so extensions can release long-lived resources and non-interactive runs terminate cleanly ([#2576](https://github.com/badlogic/pi-mono/issues/2576))
## [0.62.0] - 2026-03-23 ## [0.62.0] - 2026-03-23

View File

@@ -882,7 +882,7 @@ export async function main(args: string[]) {
printTimings(); printTimings();
await interactiveMode.run(); await interactiveMode.run();
} else { } else {
await runPrintMode(session, { const exitCode = await runPrintMode(session, {
mode, mode,
messages: parsed.messages, messages: parsed.messages,
initialMessage, initialMessage,
@@ -890,6 +890,9 @@ export async function main(args: string[]) {
}); });
stopThemeWatcher(); stopThemeWatcher();
restoreStdout(); restoreStdout();
if (exitCode !== 0) {
process.exitCode = exitCode;
}
return; return;
} }
} }

View File

@@ -28,8 +28,11 @@ export interface PrintModeOptions {
* Run in print (single-shot) mode. * Run in print (single-shot) mode.
* Sends prompts to the agent and outputs the result. * Sends prompts to the agent and outputs the result.
*/ */
export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<void> { export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<number> {
const { mode, messages = [], initialMessage, initialImages } = options; const { mode, messages = [], initialMessage, initialImages } = options;
let exitCode = 0;
try {
if (mode === "json") { if (mode === "json") {
const header = session.sessionManager.getHeader(); const header = session.sessionManager.getHeader();
if (header) { if (header) {
@@ -102,9 +105,8 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
// Check for error/aborted // Check for error/aborted
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
process.exit(1); exitCode = 1;
} } else {
// Output text content // Output text content
for (const content of assistantMsg.content) { for (const content of assistantMsg.content) {
if (content.type === "text") { if (content.type === "text") {
@@ -113,8 +115,17 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
} }
} }
} }
}
return exitCode;
} finally {
const extensionRunner = session.extensionRunner;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({ type: "session_shutdown" });
}
// Ensure stdout is fully flushed before returning // Ensure stdout is fully flushed before returning
// This prevents race conditions where the process exits before all output is written // This prevents race conditions where the process exits before all output is written
await flushRawStdout(); await flushRawStdout();
} }
}

View File

@@ -0,0 +1,124 @@
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runPrintMode } from "../src/modes/print-mode.js";
type EmitEvent = { type: string };
type FakeExtensionRunner = {
hasHandlers: (eventType: string) => boolean;
emit: ReturnType<typeof vi.fn<(event: EmitEvent) => Promise<void>>>;
};
type FakeSession = {
sessionManager: { getHeader: () => object | undefined };
agent: { waitForIdle: () => Promise<void> };
state: { messages: AssistantMessage[] };
extensionRunner: FakeExtensionRunner;
bindExtensions: ReturnType<typeof vi.fn>;
subscribe: ReturnType<typeof vi.fn>;
prompt: ReturnType<typeof vi.fn>;
newSession: ReturnType<typeof vi.fn>;
fork: ReturnType<typeof vi.fn>;
navigateTree: ReturnType<typeof vi.fn>;
switchSession: ReturnType<typeof vi.fn>;
reload: ReturnType<typeof vi.fn>;
};
function createAssistantMessage(options?: {
text?: string;
stopReason?: AssistantMessage["stopReason"];
errorMessage?: string;
}): AssistantMessage {
return {
role: "assistant",
content: options?.text ? [{ type: "text", text: options.text }] : [],
api: "openai-responses",
provider: "openai",
model: "gpt-4o-mini",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: options?.stopReason ?? "stop",
errorMessage: options?.errorMessage,
timestamp: Date.now(),
};
}
function createSession(assistantMessage: AssistantMessage): FakeSession {
const extensionRunner: FakeExtensionRunner = {
hasHandlers: (eventType: string) => eventType === "session_shutdown",
emit: vi.fn(async () => {}),
};
const state = { messages: [assistantMessage] };
return {
sessionManager: { getHeader: () => undefined },
agent: { waitForIdle: async () => {} },
state,
extensionRunner,
bindExtensions: vi.fn(async () => {}),
subscribe: vi.fn(() => () => {}),
prompt: vi.fn(async () => {}),
newSession: vi.fn(async () => true),
fork: vi.fn(async () => ({ cancelled: false })),
navigateTree: vi.fn(async () => ({ cancelled: false })),
switchSession: vi.fn(async () => true),
reload: vi.fn(async () => {}),
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("runPrintMode", () => {
it("emits session_shutdown in text mode", async () => {
const session = createSession(createAssistantMessage({ text: "done" }));
const images: ImageContent[] = [{ type: "image", mimeType: "image/png", data: "abc" }];
const exitCode = await runPrintMode(session as unknown as Parameters<typeof runPrintMode>[0], {
mode: "text",
initialMessage: "Say done",
initialImages: images,
});
expect(exitCode).toBe(0);
expect(session.prompt).toHaveBeenCalledWith("Say done", { images });
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown" });
});
it("emits session_shutdown in json mode", async () => {
const session = createSession(createAssistantMessage({ text: "done" }));
const exitCode = await runPrintMode(session as unknown as Parameters<typeof runPrintMode>[0], {
mode: "json",
messages: ["hello"],
});
expect(exitCode).toBe(0);
expect(session.prompt).toHaveBeenCalledWith("hello");
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown" });
});
it("emits session_shutdown and returns non-zero on assistant error", async () => {
const session = createSession(createAssistantMessage({ stopReason: "error", errorMessage: "provider failure" }));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitCode = await runPrintMode(session as unknown as Parameters<typeof runPrintMode>[0], {
mode: "text",
});
expect(exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith("provider failure");
expect(session.extensionRunner.emit).toHaveBeenCalledTimes(1);
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown" });
});
});