Skip to content

Commit 46187d8

Browse files
charlesvienk11kirky
authored andcommitted
Fix Claude adapter upgrade regressions
1 parent 0dd18f1 commit 46187d8

10 files changed

Lines changed: 282 additions & 68 deletions

File tree

apps/code/forge.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ const config: ForgeConfig = {
282282
copyNativeDependency(watcherPkg, buildPath);
283283
} else if (process.platform === "linux") {
284284
const watcherPkg =
285-
process.arch === "arm64"
285+
targetArch === "arm64"
286286
? "@parcel/watcher-linux-arm64-glibc"
287287
: "@parcel/watcher-linux-x64-glibc";
288288
copyNativeDependency(watcherPkg, buildPath);

apps/code/vite.main.config.mts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import { defineConfig, loadEnv, type Plugin } from "vite";
2121
import tsconfigPaths from "vite-tsconfig-paths";
2222
// @ts-expect-error - plain ESM helper shared with packages/agent/tsup.config.ts
2323
import {
24+
CLAUDE_CLI_SUPPORT_DIRS,
25+
CLAUDE_CLI_SUPPORT_FILES,
2426
claudeBinName,
25-
nativeBinaryCandidates as sdkNativeBinaryCandidates,
27+
claudeExecutableCandidates as sdkClaudeExecutableCandidates,
2628
targetArch,
2729
targetPlatform,
2830
} from "../../packages/agent/build/native-binary.mjs";
@@ -145,6 +147,24 @@ function signClaudeBinary(destPath: string): void {
145147
}
146148
}
147149

