@posthog/agent 2.3.297 → 2.3.302
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/agent.js +118 -1
- package/dist/agent.js.map +1 -1
- package/dist/index.d.ts +3 -6
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +119 -3
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +119 -3
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +3 -3
- package/src/acp-extensions.test.ts +72 -0
- package/src/acp-extensions.ts +37 -6
- package/src/adapters/claude/claude-agent.refresh.test.ts +292 -0
- package/src/adapters/claude/claude-agent.ts +121 -1
- package/src/adapters/codex/codex-agent.test.ts +67 -6
- package/src/adapters/codex/codex-agent.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.302",
|
|
4
4
|
"repository": "https://github.com/PostHog/code",
|
|
5
5
|
"description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
|
|
6
6
|
"exports": {
|
|
@@ -86,8 +86,8 @@
|
|
|
86
86
|
"tsx": "^4.20.6",
|
|
87
87
|
"typescript": "^5.5.0",
|
|
88
88
|
"vitest": "^2.1.8",
|
|
89
|
-
"@posthog/
|
|
90
|
-
"@posthog/
|
|
89
|
+
"@posthog/git": "1.0.0",
|
|
90
|
+
"@posthog/shared": "1.0.0"
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
93
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isMethod,
|
|
4
|
+
isNotification,
|
|
5
|
+
POSTHOG_METHODS,
|
|
6
|
+
POSTHOG_NOTIFICATIONS,
|
|
7
|
+
} from "./acp-extensions";
|
|
8
|
+
|
|
9
|
+
describe("isNotification", () => {
|
|
10
|
+
it("matches the exact notification name", () => {
|
|
11
|
+
expect(
|
|
12
|
+
isNotification(
|
|
13
|
+
POSTHOG_NOTIFICATIONS.TURN_COMPLETE,
|
|
14
|
+
POSTHOG_NOTIFICATIONS.TURN_COMPLETE,
|
|
15
|
+
),
|
|
16
|
+
).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches the double-underscore prefix variant", () => {
|
|
20
|
+
expect(
|
|
21
|
+
isNotification(
|
|
22
|
+
`_${POSTHOG_NOTIFICATIONS.TURN_COMPLETE}`,
|
|
23
|
+
POSTHOG_NOTIFICATIONS.TURN_COMPLETE,
|
|
24
|
+
),
|
|
25
|
+
).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns false for a different notification", () => {
|
|
29
|
+
expect(
|
|
30
|
+
isNotification(
|
|
31
|
+
POSTHOG_NOTIFICATIONS.USAGE_UPDATE,
|
|
32
|
+
POSTHOG_NOTIFICATIONS.TURN_COMPLETE,
|
|
33
|
+
),
|
|
34
|
+
).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns false for undefined", () => {
|
|
38
|
+
expect(isNotification(undefined, POSTHOG_NOTIFICATIONS.TURN_COMPLETE)).toBe(
|
|
39
|
+
false,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("isMethod", () => {
|
|
45
|
+
it("matches the exact method name", () => {
|
|
46
|
+
expect(
|
|
47
|
+
isMethod(
|
|
48
|
+
POSTHOG_METHODS.REFRESH_SESSION,
|
|
49
|
+
POSTHOG_METHODS.REFRESH_SESSION,
|
|
50
|
+
),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("matches the double-underscore prefix variant", () => {
|
|
55
|
+
expect(
|
|
56
|
+
isMethod(
|
|
57
|
+
`_${POSTHOG_METHODS.REFRESH_SESSION}`,
|
|
58
|
+
POSTHOG_METHODS.REFRESH_SESSION,
|
|
59
|
+
),
|
|
60
|
+
).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns false for unrelated method strings", () => {
|
|
64
|
+
expect(isMethod("session/prompt", POSTHOG_METHODS.REFRESH_SESSION)).toBe(
|
|
65
|
+
false,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns false for undefined", () => {
|
|
70
|
+
expect(isMethod(undefined, POSTHOG_METHODS.REFRESH_SESSION)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/acp-extensions.ts
CHANGED
|
@@ -68,17 +68,48 @@ export const POSTHOG_NOTIFICATIONS = {
|
|
|
68
68
|
PERMISSION_RESPONSE: "_posthog/permission_response",
|
|
69
69
|
} as const;
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Custom request methods for PostHog-specific operations that need a response
|
|
73
|
+
* (request/response, not fire-and-forget). Used with
|
|
74
|
+
* ClientSideConnection.extMethod() on the sender and Agent.extMethod() on the
|
|
75
|
+
* receiver.
|
|
76
|
+
*/
|
|
77
|
+
export const POSTHOG_METHODS = {
|
|
78
|
+
/**
|
|
79
|
+
* Client requests a session refresh between turns. Payload may include
|
|
80
|
+
* `mcpServers` to trigger a resume-with-new-options reinit; future fields
|
|
81
|
+
* can extend this without adding new methods. Returns once the refresh has
|
|
82
|
+
* completed so the caller can safely send the next prompt.
|
|
83
|
+
*/
|
|
84
|
+
REFRESH_SESSION: "_posthog/refresh_session",
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
type PosthogNotification =
|
|
72
88
|
(typeof POSTHOG_NOTIFICATIONS)[keyof typeof POSTHOG_NOTIFICATIONS];
|
|
73
89
|
|
|
90
|
+
type PosthogMethod = (typeof POSTHOG_METHODS)[keyof typeof POSTHOG_METHODS];
|
|
91
|
+
|
|
74
92
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
93
|
+
* Does `method` match `expected`? Shared by notification and method matchers.
|
|
94
|
+
* Handles the `__posthog/` double-prefix that extNotification() can produce.
|
|
77
95
|
*/
|
|
96
|
+
function matchesExt(method: string | undefined, expected: string): boolean {
|
|
97
|
+
if (!method) return false;
|
|
98
|
+
return method === expected || method === `_${expected}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Dispatcher check for incoming `extNotification` calls on the agent side. */
|
|
78
102
|
export function isNotification(
|
|
79
103
|
method: string | undefined,
|
|
80
|
-
|
|
104
|
+
expected: PosthogNotification,
|
|
81
105
|
): boolean {
|
|
82
|
-
|
|
83
|
-
|
|
106
|
+
return matchesExt(method, expected);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Dispatcher check for incoming `extMethod` calls on the agent side. */
|
|
110
|
+
export function isMethod(
|
|
111
|
+
method: string | undefined,
|
|
112
|
+
expected: PosthogMethod,
|
|
113
|
+
): boolean {
|
|
114
|
+
return matchesExt(method, expected);
|
|
84
115
|
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { POSTHOG_METHODS } from "../../acp-extensions";
|
|
4
|
+
import { Pushable } from "../../utils/streams";
|
|
5
|
+
|
|
6
|
+
type InitResult = {
|
|
7
|
+
result: "success";
|
|
8
|
+
commands?: unknown[];
|
|
9
|
+
models?: unknown[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type SdkQueryHandle = {
|
|
13
|
+
interrupt: ReturnType<typeof vi.fn>;
|
|
14
|
+
setModel: ReturnType<typeof vi.fn>;
|
|
15
|
+
setMcpServers: ReturnType<typeof vi.fn>;
|
|
16
|
+
supportedCommands: ReturnType<typeof vi.fn>;
|
|
17
|
+
initializationResult: ReturnType<typeof vi.fn>;
|
|
18
|
+
[Symbol.asyncIterator]: () => AsyncIterator<never>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let nextInitPromise: Promise<InitResult> = Promise.resolve({
|
|
22
|
+
result: "success",
|
|
23
|
+
commands: [],
|
|
24
|
+
models: [],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function makeQueryHandle(): SdkQueryHandle {
|
|
28
|
+
return {
|
|
29
|
+
interrupt: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
setModel: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
setMcpServers: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
supportedCommands: vi.fn().mockResolvedValue([]),
|
|
33
|
+
initializationResult: vi.fn().mockImplementation(() => nextInitPromise),
|
|
34
|
+
[Symbol.asyncIterator]: async function* () {
|
|
35
|
+
/* never yields */
|
|
36
|
+
} as never,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const lastQueryCall: { options?: Record<string, unknown> } = {};
|
|
41
|
+
const createdQueries: SdkQueryHandle[] = [];
|
|
42
|
+
|
|
43
|
+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
44
|
+
query: vi.fn((params: { options: Record<string, unknown> }) => {
|
|
45
|
+
lastQueryCall.options = params.options;
|
|
46
|
+
const handle = makeQueryHandle();
|
|
47
|
+
createdQueries.push(handle);
|
|
48
|
+
return handle;
|
|
49
|
+
}),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const fetchMcpToolMetadataMock = vi.fn().mockResolvedValue(undefined);
|
|
53
|
+
vi.mock("./mcp/tool-metadata", () => ({
|
|
54
|
+
fetchMcpToolMetadata: fetchMcpToolMetadataMock,
|
|
55
|
+
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// Import after the mocks so ClaudeAcpAgent resolves the mocked SDK
|
|
59
|
+
const { ClaudeAcpAgent } = await import("./claude-agent");
|
|
60
|
+
type Agent = InstanceType<typeof ClaudeAcpAgent>;
|
|
61
|
+
|
|
62
|
+
function makeAgent(): Agent {
|
|
63
|
+
const client = {
|
|
64
|
+
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
|
65
|
+
extNotification: vi.fn().mockResolvedValue(undefined),
|
|
66
|
+
} as unknown as AgentSideConnection;
|
|
67
|
+
return new ClaudeAcpAgent(client);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function installFakeSession(
|
|
71
|
+
agent: Agent,
|
|
72
|
+
sessionId: string,
|
|
73
|
+
overrides: Partial<{ modelId: string }> = {},
|
|
74
|
+
) {
|
|
75
|
+
const oldQuery = makeQueryHandle();
|
|
76
|
+
const input = new Pushable();
|
|
77
|
+
const endSpy = vi.spyOn(input, "end");
|
|
78
|
+
const abortController = new AbortController();
|
|
79
|
+
|
|
80
|
+
const session = {
|
|
81
|
+
query: oldQuery,
|
|
82
|
+
queryOptions: {
|
|
83
|
+
sessionId,
|
|
84
|
+
cwd: "/tmp/repo",
|
|
85
|
+
model: "claude-sonnet-4-6",
|
|
86
|
+
mcpServers: { posthog: { type: "http", url: "https://old" } },
|
|
87
|
+
abortController,
|
|
88
|
+
},
|
|
89
|
+
input,
|
|
90
|
+
cancelled: false,
|
|
91
|
+
settingsManager: { dispose: vi.fn() },
|
|
92
|
+
permissionMode: "default",
|
|
93
|
+
abortController,
|
|
94
|
+
accumulatedUsage: {
|
|
95
|
+
inputTokens: 42,
|
|
96
|
+
outputTokens: 17,
|
|
97
|
+
cachedReadTokens: 0,
|
|
98
|
+
cachedWriteTokens: 0,
|
|
99
|
+
},
|
|
100
|
+
configOptions: [],
|
|
101
|
+
promptRunning: false,
|
|
102
|
+
pendingMessages: new Map(),
|
|
103
|
+
nextPendingOrder: 0,
|
|
104
|
+
cwd: "/tmp/repo",
|
|
105
|
+
notificationHistory: [{ foo: "bar" }],
|
|
106
|
+
taskRunId: "run-1",
|
|
107
|
+
modelId: overrides.modelId,
|
|
108
|
+
} as unknown as Parameters<typeof Object.assign>[0];
|
|
109
|
+
|
|
110
|
+
(agent as unknown as { session: unknown }).session = session;
|
|
111
|
+
(agent as unknown as { sessionId: string }).sessionId = sessionId;
|
|
112
|
+
|
|
113
|
+
return { session, oldQuery, endSpy, abortController };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const freshMcpServers = [
|
|
117
|
+
{
|
|
118
|
+
name: "posthog",
|
|
119
|
+
type: "http" as const,
|
|
120
|
+
url: "https://fresh",
|
|
121
|
+
headers: [{ name: "x-foo", value: "bar" }],
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
describe("ClaudeAcpAgent.extMethod refresh_session", () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
lastQueryCall.options = undefined;
|
|
128
|
+
createdQueries.length = 0;
|
|
129
|
+
nextInitPromise = Promise.resolve({
|
|
130
|
+
result: "success",
|
|
131
|
+
commands: [],
|
|
132
|
+
models: [],
|
|
133
|
+
});
|
|
134
|
+
fetchMcpToolMetadataMock.mockClear();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns methodNotFound for unknown extension methods", async () => {
|
|
138
|
+
const agent = makeAgent();
|
|
139
|
+
await expect(agent.extMethod("_posthog/nope", {})).rejects.toThrow(
|
|
140
|
+
/Method not found/i,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects when payload has no refreshable fields", async () => {
|
|
145
|
+
const agent = makeAgent();
|
|
146
|
+
installFakeSession(agent, "s-empty");
|
|
147
|
+
|
|
148
|
+
await expect(
|
|
149
|
+
agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {}),
|
|
150
|
+
).rejects.toThrow(/requires at least one refreshable field/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("rejects when mcpServers is not an array", async () => {
|
|
154
|
+
const agent = makeAgent();
|
|
155
|
+
installFakeSession(agent, "s-malformed");
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
|
|
159
|
+
mcpServers: "not-an-array",
|
|
160
|
+
}),
|
|
161
|
+
).rejects.toThrow(/mcpServers must be an array/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("rejects refresh while a prompt is in flight", async () => {
|
|
165
|
+
const agent = makeAgent();
|
|
166
|
+
const { session } = installFakeSession(agent, "s-1");
|
|
167
|
+
(session as unknown as { promptRunning: boolean }).promptRunning = true;
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
|
|
171
|
+
mcpServers: freshMcpServers,
|
|
172
|
+
}),
|
|
173
|
+
).rejects.toThrow(/prompt turn is in flight/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rejects when session model does not support MCP injection", async () => {
|
|
177
|
+
const agent = makeAgent();
|
|
178
|
+
installFakeSession(agent, "s-haiku", { modelId: "claude-haiku-4-5" });
|
|
179
|
+
|
|
180
|
+
await expect(
|
|
181
|
+
agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
|
|
182
|
+
mcpServers: freshMcpServers,
|
|
183
|
+
}),
|
|
184
|
+
).rejects.toThrow(/does not support MCP injection/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("throws when initialization of the new query times out", async () => {
|
|
188
|
+
vi.useFakeTimers();
|
|
189
|
+
try {
|
|
190
|
+
const agent = makeAgent();
|
|
191
|
+
installFakeSession(agent, "s-timeout");
|
|
192
|
+
// Never resolves — withTimeout must win the race.
|
|
193
|
+
nextInitPromise = new Promise<InitResult>(() => {});
|
|
194
|
+
|
|
195
|
+
const promise = agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
|
|
196
|
+
mcpServers: freshMcpServers,
|
|
197
|
+
});
|
|
198
|
+
// Drop the rejection on the floor so an unhandled-rejection warning
|
|
199
|
+
// doesn't race the assertion below.
|
|
200
|
+
promise.catch(() => {});
|
|
201
|
+
|
|
202
|
+
await vi.advanceTimersByTimeAsync(30_001);
|
|
203
|
+
|
|
204
|
+
await expect(promise).rejects.toThrow(
|
|
205
|
+
/Session refresh timed out for s-timeout/,
|
|
206
|
+
);
|
|
207
|
+
} finally {
|
|
208
|
+
vi.useRealTimers();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("swaps query/input/options and preserves session state", async () => {
|
|
213
|
+
const agent = makeAgent();
|
|
214
|
+
const { session, oldQuery, endSpy } = installFakeSession(agent, "s-2");
|
|
215
|
+
|
|
216
|
+
const result = await agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
|
|
217
|
+
mcpServers: freshMcpServers,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(result).toEqual({ refreshed: true });
|
|
221
|
+
expect(oldQuery.interrupt).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(endSpy).toHaveBeenCalledTimes(1);
|
|
223
|
+
|
|
224
|
+
// New query was built with resume identity (not sessionId) and new servers
|
|
225
|
+
expect(lastQueryCall.options).toMatchObject({
|
|
226
|
+
resume: "s-2",
|
|
227
|
+
forkSession: false,
|
|
228
|
+
mcpServers: {
|
|
229
|
+
posthog: {
|
|
230
|
+
type: "http",
|
|
231
|
+
url: "https://fresh",
|
|
232
|
+
headers: { "x-foo": "bar" },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
expect(lastQueryCall.options?.sessionId).toBeUndefined();
|
|
237
|
+
|
|
238
|
+
// Session fields swapped to the new instances
|
|
239
|
+
const updated = session as unknown as {
|
|
240
|
+
query: SdkQueryHandle;
|
|
241
|
+
input: unknown;
|
|
242
|
+
queryOptions: Record<string, unknown>;
|
|
243
|
+
accumulatedUsage: { inputTokens: number };
|
|
244
|
+
notificationHistory: unknown[];
|
|
245
|
+
};
|
|
246
|
+
expect(updated.query).toBe(createdQueries[0]);
|
|
247
|
+
expect(updated.query).not.toBe(oldQuery);
|
|
248
|
+
expect(updated.input).toBeInstanceOf(Pushable);
|
|
249
|
+
expect(updated.queryOptions).toBe(lastQueryCall.options);
|
|
250
|
+
|
|
251
|
+
// Preserves session-level state (usage, notification history)
|
|
252
|
+
expect(updated.accumulatedUsage.inputTokens).toBe(42);
|
|
253
|
+
expect(updated.notificationHistory).toEqual([{ foo: "bar" }]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("aborts the old controller and allocates a fresh one for the new query", async () => {
|
|
257
|
+
const agent = makeAgent();
|
|
258
|
+
const { session, abortController: oldController } = installFakeSession(
|
|
259
|
+
agent,
|
|
260
|
+
"s-abort",
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
|
|
264
|
+
mcpServers: freshMcpServers,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(oldController.signal.aborted).toBe(true);
|
|
268
|
+
|
|
269
|
+
const updated = session as unknown as {
|
|
270
|
+
abortController: AbortController;
|
|
271
|
+
queryOptions: { abortController: AbortController };
|
|
272
|
+
};
|
|
273
|
+
expect(updated.abortController).not.toBe(oldController);
|
|
274
|
+
expect(updated.abortController.signal.aborted).toBe(false);
|
|
275
|
+
expect(updated.queryOptions.abortController).toBe(updated.abortController);
|
|
276
|
+
expect(lastQueryCall.options?.abortController).toBe(
|
|
277
|
+
updated.abortController,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("re-fetches MCP tool metadata for the new query", async () => {
|
|
282
|
+
const agent = makeAgent();
|
|
283
|
+
installFakeSession(agent, "s-metadata");
|
|
284
|
+
|
|
285
|
+
await agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, {
|
|
286
|
+
mcpServers: freshMcpServers,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(fetchMcpToolMetadataMock).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(fetchMcpToolMetadataMock.mock.calls[0][0]).toBe(createdQueries[0]);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -38,13 +38,19 @@ import {
|
|
|
38
38
|
type CanUseTool,
|
|
39
39
|
getSessionMessages,
|
|
40
40
|
listSessions,
|
|
41
|
+
type McpServerConfig,
|
|
42
|
+
type Options,
|
|
41
43
|
type Query,
|
|
42
44
|
query,
|
|
43
45
|
type SDKUserMessage,
|
|
44
46
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
45
47
|
import { v7 as uuidv7 } from "uuid";
|
|
46
48
|
import packageJson from "../../../package.json" with { type: "json" };
|
|
47
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
isMethod,
|
|
51
|
+
POSTHOG_METHODS,
|
|
52
|
+
POSTHOG_NOTIFICATIONS,
|
|
53
|
+
} from "../../acp-extensions";
|
|
48
54
|
import { unreachable, withTimeout } from "../../utils/common";
|
|
49
55
|
import { Logger } from "../../utils/logger";
|
|
50
56
|
import { Pushable } from "../../utils/streams";
|
|
@@ -652,6 +658,120 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
652
658
|
await this.session.query.interrupt();
|
|
653
659
|
}
|
|
654
660
|
|
|
661
|
+
/**
|
|
662
|
+
* Refresh the session between turns. Currently the only refreshable field
|
|
663
|
+
* is `mcpServers` — a resume-with-new-options reinit that bakes the servers
|
|
664
|
+
* into query() options (preserving conversation history via resume).
|
|
665
|
+
*
|
|
666
|
+
* This is an `extMethod` (request/response), not `extNotification`, so the
|
|
667
|
+
* caller can await completion before sending the next prompt. The sandbox
|
|
668
|
+
* agent-server uses this on pre-prompt TTL checks.
|
|
669
|
+
*
|
|
670
|
+
* Why resume+rebuild instead of query.setMcpServers()?
|
|
671
|
+
* setMcpServers() does NOT always overwrite servers installed by local/plugin
|
|
672
|
+
* config — it can non-deterministically surface either the config-provided
|
|
673
|
+
* server or the plugin-installed one. In the sandbox, repos may have Claude
|
|
674
|
+
* plugins with their own MCPs, and we want the CLI-supplied set to fully win.
|
|
675
|
+
* Passing mcpServers via query() options (as a "managed"/static set) has that
|
|
676
|
+
* overwrite guarantee, so we tear down the current Query and construct a new
|
|
677
|
+
* one with resume.
|
|
678
|
+
*
|
|
679
|
+
* Caller contract: only call REFRESH_SESSION between turns (no prompt in flight).
|
|
680
|
+
*/
|
|
681
|
+
async extMethod(
|
|
682
|
+
method: string,
|
|
683
|
+
params: Record<string, unknown>,
|
|
684
|
+
): Promise<Record<string, unknown>> {
|
|
685
|
+
if (!isMethod(method, POSTHOG_METHODS.REFRESH_SESSION)) {
|
|
686
|
+
throw RequestError.methodNotFound(method);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Trust boundary: refresh is only safe when the caller is trusted infra
|
|
690
|
+
// (e.g. the sandbox agent-server). Do not route this method from
|
|
691
|
+
// untrusted clients — parseMcpServers does no URL/command validation.
|
|
692
|
+
if (params.mcpServers === undefined) {
|
|
693
|
+
throw new RequestError(
|
|
694
|
+
-32602,
|
|
695
|
+
"refresh_session requires at least one refreshable field (e.g. mcpServers)",
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
if (!Array.isArray(params.mcpServers)) {
|
|
699
|
+
throw new RequestError(
|
|
700
|
+
-32602,
|
|
701
|
+
"refresh_session: mcpServers must be an array",
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const mcpServers = parseMcpServers(
|
|
706
|
+
params as Pick<NewSessionRequest, "mcpServers">,
|
|
707
|
+
);
|
|
708
|
+
await this.refreshSession(mcpServers);
|
|
709
|
+
return { refreshed: true };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private async refreshSession(
|
|
713
|
+
mcpServers: Record<string, McpServerConfig>,
|
|
714
|
+
): Promise<void> {
|
|
715
|
+
const prev = this.session;
|
|
716
|
+
if (prev.promptRunning) {
|
|
717
|
+
throw new RequestError(
|
|
718
|
+
-32002,
|
|
719
|
+
"Cannot refresh session while a prompt turn is in flight",
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (prev.modelId && !supportsMcpInjection(prev.modelId)) {
|
|
723
|
+
throw new RequestError(
|
|
724
|
+
-32002,
|
|
725
|
+
`Model ${prev.modelId} does not support MCP injection; cannot refresh`,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
this.logger.info("Refreshing session with fresh MCP servers", {
|
|
730
|
+
serverCount: Object.keys(mcpServers).length,
|
|
731
|
+
sessionId: this.sessionId,
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Abort FIRST so any stuck in-flight HTTP request unblocks — otherwise
|
|
735
|
+
// interrupt() can deadlock waiting on an API call that never returns.
|
|
736
|
+
// We allocate a fresh controller for the new Query below so aborting
|
|
737
|
+
// the old one doesn't poison it.
|
|
738
|
+
prev.abortController.abort();
|
|
739
|
+
await prev.query.interrupt();
|
|
740
|
+
prev.input.end();
|
|
741
|
+
|
|
742
|
+
// Reuse every option from the running session; swap mcpServers, re-root
|
|
743
|
+
// identity on `resume` instead of `sessionId`, and give the new Query a
|
|
744
|
+
// fresh AbortController.
|
|
745
|
+
const newAbortController = new AbortController();
|
|
746
|
+
const { sessionId: _drop, ...rest } = prev.queryOptions;
|
|
747
|
+
const newOptions: Options = {
|
|
748
|
+
...rest,
|
|
749
|
+
mcpServers,
|
|
750
|
+
resume: this.sessionId,
|
|
751
|
+
forkSession: false,
|
|
752
|
+
abortController: newAbortController,
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const newInput = new Pushable<SDKUserMessage>();
|
|
756
|
+
const newQuery = query({ prompt: newInput, options: newOptions });
|
|
757
|
+
|
|
758
|
+
prev.query = newQuery;
|
|
759
|
+
prev.input = newInput;
|
|
760
|
+
prev.queryOptions = newOptions;
|
|
761
|
+
prev.abortController = newAbortController;
|
|
762
|
+
|
|
763
|
+
const result = await withTimeout(
|
|
764
|
+
newQuery.initializationResult(),
|
|
765
|
+
SESSION_VALIDATION_TIMEOUT_MS,
|
|
766
|
+
);
|
|
767
|
+
if (result.result === "timeout") {
|
|
768
|
+
throw new Error(`Session refresh timed out for ${this.sessionId}`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Re-fetch MCP tool metadata + slash commands — the server list changed.
|
|
772
|
+
this.deferBackgroundFetches(newQuery);
|
|
773
|
+
}
|
|
774
|
+
|
|
655
775
|
async unstable_setSessionModel(
|
|
656
776
|
params: SetSessionModelRequest,
|
|
657
777
|
): Promise<SetSessionModelResponse | undefined> {
|
|
@@ -60,20 +60,32 @@ describe("CodexAcpAgent", () => {
|
|
|
60
60
|
vi.clearAllMocks();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
function createAgent(
|
|
63
|
+
function createAgent(overrides: Partial<AgentSideConnection> = {}): {
|
|
64
|
+
agent: CodexAcpAgent;
|
|
65
|
+
client: AgentSideConnection & {
|
|
66
|
+
extNotification: ReturnType<typeof vi.fn>;
|
|
67
|
+
sessionUpdate: ReturnType<typeof vi.fn>;
|
|
68
|
+
};
|
|
69
|
+
} {
|
|
64
70
|
const client = {
|
|
65
71
|
extNotification: vi.fn(),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
sessionUpdate: vi.fn(),
|
|
73
|
+
...overrides,
|
|
74
|
+
} as unknown as AgentSideConnection & {
|
|
75
|
+
extNotification: ReturnType<typeof vi.fn>;
|
|
76
|
+
sessionUpdate: ReturnType<typeof vi.fn>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const agent = new CodexAcpAgent(client, {
|
|
69
80
|
codexProcessOptions: {
|
|
70
81
|
cwd: process.cwd(),
|
|
71
82
|
},
|
|
72
83
|
});
|
|
84
|
+
return { agent, client };
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
it("applies the requested initial mode for a new session", async () => {
|
|
76
|
-
const agent = createAgent();
|
|
88
|
+
const { agent } = createAgent();
|
|
77
89
|
mockCodexConnection.newSession.mockResolvedValue({
|
|
78
90
|
sessionId: "session-1",
|
|
79
91
|
modes: { currentModeId: "auto", availableModes: [] },
|
|
@@ -96,7 +108,7 @@ describe("CodexAcpAgent", () => {
|
|
|
96
108
|
});
|
|
97
109
|
|
|
98
110
|
it("preserves the live session mode when loading an existing session", async () => {
|
|
99
|
-
const agent = createAgent();
|
|
111
|
+
const { agent } = createAgent();
|
|
100
112
|
mockCodexConnection.loadSession.mockResolvedValue({
|
|
101
113
|
modes: { currentModeId: "read-only", availableModes: [] },
|
|
102
114
|
configOptions: [],
|
|
@@ -114,4 +126,53 @@ describe("CodexAcpAgent", () => {
|
|
|
114
126
|
.sessionState.permissionMode,
|
|
115
127
|
).toBe("read-only");
|
|
116
128
|
});
|
|
129
|
+
|
|
130
|
+
it("broadcasts user prompt as user_message_chunk before delegating to codex-acp", async () => {
|
|
131
|
+
const { agent, client } = createAgent();
|
|
132
|
+
// Seed an active session so prompt() has the state it expects.
|
|
133
|
+
mockCodexConnection.newSession.mockResolvedValue({
|
|
134
|
+
sessionId: "session-1",
|
|
135
|
+
modes: { currentModeId: "auto", availableModes: [] },
|
|
136
|
+
configOptions: [],
|
|
137
|
+
} satisfies Partial<NewSessionResponse>);
|
|
138
|
+
await agent.newSession({
|
|
139
|
+
cwd: process.cwd(),
|
|
140
|
+
} as never);
|
|
141
|
+
|
|
142
|
+
const callOrder: string[] = [];
|
|
143
|
+
client.sessionUpdate.mockImplementation(async () => {
|
|
144
|
+
callOrder.push("sessionUpdate");
|
|
145
|
+
});
|
|
146
|
+
mockCodexConnection.prompt.mockImplementation(async () => {
|
|
147
|
+
callOrder.push("prompt");
|
|
148
|
+
return { stopReason: "end_turn" };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await agent.prompt({
|
|
152
|
+
sessionId: "session-1",
|
|
153
|
+
prompt: [
|
|
154
|
+
{ type: "text", text: "first chunk" },
|
|
155
|
+
{ type: "text", text: "second chunk" },
|
|
156
|
+
],
|
|
157
|
+
} as never);
|
|
158
|
+
|
|
159
|
+
expect(client.sessionUpdate).toHaveBeenCalledTimes(2);
|
|
160
|
+
expect(client.sessionUpdate).toHaveBeenNthCalledWith(1, {
|
|
161
|
+
sessionId: "session-1",
|
|
162
|
+
update: {
|
|
163
|
+
sessionUpdate: "user_message_chunk",
|
|
164
|
+
content: { type: "text", text: "first chunk" },
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
expect(client.sessionUpdate).toHaveBeenNthCalledWith(2, {
|
|
168
|
+
sessionId: "session-1",
|
|
169
|
+
update: {
|
|
170
|
+
sessionUpdate: "user_message_chunk",
|
|
171
|
+
content: { type: "text", text: "second chunk" },
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
// Broadcast must land before the prompt reaches codex-acp so the user
|
|
175
|
+
// turn is persisted even if the underlying prompt fails.
|
|
176
|
+
expect(callOrder).toEqual(["sessionUpdate", "sessionUpdate", "prompt"]);
|
|
177
|
+
});
|
|
117
178
|
});
|