fix(coding-agent): preserved noninteractive cross-project resume failures

Separated the fork prompt result into accepted, declined, and unavailable states.
Interactive declines now return cleanly through runRootCommand, while non-TTY
invocations continue to fail with a diagnostic instead of silently exiting 0.

Updated regression coverage for both branches.

Fixes #1668
This commit is contained in:
roboomp
2026-06-01 22:36:10 +00:00
parent cea19b6ebf
commit aeea2c8a8a
3 changed files with 42 additions and 21 deletions
+1 -1
View File
@@ -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 <id>` 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 <id>` 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
+18 -7
View File
@@ -312,15 +312,19 @@ async function runInteractiveMode(
}
}
async function promptForkSession(session: SessionInfo): Promise<boolean> {
type ForkSessionPromptResult = "accepted" | "declined" | "unavailable";
type ForkSessionPrompt = (session: SessionInfo) => Promise<ForkSessionPromptResult>;
async function promptForkSession(session: SessionInfo): Promise<ForkSessionPromptResult> {
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<void> {
}
}
/** 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<SessionManager | undefined> {
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;
@@ -1,12 +1,7 @@
/**
* Regression: declining the cross-project fork prompt during `--resume <id>`
* 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.',
);
});
});