feat(coding-agent): print resume hint on interactive exit
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { APP_NAME } from "../../../src/config.ts";
|
||||
import type { SessionManager } from "../../../src/core/session-manager.ts";
|
||||
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||
|
||||
// Regression for https://github.com/earendil-works/pi/issues/5080
|
||||
@@ -16,6 +22,7 @@ type ShutdownThis = {
|
||||
runtimeHost: { dispose: () => Promise<void> };
|
||||
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
|
||||
stop: () => void;
|
||||
sessionManager: SessionManager;
|
||||
};
|
||||
|
||||
type InteractiveModePrototypeWithShutdown = {
|
||||
@@ -23,10 +30,42 @@ type InteractiveModePrototypeWithShutdown = {
|
||||
};
|
||||
|
||||
const interactiveModePrototype = InteractiveMode.prototype as unknown;
|
||||
const tempDirs: string[] = [];
|
||||
const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
|
||||
class ProcessExitError extends Error {}
|
||||
|
||||
function createContext(order: string[]): ShutdownThis {
|
||||
function createSessionManager(options: { sessionFile?: string } = {}): SessionManager {
|
||||
return {
|
||||
isPersisted: () => options.sessionFile !== undefined,
|
||||
getSessionFile: () => options.sessionFile,
|
||||
getSessionId: () => "test-session",
|
||||
getSessionDir: () => "/tmp/pi-sessions",
|
||||
usesDefaultSessionDir: () => true,
|
||||
} as unknown as SessionManager;
|
||||
}
|
||||
|
||||
function createTempFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-shutdown-resume-hint-"));
|
||||
tempDirs.push(dir);
|
||||
const file = join(dir, "session.jsonl");
|
||||
writeFileSync(file, "\n");
|
||||
return file;
|
||||
}
|
||||
|
||||
function setStdoutIsTTY(value: boolean): void {
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value });
|
||||
}
|
||||
|
||||
function restoreStdoutIsTTY(): void {
|
||||
if (originalStdoutIsTTY) {
|
||||
Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY);
|
||||
} else {
|
||||
Reflect.deleteProperty(process.stdout, "isTTY");
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(order: string[], sessionManager = createSessionManager()): ShutdownThis {
|
||||
return {
|
||||
isShuttingDown: false,
|
||||
unregisterSignalHandlers: vi.fn(),
|
||||
@@ -45,6 +84,7 @@ function createContext(order: string[]): ShutdownThis {
|
||||
stop: vi.fn(() => {
|
||||
order.push("stop");
|
||||
}),
|
||||
sessionManager,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +99,10 @@ async function callShutdown(context: ShutdownThis, options?: { fromSignal?: bool
|
||||
describe("InteractiveMode.shutdown ordering (#5080)", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
restoreStdoutIsTTY();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("signal-triggered shutdown emits session_shutdown before terminal writes", async () => {
|
||||
@@ -87,6 +131,43 @@ describe("InteractiveMode.shutdown ordering (#5080)", () => {
|
||||
expect(order).toEqual(["drainInput", "stop", "dispose"]);
|
||||
});
|
||||
|
||||
test("interactive quit prints a resume hint for persisted sessions", async () => {
|
||||
vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new ProcessExitError();
|
||||
}) as typeof process.exit);
|
||||
const stdoutWrite = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((() => true) as typeof process.stdout.write);
|
||||
setStdoutIsTTY(true);
|
||||
const order: string[] = [];
|
||||
const context = createContext(order, createSessionManager({ sessionFile: createTempFile() }));
|
||||
|
||||
await callShutdown(context);
|
||||
|
||||
expect(order).toEqual(["drainInput", "stop", "dispose"]);
|
||||
expect(stdoutWrite).toHaveBeenCalledWith(
|
||||
` ${chalk.dim("To resume this session:")} ${APP_NAME} --session test-session\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test("signal-triggered shutdown does not print a resume hint", async () => {
|
||||
vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new ProcessExitError();
|
||||
}) as typeof process.exit);
|
||||
const stdoutWrite = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((() => true) as typeof process.stdout.write);
|
||||
setStdoutIsTTY(true);
|
||||
const order: string[] = [];
|
||||
const context = createContext(order, createSessionManager({ sessionFile: createTempFile() }));
|
||||
|
||||
await callShutdown(context, { fromSignal: true });
|
||||
|
||||
for (const call of stdoutWrite.mock.calls) {
|
||||
expect(call[0]).not.toContain("To resume this session:");
|
||||
}
|
||||
});
|
||||
|
||||
test("re-entrant shutdown is a no-op", async () => {
|
||||
vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new ProcessExitError();
|
||||
|
||||
Reference in New Issue
Block a user