diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 103a07bae5..cd4152f173 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed +- Confirming an interactive cross-project `--session` resume with `y` or `yes` now forks the session into the current project instead of being overwritten by readline's close fallback and aborting ([#1098](https://github.com/code-yeongyu/senpi/pull/1098) by [@realsigridjin](https://github.com/realsigridjin)). - Webfetch now safely discards redirect response bodies under Bun 1.4.0's bare `undici`, which may omit `body.dump()`, by falling back to argument-free stream destruction instead of re-emitting cleanup failures as uncaught stream errors ([#1089](https://github.com/code-yeongyu/senpi/issues/1089)). ### Added diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index a874822110..6b607546c1 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,5 +1,25 @@ # changes +## 2026-08-24 - Preserve interactive cross-project session confirmations + +### What changed + +- `packages/coding-agent/src/main.ts`: settle an answered confirmation before closing readline so `y` and `yes` can reach the cross-project session fork. +- `packages/coding-agent/test/suite/regressions/756-session-cross-project-resume.test.ts`: drive the real interactive `createSessionManager()` branch and verify that confirmation creates a fork in the current project. + +### Why + +- `rl.close()` emits `close` synchronously, so calling it before resolving the parsed answer let the fallback listener settle the promise as `false` and turned every affirmative response into `Aborted.` + +### Why an extension could not handle it + +- Cross-project session confirmation runs in the core CLI session-resolution path before extensions can take over. + +### Expected merge conflict zones + +- `packages/coding-agent/src/main.ts` `promptConfirm` +- `packages/coding-agent/test/suite/regressions/756-session-cross-project-resume.test.ts` + ## 2026-08-22 - emit agent_idle after settlement-deferred turns resolve ### What changed diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index cc0a6f79f7..9fee65144f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -340,8 +340,8 @@ async function promptConfirm(message: string): Promise { output: process.stdout, }); rl.question(`${message} [y/N] `, (answer) => { - rl.close(); resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + rl.close(); }); rl.on("close", () => resolve(false)); }); diff --git a/packages/coding-agent/test/suite/regressions/756-session-cross-project-resume.test.ts b/packages/coding-agent/test/suite/regressions/756-session-cross-project-resume.test.ts index eda28158d4..d6057f4ddd 100644 --- a/packages/coding-agent/test/suite/regressions/756-session-cross-project-resume.test.ts +++ b/packages/coding-agent/test/suite/regressions/756-session-cross-project-resume.test.ts @@ -1,5 +1,5 @@ import { type ChildProcess, spawn } from "node:child_process"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -29,10 +29,12 @@ assertWorkspaceBuildPrerequisite(import.meta.url); const cliPath = resolve(__dirname, "../../../src/cli.ts"); const cliMainPath = resolve(__dirname, "../../../src/cli-main.ts"); +const mainPath = resolve(__dirname, "../../../src/main.ts"); const rootTsconfigPath = resolve(__dirname, "../../../../..", "tsconfig.json"); const SESSION_ID = "0197f6e4-4cf9-7f44-a2d8-f8f7f49ee9d3"; const FORK_PROMPT = "Fork this session into current directory?"; const CROSS_PROJECT_NOTICE = "Session found in different project:"; +const FORK_COMPLETE_SENTINEL = "SESSION_FORKED"; const CHILD_TIMEOUT_MS = 15_000; const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g"); @@ -120,11 +122,22 @@ function interactiveTtyArgs(fixture: CliFixture): string[] { return ["--input-type=module", "-e", bootstrap]; } +function interactiveConfirmArgs(fixture: CliFixture): string[] { + const bootstrap = [ + "process.stdin.isTTY = true;", + "process.stdout.isTTY = true;", + `const { createSessionManager } = await import(${JSON.stringify(pathToFileURL(mainPath).href)});`, + `await createSessionManager({ session: ${JSON.stringify(SESSION_ID)} }, ${JSON.stringify(fixture.projectDir)}, ${JSON.stringify(fixture.sessionDir)}, undefined, "interactive");`, + `process.stdout.write(${JSON.stringify(`${FORK_COMPLETE_SENTINEL}\n`)}, () => process.exit(0));`, + ].join("\n"); + return ["--input-type=module", "-e", bootstrap]; +} + function stripAnsi(value: string): string { return value.replace(ANSI_PATTERN, ""); } -async function runCli(args: string[], fixture: CliFixture): Promise { +async function runCli(args: string[], fixture: CliFixture, stdinInput?: string): Promise { const child = spawn(process.execPath, args, { cwd: fixture.projectDir, env: { @@ -133,9 +146,12 @@ async function runCli(args: string[], fixture: CliFixture): Promise { PI_OFFLINE: "1", TSX_TSCONFIG_PATH: rootTsconfigPath, }, - stdio: ["ignore", "pipe", "pipe"], + stdio: [stdinInput === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); liveChildren.add(child); + if (stdinInput !== undefined) { + child.stdin?.write(stdinInput); + } let captured = ""; child.stdout?.on("data", (chunk: Buffer) => { @@ -190,4 +206,25 @@ describe("issue #756 cross-project --session resume", () => { expect(result.output).toContain(`--fork '${SESSION_ID}'`); expect(result.output).not.toContain(FORK_PROMPT); }); + + it("forks when an interactive confirmation answers yes", async () => { + const fixture = createFixture(); + + const result = await runCli(interactiveConfirmArgs(fixture), fixture, "y\n"); + + expect(result.timedOut).toBe(false); + expect(result.code).toBe(0); + expect(result.output).toContain(FORK_COMPLETE_SENTINEL); + + const forkFiles = readdirSync(fixture.sessionDir).filter( + (name) => name.endsWith(".jsonl") && name !== `${SESSION_ID}.jsonl`, + ); + expect(forkFiles).toHaveLength(1); + const header = JSON.parse(readFileSync(join(fixture.sessionDir, forkFiles[0]), "utf8").split("\n", 1)[0]) as { + cwd: string; + id: string; + }; + expect(header.cwd).toBe(fixture.projectDir); + expect(header.id).not.toBe(SESSION_ID); + }); });