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:
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user