mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-02 23:41:23 +00:00
Compare commits
1 Commits
main
...
fix/restor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2568858cd |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user