Compare commits

...

1 Commits

Author SHA1 Message Date
Codex
e2568858cd fix: use configured fallback for unavailable restored modes 2026-11-02 22:21:03 +01:00
4 changed files with 132 additions and 60 deletions

View File

@@ -79,8 +79,9 @@ type InitializeSessionModeParams = {
* The permission mode a new Claude query starts in.
*
* An explicit mode wins. A resume or a load continues in the mode of the last
* user record of the transcript. If that mode is unknown or not available now,
* for example bypass, the session starts in `defaultMode` of the settings.
* user record of the transcript. If that mode is unknown or absent from the
* session's mode catalog, use an available `defaultMode` from settings, or
* `default` when that setting is unavailable too.
*/
export function resolveInitialPermissionMode(
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
@@ -97,13 +98,23 @@ export function resolveInitialPermissionMode(
{ error: () => (rejected = true) },
allowBypass,
);
if (!rejected) return mode;
if (!rejected && buildAvailableModes(allowBypass).some((available) => available.id === mode)) {
return mode;
}
logger.error(
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
`not available in this session.`,
);
}
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
const configuredMode = resolvePermissionMode(sources.defaultMode, logger, allowBypass);
// A rejected transcript mode falls back only to a mode this session can offer.
if (
sources.resumed !== undefined &&
!buildAvailableModes(allowBypass).some((available) => available.id === configuredMode)
) {
return "default";
}
return configuredMode;
}
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
@@ -120,7 +131,7 @@ export class SessionModeManager<S extends SessionMode> {
modes: SessionModeState;
autoModeFallbackWarningPending: boolean;
}> {
const availableModes = this.buildAvailableModes(allowBypass);
const availableModes = buildAvailableModes(allowBypass, this.options.airClient?.() ?? false);
let effectiveMode = requestedMode;
let autoModeFallbackWarningPending = false;
@@ -350,50 +361,6 @@ export class SessionModeManager<S extends SessionMode> {
}
}
private buildAvailableModes(allowBypass: boolean): SessionModeState["availableModes"] {
// Only AIR gets the mode kind.
const air = this.options.airClient?.() ?? false;
const kind = (value: string) => {
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
return meta ? { _meta: meta } : {};
};
const modes: SessionModeState["availableModes"] = [
{
id: "default",
name: "Manual",
description: "Always ask before making changes",
...kind("standard"),
},
{
id: "acceptEdits",
name: "Accept edits",
description: "Automatically accept all file edits",
...kind("standard"),
},
{
id: "plan",
name: "Plan",
description: "Create a plan before making changes",
...kind("plan"),
},
{
id: "auto",
name: "Auto",
description: "Claude handles permission decisions",
...kind("auto_review"),
},
];
if (allowBypass) {
modes.push({
id: "bypassPermissions",
name: "Bypass permissions",
description: "Accepts all permissions",
...kind("full_access"),
});
}
return modes;
}
private async trySyncMode(
query: Pick<Query, "setPermissionMode">,
mode: PermissionMode,
@@ -406,3 +373,49 @@ export class SessionModeManager<S extends SessionMode> {
}
}
}
function buildAvailableModes(
allowBypass: boolean,
air = false,
): SessionModeState["availableModes"] {
// Only AIR gets the mode kind.
const kind = (value: string) => {
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
return meta ? { _meta: meta } : {};
};
const modes: SessionModeState["availableModes"] = [
{
id: "default",
name: "Manual",
description: "Always ask before making changes",
...kind("standard"),
},
{
id: "acceptEdits",
name: "Accept edits",
description: "Automatically accept all file edits",
...kind("standard"),
},
{
id: "plan",
name: "Plan",
description: "Create a plan before making changes",
...kind("plan"),
},
{
id: "auto",
name: "Auto",
description: "Claude handles permission decisions",
...kind("auto_review"),
},
];
if (allowBypass) {
modes.push({
id: "bypassPermissions",
name: "Bypass permissions",
description: "Accepts all permissions",
...kind("full_access"),
});
}
return modes;
}

View File

