Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,8 @@ async function promptConfirm(message: string): Promise<boolean> {
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));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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<CliResult> {
async function runCli(args: string[], fixture: CliFixture, stdinInput?: string): Promise<CliResult> {
const child = spawn(process.execPath, args, {
cwd: fixture.projectDir,
env: {
Expand All @@ -133,9 +146,12 @@ async function runCli(args: string[], fixture: CliFixture): Promise<CliResult> {
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) => {
Expand Down Expand Up @@ -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);
});
});