150+
function copyClaudeSupportAssets(sourcePath: string, destDir: string): void {
151+
const sourceDir = dirname(sourcePath);
152+
153+
for (const file of CLAUDE_CLI_SUPPORT_FILES) {
154+
const source = join(sourceDir, file);
155+
if (existsSync(source)) {
156+
copyFileSync(source, join(destDir, file));
157+
}
158+
}
159+
160+
for (const dir of CLAUDE_CLI_SUPPORT_DIRS) {
161+
const source = join(sourceDir, dir);
162+
if (existsSync(source)) {
163+
cpSync(source, join(destDir, dir), { recursive: true });
164+
}
165+
}
166+
}
167+
148168
function copyClaudeExecutable(): Plugin {
149169
return {
150170
name: "copy-claude-executable",
@@ -170,8 +190,8 @@ function copyClaudeExecutable(): Plugin {
170190
binName,
171191
),
172192
join(__dirname, "../../packages/agent/dist/claude-cli", binName),
173-
...sdkNativeBinaryCandidates(join(__dirname, "node_modules")),
174-
...sdkNativeBinaryCandidates(join(__dirname, "../../node_modules")),
193+
...sdkClaudeExecutableCandidates(join(__dirname, "node_modules")),
194+
...sdkClaudeExecutableCandidates(join(__dirname, "../../node_modules")),
175195
];
176196

177197
const source = packageCandidates.find((p: string) => existsSync(p));
@@ -187,6 +207,7 @@ function copyClaudeExecutable(): Plugin {
187207
if (targetPlatform() !== "win32") {
188208
execSync(`chmod +x "${destBinary}"`);
189209
}
210+
copyClaudeSupportAssets(source, destDir);
190211
verifyBinaryArch(destBinary);
191212
signClaudeBinary(destBinary);
192213
claudeCliCopied = true;

packages/agent/build/native-binary.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ export function claudeBinName(platform = targetPlatform()) {
2727
return platform === "win32" ? "claude.exe" : "claude";
2828
}
2929

30+
export const CLAUDE_CLI_SUPPORT_FILES = [
31+
"package.json",
32+
"manifest.json",
33+
"manifest.zst.json",
34+
"yoga.wasm",
35+
];
36+
37+
export const CLAUDE_CLI_SUPPORT_DIRS = ["vendor"];
38+
3039
/**
3140
* Detect whether the *current* Node was built against musl libc (not glibc).
3241
* Only meaningful when targetPlatform() === "linux" and we're running on
@@ -58,3 +67,20 @@ export function nativeBinaryCandidates(rootNodeModules) {
5867
join(rootNodeModules, `@anthropic-ai/claude-agent-sdk-${slug}`, binary),
5968
);
6069
}
70+
71+
/**
72+
* SDK 0.3.x is in the middle of transitioning from a monolithic `cli.js`
73+
* package layout to platform-specific native executables. Keep the legacy
74+
* entrypoint as a fallback until the optional native packages are universally
75+
* available across our build environments.
76+
*/
77+
export function legacyCliCandidates(rootNodeModules) {
78+
return [join(rootNodeModules, "@anthropic-ai/claude-agent-sdk", "cli.js")];
79+
}
80+
81+
export function claudeExecutableCandidates(rootNodeModules) {
82+
return [
83+
...nativeBinaryCandidates(rootNodeModules),
84+
...legacyCliCandidates(rootNodeModules),
85+
];
86+
}

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ import {
100100
import { canUseTool } from "./permissions/permission-handlers";
101101
import { getAvailableSlashCommands } from "./session/commands";
102102
import { parseMcpServers } from "./session/mcp-config";
103+
import {
104+
applyAvailableModelsAllowlist,
105+
resolveInitialModelId,
106+
} from "./session/model-config";
103107
import {
104108
DEFAULT_MODEL,
105109
getEffortOptions,
@@ -184,45 +188,6 @@ function shouldEmitRawMessage(
184188
);
185189
}
186190

187-
/**
188-
* Restrict gateway model options to the user's `availableModels` allowlist
189-
* from settings.json. Unknown allowlist entries (e.g. retired model IDs like
190-
* `claude-opus-4-5` left in stale settings) are dropped: passing them through
191-
* would make setModel reject the resolved id and break session init. If every
192-
* entry is unknown we fall back to the gateway list as a safety net.
193-
*/
194-
function applyAvailableModelsAllowlist(
195-
modelOptions: {
196-
currentModelId: string;
197-
options: SessionConfigSelectOption[];
198-
},
199-
allowlist: string[],
200-
): { currentModelId: string; options: SessionConfigSelectOption[] } {
201-
const filtered: SessionConfigSelectOption[] = [];
202-
const seen = new Set<string>();
203-
204-
for (const entry of allowlist) {
205-
const trimmed = entry.trim();
206-
if (!trimmed || seen.has(trimmed)) continue;
207-
208-
const match = modelOptions.options.find((o) => o.value === trimmed);
209-
if (match) {
210-
filtered.push(match);
211-
seen.add(trimmed);
212-
}
213-
}
214-
215-
if (filtered.length === 0) return modelOptions;
216-
217-
const currentModelId = filtered.some(
218-
(o) => o.value === modelOptions.currentModelId,
219-
)
220-
? modelOptions.currentModelId
221-
: filtered[0].value;
222-
223-
return { currentModelId, options: filtered };
224-
}
225-
226191
export interface ClaudeAcpAgentOptions {
227192
onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
228193
onProcessExited?: (pid: number) => void;
@@ -1587,10 +1552,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
15871552
}
15881553
}
15891554

1590-
const settingsModel = settingsManager.getSettings().model;
1591-
const metaModel = meta?.model;
1592-
const resolvedModelId =
1593-
settingsModel || metaModel || modelOptions.currentModelId;
1555+
const resolvedModelId = resolveInitialModelId(modelOptions, [
1556+
settingsManager.getSettings().model,
1557+
meta?.model,
1558+
]);
15941559
session.modelId = resolvedModelId;
15951560
session.lastContextWindowSize =
15961561
this.getContextWindowForModel(resolvedModelId);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
applyAvailableModelsAllowlist,
4+
resolveInitialModelId,
5+
} from "./model-config";
6+
7+
const rawModelOptions = {
8+
currentModelId: "claude-opus-4-8",
9+
options: [
10+
{ value: "claude-opus-4-8", name: "Claude Opus 4.8" },
11+
{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
12+
],
13+
};
14+
15+
describe("applyAvailableModelsAllowlist", () => {
16+
it("falls back to the unfiltered gateway list when every allowlisted model is unknown", () => {
17+
expect(
18+
applyAvailableModelsAllowlist(rawModelOptions, ["claude-opus-4-5"]),
19+
).toEqual(rawModelOptions);
20+
});
21+
22+
it("switches the current model when the previous one is filtered out", () => {
23+
expect(
24+
applyAvailableModelsAllowlist(rawModelOptions, ["claude-sonnet-4-6"]),
25+
).toEqual({
26+
currentModelId: "claude-sonnet-4-6",
27+
options: [{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }],
28+
});
29+
});
30+
});
31+
32+
describe("resolveInitialModelId", () => {
33+
it("keeps a preferred model when it survives filtering", () => {
34+
const filteredModelOptions = applyAvailableModelsAllowlist(
35+
rawModelOptions,
36+
["claude-opus-4-8", "claude-sonnet-4-6"],
37+
);
38+
39+
expect(
40+
resolveInitialModelId(filteredModelOptions, [
41+
"claude-opus-4-8",
42+
"claude-sonnet-4-6",
43+
]),
44+
).toBe("claude-opus-4-8");
45+
});
46+
47+
it("falls back to the filtered current model when the preferred one is disallowed", () => {
48+
const filteredModelOptions = applyAvailableModelsAllowlist(
49+
rawModelOptions,
50+
["claude-sonnet-4-6"],
51+
);
52+
53+
expect(
54+
resolveInitialModelId(filteredModelOptions, [
55+
"claude-opus-4-8",
56+
"claude-sonnet-4-6",
57+
]),
58+
).toBe("claude-sonnet-4-6");
59+
});
60+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { SessionConfigSelectOption } from "@agentclientprotocol/sdk";
2+
3+
export interface ModelConfigOptions {
4+
currentModelId: string;
5+
options: SessionConfigSelectOption[];
6+
}
7+
8+
/**
9+
* Restrict gateway model options to the user's `availableModels` allowlist
10+
* from settings.json. Unknown allowlist entries are dropped; if every entry
11+
* is unknown we fall back to the gateway list as a safety net.
12+
*/
13+
export function applyAvailableModelsAllowlist(
14+
modelOptions: ModelConfigOptions,
15+
allowlist: string[],
16+
): ModelConfigOptions {
17+
const filtered: SessionConfigSelectOption[] = [];
18+
const seen = new Set<string>();
19+
20+
for (const entry of allowlist) {
21+
const trimmed = entry.trim();
22+
if (!trimmed || seen.has(trimmed)) continue;
23+
24+
const match = modelOptions.options.find((o) => o.value === trimmed);
25+
if (match) {
26+
filtered.push(match);
27+
seen.add(trimmed);
28+
}
29+
}
30+
31+
if (filtered.length === 0) return modelOptions;
32+
33+
const currentModelId = filtered.some(
34+
(o) => o.value === modelOptions.currentModelId,
35+
)
36+
? modelOptions.currentModelId
37+
: filtered[0].value;
38+
39+
return { currentModelId, options: filtered };
40+
}
41+
42+
export function resolveInitialModelId(
43+
modelOptions: ModelConfigOptions,
44+
preferredModelIds: Array<string | undefined>,
45+
): string {
46+
const allowedModelIds = new Set(modelOptions.options.map((opt) => opt.value));
47+
48+
for (const candidate of preferredModelIds) {
49+
const trimmed = candidate?.trim();
50+
if (trimmed && allowedModelIds.has(trimmed)) {
51+
return trimmed;
52+
}
53+
}
54+
55+
return modelOptions.currentModelId;
56+
}

packages/agent/src/adapters/claude/session/settings.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as os from "node:os";
44
import * as path from "node:path";
55
import { afterEach, beforeEach, describe, expect, it } from "vitest";
66
import { resolveMainRepoPath } from "./repo-path";
7-
import { SettingsManager } from "./settings";
7+
import { mergeAvailableModels, SettingsManager } from "./settings";
88

99
function runGit(cwd: string, args: string[]): void {
1010
execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
@@ -286,3 +286,25 @@ describe("availableModels merge", () => {
286286
expect(manager.getSettings().availableModels).toBeUndefined();
287287
});
288288
});
289+
290+
describe("mergeAvailableModels", () => {
291+
it("merges and dedupes non-enterprise layers", () => {
292+
expect(
293+
mergeAvailableModels(
294+
["model-a", "model-b"],
295+
["model-b", "model-c"],
296+
"project",
297+
),
298+
).toEqual(["model-a", "model-b", "model-c"]);
299+
});
300+
301+
it("lets enterprise settings replace lower-precedence allowlists", () => {
302+
expect(
303+
mergeAvailableModels(
304+
["model-a", "model-b"],
305+
["managed-a", "managed-a"],
306+
"enterprise",
307+
),
308+
).toEqual(["managed-a"]);
309+
});
310+
});

0 commit comments

Comments
 (0)