@vellumai/assistant 0.8.9-staging.1 → 0.8.9-staging.2
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/ARCHITECTURE.md +6 -6
- package/openapi.yaml +6 -7
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +33 -18
- package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
- package/src/__tests__/app-executors.test.ts +132 -0
- package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
- package/src/__tests__/context-overflow-reducer.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +19 -16
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +48 -29
- package/src/__tests__/conversation-agent-loop.test.ts +48 -96
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +52 -49
- package/src/__tests__/memory-retrieval-hook.test.ts +68 -30
- package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
- package/src/acp/__tests__/helpers/exec-file-stub.ts +12 -7
- package/src/acp/__tests__/prepare-agent-env.test.ts +0 -40
- package/src/acp/__tests__/session-manager-resume.test.ts +179 -27
- package/src/acp/auto-install.test.ts +130 -46
- package/src/acp/auto-install.ts +86 -31
- package/src/acp/prepare-agent-env.ts +8 -9
- package/src/acp/resolve-agent.test.ts +26 -123
- package/src/acp/resolve-agent.ts +26 -85
- package/src/acp/resume-hint.ts +1 -3
- package/src/acp/session-manager.ts +32 -20
- package/src/acp/types.ts +0 -8
- package/src/agent/loop.ts +42 -26
- package/src/config/bundled-skills/acp/SKILL.md +14 -14
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
- package/src/config/bundled-skills/app-builder/TOOLS.json +43 -2
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/schemas/call-site-catalog.ts +0 -14
- package/src/config/schemas/llm.ts +0 -2
- package/src/context/compactor.ts +22 -4
- package/src/daemon/conversation-agent-loop.ts +15 -57
- package/src/daemon/conversation.ts +23 -3
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +0 -7
- package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
- package/src/plugins/defaults/compaction/manager-store.ts +13 -0
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +6 -23
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +24 -25
- package/src/runtime/routes/__tests__/acp-routes.test.ts +71 -39
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +29 -4
- package/src/runtime/routes/plugins-routes.ts +10 -9
- package/src/tools/acp/list-agents.test.ts +2 -2
- package/src/tools/acp/spawn.test.ts +103 -225
- package/src/tools/acp/spawn.ts +10 -120
- package/src/tools/apps/executors.ts +153 -42
- package/src/proactive-artifact/aux-message-injector.ts +0 -97
- package/src/proactive-artifact/decision.test.ts +0 -226
- package/src/proactive-artifact/decision.ts +0 -165
- package/src/proactive-artifact/index.ts +0 -7
- package/src/proactive-artifact/job.test.ts +0 -962
- package/src/proactive-artifact/job.ts +0 -372
- package/src/proactive-artifact/message-copy.ts +0 -58
- package/src/proactive-artifact/trigger-state.test.ts +0 -286
- package/src/proactive-artifact/trigger-state.ts +0 -123
|
@@ -15,8 +15,12 @@ const {
|
|
|
15
15
|
reset: resetExecFileStub,
|
|
16
16
|
} = installExecFileStub();
|
|
17
17
|
|
|
18
|
+
/** Fixed resolved `bun` path so install script keys are predictable. */
|
|
19
|
+
const BUN_BIN = "/usr/local/bin/bun";
|
|
20
|
+
const BUN_ADD_KEY = `${BUN_BIN} add`;
|
|
21
|
+
|
|
18
22
|
// Default ACP config used by these tests: the `unknown-agent` entry is here
|
|
19
|
-
// to give the "
|
|
23
|
+
// to give the "unmapped binary" tests a configured agent whose command isn't
|
|
20
24
|
// in DEFAULT_AGENT_NPM_PACKAGES.
|
|
21
25
|
const DEFAULT_TEST_AGENTS = {
|
|
22
26
|
claude: { command: "claude-agent-acp", args: [] },
|
|
@@ -143,8 +147,7 @@ mock.module("../../acp/index.js", () => ({
|
|
|
143
147
|
getAcpSessionManager: () => ({ spawn: spawnMock }),
|
|
144
148
|
}));
|
|
145
149
|
|
|
146
|
-
const { executeAcpSpawn
|
|
147
|
-
await import("./spawn.js");
|
|
150
|
+
const { executeAcpSpawn } = await import("./spawn.js");
|
|
148
151
|
const { _resetAdapterInstallCacheForTests } = await import(
|
|
149
152
|
"../../acp/auto-install.js"
|
|
150
153
|
);
|
|
@@ -165,9 +168,10 @@ function makeContext(): ToolContext {
|
|
|
165
168
|
beforeEach(() => {
|
|
166
169
|
resetExecFileStub();
|
|
167
170
|
spawnMock.mockClear();
|
|
168
|
-
_resetAdapterVersionCacheForTests();
|
|
169
171
|
_resetAdapterInstallCacheForTests();
|
|
170
172
|
config.setConfig({ agents: DEFAULT_TEST_AGENTS });
|
|
173
|
+
// Default: every command (including bun and the adapters) on PATH, so
|
|
174
|
+
// spawns resolve directly with no install.
|
|
171
175
|
which.setWhich((cmd) => `/usr/local/bin/${cmd}`);
|
|
172
176
|
// Default: vault has a claude token so the preflight in `prepareAgentEnv`
|
|
173
177
|
// succeeds for tests that don't care about env injection. Env-injection
|
|
@@ -181,135 +185,41 @@ beforeEach(() => {
|
|
|
181
185
|
// Tests
|
|
182
186
|
// ---------------------------------------------------------------------------
|
|
183
187
|
|
|
184
|
-
describe("executeAcpSpawn
|
|
185
|
-
test("
|
|
186
|
-
execScripts.set("npm ls", { error: new Error("npm not installed") });
|
|
187
|
-
execScripts.set("npm view", { error: new Error("npm not installed") });
|
|
188
|
-
|
|
188
|
+
describe("executeAcpSpawn - happy path", () => {
|
|
189
|
+
test("binary on PATH: spawns directly with no install and no npm probe", async () => {
|
|
189
190
|
const result = await executeAcpSpawn(
|
|
190
191
|
{ agent: "claude", task: "do something" },
|
|
191
192
|
makeContext(),
|
|
192
193
|
);
|
|
193
194
|
|
|
194
195
|
expect(result.isError).toBe(false);
|
|
196
|
+
// No package manager is ever invoked when the binary is already present.
|
|
197
|
+
expect(execFileMock).not.toHaveBeenCalled();
|
|
195
198
|
expect(result.content).not.toContain("outdated");
|
|
196
|
-
expect(result.content).not.toContain("Note:");
|
|
197
|
-
// Spawn was actually invoked.
|
|
198
199
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
199
|
-
|
|
200
|
-
const lines = result.content.split("\n\n");
|
|
201
|
-
const payload = JSON.parse(lines[0]);
|
|
200
|
+
const payload = JSON.parse(result.content);
|
|
202
201
|
expect(payload.acpSessionId).toBe("acp-session-test");
|
|
203
202
|
expect(payload.status).toBe("running");
|
|
203
|
+
// The real adapter binary is spawned (no `bun x` wrapper).
|
|
204
|
+
const agentConfigArg = spawnMock.mock.calls[0][1] as { command: string };
|
|
205
|
+
expect(agentConfigArg.command).toBe("claude-agent-acp");
|
|
204
206
|
});
|
|
205
207
|
|
|
206
|
-
test("
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
},
|
|
212
|
-
}),
|
|
213
|
-
});
|
|
214
|
-
execScripts.set("npm view", { stdout: "0.2.0\n" });
|
|
215
|
-
|
|
216
|
-
const result = await executeAcpSpawn(
|
|
217
|
-
{ agent: "claude", task: "do something" },
|
|
218
|
-
makeContext(),
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
expect(result.isError).toBe(false);
|
|
222
|
-
expect(result.content).toContain("outdated");
|
|
223
|
-
expect(result.content).toContain("@agentclientprotocol/claude-agent-acp");
|
|
224
|
-
expect(result.content).toContain("0.1.0");
|
|
225
|
-
expect(result.content).toContain("0.2.0");
|
|
226
|
-
expect(result.content).toContain(
|
|
227
|
-
"npm install -g @agentclientprotocol/claude-agent-acp@0.2.0",
|
|
228
|
-
);
|
|
229
|
-
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
230
|
-
// Payload still parses as JSON before the warning suffix.
|
|
231
|
-
const [payloadJson] = result.content.split("\n\n");
|
|
232
|
-
const payload = JSON.parse(payloadJson);
|
|
233
|
-
expect(payload.acpSessionId).toBe("acp-session-test");
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test("up-to-date version: spawn proceeds, no warning", async () => {
|
|
237
|
-
execScripts.set("npm ls", {
|
|
238
|
-
stdout: JSON.stringify({
|
|
239
|
-
dependencies: {
|
|
240
|
-
"@agentclientprotocol/claude-agent-acp": { version: "0.2.0" },
|
|
241
|
-
},
|
|
242
|
-
}),
|
|
243
|
-
});
|
|
244
|
-
execScripts.set("npm view", { stdout: "0.2.0\n" });
|
|
245
|
-
|
|
246
|
-
const result = await executeAcpSpawn(
|
|
247
|
-
{ agent: "claude", task: "do something" },
|
|
248
|
-
makeContext(),
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
expect(result.isError).toBe(false);
|
|
252
|
-
expect(result.content).not.toContain("outdated");
|
|
253
|
-
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
test("unknown command: no version check is performed", async () => {
|
|
257
|
-
// No execScripts set — if the implementation tried to run npm, the
|
|
258
|
-
// mock would callback with "No script for ..." and we could detect
|
|
259
|
-
// the failure. But since the registry doesn't include this command,
|
|
260
|
-
// the implementation should skip the check entirely without calling
|
|
261
|
-
// execFile.
|
|
262
|
-
execFileMock.mockClear();
|
|
208
|
+
test("default-profile fallback when user config is empty", async () => {
|
|
209
|
+
// No user `agents.codex` entry, but `agent: "codex"` works via the bundled
|
|
210
|
+
// default profile (command: "codex-acp"). The resolver merges defaults
|
|
211
|
+
// automatically.
|
|
212
|
+
config.setConfig({ agents: {} });
|
|
263
213
|
|
|
264
214
|
const result = await executeAcpSpawn(
|
|
265
|
-
{ agent: "
|
|
215
|
+
{ agent: "codex", task: "do something" },
|
|
266
216
|
makeContext(),
|
|
267
217
|
);
|
|
268
218
|
|
|
269
219
|
expect(result.isError).toBe(false);
|
|
270
|
-
expect(execFileMock).not.toHaveBeenCalled();
|
|
271
220
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
272
|
-
|
|
273
|
-
expect(
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("cached null result: second call does not reprobe", async () => {
|
|
277
|
-
execScripts.set("npm ls", { error: new Error("npm not installed") });
|
|
278
|
-
execScripts.set("npm view", { error: new Error("npm not installed") });
|
|
279
|
-
|
|
280
|
-
await executeAcpSpawn({ agent: "claude", task: "task 1" }, makeContext());
|
|
281
|
-
const firstCallCount = execFileMock.mock.calls.length;
|
|
282
|
-
expect(firstCallCount).toBeGreaterThan(0);
|
|
283
|
-
|
|
284
|
-
await executeAcpSpawn({ agent: "claude", task: "task 2" }, makeContext());
|
|
285
|
-
// No additional execFile calls — result was cached.
|
|
286
|
-
expect(execFileMock.mock.calls.length).toBe(firstCallCount);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test("cached outdated result: second call does not reprobe but still warns", async () => {
|
|
290
|
-
execScripts.set("npm ls", {
|
|
291
|
-
stdout: JSON.stringify({
|
|
292
|
-
dependencies: {
|
|
293
|
-
"@agentclientprotocol/claude-agent-acp": { version: "0.1.0" },
|
|
294
|
-
},
|
|
295
|
-
}),
|
|
296
|
-
});
|
|
297
|
-
execScripts.set("npm view", { stdout: "0.2.0\n" });
|
|
298
|
-
|
|
299
|
-
const first = await executeAcpSpawn(
|
|
300
|
-
{ agent: "claude", task: "task 1" },
|
|
301
|
-
makeContext(),
|
|
302
|
-
);
|
|
303
|
-
expect(first.content).toContain("outdated");
|
|
304
|
-
const firstCallCount = execFileMock.mock.calls.length;
|
|
305
|
-
|
|
306
|
-
const second = await executeAcpSpawn(
|
|
307
|
-
{ agent: "claude", task: "task 2" },
|
|
308
|
-
makeContext(),
|
|
309
|
-
);
|
|
310
|
-
expect(second.content).toContain("outdated");
|
|
311
|
-
// No additional execFile calls — outdated info was cached.
|
|
312
|
-
expect(execFileMock.mock.calls.length).toBe(firstCallCount);
|
|
221
|
+
const agentConfigArg = spawnMock.mock.calls[0][1] as { command: string };
|
|
222
|
+
expect(agentConfigArg.command).toBe("codex-acp");
|
|
313
223
|
});
|
|
314
224
|
});
|
|
315
225
|
|
|
@@ -350,9 +260,8 @@ describe("executeAcpSpawn — input validation", () => {
|
|
|
350
260
|
expect(result.content).toContain("acp.enabled");
|
|
351
261
|
});
|
|
352
262
|
|
|
353
|
-
test("missing binary returns install hint
|
|
354
|
-
which.setWhich({});
|
|
355
|
-
execScripts.set("npm i", { error: new Error("npm not installed") });
|
|
263
|
+
test("missing binary + bun absent returns install hint, no install attempted", async () => {
|
|
264
|
+
which.setWhich({}); // neither bun nor the adapter on PATH
|
|
356
265
|
const result = await executeAcpSpawn(
|
|
357
266
|
{ agent: "claude", task: "do something" },
|
|
358
267
|
makeContext(),
|
|
@@ -360,47 +269,29 @@ describe("executeAcpSpawn — input validation", () => {
|
|
|
360
269
|
expect(result.isError).toBe(true);
|
|
361
270
|
expect(result.content).toContain("claude-agent-acp is not on PATH");
|
|
362
271
|
expect(result.content).toContain(
|
|
363
|
-
"
|
|
272
|
+
"bun add -g @agentclientprotocol/claude-agent-acp",
|
|
364
273
|
);
|
|
274
|
+
expect(execFileMock).not.toHaveBeenCalled();
|
|
365
275
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
366
276
|
});
|
|
367
|
-
|
|
368
|
-
test("default-profile fallback when user config is empty", async () => {
|
|
369
|
-
// No user `agents.codex` entry, but `agent: "codex"` works via the bundled
|
|
370
|
-
// default profile (command: "codex-acp"). The resolver merges defaults
|
|
371
|
-
// automatically.
|
|
372
|
-
config.setConfig({ agents: {} });
|
|
373
|
-
execScripts.set("npm ls", { error: new Error("npm not installed") });
|
|
374
|
-
execScripts.set("npm view", { error: new Error("npm not installed") });
|
|
375
|
-
|
|
376
|
-
const result = await executeAcpSpawn(
|
|
377
|
-
{ agent: "codex", task: "do something" },
|
|
378
|
-
makeContext(),
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
expect(result.isError).toBe(false);
|
|
382
|
-
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
383
|
-
// The agentConfig handed to spawn() should be the bundled default.
|
|
384
|
-
const agentConfigArg = spawnMock.mock.calls[0][1] as { command: string };
|
|
385
|
-
expect(agentConfigArg.command).toBe("codex-acp");
|
|
386
|
-
});
|
|
387
277
|
});
|
|
388
278
|
|
|
389
|
-
describe("executeAcpSpawn: auto-install on missing binary", () => {
|
|
390
|
-
test("known command: installs
|
|
391
|
-
//
|
|
392
|
-
// successful global install.
|
|
279
|
+
describe("executeAcpSpawn: sandboxed bun auto-install on missing binary", () => {
|
|
280
|
+
test("known command + bun present: installs via bun and spawn proceeds with a note", async () => {
|
|
281
|
+
// Only bun is on PATH until `bun add --global` runs, simulating a
|
|
282
|
+
// successful global install that links the adapter bin onto PATH.
|
|
393
283
|
let binaryOnPath = false;
|
|
394
|
-
which.setWhich((cmd) =>
|
|
395
|
-
|
|
284
|
+
which.setWhich((cmd) => {
|
|
285
|
+
if (cmd === "bun") return BUN_BIN;
|
|
286
|
+
if (binaryOnPath) return `/usr/local/bin/${cmd}`;
|
|
287
|
+
return null;
|
|
288
|
+
});
|
|
289
|
+
execScripts.set(BUN_ADD_KEY, {
|
|
396
290
|
stdout: "",
|
|
397
291
|
onCall: () => {
|
|
398
292
|
binaryOnPath = true;
|
|
399
293
|
},
|
|
400
294
|
});
|
|
401
|
-
// Version probes after the install: best-effort, scripted to skip.
|
|
402
|
-
execScripts.set("npm ls", { error: new Error("skip") });
|
|
403
|
-
execScripts.set("npm view", { error: new Error("skip") });
|
|
404
295
|
|
|
405
296
|
const result = await executeAcpSpawn(
|
|
406
297
|
{ agent: "claude", task: "do something" },
|
|
@@ -409,23 +300,67 @@ describe("executeAcpSpawn: auto-install on missing binary", () => {
|
|
|
409
300
|
|
|
410
301
|
expect(result.isError).toBe(false);
|
|
411
302
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
412
|
-
const
|
|
413
|
-
const payload = JSON.parse(payloadJson);
|
|
303
|
+
const payload = JSON.parse(result.content);
|
|
414
304
|
expect(payload.message).toContain(
|
|
415
305
|
"Installed @agentclientprotocol/claude-agent-acp automatically.",
|
|
416
306
|
);
|
|
417
|
-
|
|
418
|
-
|
|
307
|
+
// The real binary was spawned with cwd = the project dir and token
|
|
308
|
+
// injected (trusted-binary config, no resolution at spawn).
|
|
309
|
+
const agentConfigArg = spawnMock.mock.calls[0][1] as {
|
|
310
|
+
command: string;
|
|
311
|
+
env?: Record<string, string>;
|
|
312
|
+
};
|
|
313
|
+
expect(agentConfigArg.command).toBe("claude-agent-acp");
|
|
314
|
+
expect(agentConfigArg.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe(
|
|
315
|
+
"default-test-token",
|
|
419
316
|
);
|
|
420
|
-
expect(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
317
|
+
expect(spawnMock.mock.calls[0][3]).toBe("/tmp");
|
|
318
|
+
|
|
319
|
+
// Exactly one install, and it was `bun add --global` (never npm).
|
|
320
|
+
expect(execFileMock).toHaveBeenCalledTimes(1);
|
|
321
|
+
const [command, args] = execFileMock.mock.calls[0];
|
|
322
|
+
expect(command).toBe(BUN_BIN);
|
|
323
|
+
expect(args).toEqual([
|
|
324
|
+
"add",
|
|
325
|
+
"--global",
|
|
424
326
|
"@agentclientprotocol/claude-agent-acp",
|
|
425
327
|
]);
|
|
426
328
|
});
|
|
427
329
|
|
|
428
|
-
test("
|
|
330
|
+
test("the installer cwd is a temp dir (not the project cwd) with secrets stripped", async () => {
|
|
331
|
+
let binaryOnPath = false;
|
|
332
|
+
which.setWhich((cmd) => {
|
|
333
|
+
if (cmd === "bun") return BUN_BIN;
|
|
334
|
+
if (binaryOnPath) return `/usr/local/bin/${cmd}`;
|
|
335
|
+
return null;
|
|
336
|
+
});
|
|
337
|
+
execScripts.set(BUN_ADD_KEY, {
|
|
338
|
+
stdout: "",
|
|
339
|
+
onCall: () => {
|
|
340
|
+
binaryOnPath = true;
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await executeAcpSpawn(
|
|
345
|
+
{ agent: "claude", task: "do something", cwd: "/untrusted/project" },
|
|
346
|
+
makeContext(),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const options = execFileMock.mock.calls[0][2] as {
|
|
350
|
+
cwd?: string;
|
|
351
|
+
env?: NodeJS.ProcessEnv;
|
|
352
|
+
};
|
|
353
|
+
expect(options.cwd).toBeDefined();
|
|
354
|
+
expect(options.cwd).not.toBe("/untrusted/project");
|
|
355
|
+
expect(options.cwd).toContain("vellum-acp-install-");
|
|
356
|
+
expect(options.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
|
357
|
+
expect(options.env?.GEMINI_API_KEY).toBeUndefined();
|
|
358
|
+
expect(options.env?.BUN_CONFIG_REGISTRY).toBe(
|
|
359
|
+
"https://registry.npmjs.org/",
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("unmapped command: no install attempted, plain hint returned", async () => {
|
|
429
364
|
which.setWhich({});
|
|
430
365
|
|
|
431
366
|
const result = await executeAcpSpawn(
|
|
@@ -444,9 +379,9 @@ describe("executeAcpSpawn: auto-install on missing binary", () => {
|
|
|
444
379
|
test("no client connected: no install attempted even when the binary is missing", async () => {
|
|
445
380
|
// The no-client guard is a pure precondition and must run BEFORE the
|
|
446
381
|
// auto-install side effect: without a client the spawn fails anyway, so
|
|
447
|
-
// the host must not be mutated by
|
|
448
|
-
// up to the install timeout).
|
|
449
|
-
which.setWhich({});
|
|
382
|
+
// the host must not be mutated by a global install (which can also block
|
|
383
|
+
// for up to the install timeout).
|
|
384
|
+
which.setWhich({ bun: BUN_BIN });
|
|
450
385
|
|
|
451
386
|
const result = await executeAcpSpawn(
|
|
452
387
|
{ agent: "claude", task: "do something" },
|
|
@@ -459,9 +394,9 @@ describe("executeAcpSpawn: auto-install on missing binary", () => {
|
|
|
459
394
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
460
395
|
});
|
|
461
396
|
|
|
462
|
-
test("
|
|
463
|
-
which.setWhich({});
|
|
464
|
-
execScripts.set(
|
|
397
|
+
test("install failure: hint and install failure both surface, never npm", async () => {
|
|
398
|
+
which.setWhich({ bun: BUN_BIN }); // bun present, adapter never appears
|
|
399
|
+
execScripts.set(BUN_ADD_KEY, {
|
|
465
400
|
error: new Error("EACCES: permission denied"),
|
|
466
401
|
});
|
|
467
402
|
|
|
@@ -473,75 +408,26 @@ describe("executeAcpSpawn: auto-install on missing binary", () => {
|
|
|
473
408
|
expect(result.isError).toBe(true);
|
|
474
409
|
expect(result.content).toContain("claude-agent-acp is not on PATH");
|
|
475
410
|
expect(result.content).toContain(
|
|
476
|
-
"
|
|
411
|
+
"bun add -g @agentclientprotocol/claude-agent-acp",
|
|
477
412
|
);
|
|
478
413
|
expect(result.content).toContain("auto-install failed");
|
|
479
414
|
expect(result.content).toContain("EACCES");
|
|
415
|
+
for (const call of execFileMock.mock.calls) {
|
|
416
|
+
expect(call[0]).not.toBe("npm");
|
|
417
|
+
}
|
|
480
418
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
481
419
|
});
|
|
482
420
|
});
|
|
483
421
|
|
|
484
|
-
describe("executeAcpSpawn - bunx fallback when binary missing", () => {
|
|
485
|
-
test("binary missing + bun present: spawns via `bun x` with env injection and resume hint, no npm calls", async () => {
|
|
486
|
-
// Only bun is on PATH - the platform-hosted image (bun, no node/npm).
|
|
487
|
-
which.setWhich({ bun: "/usr/local/bin/bun" });
|
|
488
|
-
|
|
489
|
-
const result = await executeAcpSpawn(
|
|
490
|
-
{ agent: "claude", task: "do something" },
|
|
491
|
-
makeContext(),
|
|
492
|
-
);
|
|
493
|
-
|
|
494
|
-
expect(result.isError).toBe(false);
|
|
495
|
-
// No npm install AND no npm version probe: bunx fetches the package on
|
|
496
|
-
// first use and there is no global install to compare.
|
|
497
|
-
expect(execFileMock).not.toHaveBeenCalled();
|
|
498
|
-
expect(result.content).not.toContain("outdated");
|
|
499
|
-
|
|
500
|
-
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
501
|
-
const agentConfigArg = spawnMock.mock.calls[0][1] as {
|
|
502
|
-
command: string;
|
|
503
|
-
args: string[];
|
|
504
|
-
adapterCommand?: string;
|
|
505
|
-
env?: Record<string, string>;
|
|
506
|
-
};
|
|
507
|
-
expect(agentConfigArg.command).toBe("bun");
|
|
508
|
-
expect(agentConfigArg.args).toEqual([
|
|
509
|
-
"x",
|
|
510
|
-
"--bun",
|
|
511
|
-
"@agentclientprotocol/claude-agent-acp",
|
|
512
|
-
]);
|
|
513
|
-
expect(agentConfigArg.adapterCommand).toBe("claude-agent-acp");
|
|
514
|
-
// CLAUDE_CODE_OAUTH_TOKEN injection still applies to the bunx-resolved
|
|
515
|
-
// claude adapter (gated on adapterCommand, not the spawn command).
|
|
516
|
-
expect(agentConfigArg.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe(
|
|
517
|
-
"default-test-token",
|
|
518
|
-
);
|
|
519
|
-
|
|
520
|
-
const [payloadJson] = result.content.split("\n\n");
|
|
521
|
-
const payload = JSON.parse(payloadJson);
|
|
522
|
-
// No auto-install happened; the claude resume hint still fires.
|
|
523
|
-
expect(payload.message).not.toContain("Installed");
|
|
524
|
-
expect(payload.message).toContain("claude --resume");
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
// The bun-absent npm fallback is covered by the existing auto-install
|
|
528
|
-
// suite below ("known command: installs the mapped package..."), whose
|
|
529
|
-
// which-stub leaves bun off PATH until the install completes.
|
|
530
|
-
});
|
|
531
|
-
|
|
532
422
|
describe("executeAcpSpawn — per-agent resume hint", () => {
|
|
533
423
|
test("claude payload includes the `claude --resume` hint", async () => {
|
|
534
|
-
execScripts.set("npm ls", { error: new Error("npm not installed") });
|
|
535
|
-
execScripts.set("npm view", { error: new Error("npm not installed") });
|
|
536
|
-
|
|
537
424
|
const result = await executeAcpSpawn(
|
|
538
425
|
{ agent: "claude", task: "do something" },
|
|
539
426
|
makeContext(),
|
|
540
427
|
);
|
|
541
428
|
|
|
542
429
|
expect(result.isError).toBe(false);
|
|
543
|
-
const
|
|
544
|
-
const payload = JSON.parse(payloadJson);
|
|
430
|
+
const payload = JSON.parse(result.content);
|
|
545
431
|
expect(payload.message).toContain("claude --resume");
|
|
546
432
|
expect(payload.message).toContain("To resume:");
|
|
547
433
|
});
|
|
@@ -549,17 +435,13 @@ describe("executeAcpSpawn — per-agent resume hint", () => {
|
|
|
549
435
|
test("non-claude payload omits the `claude --resume` hint", async () => {
|
|
550
436
|
// `claude --resume <id>` is Claude Code-specific. Codex (and any other
|
|
551
437
|
// adapter) should not have that command suggested back to the user.
|
|
552
|
-
execScripts.set("npm ls", { error: new Error("npm not installed") });
|
|
553
|
-
execScripts.set("npm view", { error: new Error("npm not installed") });
|
|
554
|
-
|
|
555
438
|
const result = await executeAcpSpawn(
|
|
556
439
|
{ agent: "codex", task: "do something" },
|
|
557
440
|
makeContext(),
|
|
558
441
|
);
|
|
559
442
|
|
|
560
443
|
expect(result.isError).toBe(false);
|
|
561
|
-
const
|
|
562
|
-
const payload = JSON.parse(payloadJson);
|
|
444
|
+
const payload = JSON.parse(result.content);
|
|
563
445
|
expect(payload.message).not.toContain("claude --resume");
|
|
564
446
|
expect(payload.message).not.toContain("To resume:");
|
|
565
447
|
});
|
|
@@ -585,8 +467,6 @@ describe("executeAcpSpawn — CLAUDE_CODE_OAUTH_TOKEN injection", () => {
|
|
|
585
467
|
vaultStore.clear();
|
|
586
468
|
metadataStore.clear();
|
|
587
469
|
vaultStore.set("acp/claude_oauth_token", "tool-vault-token-abc");
|
|
588
|
-
execScripts.set("npm ls", { error: new Error("npm not installed") });
|
|
589
|
-
execScripts.set("npm view", { error: new Error("npm not installed") });
|
|
590
470
|
|
|
591
471
|
const result = await executeAcpSpawn(
|
|
592
472
|
{ agent: "claude", task: "do something" },
|
|
@@ -617,8 +497,6 @@ describe("executeAcpSpawn — CLAUDE_CODE_OAUTH_TOKEN injection", () => {
|
|
|
617
497
|
"unknown-agent": { command: "some-other-binary", args: [] },
|
|
618
498
|
},
|
|
619
499
|
});
|
|
620
|
-
execScripts.set("npm ls", { error: new Error("npm not installed") });
|
|
621
|
-
execScripts.set("npm view", { error: new Error("npm not installed") });
|
|
622
500
|
|
|
623
501
|
const result = await executeAcpSpawn(
|
|
624
502
|
{ agent: "claude", task: "do something" },
|
package/src/tools/acp/spawn.ts
CHANGED
|
@@ -1,108 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from "../../acp/auto-install.js";
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { resolveAgentWithAutoInstall } from "../../acp/auto-install.js";
|
|
5
4
|
import { getAcpSessionManager } from "../../acp/index.js";
|
|
6
5
|
import { prepareAgentEnv } from "../../acp/prepare-agent-env.js";
|
|
7
|
-
import {
|
|
8
|
-
adapterCommandOf,
|
|
9
|
-
formatResolveFailure,
|
|
10
|
-
runsViaBunx,
|
|
11
|
-
} from "../../acp/resolve-agent.js";
|
|
6
|
+
import { formatResolveFailure } from "../../acp/resolve-agent.js";
|
|
12
7
|
import { claudeResumeHint } from "../../acp/resume-hint.js";
|
|
13
|
-
import { DEFAULT_AGENT_NPM_PACKAGES } from "../../config/acp-defaults.js";
|
|
14
|
-
import { getLogger } from "../../util/logger.js";
|
|
15
8
|
import type { ToolContext, ToolExecutionResult } from "../types.js";
|
|
16
9
|
import { getSendToClient } from "./context.js";
|
|
17
10
|
|
|
18
|
-
const log = getLogger("acp:spawn");
|
|
19
|
-
|
|
20
|
-
/** Per-call timeout for `npm` probes. Best-effort: timeouts are non-fatal. */
|
|
21
|
-
const NPM_PROBE_TIMEOUT_MS = 5_000;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Cache of resolved version-check outcomes — including `null` for "skipped" —
|
|
25
|
-
* keyed by command. Lives for the process lifetime so retries don't reprobe.
|
|
26
|
-
*/
|
|
27
|
-
const adapterVersionCache = new Map<string, AdapterVersionInfo | null>();
|
|
28
|
-
|
|
29
|
-
interface AdapterVersionInfo {
|
|
30
|
-
outdated: true;
|
|
31
|
-
installed: string;
|
|
32
|
-
latest: string;
|
|
33
|
-
packageName: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Checks if the globally-installed ACP adapter for `command` is outdated.
|
|
38
|
-
* Best-effort: any error or timeout returns `null` (skipped). Unknown
|
|
39
|
-
* commands also return `null`. Results are cached per-command for the
|
|
40
|
-
* process lifetime.
|
|
41
|
-
*
|
|
42
|
-
* Note: `npm ls -g` doesn't see Homebrew/tarball installs, so a "not found"
|
|
43
|
-
* here doesn't mean the binary is missing — it just means we can't compare
|
|
44
|
-
* versions. The caller must NEVER block the spawn on this result.
|
|
45
|
-
*/
|
|
46
|
-
async function checkAdapterVersion(
|
|
47
|
-
command: string,
|
|
48
|
-
): Promise<AdapterVersionInfo | null> {
|
|
49
|
-
if (adapterVersionCache.has(command)) {
|
|
50
|
-
return adapterVersionCache.get(command) ?? null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const packageName = DEFAULT_AGENT_NPM_PACKAGES[command];
|
|
54
|
-
if (!packageName) {
|
|
55
|
-
adapterVersionCache.set(command, null);
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const [installedRaw, latestRaw] = await Promise.all([
|
|
61
|
-
execFileWithTimeout(
|
|
62
|
-
"npm",
|
|
63
|
-
["ls", "-g", "--json", packageName],
|
|
64
|
-
NPM_PROBE_TIMEOUT_MS,
|
|
65
|
-
),
|
|
66
|
-
execFileWithTimeout(
|
|
67
|
-
"npm",
|
|
68
|
-
["view", packageName, "version"],
|
|
69
|
-
NPM_PROBE_TIMEOUT_MS,
|
|
70
|
-
),
|
|
71
|
-
]);
|
|
72
|
-
|
|
73
|
-
const installed =
|
|
74
|
-
JSON.parse(installedRaw)?.dependencies?.[packageName]?.version;
|
|
75
|
-
const latest = latestRaw.trim();
|
|
76
|
-
|
|
77
|
-
if (!installed || !latest || installed === latest) {
|
|
78
|
-
adapterVersionCache.set(command, null);
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
log.info({ installed, latest, packageName }, "ACP adapter is outdated");
|
|
83
|
-
const info: AdapterVersionInfo = {
|
|
84
|
-
outdated: true,
|
|
85
|
-
installed,
|
|
86
|
-
latest,
|
|
87
|
-
packageName,
|
|
88
|
-
};
|
|
89
|
-
adapterVersionCache.set(command, info);
|
|
90
|
-
return info;
|
|
91
|
-
} catch (err) {
|
|
92
|
-
log.warn(
|
|
93
|
-
{ err, packageName },
|
|
94
|
-
"Failed to check ACP adapter version (best-effort, skipping)",
|
|
95
|
-
);
|
|
96
|
-
adapterVersionCache.set(command, null);
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** @internal — exposed for tests only. */
|
|
102
|
-
export function _resetAdapterVersionCacheForTests(): void {
|
|
103
|
-
adapterVersionCache.clear();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
11
|
export async function executeAcpSpawn(
|
|
107
12
|
input: Record<string, unknown>,
|
|
108
13
|
context: ToolContext,
|
|
@@ -115,8 +20,9 @@ export async function executeAcpSpawn(
|
|
|
115
20
|
}
|
|
116
21
|
|
|
117
22
|
// Pure precondition: check for a connected client BEFORE any side effects
|
|
118
|
-
// (auto-install mutates the host via `
|
|
119
|
-
// the install timeout). Without a client the spawn cannot
|
|
23
|
+
// (auto-install mutates the host via a `bun` global install and can block
|
|
24
|
+
// for up to the install timeout). Without a client the spawn cannot
|
|
25
|
+
// succeed anyway.
|
|
120
26
|
const sendToClient = getSendToClient(context);
|
|
121
27
|
if (!sendToClient) {
|
|
122
28
|
return {
|
|
@@ -150,14 +56,6 @@ export async function executeAcpSpawn(
|
|
|
150
56
|
return { content: msg, isError: true };
|
|
151
57
|
}
|
|
152
58
|
|
|
153
|
-
// Best-effort version check — never blocks the spawn. If outdated, we
|
|
154
|
-
// append a non-blocking warning to the success payload. Skipped for
|
|
155
|
-
// bunx-resolved agents: there is no global npm install to compare (bun
|
|
156
|
-
// fetches the package on first use), and npm may not exist on the host.
|
|
157
|
-
const versionInfo = runsViaBunx(agentConfig)
|
|
158
|
-
? null
|
|
159
|
-
: await checkAdapterVersion(adapterCommandOf(agentConfig));
|
|
160
|
-
|
|
161
59
|
try {
|
|
162
60
|
const manager = getAcpSessionManager();
|
|
163
61
|
const cwd = (input.cwd as string) || context.workingDir;
|
|
@@ -171,10 +69,10 @@ export async function executeAcpSpawn(
|
|
|
171
69
|
);
|
|
172
70
|
|
|
173
71
|
// Claude Code-only resume hint; empty for other adapters. Keyed off the
|
|
174
|
-
//
|
|
72
|
+
// resolved command basename (always the real adapter binary). See
|
|
175
73
|
// acp/resume-hint.ts for the gating rationale.
|
|
176
74
|
const hint = claudeResumeHint(
|
|
177
|
-
|
|
75
|
+
basename(agentConfig.command),
|
|
178
76
|
cwd,
|
|
179
77
|
protocolSessionId,
|
|
180
78
|
);
|
|
@@ -194,15 +92,7 @@ export async function executeAcpSpawn(
|
|
|
194
92
|
`${installNote}${resumeHint}`,
|
|
195
93
|
});
|
|
196
94
|
|
|
197
|
-
|
|
198
|
-
if (versionInfo) {
|
|
199
|
-
content +=
|
|
200
|
-
`\n\nNote: ${versionInfo.packageName} is outdated ` +
|
|
201
|
-
`(installed: ${versionInfo.installed}, latest: ${versionInfo.latest}). ` +
|
|
202
|
-
`To update, run: npm install -g ${versionInfo.packageName}@${versionInfo.latest}`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return { content, isError: false };
|
|
95
|
+
return { content: payload, isError: false };
|
|
206
96
|
} catch (err) {
|
|
207
97
|
const msg =
|
|
208
98
|
err instanceof Error
|