From d0990a5f93e5e1dd7fc0b36d3ab80139beda18ff Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Mon, 9 Mar 2026 13:24:49 -0700 Subject: [PATCH] feat: add --resume-session flag to attach to existing agent sessions Add --resume-session to 'sessions new' and 'sessions ensure', allowing users to create an acpx session that resumes an existing agent session (e.g., one started directly in Codex or Claude Code) instead of creating a fresh one. When resumeSessionId is provided, createSession() calls client.loadSession() instead of client.createSession(). Errors hard if the agent doesn't support session/load or the session ID isn't found (no silent fallback). Includes 4 new integration tests covering success, unsupported agent, not-found session, and ensure path. --- src/cli-core.ts | 8 ++ src/cli/flags.ts | 1 + src/session-runtime.ts | 47 +++++++--- test/cli.test.ts | 190 +++++++++++++++++++++++++++++++++++++++++ test/mock-agent.ts | 14 +++ 5 files changed, 250 insertions(+), 10 deletions(-) diff --git a/src/cli-core.ts b/src/cli-core.ts index e37598c0..6d3d8913 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -609,6 +609,7 @@ async function handleSessionsNew( agentCommand: agent.agentCommand, cwd: agent.cwd, name: flags.name, + resumeSessionId: flags.resumeSession, permissionMode, nonInteractivePermissions: globalFlags.nonInteractivePermissions, authCredentials: config.auth, @@ -647,6 +648,7 @@ async function handleSessionsEnsure( agentCommand: agent.agentCommand, cwd: agent.cwd, name: flags.name, + resumeSessionId: flags.resumeSession, permissionMode, nonInteractivePermissions: globalFlags.nonInteractivePermissions, authCredentials: config.auth, @@ -1066,6 +1068,9 @@ function registerSessionsCommand( .command("new") .description("Create a fresh session for current cwd") .option("--name ", "Session name", parseSessionName) + .option("--resume-session ", "Resume existing ACP session id", (value: string) => + parseNonEmptyValue("Resume session id", value), + ) .action(async function (this: Command, flags: SessionsNewFlags) { await handleSessionsNew(explicitAgentName, flags, this, config); }); @@ -1074,6 +1079,9 @@ function registerSessionsCommand( .command("ensure") .description("Ensure a session exists for current cwd or ancestor") .option("--name ", "Session name", parseSessionName) + .option("--resume-session ", "Resume existing ACP session id", (value: string) => + parseNonEmptyValue("Resume session id", value), + ) .action(async function (this: Command, flags: SessionsNewFlags) { await handleSessionsEnsure(explicitAgentName, flags, this, config); }); diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 93a8529b..6c7771b8 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -51,6 +51,7 @@ export type ExecFlags = { export type SessionsNewFlags = { name?: string; + resumeSession?: string; }; export type SessionsHistoryFlags = { diff --git a/src/session-runtime.ts b/src/session-runtime.ts index 3ea86324..9214c3db 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -118,6 +118,7 @@ export type SessionCreateOptions = { agentCommand: string; cwd: string; name?: string; + resumeSessionId?: string; permissionMode: PermissionMode; nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; @@ -146,6 +147,7 @@ export type SessionEnsureOptions = { agentCommand: string; cwd: string; name?: string; + resumeSessionId?: string; permissionMode: PermissionMode; nonInteractivePermissions?: NonInteractivePermissionPolicy; authCredentials?: Record; @@ -675,19 +677,43 @@ export async function createSession(options: SessionCreateOptions): Promise { + const cwd = absolutePath(options.cwd); await measurePerf("runtime.session_create.start", async () => { await withTimeout(client.start(), options.timeoutMs); }); - const createdSession = await measurePerf( - "runtime.session_create.create_session", - async () => { - return await withTimeout( - client.createSession(absolutePath(options.cwd)), + let sessionId: string; + let agentSessionId: string | undefined; + + if (options.resumeSessionId) { + if (!client.supportsLoadSession()) { + throw new Error( + `Agent command "${options.agentCommand}" does not support session/load; cannot resume session ${options.resumeSessionId}`, + ); + } + + try { + const loadedSession = await withTimeout( + client.loadSession(options.resumeSessionId, cwd), options.timeoutMs, ); - }, - ); - const sessionId = createdSession.sessionId; + sessionId = options.resumeSessionId; + agentSessionId = normalizeRuntimeSessionId(loadedSession.agentSessionId); + } catch (error) { + throw new Error( + `Failed to resume ACP session ${options.resumeSessionId}: ${formatErrorMessage(error)}`, + { + cause: error, + }, + ); + } + } else { + const createdSession = await measurePerf( + "runtime.session_create.create_session", + async () => await withTimeout(client.createSession(cwd), options.timeoutMs), + ); + sessionId = createdSession.sessionId; + agentSessionId = normalizeRuntimeSessionId(createdSession.agentSessionId); + } const lifecycle = client.getAgentLifecycleSnapshot(); const now = isoNow(); @@ -695,9 +721,9 @@ export async function createSession(options: SessionCreateOptions): Promise { const newHelp = await runCli(["sessions", "new", "--help"], homeDir); assert.equal(newHelp.code, 0, newHelp.stderr); assert.match(newHelp.stdout, /--name /); + assert.match(newHelp.stdout, /--resume-session /); const ensureHelp = await runCli(["sessions", "ensure", "--help"], homeDir); assert.equal(ensureHelp.code, 0, ensureHelp.stderr); @@ -228,6 +233,140 @@ test("sessions new command is present in help output", async () => { const readHelp = await runCli(["sessions", "read", "--help"], homeDir); assert.equal(readHelp.code, 0, readHelp.stderr); assert.match(readHelp.stdout, /--tail /); + assert.match(ensureHelp.stdout, /--resume-session /); + }); +}); + +test("sessions new --resume-session loads ACP session and stores resumed ids", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify( + { + agents: { + codex: { + command: MOCK_AGENT_WITH_DISTINCT_CREATE_AND_LOAD_RUNTIME_SESSION_IDS, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const resumeSessionId = "cs_resume123"; + const result = await runCli( + [ + "--cwd", + cwd, + "--format", + "json", + "codex", + "sessions", + "new", + "--resume-session", + resumeSessionId, + ], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + + const payload = JSON.parse(result.stdout.trim()) as { + action?: unknown; + created?: unknown; + acpxRecordId?: unknown; + acpxSessionId?: unknown; + agentSessionId?: unknown; + }; + assert.equal(payload.action, "session_ensured"); + assert.equal(payload.created, true); + assert.equal(payload.acpxRecordId, resumeSessionId); + assert.equal(payload.acpxSessionId, resumeSessionId); + assert.equal(payload.agentSessionId, "resumed-runtime-session"); + + const storedRecordPath = path.join( + homeDir, + ".acpx", + "sessions", + `${encodeURIComponent(resumeSessionId)}.json`, + ); + const storedRecord = JSON.parse(await fs.readFile(storedRecordPath, "utf8")) as { + acp_session_id?: unknown; + agent_session_id?: unknown; + }; + assert.equal(storedRecord.acp_session_id, resumeSessionId); + assert.equal(storedRecord.agent_session_id, "resumed-runtime-session"); + }); +}); + +test("sessions new --resume-session fails when agent does not support session/load", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify( + { + agents: { + codex: { + command: MOCK_AGENT_COMMAND, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = await runCli( + ["--cwd", cwd, "codex", "sessions", "new", "--resume-session", "cs_unsupported"], + homeDir, + ); + + assert.equal(result.code, 1, result.stderr); + assert.match(result.stderr, /does not support session\/load/i); + }); +}); + +test("sessions new --resume-session surfaces not-found loadSession errors without fallback", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify( + { + agents: { + codex: { + command: MOCK_AGENT_WITH_LOAD_SESSION_NOT_FOUND, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const resumeSessionId = "cs_missing"; + const result = await runCli( + ["--cwd", cwd, "codex", "sessions", "new", "--resume-session", resumeSessionId], + homeDir, + ); + + assert.equal(result.code, 4, result.stderr); + assert.match(result.stderr, /Failed to resume ACP session cs_missing: Resource not found/); + + const sessionsDir = path.join(homeDir, ".acpx", "sessions"); + const entries = await fs.readdir(sessionsDir).catch(() => [] as string[]); + assert.equal(entries.includes(`${encodeURIComponent(resumeSessionId)}.json`), false); }); }); @@ -273,6 +412,57 @@ test("sessions ensure creates when missing and returns existing on subsequent ca }); }); +test("sessions ensure --resume-session loads ACP session when creating missing session", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify( + { + agents: { + codex: { + command: MOCK_AGENT_WITH_DISTINCT_CREATE_AND_LOAD_RUNTIME_SESSION_IDS, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const resumeSessionId = "cs_ensure_resume"; + const result = await runCli( + [ + "--cwd", + cwd, + "--format", + "json", + "codex", + "sessions", + "ensure", + "--resume-session", + resumeSessionId, + ], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + + const payload = JSON.parse(result.stdout.trim()) as { + created?: unknown; + acpxRecordId?: unknown; + acpxSessionId?: unknown; + agentSessionId?: unknown; + }; + assert.equal(payload.created, true); + assert.equal(payload.acpxRecordId, resumeSessionId); + assert.equal(payload.acpxSessionId, resumeSessionId); + assert.equal(payload.agentSessionId, "resumed-runtime-session"); + }); +}); + test("sessions ensure exits even when agent ignores SIGTERM", async () => { await withTempHome(async (homeDir) => { const cwd = path.join(homeDir, "workspace"); diff --git a/test/mock-agent.ts b/test/mock-agent.ts index c3fa0ed4..34dad528 100644 --- a/test/mock-agent.ts +++ b/test/mock-agent.ts @@ -5,6 +5,7 @@ import { Readable, Writable } from "node:stream"; import { AgentSideConnection, PROTOCOL_VERSION, + RequestError, ndJsonStream, type Agent, type AgentSideConnection as AgentConnection, @@ -32,6 +33,7 @@ type MockAgentOptions = { newSessionMeta?: Record; loadSessionMeta?: Record; supportsLoadSession: boolean; + loadSessionNotFound: boolean; loadSessionFailsOnEmpty: boolean; replayLoadSessionUpdates: boolean; loadReplayText: string; @@ -262,6 +264,7 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions { const newSessionMeta: Record = {}; const loadSessionMeta: Record = {}; let supportsLoadSession = false; + let loadSessionNotFound = false; let loadSessionFailsOnEmpty = false; let replayLoadSessionUpdates = false; let loadReplayText = "replayed load session update"; @@ -282,6 +285,12 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions { continue; } + if (token === "--load-session-not-found") { + supportsLoadSession = true; + loadSessionNotFound = true; + continue; + } + if (token === "--replay-load-session-updates") { supportsLoadSession = true; replayLoadSessionUpdates = true; @@ -329,6 +338,7 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions { newSessionMeta: Object.keys(newSessionMeta).length > 0 ? { ...newSessionMeta } : undefined, loadSessionMeta: Object.keys(loadSessionMeta).length > 0 ? { ...loadSessionMeta } : undefined, supportsLoadSession, + loadSessionNotFound, loadSessionFailsOnEmpty, replayLoadSessionUpdates, loadReplayText, @@ -423,6 +433,10 @@ class MockAgent implements Agent { throw new Error("loadSession is not supported"); } + if (this.options.loadSessionNotFound) { + throw RequestError.resourceNotFound(params.sessionId); + } + const existing = this.sessions.get(params.sessionId); if (this.options.loadSessionFailsOnEmpty && (!existing || !existing.hasCompletedPrompt)) { const error = new Error("Internal error") as Error & {