@posthog/agent 2.3.735 → 2.3.736

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +2 -10
  2. package/dist/adapters/claude/conversion/tool-use-to-acp.js +24 -10
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
  4. package/dist/adapters/claude/permissions/permission-options.js +6 -4
  5. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  6. package/dist/adapters/claude/session/jsonl-hydration.js +1 -0
  7. package/dist/adapters/claude/session/jsonl-hydration.js.map +1 -1
  8. package/dist/adapters/claude/session/models.js +20 -1
  9. package/dist/adapters/claude/session/models.js.map +1 -1
  10. package/dist/adapters/claude/tools.js +4 -2
  11. package/dist/adapters/claude/tools.js.map +1 -1
  12. package/dist/adapters/reasoning-effort.js +3 -1
  13. package/dist/adapters/reasoning-effort.js.map +1 -1
  14. package/dist/agent.js +524 -72
  15. package/dist/agent.js.map +1 -1
  16. package/dist/posthog-api.js +4 -4
  17. package/dist/posthog-api.js.map +1 -1
  18. package/dist/server/agent-server.js +524 -72
  19. package/dist/server/agent-server.js.map +1 -1
  20. package/dist/server/bin.cjs +524 -72
  21. package/dist/server/bin.cjs.map +1 -1
  22. package/package.json +6 -6
  23. package/src/adapters/claude/UPSTREAM.md +80 -3
  24. package/src/adapters/claude/claude-agent.ts +201 -18
  25. package/src/adapters/claude/conversion/sdk-to-acp.ts +182 -16
  26. package/src/adapters/claude/conversion/task-state.test.ts +338 -0
  27. package/src/adapters/claude/conversion/task-state.ts +178 -0
  28. package/src/adapters/claude/conversion/tool-use-to-acp.ts +29 -19
  29. package/src/adapters/claude/hooks.test.ts +162 -0
  30. package/src/adapters/claude/hooks.ts +44 -3
  31. package/src/adapters/claude/permissions/permission-options.ts +7 -2
  32. package/src/adapters/claude/session/commands.ts +1 -0
  33. package/src/adapters/claude/session/mcp-config.ts +23 -7
  34. package/src/adapters/claude/session/model-config.test.ts +60 -0
  35. package/src/adapters/claude/session/model-config.ts +56 -0
  36. package/src/adapters/claude/session/models.test.ts +119 -0
  37. package/src/adapters/claude/session/models.ts +31 -0
  38. package/src/adapters/claude/session/options.test.ts +1 -0
  39. package/src/adapters/claude/session/options.ts +29 -1
  40. package/src/adapters/claude/session/settings.test.ts +102 -1
  41. package/src/adapters/claude/session/settings.ts +33 -6
  42. package/src/adapters/claude/tools.ts +4 -2
  43. package/src/adapters/claude/types.ts +8 -0
  44. package/src/adapters/codex/codex-agent.ts +2 -2
  45. package/src/server/agent-server.test.ts +173 -0
  46. package/src/test/mocks/claude-sdk.ts +4 -0
  47. package/src/test/native-binary.test.ts +27 -0
  48. package/dist/claude-cli/cli.js +0 -18412
  49. package/dist/claude-cli/vendor/audio-capture/arm64-darwin/audio-capture.node +0 -0
  50. package/dist/claude-cli/vendor/audio-capture/arm64-linux/audio-capture.node +0 -0
  51. package/dist/claude-cli/vendor/audio-capture/arm64-win32/audio-capture.node +0 -0
  52. package/dist/claude-cli/vendor/audio-capture/x64-darwin/audio-capture.node +0 -0
  53. package/dist/claude-cli/vendor/audio-capture/x64-linux/audio-capture.node +0 -0
  54. package/dist/claude-cli/vendor/audio-capture/x64-win32/audio-capture.node +0 -0
  55. package/dist/claude-cli/vendor/ripgrep/COPYING +0 -3
  56. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
  57. package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
  58. package/dist/claude-cli/vendor/ripgrep/arm64-win32/rg.exe +0 -0
  59. package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
  60. package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
  61. package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
  62. package/dist/claude-cli/yoga.wasm +0 -0
