@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.
- package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +2 -10
- package/dist/adapters/claude/conversion/tool-use-to-acp.js +24 -10
- package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
- package/dist/adapters/claude/permissions/permission-options.js +6 -4
- package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
- package/dist/adapters/claude/session/jsonl-hydration.js +1 -0
- package/dist/adapters/claude/session/jsonl-hydration.js.map +1 -1
- package/dist/adapters/claude/session/models.js +20 -1
- package/dist/adapters/claude/session/models.js.map +1 -1
- package/dist/adapters/claude/tools.js +4 -2
- package/dist/adapters/claude/tools.js.map +1 -1
- package/dist/adapters/reasoning-effort.js +3 -1
- package/dist/adapters/reasoning-effort.js.map +1 -1
- package/dist/agent.js +524 -72
- package/dist/agent.js.map +1 -1
- package/dist/posthog-api.js +4 -4
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +524 -72
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +524 -72
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +6 -6
- package/src/adapters/claude/UPSTREAM.md +80 -3
- package/src/adapters/claude/claude-agent.ts +201 -18
- package/src/adapters/claude/conversion/sdk-to-acp.ts +182 -16
- package/src/adapters/claude/conversion/task-state.test.ts +338 -0
- package/src/adapters/claude/conversion/task-state.ts +178 -0
- package/src/adapters/claude/conversion/tool-use-to-acp.ts +29 -19
- package/src/adapters/claude/hooks.test.ts +162 -0
- package/src/adapters/claude/hooks.ts +44 -3
- package/src/adapters/claude/permissions/permission-options.ts +7 -2
- package/src/adapters/claude/session/commands.ts +1 -0
- package/src/adapters/claude/session/mcp-config.ts +23 -7
- package/src/adapters/claude/session/model-config.test.ts +60 -0
- package/src/adapters/claude/session/model-config.ts +56 -0
- package/src/adapters/claude/session/models.test.ts +119 -0
- package/src/adapters/claude/session/models.ts +31 -0
- package/src/adapters/claude/session/options.test.ts +1 -0
- package/src/adapters/claude/session/options.ts +29 -1
- package/src/adapters/claude/session/settings.test.ts +102 -1
- package/src/adapters/claude/session/settings.ts +33 -6
- package/src/adapters/claude/tools.ts +4 -2
- package/src/adapters/claude/types.ts +8 -0
- package/src/adapters/codex/codex-agent.ts +2 -2
- package/src/server/agent-server.test.ts +173 -0
- package/src/test/mocks/claude-sdk.ts +4 -0
- package/src/test/native-binary.test.ts +27 -0
- package/dist/claude-cli/cli.js +0 -18412
- package/dist/claude-cli/vendor/audio-capture/arm64-darwin/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/arm64-linux/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/arm64-win32/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/x64-darwin/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/x64-linux/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/x64-win32/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/ripgrep/COPYING +0 -3
- package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/arm64-win32/rg.exe +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
- 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 "
|
|
373
|
+
case "TaskCreate": {
|
|
374
|
+
const subject =
|
|
375
|
+
typeof input?.subject === "string" ? input.subject : undefined;
|
|
375
376
|
return {
|
|
376
|
-
title:
|
|
377
|
-
|
|
378
|
-
|
|
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 (
|
|
89
|
-
|
|
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");
|
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|