diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 699251cd9..d46a68247 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `omp --resume ` / `--fork ` crashing with `[Uncaught Exception]` when the id did not match a known session. `createSessionManager` now throws a dedicated `SessionResolutionError`, which `runRootCommand` catches to print `Error: Session "..." not found.` plus a hint to stderr and exit with code 1. The same path covers `--fork` combined with `--no-session` and the non-interactive cross-project / moved-cwd prompts that previously surfaced raw stack traces ([#2084](https://github.com/can1357/oh-my-pi/issues/2084)). + ## [15.10.2] - 2026-06-08 ### Added diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index b36a59eba..6c9e62fa7 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -381,6 +381,22 @@ async function promptMoveSession(session: SessionInfo): Promise { if (parsed.fork) { if (parsed.noSession) { - throw new Error("--fork requires session persistence"); + throw new SessionResolutionError("--fork requires session persistence"); } const forkSource = parsed.fork; if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) { @@ -471,7 +487,10 @@ export async function createSessionManager( } const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir); if (!match) { - throw new Error(`Session "${forkSource}" not found.`); + throw new SessionResolutionError( + `Session "${forkSource}" not found.`, + "Run `omp --resume` without an argument to pick from recent sessions, or `omp` to start a new one.", + ); } return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir); } @@ -486,7 +505,10 @@ export async function createSessionManager( } const match = await resolveResumableSession(sessionArg, cwd, parsed.sessionDir); if (!match) { - throw new Error(`Session "${sessionArg}" not found.`); + throw new SessionResolutionError( + `Session "${sessionArg}" not found.`, + "Run `omp --resume` without an argument to pick from recent sessions, or `omp` to start a new one.", + ); } if (match.scope === "local") { const moveResult = await moveMissingCwdSessionIfNeeded( @@ -522,7 +544,7 @@ export async function createSessionManager( } const forkPromptResult = await askToForkSession(match.session); if (forkPromptResult === "unavailable") { - throw new Error( + throw new SessionResolutionError( `Session "${sessionArg}" is in another project (${match.session.cwd}); run interactively to fork it into the current project.`, ); } @@ -919,14 +941,29 @@ export async function runRootCommand( ); } - // Create session manager based on CLI flags - let sessionManager = await logger.time( - "createSessionManager", - createSessionManager, - parsedArgs, - cwd, - settingsInstance, - ); + // Create session manager based on CLI flags. SessionResolutionError signals a + // user-facing failure (unknown --resume/--fork id, non-interactive fork + // prompt, --fork with --no-session): print + exit cleanly instead of letting + // it surface as `[Uncaught Exception]` (see issue #2084). + let sessionManager: SessionManager | undefined; + try { + sessionManager = await logger.time( + "createSessionManager", + createSessionManager, + parsedArgs, + cwd, + settingsInstance, + ); + } catch (error: unknown) { + if (error instanceof SessionResolutionError) { + process.stderr.write(`${chalk.red(`Error: ${error.message}`)}\n`); + if (error.hint) { + process.stderr.write(`${chalk.dim(error.hint)}\n`); + } + process.exit(1); + } + throw error; + } // User declined the cross-project fork prompt — exit cleanly with a friendly // message rather than letting the decline bubble up as an uncaught exception diff --git a/packages/coding-agent/test/main-session-resolution-error.test.ts b/packages/coding-agent/test/main-session-resolution-error.test.ts new file mode 100644 index 000000000..ddd52501f --- /dev/null +++ b/packages/coding-agent/test/main-session-resolution-error.test.ts @@ -0,0 +1,91 @@ +/** + * Regression for #2084: `createSessionManager` must reject with + * `SessionResolutionError` (and a usage hint) when `--resume` / `--fork` are + * given a non-existent session id, so `runRootCommand` can convert it into a + * clean stderr message + non-zero exit instead of letting it surface as + * `[Uncaught Exception]`. + */ +import { describe, expect, it, vi } from "bun:test"; +import type { Args } from "@oh-my-pi/pi-coding-agent/cli/args"; +import type { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createSessionManager, SessionResolutionError } from "@oh-my-pi/pi-coding-agent/main"; +import * as sessionManagerModule from "@oh-my-pi/pi-coding-agent/session/session-manager"; + +function buildResumeArgs(resume: string): Args { + return { + resume, + messages: [], + fileArgs: [], + unknownFlags: new Map(), + }; +} + +function buildForkArgs(fork: string, noSession = false): Args { + return { + fork, + noSession: noSession || undefined, + messages: [], + fileArgs: [], + unknownFlags: new Map(), + }; +} + +const stubSettings = { get: () => undefined } as unknown as Settings; + +describe("createSessionManager — missing session (#2084)", () => { + it("rejects --resume with SessionResolutionError carrying a usage hint", async () => { + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(undefined); + try { + await expect( + createSessionManager( + buildResumeArgs("019ea530-0000-7000-0000-000000000000"), + "/current/project", + stubSettings, + ), + ).rejects.toMatchObject({ + name: "SessionResolutionError", + message: 'Session "019ea530-0000-7000-0000-000000000000" not found.', + hint: expect.stringContaining("omp --resume"), + }); + + // Confirm it's the exported class so `runRootCommand`'s `instanceof` check works. + const caught = await createSessionManager( + buildResumeArgs("019ea530-0000-7000-0000-000000000000"), + "/current/project", + stubSettings, + ).catch((err: unknown) => err); + expect(caught).toBeInstanceOf(SessionResolutionError); + } finally { + vi.restoreAllMocks(); + } + }); + + it("rejects --fork with SessionResolutionError carrying a usage hint", async () => { + vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(undefined); + try { + await expect( + createSessionManager( + buildForkArgs("019ea530-0000-7000-0000-000000000000"), + "/current/project", + stubSettings, + ), + ).rejects.toMatchObject({ + name: "SessionResolutionError", + message: 'Session "019ea530-0000-7000-0000-000000000000" not found.', + hint: expect.stringContaining("omp --resume"), + }); + } finally { + vi.restoreAllMocks(); + } + }); + + it("rejects --fork combined with --no-session as a SessionResolutionError (no hint)", async () => { + await expect( + createSessionManager(buildForkArgs("019ea530", true), "/current/project", stubSettings), + ).rejects.toMatchObject({ + name: "SessionResolutionError", + message: "--fork requires session persistence", + hint: undefined, + }); + }); +});