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 & {