@@ -0,0 +1,178 @@
1
+ import type { PlanEntry } from "@agentclientprotocol/sdk";
2
+ import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
3
+ import type {
4
+ TaskCreateInput,
5
+ TaskCreateOutput,
6
+ TaskUpdateInput,
7
+ } from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
8
+
9
+ export type TaskEntry = {
10
+ subject: string;
11
+ status: "pending" | "in_progress" | "completed";
12
+ activeForm?: string;
13
+ description?: string;
14
+ };
15
+
16
+ export type TaskState = Map<string, TaskEntry>;
17
+
18
+ export function parseTaskCreateOutput(
19
+ content: unknown,
20
+ ): TaskCreateOutput | undefined {
21
+ const tryParse = (text: string): TaskCreateOutput | undefined => {
22
+ try {
23
+ const parsed = JSON.parse(text);
24
+ if (
25
+ parsed &&
26
+ typeof parsed === "object" &&
27
+ parsed.task &&
28
+ typeof parsed.task.id === "string"
29
+ ) {
30
+ return parsed as TaskCreateOutput;
31
+ }
32
+ } catch {
33
+ // ignore
34
+ }
35
+ return undefined;
36
+ };
37
+
38
+ if (typeof content === "string") {
39
+ return tryParse(content);
40
+ }
41
+ if (Array.isArray(content)) {
42
+ for (const block of content) {
43
+ if (
44
+ block &&
45
+ typeof block === "object" &&
46
+ "type" in block &&
47
+ block.type === "text"
48
+ ) {
49
+ const text = (block as { text?: unknown }).text;
50
+ if (typeof text === "string") {
51
+ const parsed = tryParse(text);
52
+ if (parsed) return parsed;
53
+ }
54
+ }
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ export function applyTaskCreate(
61
+ state: TaskState,
62
+ input: TaskCreateInput | undefined,
63
+ output: TaskCreateOutput | undefined,
64
+ ): void {
65
+ const taskId = output?.task?.id;
66
+ if (!taskId || !input) return;
67
+ state.set(taskId, {
68
+ subject: input.subject,
69
+ status: "pending",
70
+ activeForm: input.activeForm,
71
+ description: input.description,
72
+ });
73
+ }
74
+
75
+ export function applyTaskUpdate(
76
+ state: TaskState,
77
+ input: TaskUpdateInput | undefined,
78
+ ): void {
79
+ if (!input?.taskId) return;
80
+ if (input.status === "deleted") {
81
+ state.delete(input.taskId);
82
+ return;
83
+ }
84
+ const existing = state.get(input.taskId);
85
+ const subject = input.subject ?? existing?.subject;
86
+ if (!subject) return;
87
+ state.set(input.taskId, {
88
+ subject,
89
+ status: input.status ?? existing?.status ?? "pending",
90
+ activeForm: input.activeForm ?? existing?.activeForm,
91
+ description: input.description ?? existing?.description,
92
+ });
93
+ }
94
+
95
+ export function taskStateToPlanEntries(state: TaskState): PlanEntry[] {
96
+ return Array.from(state.values()).map((task) => ({
97
+ content: task.subject,
98
+ status: task.status,
99
+ priority: "medium",
100
+ }));
101
+ }
102
+
103
+ type ToolUseBlock = {
104
+ type: "tool_use";
105
+ id: string;
106
+ name: string;
107
+ input?: unknown;
108
+ };
109
+
110
+ type ToolResultBlock = {
111
+ type: "tool_result";
112
+ tool_use_id: string;
113
+ content?: unknown;
114
+ is_error?: boolean;
115
+ };
116
+
117
+ function isToolUseBlock(block: unknown): block is ToolUseBlock {
118
+ return (
119
+ !!block &&
120
+ typeof block === "object" &&
121
+ (block as { type?: unknown }).type === "tool_use" &&
122
+ typeof (block as { id?: unknown }).id === "string" &&
123
+ typeof (block as { name?: unknown }).name === "string"
124
+ );
125
+ }
126
+
127
+ function isToolResultBlock(block: unknown): block is ToolResultBlock {
128
+ return (
129
+ !!block &&
130
+ typeof block === "object" &&
131
+ (block as { type?: unknown }).type === "tool_result" &&
132
+ typeof (block as { tool_use_id?: unknown }).tool_use_id === "string"
133
+ );
134
+ }
135
+
136
+ /**
137
+ * Rebuild `state` from a JSONL message transcript by replaying Task tool
138
+ * inputs/outputs. Used by `resumeSession` to recover the plan panel when the
139
+ * agent restarts mid-conversation; `loadSession` covers the same ground via
140
+ * the full notification replay in `replaySessionHistory`.
141
+ */
142
+ export function rehydrateTaskState(
143
+ messages: ReadonlyArray<SessionMessage>,
144
+ state: TaskState,
145
+ ): void {
146
+ const pendingInputs = new Map<string, { name: string; input: unknown }>();
147
+ for (const msg of messages) {
148
+ const content = (msg.message as { content?: unknown } | null | undefined)
149
+ ?.content;
150
+ if (!Array.isArray(content)) continue;
151
+ if (msg.type === "assistant") {
152
+ for (const block of content) {
153
+ if (
154
+ isToolUseBlock(block) &&
155
+ (block.name === "TaskCreate" || block.name === "TaskUpdate")
156
+ ) {
157
+ pendingInputs.set(block.id, { name: block.name, input: block.input });
158
+ }
159
+ }
160
+ } else if (msg.type === "user") {
161
+ for (const block of content) {
162
+ if (!isToolResultBlock(block) || block.is_error) continue;
163
+ const cached = pendingInputs.get(block.tool_use_id);
164
+ if (!cached) continue;
165
+ if (cached.name === "TaskCreate") {
166
+ applyTaskCreate(
167
+ state,
168
+ cached.input as TaskCreateInput | undefined,
169
+ parseTaskCreateOutput(block.content),
170
+ );
171
+ } else if (cached.name === "TaskUpdate") {
172
+ applyTaskUpdate(state, cached.input as TaskUpdateInput | undefined);
173
+ }
174
+ pendingInputs.delete(block.tool_use_id);
175
+ }
176
+ }
177
+ }
178
+ }
@@ -1,7 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type {
4
- PlanEntry,
5
4
  ToolCall,
6
5
  ToolCallContent,
7
6
  ToolCallLocation,
@@ -371,11 +370,36 @@ export function toolInfoFromToolUse(
371
370
  };
372
371
  }
373
372
 
374
- case "TodoWrite":
373
+ case "TaskCreate": {
374
+ const subject =
375
+ typeof input?.subject === "string" ? input.subject : undefined;
375
376
  return {
376
- title: Array.isArray(input?.todos)
377
- ? `Update TODOs: ${input.todos.map((todo: { content?: string }) => todo.content).join(", ")}`
378
- : "Update TODOs",
377
+ title: subject ? `Create task: ${subject}` : "Create task",
378
+ kind: "think",
379
+ content: [],
380
+ };
381
+ }
382
+
383
+ case "TaskUpdate": {
384
+ const subject =
385
+ typeof input?.subject === "string" ? input.subject : undefined;
386
+ return {
387
+ title: subject ? `Update task: ${subject}` : "Update task",
388
+ kind: "think",
389
+ content: [],
390
+ };
391
+ }
392
+
393
+ case "TaskList":
394
+ return {
395
+ title: "List tasks",
396
+ kind: "think",
397
+ content: [],
398
+ };
399
+
400
+ case "TaskGet":
401
+ return {
402
+ title: "Get task",
379
403
  kind: "think",
380
404
  content: [],
381
405
  };
@@ -775,20 +799,6 @@ function toAcpContentUpdate(
775
799
  return {};
776
800
  }
777
801
 
778
- export type ClaudePlanEntry = {
779
- content: string;
780
- status: "pending" | "in_progress" | "completed";
781
- activeForm: string;
782
- };
783
-
784
- export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
785
- return input.todos.map((input) => ({
786
- content: input.content,
787
- status: input.status,
788
- priority: "medium",
789
- }));
790
- }
791
-
792
802
  /**
793
803
  * attempt to resolve full file contents for diff generation
794
804
  *
@@ -8,10 +8,12 @@ vi.mock("../../enrichment/file-enricher", () => ({
8
8
  }));
9
9
 
10
10
  import { Logger } from "../../utils/logger";
11
+ import type { TaskState } from "./conversion/task-state";
11
12
  import {
12
13
  createPreToolUseHook,
13
14
  createReadEnrichmentHook,
14
15
  createSignedCommitGuardHook,
16
+ createTaskHook,
15
17
  type EnrichedReadCache,
16
18
  } from "./hooks";
17
19
  import type {
@@ -365,3 +367,163 @@ describe("createSignedCommitGuardHook", () => {
365
367
  expect(result).toEqual({ continue: true });
366
368
  });
367
369
  });
370
+
371
+ describe("createTaskHook", () => {
372
+ const baseInput = {
373
+ session_id: "s",
374
+ transcript_path: "/tmp/t",
375
+ cwd: "/tmp",
376
+ };
377
+
378
+ test("ignores hook events without a task_id", async () => {
379
+ const state: TaskState = new Map();
380
+ const onChange = vi.fn(async () => {});
381
+ const hook = createTaskHook(state, onChange);
382
+ const result = await hook(
383
+ { ...baseInput, hook_event_name: "PostToolUse" } as HookInput,
384
+ undefined,
385
+ { signal: new AbortController().signal },
386
+ );
387
+ expect(result).toEqual({ continue: true });
388
+ expect(state.size).toBe(0);
389
+ expect(onChange).not.toHaveBeenCalled();
390
+ });
391
+
392
+ test("TaskCreated inserts a pending entry and fires onChange", async () => {
393
+ const state: TaskState = new Map();
394
+ const onChange = vi.fn(async () => {});
395
+ const hook = createTaskHook(state, onChange);
396
+ const result = await hook(
397
+ {
398
+ ...baseInput,
399
+ hook_event_name: "TaskCreated",
400
+ task_id: "t1",
401
+ task_subject: "Fix bug",
402
+ task_description: "details",
403
+ } as unknown as HookInput,
404
+ undefined,
405
+ { signal: new AbortController().signal },
406
+ );
407
+ expect(result).toEqual({ continue: true });
408
+ expect(state.get("t1")).toEqual({
409
+ subject: "Fix bug",
410
+ status: "pending",
411
+ description: "details",
412
+ });
413
+ expect(onChange).toHaveBeenCalledOnce();
414
+ });
415
+
416
+ test("TaskCreated is idempotent for an existing task_id", async () => {
417
+ const state: TaskState = new Map([
418
+ [
419
+ "t1",
420
+ {
421
+ subject: "Original",
422
+ status: "in_progress" as const,
423
+ },
424
+ ],
425
+ ]);
426
+ const onChange = vi.fn(async () => {});
427
+ const hook = createTaskHook(state, onChange);
428
+ await hook(
429
+ {
430
+ ...baseInput,
431
+ hook_event_name: "TaskCreated",
432
+ task_id: "t1",
433
+ task_subject: "Overwrite attempt",
434
+ } as unknown as HookInput,
435
+ undefined,
436
+ { signal: new AbortController().signal },
437
+ );
438
+ expect(state.get("t1")?.subject).toBe("Original");
439
+ expect(state.get("t1")?.status).toBe("in_progress");
440
+ expect(onChange).not.toHaveBeenCalled();
441
+ });
442
+
443
+ test("TaskCreated without task_subject is a no-op", async () => {
444
+ const state: TaskState = new Map();
445
+ const onChange = vi.fn(async () => {});
446
+ const hook = createTaskHook(state, onChange);
447
+ await hook(
448
+ {
449
+ ...baseInput,
450
+ hook_event_name: "TaskCreated",
451
+ task_id: "t1",
452
+ } as unknown as HookInput,
453
+ undefined,
454
+ { signal: new AbortController().signal },
455
+ );
456
+ expect(state.size).toBe(0);
457
+ expect(onChange).not.toHaveBeenCalled();
458
+ });
459
+
460
+ test("TaskCompleted flips status and fires onChange", async () => {
461
+ const state: TaskState = new Map([
462
+ ["t1", { subject: "Existing", status: "in_progress" as const }],
463
+ ]);
464
+ const onChange = vi.fn(async () => {});
465
+ const hook = createTaskHook(state, onChange);
466
+ await hook(
467
+ {
468
+ ...baseInput,
469
+ hook_event_name: "TaskCompleted",
470
+ task_id: "t1",
471
+ } as unknown as HookInput,
472
+ undefined,
473
+ { signal: new AbortController().signal },
474
+ );
475
+ expect(state.get("t1")?.status).toBe("completed");
476
+ expect(onChange).toHaveBeenCalledOnce();
477
+ });
478
+
479
+ test("TaskCompleted is a no-op for unknown task_id", async () => {
480
+ const state: TaskState = new Map();
481
+ const onChange = vi.fn(async () => {});
482
+ const hook = createTaskHook(state, onChange);
483
+ await hook(
484
+ {
485
+ ...baseInput,
486
+ hook_event_name: "TaskCompleted",
487
+ task_id: "unknown",
488
+ } as unknown as HookInput,
489
+ undefined,
490
+ { signal: new AbortController().signal },
491
+ );
492
+ expect(state.size).toBe(0);
493
+ expect(onChange).not.toHaveBeenCalled();
494
+ });
495
+
496
+ test("TaskCompleted is a no-op for already-completed task", async () => {
497
+ const state: TaskState = new Map([
498
+ ["t1", { subject: "Existing", status: "completed" as const }],
499
+ ]);
500
+ const onChange = vi.fn(async () => {});
501
+ const hook = createTaskHook(state, onChange);
502
+ await hook(
503
+ {
504
+ ...baseInput,
505
+ hook_event_name: "TaskCompleted",
506
+ task_id: "t1",
507
+ } as unknown as HookInput,
508
+ undefined,
509
+ { signal: new AbortController().signal },
510
+ );
511
+ expect(onChange).not.toHaveBeenCalled();
512
+ });
513
+
514
+ test("works without an onChange callback", async () => {
515
+ const state: TaskState = new Map();
516
+ const hook = createTaskHook(state);
517
+ await hook(
518
+ {
519
+ ...baseInput,
520
+ hook_event_name: "TaskCreated",
521
+ task_id: "t1",
522
+ task_subject: "Fix bug",
523
+ } as unknown as HookInput,
524
+ undefined,
525
+ { signal: new AbortController().signal },
526
+ );
527
+ expect(state.get("t1")?.subject).toBe("Fix bug");
528
+ });
529
+ });
@@ -6,6 +6,7 @@ import {
6
6
  import type { Logger } from "../../utils/logger";
7
7
  import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared";
8
8
  import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
9
+ import type { TaskState } from "./conversion/task-state";
9
10
  import {
10
11
  extractPostHogSubTool,
11
12
  isPostHogDestructiveSubTool,
@@ -129,6 +130,48 @@ export const registerHookCallback = (
129
130
  };
130
131
  };
131
132
 
133
+ /**
134
+ * Pre-populate the per-session task list from SDK TaskCreated/TaskCompleted
135
+ * hook events. These fire before the matching tool_result chunk arrives, so
136
+ * by the time TaskUpdate runs (which only carries taskId + status) the entry
137
+ * already exists with a real subject — no placeholder with empty content.
138
+ *
139
+ * Plan-update emission happens in the tool_result handler, which mirrors the
140
+ * old TodoWrite suppress-tool-call + emit-plan flow.
141
+ */
142
+ export const createTaskHook =
143
+ (taskState: TaskState, onChange?: () => Promise<void>): HookCallback =>
144
+ async (input: HookInput): Promise<{ continue: boolean }> => {
145
+ const taskId =
146
+ "task_id" in input && typeof input.task_id === "string"
147
+ ? input.task_id
148
+ : undefined;
149
+ if (!taskId) return { continue: true };
150
+
151
+ let mutated = false;
152
+ if (input.hook_event_name === "TaskCreated") {
153
+ if (!input.task_subject) return { continue: true };
154
+ // Guard against the SDK firing TaskCreated twice for the same id —
155
+ // re-entry would clobber any TaskUpdate that landed in between.
156
+ if (taskState.has(taskId)) return { continue: true };
157
+ taskState.set(taskId, {
158
+ subject: input.task_subject,
159
+ status: "pending",
160
+ description: input.task_description,
161
+ });
162
+ mutated = true;
163
+ } else if (input.hook_event_name === "TaskCompleted") {
164
+ const existing = taskState.get(taskId);
165
+ if (!existing || existing.status === "completed") {
166
+ return { continue: true };
167
+ }
168
+ taskState.set(taskId, { ...existing, status: "completed" });
169
+ mutated = true;
170
+ }
171
+ if (mutated && onChange) await onChange();
172
+ return { continue: true };
173
+ };
174
+
132
175
  export type OnModeChange = (mode: CodeExecutionMode) => Promise<void>;
133
176
 
134
177
  interface CreatePostToolUseHookParams {
@@ -157,10 +200,8 @@ export const createPostToolUseHook =
157
200
  input.tool_input,
158
201
  input.tool_response,
159
202
  );
160
- delete toolUseCallbacks[toolUseID];
161
- } else {
162
- delete toolUseCallbacks[toolUseID];
163
203
  }
204
+ delete toolUseCallbacks[toolUseID];
164
205
  }
