diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c9db6c808..bef33b71a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed - Fixed a module-load crash (`ReferenceError: Cannot access 'evalToolRenderer' before initialization`) triggered whenever `tools/eval` was imported before `tools/renderers`. The eval JS backend statically pulls the agent/task/sdk/extension chain, which re-enters the root barrel → `modes/components` → `tool-execution` → `renderers` while `eval.ts` was still initializing, so `renderers.ts` read `evalToolRenderer` in its TDZ. The eval TUI renderer is now split into a dependency-light `tools/eval-render.ts` that `renderers.ts` imports directly (decoupling pure rendering from the eval runtime); `eval.ts` re-exports `evalToolRenderer`/`EVAL_DEFAULT_PREVIEW_LINES` for compatibility. -- Fixed `omp --resume ` crashing with an uncaught exception when the user declined the cross-project fork prompt. `createSessionManager` now returns `undefined` for the cancellation, and `runRootCommand` prints a dimmed `Resume cancelled` message and exits cleanly ([#1668](https://github.com/can1357/oh-my-pi/issues/1668)). +- Fixed `omp --resume ` crashing with an uncaught exception when an interactive user declined the cross-project fork prompt. `createSessionManager` now returns `undefined` for that cancellation, while non-interactive invocations still fail with a diagnostic when they cannot answer the fork prompt ([#1668](https://github.com/can1357/oh-my-pi/issues/1668)). ## [15.7.6] - 2026-06-01 ### Added diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 55e41231a..ee57a1fa2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -312,15 +312,19 @@ async function runInteractiveMode( } } -async function promptForkSession(session: SessionInfo): Promise { +type ForkSessionPromptResult = "accepted" | "declined" | "unavailable"; + +type ForkSessionPrompt = (session: SessionInfo) => Promise; + +async function promptForkSession(session: SessionInfo): Promise { if (!process.stdin.isTTY) { - return false; + return "unavailable"; } const message = `Session found in different project: ${session.cwd}. Fork into current directory? [y/N] `; const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = (await rl.question(message)).trim().toLowerCase(); - return answer === "y" || answer === "yes"; + return answer === "y" || answer === "yes" ? "accepted" : "declined"; } finally { rl.close(); } @@ -366,10 +370,12 @@ async function flushChangelogVersion(): Promise { } } +/** Resolves CLI session flags into an existing, forked, in-memory, or cancelled session manager. */ export async function createSessionManager( parsed: Args, cwd: string, activeSettings: Settings = settings, + askToForkSession: ForkSessionPrompt = promptForkSession, ): Promise { if (parsed.fork) { if (parsed.noSession) { @@ -402,8 +408,13 @@ export async function createSessionManager( const normalizedCwd = normalizePathForComparison(cwd); const normalizedMatchCwd = normalizePathForComparison(match.session.cwd || cwd); if (normalizedCwd !== normalizedMatchCwd) { - const shouldFork = await promptForkSession(match.session); - if (!shouldFork) { + const forkPromptResult = await askToForkSession(match.session); + if (forkPromptResult === "unavailable") { + throw new Error( + `Session "${sessionArg}" is in another project (${match.session.cwd}); run interactively to fork it into the current project.`, + ); + } + if (forkPromptResult === "declined") { // User declined the cross-project fork prompt. Caller distinguishes // this cancellation from the "default new session" undefined return // by checking `typeof parsed.resume === "string"`. @@ -850,8 +861,8 @@ export async function runRootCommand( ); // User declined the cross-project fork prompt — exit cleanly with a friendly - // message rather than letting the deprecated throw bubble up as an uncaught - // exception (see issue #1668). + // message rather than letting the decline bubble up as an uncaught exception + // (see issue #1668). if (typeof parsedArgs.resume === "string" && !sessionManager) { process.stdout.write(`${chalk.dim("Resume cancelled: session is in another project.")}\n`); return; diff --git a/packages/coding-agent/test/main-cross-project-resume.test.ts b/packages/coding-agent/test/main-cross-project-resume.test.ts index 2b130496d..02348fc4d 100644 --- a/packages/coding-agent/test/main-cross-project-resume.test.ts +++ b/packages/coding-agent/test/main-cross-project-resume.test.ts @@ -1,12 +1,7 @@ /** * Regression: declining the cross-project fork prompt during `--resume ` - * must exit cleanly instead of throwing an uncaught exception. See #1668. - * - * The contract: when `promptForkSession` returns false (which it does in - * non-TTY environments such as the test runner), `createSessionManager` - * returns `undefined` rather than throwing. `runRootCommand` separately - * distinguishes that cancellation from the "default new session" undefined - * return by inspecting `parsed.resume`. + * must exit cleanly, while non-interactive resume still fails instead of + * silently succeeding. See #1668. */ import { afterEach, describe, expect, it, vi } from "bun:test"; import type { Args } from "@oh-my-pi/pi-coding-agent/cli/args"; @@ -47,9 +42,24 @@ describe("createSessionManager — cross-project --resume cancellation (#1668)", vi.restoreAllMocks(); }); - it("returns undefined when the user declines the fork prompt instead of throwing", async () => { - // promptForkSession returns false for non-TTY stdin (the test runner), so - // the decline path is exercised without further mocking. + it("returns undefined when an interactive user declines the fork prompt instead of throwing", async () => { + const sessionCwd = "/some/other/project"; + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(sessionCwd)); + + const args = buildArgs("019e84ed"); + const stubSettings = { get: () => undefined } as unknown as Settings; + + const result = await createSessionManager( + args, + "/current/project", + stubSettings, + async () => "declined" as const, + ); + + expect(result).toBeUndefined(); + }); + + it("throws when the cross-project fork prompt is unavailable in non-interactive mode", async () => { expect(process.stdin.isTTY).toBeFalsy(); const sessionCwd = "/some/other/project"; @@ -58,8 +68,8 @@ describe("createSessionManager — cross-project --resume cancellation (#1668)", const args = buildArgs("019e84ed"); const stubSettings = { get: () => undefined } as unknown as Settings; - const result = await createSessionManager(args, "/current/project", stubSettings); - - expect(result).toBeUndefined(); + await expect(createSessionManager(args, "/current/project", stubSettings)).rejects.toThrow( + 'Session "019e84ed" is in another project (/some/other/project); run interactively to fork it into the current project.', + ); }); });