Skip to content
Merged
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
8 changes: 8 additions & 0 deletions src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1066,6 +1068,9 @@ function registerSessionsCommand(
.command("new")
.description("Create a fresh session for current cwd")
.option("--name <name>", "Session name", parseSessionName)
.option("--resume-session <id>", "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);
});
Expand All @@ -1074,6 +1079,9 @@ function registerSessionsCommand(
.command("ensure")
.description("Ensure a session exists for current cwd or ancestor")
.option("--name <name>", "Session name", parseSessionName)
.option("--resume-session <id>", "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);
});
Expand Down
1 change: 1 addition & 0 deletions src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type ExecFlags = {

export type SessionsNewFlags = {
name?: string;
resumeSession?: string;
};

export type SessionsHistoryFlags = {
Expand Down
47 changes: 37 additions & 10 deletions src/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export type SessionCreateOptions = {
agentCommand: string;
cwd: string;
name?: string;
resumeSessionId?: string;
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
authCredentials?: Record<string, string>;
Expand Down Expand Up @@ -146,6 +147,7 @@ export type SessionEnsureOptions = {
agentCommand: string;
cwd: string;
name?: string;
resumeSessionId?: string;
permissionMode: PermissionMode;
nonInteractivePermissions?: NonInteractivePermissionPolicy;
authCredentials?: Record<string, string>;
Expand Down Expand Up @@ -675,29 +677,53 @@ export async function createSession(options: SessionCreateOptions): Promise<Sess
try {
return await withInterrupt(
async () => {
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();
const record: SessionRecord = {
schema: SESSION_RECORD_SCHEMA,
acpxRecordId: sessionId,
acpSessionId: sessionId,
agentSessionId: normalizeRuntimeSessionId(createdSession.agentSessionId),
agentSessionId,
agentCommand: options.agentCommand,
cwd: absolutePath(options.cwd),
cwd,
name: normalizeName(options.name),
createdAt: now,
lastUsedAt: now,
Expand Down Expand Up @@ -747,6 +773,7 @@ export async function ensureSession(options: SessionEnsureOptions): Promise<Sess
agentCommand: options.agentCommand,
cwd,
name: options.name,
resumeSessionId: options.resumeSessionId,
permissionMode: options.permissionMode,
nonInteractivePermissions: options.nonInteractivePermissions,
authCredentials: options.authCredentials,
Expand Down
190 changes: 190 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ const MOCK_AGENT_IGNORING_SIGTERM = `${MOCK_AGENT_COMMAND} --ignore-sigterm`;
const MOCK_CODEX_AGENT_WITH_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --codex-session-id codex-runtime-session`;
const MOCK_CLAUDE_AGENT_WITH_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --claude-session-id claude-runtime-session`;
const MOCK_AGENT_WITH_LOAD_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --supports-load-session --load-runtime-session-id loaded-runtime-session`;
const MOCK_AGENT_WITH_DISTINCT_CREATE_AND_LOAD_RUNTIME_SESSION_IDS =
`${MOCK_AGENT_COMMAND} --runtime-session-id fresh-runtime-session ` +
"--supports-load-session --load-runtime-session-id resumed-runtime-session";
const MOCK_AGENT_WITH_LOAD_FALLBACK = `${MOCK_AGENT_COMMAND} --supports-load-session --load-session-fails-on-empty`;
const MOCK_AGENT_WITH_LOAD_SESSION_NOT_FOUND = `${MOCK_AGENT_COMMAND} --supports-load-session --load-session-not-found`;

type CliRunResult = {
code: number | null;
Expand Down Expand Up @@ -220,6 +224,7 @@ test("sessions new command is present in help output", async () => {
const newHelp = await runCli(["sessions", "new", "--help"], homeDir);
assert.equal(newHelp.code, 0, newHelp.stderr);
assert.match(newHelp.stdout, /--name <name>/);
assert.match(newHelp.stdout, /--resume-session <id>/);

const ensureHelp = await runCli(["sessions", "ensure", "--help"], homeDir);
assert.equal(ensureHelp.code, 0, ensureHelp.stderr);
Expand All @@ -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 <count>/);
assert.match(ensureHelp.stdout, /--resume-session <id>/);
});
});

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);
});
});

Expand Down Expand Up @@ -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");
Expand Down
Loading