165
206
  }
166
207
  return { continue: true };
@@ -85,8 +85,13 @@ export function buildPermissionOptions(
85
85
  return permissionOptions("Yes, allow all sub-tasks");
86
86
  }
87
87
 
88
- if (toolName === "TodoWrite") {
89
- return permissionOptions("Yes, allow all todo updates");
88
+ if (
89
+ toolName === "TaskCreate" ||
90
+ toolName === "TaskUpdate" ||
91
+ toolName === "TaskGet" ||
92
+ toolName === "TaskList"
93
+ ) {
94
+ return permissionOptions("Yes, allow all task updates");
90
95
  }
91
96
 
92
97
  return permissionOptions("Yes, always allow");
@@ -2,6 +2,7 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk";
2
2
  import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk";
3
3
 
4
4
  const UNSUPPORTED_COMMANDS = [
5
+ "clear",
5
6
  "context",
6
7
  "cost",
7
8
  "keybindings-help",
@@ -48,6 +48,7 @@ export function loadUserClaudeJsonMcpServers(
48
48
 
49
49
  export function parseMcpServers(
50
50
  params: Pick<NewSessionRequest, "mcpServers">,
51
+ logger?: Logger,
51
52
  ): Record<string, McpServerConfig> {
52
53
  const mcpServers: Record<string, McpServerConfig> = {};
53
54
  if (!Array.isArray(params.mcpServers)) {
@@ -56,13 +57,28 @@ export function parseMcpServers(
56
57
 
57
58
  for (const server of params.mcpServers) {
58
59
  if ("type" in server) {
59
- mcpServers[server.name] = {
60
- type: server.type,
61
- url: server.url,
62
- headers: server.headers
63
- ? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
64
- : undefined,
65
- };
60
+ if (server.type === "http" || server.type === "sse") {
61
+ mcpServers[server.name] = {
62
+ type: server.type,
63
+ url: server.url,
64
+ headers: server.headers
65
+ ? Object.fromEntries(
66
+ server.headers.map((e: { name: string; value: string }) => [
67
+ e.name,
68
+ e.value,
69
+ ]),
70
+ )
71
+ : undefined,
72
+ };
73
+ } else {
74
+ // ACP 0.22 introduced the `sdk` McpServerConfig variant; the SDK
75
+ // adapter doesn't construct in-process servers, so surface a warning
76
+ // rather than silently dropping the entry.
77
+ logger?.warn("parseMcpServers: dropping unsupported MCP server type", {
78
+ name: server.name,
79
+ type: (server as { type: string }).type,
80
+ });
81
+ }
66
82
  } else {
67
83
  mcpServers[server.name] = {
68
84
  type: "stdio",
@@ -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
+ });
@@ -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
+ }