@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.
Files changed (62) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/openapi.yaml +6 -7
  3. package/package.json +1 -1
  4. package/src/__tests__/agent-loop-exit-reason.test.ts +33 -18
  5. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  6. package/src/__tests__/app-executors.test.ts +132 -0
  7. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  8. package/src/__tests__/context-overflow-reducer.test.ts +1 -1
  9. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +19 -16
  10. package/src/__tests__/conversation-agent-loop-overflow.test.ts +48 -29
  11. package/src/__tests__/conversation-agent-loop.test.ts +48 -96
  12. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  13. package/src/__tests__/conversation-provider-retry-repair.test.ts +52 -49
  14. package/src/__tests__/memory-retrieval-hook.test.ts +68 -30
  15. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  16. package/src/acp/__tests__/helpers/exec-file-stub.ts +12 -7
  17. package/src/acp/__tests__/prepare-agent-env.test.ts +0 -40
  18. package/src/acp/__tests__/session-manager-resume.test.ts +179 -27
  19. package/src/acp/auto-install.test.ts +130 -46
  20. package/src/acp/auto-install.ts +86 -31
  21. package/src/acp/prepare-agent-env.ts +8 -9
  22. package/src/acp/resolve-agent.test.ts +26 -123
  23. package/src/acp/resolve-agent.ts +26 -85
  24. package/src/acp/resume-hint.ts +1 -3
  25. package/src/acp/session-manager.ts +32 -20
  26. package/src/acp/types.ts +0 -8
  27. package/src/agent/loop.ts +42 -26
  28. package/src/config/bundled-skills/acp/SKILL.md +14 -14
  29. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  30. package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
  31. package/src/config/bundled-skills/app-builder/TOOLS.json +43 -2
  32. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  33. package/src/config/bundled-tool-registry.ts +2 -0
  34. package/src/config/call-site-defaults.ts +0 -2
  35. package/src/config/feature-flag-registry.json +1 -1
  36. package/src/config/schemas/call-site-catalog.ts +0 -14
  37. package/src/config/schemas/llm.ts +0 -2
  38. package/src/context/compactor.ts +22 -4
  39. package/src/daemon/conversation-agent-loop.ts +15 -57
  40. package/src/daemon/conversation.ts +23 -3
  41. package/src/daemon/disk-pressure-policy.ts +0 -1
  42. package/src/daemon/lifecycle.ts +0 -7
  43. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
  44. package/src/plugins/defaults/compaction/manager-store.ts +13 -0
  45. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +6 -23
  46. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +24 -25
  47. package/src/runtime/routes/__tests__/acp-routes.test.ts +71 -39
  48. package/src/runtime/routes/__tests__/plugins-routes.test.ts +29 -4
  49. package/src/runtime/routes/plugins-routes.ts +10 -9
  50. package/src/tools/acp/list-agents.test.ts +2 -2
  51. package/src/tools/acp/spawn.test.ts +103 -225
  52. package/src/tools/acp/spawn.ts +10 -120
  53. package/src/tools/apps/executors.ts +153 -42
  54. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  55. package/src/proactive-artifact/decision.test.ts +0 -226
  56. package/src/proactive-artifact/decision.ts +0 -165
  57. package/src/proactive-artifact/index.ts +0 -7
  58. package/src/proactive-artifact/job.test.ts +0 -962
  59. package/src/proactive-artifact/job.ts +0 -372
  60. package/src/proactive-artifact/message-copy.ts +0 -58
  61. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  62. 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 "no version check" test a configured agent whose binary isn't
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, _resetAdapterVersionCacheForTests } =
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 — version check", () => {
185
- test("execFile failure: spawn proceeds without warning", async () => {
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
- // Sanity: payload shape preserved.
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("outdated version: spawn proceeds AND warning appears in content", async () => {
207
- execScripts.set("npm ls", {
208
- stdout: JSON.stringify({
209
- dependencies: {
210
- "@agentclientprotocol/claude-agent-acp": { version: "0.1.0" },
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: "unknown-agent", task: "do something" },
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
- // No outdated note suffix.
273
- expect(result.content).not.toContain("outdated");
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 when auto-install fails", async () => {
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
- "npm i -g @agentclientprotocol/claude-agent-acp",
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 the mapped package and spawn proceeds with a note", async () => {
391
- // Binary appears on PATH only after `npm i -g` runs, simulating a
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) => (binaryOnPath ? `/usr/local/bin/${cmd}` : null));
395
- execScripts.set("npm i", {
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 [payloadJson] = result.content.split("\n\n");
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
- const installCalls = execFileMock.mock.calls.filter(
418
- (call) => (call[1] as string[])[0] === "i",
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(installCalls).toHaveLength(1);
421
- expect(installCalls[0][1]).toEqual([
422
- "i",
423
- "-g",
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("unknown command: no install attempted, plain hint returned", async () => {
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 `npm i -g` (which can also block for
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("npm failure: hint and install failure both surface", async () => {
463
- which.setWhich({});
464
- execScripts.set("npm i", {
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
- "npm i -g @agentclientprotocol/claude-agent-acp",
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 [payloadJson] = result.content.split("\n\n");
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 [payloadJson] = result.content.split("\n\n");
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" },
@@ -1,108 +1,13 @@
1
- import {
2
- execFileWithTimeout,
3
- resolveAgentWithAutoInstall,
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 `npm i -g` and can block for up to
119
- // the install timeout). Without a client the spawn cannot succeed anyway.
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
- // canonical adapter command so it survives the bunx rewrite. See
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
- adapterCommandOf(agentConfig),
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
- let content = payload;
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