@@ -464,6 +464,16 @@ describe("ClaudeAcpAgent settings", () => {
expect(response.modes.currentModeId).toBe("plan");
});
it("uses the available configured mode when the restored mode is not in the catalog", async () => {
const { permissionMode, response } = await resume([user("dontAsk")]);
expect(permissionMode).toBe("plan");
expect(response.modes.currentModeId).toBe("plan");
expect(response.configOptions.find((option: any) => option.id === "mode").currentValue).toBe(
"plan",
);
});
it("uses permissions.defaultMode when bypass is not available now", async () => {
const { permissionMode, response } = await resume([user("bypassPermissions")], {
claudeCode: { options: { allowDangerouslySkipPermissions: false } },

View File

@@ -615,6 +615,40 @@ describe("ACP v2 sessions", () => {
});
});
it("resumes an unavailable mode using the configured mode in the v2 response", async () => {
const project = path.join(tempDir, "claude", "projects", "-project");
await mkdir(project, { recursive: true });
await writeFile(
path.join(tempDir, "claude", "settings.json"),
JSON.stringify({ permissions: { defaultMode: "plan" } }),
);
const sessionId = randomUUID();
await writeFile(
path.join(project, `${sessionId}.jsonl`),
JSON.stringify({
type: "user",
uuid: randomUUID(),
parentUuid: null,
sessionId,
cwd,
isSidechain: false,
origin: { kind: "human" },
message: { role: "user", content: "hello" },
permissionMode: "dontAsk",
}) + "\n",
);
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const response = await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
expect(sdk.queryOptions.at(-1)?.permissionMode).toBe("plan");
expect(
response.configOptions?.find((option) => option.configId === "mode")?.currentValue,
).toBe("plan");
await authUpdate(1);
});
});
it("sets a config option with a v2 value and reports modes only as config options", async () => {
const client = v2Client();
const { response, updatesBeforeResponse } = await client.app.connectWith(

View File

@@ -253,15 +253,14 @@ describe("resolveInitialPermissionMode", () => {
expect(resolveInitialPermissionMode({ defaultMode: "plan" }, logger(), true)).toBe("plan");
});
it("continues in the mode of the resumed transcript instead of defaultMode", () => {
expect(
resolveInitialPermissionMode(
{ resumed: "bypassPermissions", defaultMode: "plan" },
logger(),
true,
),
).toBe("bypassPermissions");
});
it.each(["default", "acceptEdits", "plan", "auto", "bypassPermissions"])(
"continues in the supported restored mode %s instead of defaultMode",
(resumed) => {
expect(
resolveInitialPermissionMode({ resumed, defaultMode: "dontAsk" }, logger(), true),
).toBe(resumed);
},
);
it("lets an explicit mode win over the resumed transcript", () => {
expect(
@@ -285,9 +284,25 @@ describe("resolveInitialPermissionMode", () => {
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('"bypassPermissions"'));
});
it("falls back to defaultMode for an unknown resumed mode", () => {
it.each(["warp", "dontAsk"])("falls back to defaultMode for resumed mode %s", (resumed) => {
expect(
resolveInitialPermissionMode({ resumed: "warp", defaultMode: "acceptEdits" }, logger(), true),
resolveInitialPermissionMode({ resumed, defaultMode: "acceptEdits" }, logger(), true),
).toBe("acceptEdits");
});
it.each([
{ defaultMode: "plan", allowBypass: true, expected: "plan" },
{ defaultMode: "bypassPermissions", allowBypass: true, expected: "bypassPermissions" },
{ defaultMode: "bypassPermissions", allowBypass: false, expected: "default" },
{ defaultMode: "dontAsk", allowBypass: true, expected: "default" },
{ defaultMode: "warp", allowBypass: true, expected: "default" },
{ defaultMode: undefined, allowBypass: true, expected: "default" },
])(
"uses available settings fallback $defaultMode for an unavailable restored mode",
({ defaultMode, allowBypass, expected }) => {
expect(
resolveInitialPermissionMode({ resumed: "dontAsk", defaultMode }, logger(), allowBypass),
).toBe(expected);
},
);
});