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 src/session-conversation-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
30 changes: 30 additions & 0 deletions src/session-mode-preference.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions src/session-persistence/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
13 changes: 11 additions & 2 deletions src/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -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,
};
Expand Down
23 changes: 23 additions & 0 deletions src/session-runtime/connect-load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -64,6 +65,8 @@ export async function connectAndLoadSession(
): Promise<ConnectAndLoadSessionResult> {
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;

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions src/session-runtime/prompt-runner.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
},
});

Expand All @@ -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;
},
});

Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export type SessionConversation = {

export type SessionAcpxState = {
current_mode_id?: string;
desired_mode_id?: string;
available_commands?: string[];
config_options?: SessionConfigOption[];
};
Expand Down
94 changes: 94 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading