diff --git a/src/session-conversation-model.ts b/src/session-conversation-model.ts index 8be423c..749c30b 100644 --- a/src/session-conversation-model.ts +++ b/src/session-conversation-model.ts @@ -435,6 +435,7 @@ export function cloneSessionAcpxState( return { current_mode_id: state.current_mode_id, + desired_mode_id: state.desired_mode_id, available_commands: state.available_commands ? [...state.available_commands] : undefined, config_options: state.config_options ? deepClone(state.config_options) : undefined, }; diff --git a/src/session-mode-preference.ts b/src/session-mode-preference.ts new file mode 100644 index 0000000..3397f1e --- /dev/null +++ b/src/session-mode-preference.ts @@ -0,0 +1,30 @@ +import type { SessionAcpxState, SessionRecord } from "./types.js"; + +function ensureAcpxState(state: SessionAcpxState | undefined): SessionAcpxState { + return state ?? {}; +} + +export function normalizeModeId(modeId: string | undefined): string | undefined { + if (typeof modeId !== "string") { + return undefined; + } + const trimmed = modeId.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function getDesiredModeId(state: SessionAcpxState | undefined): string | undefined { + return normalizeModeId(state?.desired_mode_id); +} + +export function setDesiredModeId(record: SessionRecord, modeId: string | undefined): void { + const acpx = ensureAcpxState(record.acpx); + const normalized = normalizeModeId(modeId); + + if (normalized) { + acpx.desired_mode_id = normalized; + } else { + delete acpx.desired_mode_id; + } + + record.acpx = acpx; +} diff --git a/src/session-persistence/parse.ts b/src/session-persistence/parse.ts index e71530a..b9ada93 100644 --- a/src/session-persistence/parse.ts +++ b/src/session-persistence/parse.ts @@ -279,6 +279,10 @@ function parseAcpxState(raw: unknown): SessionAcpxState | undefined { state.current_mode_id = record.current_mode_id; } + if (typeof record.desired_mode_id === "string") { + state.desired_mode_id = record.desired_mode_id; + } + if (isStringArray(record.available_commands)) { state.available_commands = [...record.available_commands]; } diff --git a/src/session-runtime.ts b/src/session-runtime.ts index e918a3a..766ffdf 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -35,6 +35,7 @@ import { type QueueOwnerActiveSessionController, } from "./queue-owner-turn-controller.js"; import { normalizeRuntimeSessionId } from "./runtime-session-id.js"; +import { setDesiredModeId } from "./session-mode-preference.js"; import { connectAndLoadSession } from "./session-runtime/connect-load.js"; import { applyConversation, applyLifecycleSnapshotToRecord } from "./session-runtime/lifecycle.js"; import { @@ -885,8 +886,11 @@ export async function setSessionMode( options.verbose, ); if (submittedToOwner) { + const record = await resolveSessionRecord(options.sessionId); + setDesiredModeId(record, options.modeId); + await writeSessionRecord(record); return { - record: await resolveSessionRecord(options.sessionId), + record, resumed: false, }; } @@ -913,8 +917,13 @@ export async function setSessionConfigOption( options.verbose, ); if (ownerResponse) { + const record = await resolveSessionRecord(options.sessionId); + if (options.configId === "mode") { + setDesiredModeId(record, options.value); + await writeSessionRecord(record); + } return { - record: await resolveSessionRecord(options.sessionId), + record, response: ownerResponse, resumed: false, }; diff --git a/src/session-runtime/connect-load.ts b/src/session-runtime/connect-load.ts index 8654982..f3a2ace 100644 --- a/src/session-runtime/connect-load.ts +++ b/src/session-runtime/connect-load.ts @@ -7,6 +7,7 @@ import { } from "../error-normalization.js"; import { isProcessAlive } from "../queue-ipc.js"; import type { QueueOwnerActiveSessionController } from "../queue-owner-turn-controller.js"; +import { getDesiredModeId } from "../session-mode-preference.js"; import { writeSessionRecord } from "../session-persistence.js"; import { InterruptedError, TimeoutError, withTimeout } from "../session-runtime-helpers.js"; import type { SessionRecord } from "../types.js"; @@ -64,6 +65,8 @@ export async function connectAndLoadSession( ): Promise { const record = options.record; const client = options.client; + const originalSessionId = record.acpSessionId; + const desiredModeId = getDesiredModeId(record.acpx); const storedProcessAlive = isProcessAlive(record.pid); const shouldReconnect = Boolean(record.pid) && !storedProcessAlive; @@ -90,6 +93,7 @@ export async function connectAndLoadSession( let resumed = false; let loadError: string | undefined; let sessionId = record.acpSessionId; + let createdFreshSession = false; if (client.supportsLoadSession()) { try { @@ -108,16 +112,35 @@ export async function connectAndLoadSession( } const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs); sessionId = createdSession.sessionId; + createdFreshSession = true; record.acpSessionId = sessionId; reconcileAgentSessionId(record, createdSession.agentSessionId); } } else { const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs); sessionId = createdSession.sessionId; + createdFreshSession = true; record.acpSessionId = sessionId; reconcileAgentSessionId(record, createdSession.agentSessionId); } + if (createdFreshSession && desiredModeId) { + try { + await withTimeout(client.setSessionMode(sessionId, desiredModeId), options.timeoutMs); + if (options.verbose) { + process.stderr.write( + `[acpx] replayed desired mode ${desiredModeId} on fresh ACP session ${sessionId} (previous ${originalSessionId})\n`, + ); + } + } catch (error) { + if (options.verbose) { + process.stderr.write( + `[acpx] failed to replay desired mode ${desiredModeId} on fresh ACP session ${sessionId}: ${formatErrorMessage(error)}\n`, + ); + } + } + } + options.onSessionIdResolved?.(sessionId); return { diff --git a/src/session-runtime/prompt-runner.ts b/src/session-runtime/prompt-runner.ts index 72bdde3..d374c09 100644 --- a/src/session-runtime/prompt-runner.ts +++ b/src/session-runtime/prompt-runner.ts @@ -1,5 +1,6 @@ import { AcpClient } from "../client.js"; import type { QueueOwnerActiveSessionController } from "../queue-owner-turn-controller.js"; +import { setDesiredModeId } from "../session-mode-preference.js"; import { absolutePath, isoNow, @@ -165,8 +166,9 @@ export async function runSessionSetModeDirect( verbose: options.verbose, onClientAvailable: options.onClientAvailable, onClientClosed: options.onClientClosed, - run: async (client, sessionId) => { + run: async (client, sessionId, record) => { await withTimeout(client.setSessionMode(sessionId, options.modeId), options.timeoutMs); + setDesiredModeId(record, options.modeId); }, }); @@ -189,11 +191,15 @@ export async function runSessionSetConfigOptionDirect( verbose: options.verbose, onClientAvailable: options.onClientAvailable, onClientClosed: options.onClientClosed, - run: async (client, sessionId) => { - return await withTimeout( + run: async (client, sessionId, record) => { + const response = await withTimeout( client.setSessionConfigOption(sessionId, options.configId, options.value), options.timeoutMs, ); + if (options.configId === "mode") { + setDesiredModeId(record, options.value); + } + return response; }, }); diff --git a/src/types.ts b/src/types.ts index 143019b..f6e530d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -255,6 +255,7 @@ export type SessionConversation = { export type SessionAcpxState = { current_mode_id?: string; + desired_mode_id?: string; available_commands?: string[]; config_options?: SessionConfigOption[]; }; diff --git a/test/cli.test.ts b/test/cli.test.ts index ac41fa4..f0ad7db 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -50,6 +50,7 @@ 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_LOAD_FALLBACK = `${MOCK_AGENT_COMMAND} --supports-load-session --load-session-fails-on-empty`; type CliRunResult = { code: number | null; @@ -442,6 +443,99 @@ test("prompt reconciles agentSessionId from loadSession metadata", async () => { }); }); +test("set-mode persists across load fallback and replays on fresh ACP sessions", 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_FALLBACK, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const sessionId = "mode-replay-session"; + await writeSessionRecord(homeDir, { + acpxRecordId: sessionId, + acpSessionId: sessionId, + agentCommand: MOCK_AGENT_WITH_LOAD_FALLBACK, + cwd, + createdAt: "2026-01-01T00:00:00.000Z", + lastUsedAt: "2026-01-01T00:00:00.000Z", + closed: false, + }); + + const setPlan = await runCli( + ["--cwd", cwd, "--format", "json", "codex", "set-mode", "plan"], + homeDir, + ); + assert.equal(setPlan.code, 0, setPlan.stderr); + const setPlanPayload = JSON.parse(setPlan.stdout.trim()) as { + acpxSessionId?: unknown; + }; + + const checkPlan = await runCli( + ["--cwd", cwd, "--format", "json", "codex", "set", "reasoning_effort", "high"], + homeDir, + ); + assert.equal(checkPlan.code, 0, checkPlan.stderr); + const checkPlanPayload = JSON.parse(checkPlan.stdout.trim()) as { + acpxSessionId?: unknown; + configOptions?: Array<{ id?: string; currentValue?: string }>; + }; + const modeAfterPlan = + checkPlanPayload.configOptions?.find((option) => option.id === "mode")?.currentValue ?? ""; + assert.equal(modeAfterPlan, "plan"); + assert.notEqual(checkPlanPayload.acpxSessionId, setPlanPayload.acpxSessionId); + + const setAuto = await runCli( + ["--cwd", cwd, "--format", "json", "codex", "set-mode", "auto"], + homeDir, + ); + assert.equal(setAuto.code, 0, setAuto.stderr); + const setAutoPayload = JSON.parse(setAuto.stdout.trim()) as { + acpxSessionId?: unknown; + }; + + const checkAuto = await runCli( + ["--cwd", cwd, "--format", "json", "codex", "set", "reasoning_effort", "medium"], + homeDir, + ); + assert.equal(checkAuto.code, 0, checkAuto.stderr); + const checkAutoPayload = JSON.parse(checkAuto.stdout.trim()) as { + acpxSessionId?: unknown; + configOptions?: Array<{ id?: string; currentValue?: string }>; + }; + const modeAfterAuto = + checkAutoPayload.configOptions?.find((option) => option.id === "mode")?.currentValue ?? ""; + assert.equal(modeAfterAuto, "auto"); + assert.notEqual(checkAutoPayload.acpxSessionId, setAutoPayload.acpxSessionId); + + const storedRecordPath = path.join( + homeDir, + ".acpx", + "sessions", + `${encodeURIComponent(sessionId)}.json`, + ); + const storedRecord = JSON.parse(await fs.readFile(storedRecordPath, "utf8")) as { + acpx?: { + desired_mode_id?: string; + }; + }; + assert.equal(storedRecord.acpx?.desired_mode_id, "auto"); + }); +}); + test("--ttl flag is parsed for sessions commands", async () => { await withTempHome(async (homeDir) => { const ok = await runCli(["--ttl", "30", "--format", "json", "sessions"], homeDir); diff --git a/test/mock-agent.ts b/test/mock-agent.ts index 51916ce..81a9362 100644 --- a/test/mock-agent.ts +++ b/test/mock-agent.ts @@ -15,6 +15,9 @@ import { type NewSessionResponse, type PromptRequest, type PromptResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModeRequest, type SessionId, } from "@agentclientprotocol/sdk"; @@ -36,6 +39,8 @@ type MockAgentOptions = { type SessionState = { pendingPrompt?: AbortController; hasCompletedPrompt: boolean; + modeId: string; + configValues: Record; }; class CancelledError extends Error { @@ -322,6 +327,48 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions { }; } +function createSessionState(hasCompletedPrompt = false): SessionState { + return { + hasCompletedPrompt, + modeId: "auto", + configValues: { + reasoning_effort: "medium", + }, + }; +} + +function buildConfigOptions(state: SessionState): SetSessionConfigOptionResponse["configOptions"] { + return [ + { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: state.modeId, + options: [ + { value: "read-only", name: "Read Only" }, + { value: "auto", name: "Default" }, + { value: "full-access", name: "Full Access" }, + { value: "plan", name: "Plan" }, + { value: "default", name: "Default" }, + ], + }, + { + id: "reasoning_effort", + name: "Reasoning Effort", + category: "thought_level", + type: "select", + currentValue: state.configValues.reasoning_effort ?? "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "xhigh", name: "Xhigh" }, + ], + }, + ]; +} + class MockAgent implements Agent { private readonly connection: AgentConnection; private readonly sessions = new Map(); @@ -346,7 +393,7 @@ class MockAgent implements Agent { async newSession(): Promise { const sessionId = randomUUID(); - this.sessions.set(sessionId, { hasCompletedPrompt: false }); + this.sessions.set(sessionId, createSessionState(false)); if (this.options.newSessionMeta) { return { @@ -378,7 +425,7 @@ class MockAgent implements Agent { throw error; } - this.sessions.set(params.sessionId, existing ?? { hasCompletedPrompt: false }); + this.sessions.set(params.sessionId, existing ?? createSessionState(false)); if (this.options.replayLoadSessionUpdates) { await this.sendAssistantMessage(params.sessionId, this.options.loadReplayText); @@ -427,6 +474,33 @@ class MockAgent implements Agent { this.sessions.get(params.sessionId)?.pendingPrompt?.abort(); } + async setSessionMode(params: SetSessionModeRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown session: ${params.sessionId}`); + } + session.modeId = params.modeId; + } + + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown session: ${params.sessionId}`); + } + + if (params.configId === "mode") { + session.modeId = params.value; + } else { + session.configValues[params.configId] = params.value; + } + + return { + configOptions: buildConfigOptions(session), + }; + } + private async sendAssistantMessage(sessionId: SessionId, text: string): Promise { await this.connection.sessionUpdate({ sessionId, diff --git a/test/session-conversation-model.test.ts b/test/session-conversation-model.test.ts index fc2c0e6..024b6b7 100644 --- a/test/session-conversation-model.test.ts +++ b/test/session-conversation-model.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { SessionNotification } from "@agentclientprotocol/sdk"; import { + cloneSessionAcpxState, createSessionConversation, recordClientOperation, recordPromptSubmission, @@ -204,3 +205,15 @@ test("recordClientOperation keeps state and advances timestamp", () => { assert.equal(state?.current_mode_id, "code"); assert.equal(conversation.updated_at, "2026-02-27T10:00:05.000Z"); }); + +test("cloneSessionAcpxState preserves desired mode id", () => { + const cloned = cloneSessionAcpxState({ + current_mode_id: "auto", + desired_mode_id: "plan", + available_commands: ["review"], + }); + + assert.equal(cloned?.current_mode_id, "auto"); + assert.equal(cloned?.desired_mode_id, "plan"); + assert.deepEqual(cloned?.available_commands, ["review"]); +}); diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index 497f539..22064d3 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -24,6 +24,31 @@ test("SessionRecord allows optional closed and closedAt fields", () => { assert.equal(record.closedAt, undefined); }); +test("listSessions preserves acpx desired_mode_id", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "desired-mode", + acpSessionId: "desired-mode", + agentCommand: "agent-a", + cwd, + acpx: { + desired_mode_id: "plan", + }, + }), + ); + + const sessions = await session.listSessions(); + const record = sessions.find((entry) => entry.acpxRecordId === "desired-mode"); + assert.ok(record); + assert.equal(record.acpx?.desired_mode_id, "plan"); + }); +}); + test("listSessions ignores unsupported conversation message shapes", async () => { await withTempHome(async (homeDir) => { const sessionDir = path.join(homeDir, ".acpx", "sessions");