@vellumai/assistant 0.12.0-dev.202609111417.417f7af → 0.12.0-dev.202609111458.db63c16

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 (86) hide show
  1. package/ARCHITECTURE.md +5 -8
  2. package/openapi.yaml +49 -32
  3. package/package.json +1 -1
  4. package/src/__tests__/config-watcher.test.ts +6 -4
  5. package/src/__tests__/evict-conversations-for-reload.test.ts +1 -1
  6. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -1
  7. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  8. package/src/acp/__tests__/agent-process.test.ts +258 -4
  9. package/src/acp/__tests__/auth-required.test.ts +96 -0
  10. package/src/acp/__tests__/client-handler.test.ts +90 -2
  11. package/src/acp/__tests__/helpers/acp-model-option.ts +51 -0
  12. package/src/acp/__tests__/session-manager-persistence.test.ts +5 -1
  13. package/src/acp/__tests__/session-manager-resume.test.ts +270 -6
  14. package/src/acp/__tests__/session-manager-usage.test.ts +4 -2
  15. package/src/acp/__tests__/session-manager.test.ts +867 -4
  16. package/src/acp/agent-process.test.ts +4 -1
  17. package/src/acp/agent-process.ts +94 -9
  18. package/src/acp/auth-required.ts +50 -1
  19. package/src/acp/auto-install.test.ts +9 -7
  20. package/src/acp/client-handler.ts +16 -3
  21. package/src/acp/model-config.test.ts +166 -0
  22. package/src/acp/model-config.ts +138 -0
  23. package/src/acp/resolve-agent.test.ts +220 -6
  24. package/src/acp/resolve-agent.ts +88 -23
  25. package/src/acp/session-manager.test.ts +196 -3
  26. package/src/acp/session-manager.ts +435 -18
  27. package/src/acp/types.ts +50 -5
  28. package/src/api/events/acp-session-model-update.test.ts +88 -0
  29. package/src/api/events/acp-session-model-update.ts +46 -0
  30. package/src/api/index.ts +6 -0
  31. package/src/cli/commands/channels/__tests__/send.test.ts +218 -0
  32. package/src/cli/commands/channels/index.help.ts +70 -2
  33. package/src/cli/commands/channels/index.ts +16 -3
  34. package/src/cli/commands/channels/send.ts +128 -0
  35. package/src/config/__tests__/memory-retrospective-schema.test.ts +15 -1
  36. package/src/config/acp-agent-write.ts +43 -0
  37. package/src/config/acp-defaults.test.ts +13 -3
  38. package/src/config/acp-defaults.ts +23 -6
  39. package/src/config/acp-schema.test.ts +81 -0
  40. package/src/config/acp-schema.ts +30 -2
  41. package/src/config/bundled-skills/acp/SKILL.md +20 -14
  42. package/src/config/bundled-skills/acp/TOOLS.json +4 -0
  43. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  44. package/src/config/memory-v3-gate.test.ts +31 -0
  45. package/src/config/memory-v3-gate.ts +20 -16
  46. package/src/config/schemas/memory-retrospective.ts +10 -0
  47. package/src/config/schemas/skills.ts +0 -14
  48. package/src/daemon/__tests__/skill-memory-refresh-mtime.test.ts +133 -0
  49. package/src/daemon/config-watcher.ts +4 -186
  50. package/src/daemon/conversation-skill-tools.ts +3 -3
  51. package/src/daemon/conversation-store.ts +1 -1
  52. package/src/daemon/conversation.ts +2 -1
  53. package/src/daemon/skill-memory-refresh.ts +64 -0
  54. package/src/messaging/providers/__tests__/proactive-address.test.ts +20 -65
  55. package/src/messaging/providers/channel-transport.ts +24 -18
  56. package/src/messaging/providers/discord/transport.ts +5 -11
  57. package/src/messaging/providers/slack/api.ts +0 -14
  58. package/src/messaging/providers/slack/transport.ts +5 -11
  59. package/src/messaging/providers/telegram-bot/transport.ts +2 -7
  60. package/src/messaging/providers/whatsapp/transport.ts +4 -4
  61. package/src/permissions/checker.test.ts +11 -6
  62. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +13 -10
  63. package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +4 -0
  64. package/src/plugins/defaults/memory/hooks/shutdown.ts +2 -0
  65. package/src/plugins/defaults/memory/memory-retrospective-job.ts +2 -2
  66. package/src/plugins/defaults/memory/skill-md-mtime-poll.ts +9 -0
  67. package/src/plugins/defaults/memory/startup.ts +6 -1
  68. package/src/plugins/defaults/memory/worker.ts +6 -0
  69. package/src/runtime/AGENTS.md +1 -1
  70. package/src/runtime/__tests__/channel-send.test.ts +7 -6
  71. package/src/runtime/channel-send.ts +4 -7
  72. package/src/runtime/routes/__tests__/acp-routes.test.ts +129 -4
  73. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +108 -1
  74. package/src/runtime/routes/acp-routes.test.ts +15 -0
  75. package/src/runtime/routes/acp-routes.ts +54 -9
  76. package/src/runtime/routes/channel-send-routes.ts +12 -21
  77. package/src/runtime/routes/conversation-query-routes.ts +3 -0
  78. package/src/skills/__tests__/workspace-skill-md-signature.test.ts +60 -0
  79. package/src/skills/workspace-skill-md-signature.ts +50 -0
  80. package/src/tools/acp/list-agents.test.ts +3 -1
  81. package/src/tools/acp/spawn.test.ts +85 -7
  82. package/src/tools/acp/spawn.ts +21 -14
  83. package/src/tools/acp/status.test.ts +94 -0
  84. package/src/tools/acp/status.ts +40 -3
  85. package/src/tools/policy-context.ts +6 -7
  86. package/src/__tests__/config-watcher-skill-reseed.test.ts +0 -359
package/ARCHITECTURE.md CHANGED
@@ -1294,9 +1294,8 @@ graph TB
1294
1294
  RM_DIR["rmSync skill directory"]
1295
1295
  end
1296
1296
 
1297
- subgraph "File Watcher"
1298
- WATCHER["Skills directory watcher<br/>detects changes"]
1299
- EVICT["Session eviction<br/>+ recreation"]
1297
+ subgraph "Capability reseed"
1298
+ RESEED["SKILL.md mtime poll<br/>reseeds capability cards"]
1300
1299
  end
1301
1300
 
1302
1301
  SNIPPET --> EVAL_TOOL
@@ -1309,14 +1308,12 @@ graph TB
1309
1308
  SCAFFOLD --> MANAGED_STORE
1310
1309
  MANAGED_STORE --> SKILL_DIR
1311
1310
 
1312
- SKILL_DIR --> WATCHER
1313
- WATCHER --> EVICT
1314
-
1311
+ SKILL_DIR --> RESEED
1315
1312
  SKILL_DIR --> SKILL_LOAD
1316
1313
  SKILL_LOAD --> SESSION
1317
1314
 
1318
1315
  DELETE --> RM_DIR
1319
- RM_DIR --> WATCHER
1316
+ RM_DIR --> RESEED
1320
1317
  ```
1321
1318
 
1322
1319
  **Key design decisions:**
@@ -1324,7 +1321,7 @@ graph TB
1324
1321
  - `evaluate_typescript_code` always forces `sandbox.enabled = true` regardless of global config.
1325
1322
  - Snippet contract: must export `default` or `run` with signature `(input: unknown) => unknown | Promise<unknown>`.
1326
1323
  - Managed-store writes are atomic (tmp file + rename) to prevent partial `SKILL.md` files.
1327
- - After persist or delete, the file watcher triggers conversation eviction; the next turn runs in a fresh conversation. The model's system prompt instructs it to continue normally.
1324
+ - After persist or delete, capability cards reseed from the `SKILL.md` set. The next turn continues in the same conversation.
1328
1325
  - macOS UI shows Inspect and Delete controls for managed skills only (source = "managed").
1329
1326
  - `skill_load` resolves the recursive include graph (via `include-graph.ts`) before emitting output. Missing children are listed as suggested skills without child `<loaded_skill>` markers; cycles still produce `isError: true` with no marker. Valid includes produce an "Included Skills (immediate)" metadata section showing child ID, name, description, and path.
1330
1327
 
package/openapi.yaml CHANGED
@@ -469,6 +469,33 @@ paths:
469
469
  type: string
470
470
  authErrorCode:
471
471
  type: string
472
+ model:
473
+ type: string
474
+ availableModels:
475
+ type: array
476
+ items:
477
+ type: object
478
+ properties:
479
+ value:
480
+ type: string
481
+ label:
482
+ type: string
483
+ description:
484
+ type: string
485
+ group:
486
+ type: string
487
+ required:
488
+ - value
489
+ - label
490
+ additionalProperties: false
491
+ modelRevisionEpoch:
492
+ type: string
493
+ format: uuid
494
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
495
+ modelRevision:
496
+ type: integer
497
+ minimum: 0
498
+ maximum: 9007199254740991
472
499
  usedTokens:
473
500
  type: number
474
501
  contextSize:
@@ -548,6 +575,9 @@ paths:
548
575
  cwd:
549
576
  type: string
550
577
  description: Working directory
578
+ model:
579
+ description: Optional model id or alias to request for the session.
580
+ type: string
551
581
  required:
552
582
  - agent
553
583
  - task
@@ -566,6 +596,9 @@ paths:
566
596
  type: string
567
597
  agent:
568
598
  type: string
599
+ modelWarning:
600
+ description: Why the requested model was not applied. The session is running on the agent's own model.
601
+ type: string
569
602
  required:
570
603
  - acpSessionId
571
604
  - protocolSessionId
@@ -5441,9 +5474,7 @@ paths:
5441
5474
  post:
5442
5475
  operationId: channels_send_post
5443
5476
  summary: Send text to a channel chat
5444
- description:
5445
- Deliver text to a chat or person on a channel through the channel's transport, recorded after the channel
5446
- acknowledges it.
5477
+ description: Deliver text to a chat on a channel through the channel's transport, recorded after the channel acknowledges it.
5447
5478
  tags:
5448
5479
  - channels
5449
5480
  requestBody:
@@ -5468,36 +5499,22 @@ paths:
5468
5499
  - plugin
5469
5500
  description: Channel to send on
5470
5501
  target:
5471
- oneOf:
5472
- - type: object
5473
- properties:
5474
- kind:
5475
- type: string
5476
- const: chat
5477
- chatId:
5478
- type: string
5479
- minLength: 1
5480
- description: Chat id in the channel's own id space
5481
- threadId:
5482
- description: Thread within the chat, in the channel's own id space
5483
- type: string
5484
- minLength: 1
5485
- required:
5486
- - kind
5487
- - chatId
5488
- - type: object
5489
- properties:
5490
- kind:
5491
- type: string
5492
- const: person
5493
- userId:
5494
- type: string
5495
- minLength: 1
5496
- description: Person to reach in their DM, in the channel's own id space
5497
- required:
5498
- - kind
5499
- - userId
5500
5502
  type: object
5503
+ properties:
5504
+ kind:
5505
+ type: string
5506
+ const: chat
5507
+ chatId:
5508
+ type: string
5509
+ minLength: 1
5510
+ description: Chat id in the channel's own id space
5511
+ threadId:
5512
+ description: Thread within the chat, in the channel's own id space
5513
+ type: string
5514
+ minLength: 1
5515
+ required:
5516
+ - kind
5517
+ - chatId
5501
5518
  text:
5502
5519
  type: string
5503
5520
  minLength: 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.0-dev.202609111417.417f7af",
3
+ "version": "0.12.0-dev.202609111458.db63c16",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -154,10 +154,6 @@ mock.module("../runtime/sync/resource-sync-events.js", () => ({
154
154
  },
155
155
  }));
156
156
 
157
- mock.module("../daemon/skill-memory-refresh.js", () => ({
158
- refreshSkillCapabilityMemories: () => {},
159
- }));
160
-
161
157
  mock.module("../platform/sync-identity.js", () => ({
162
158
  syncIdentityNameToPlatform: () => {},
163
159
  }));
@@ -332,6 +328,12 @@ describe("ConfigWatcher watcher lifecycle", () => {
332
328
  expect(findFileWatch(join(WORKSPACE_DIR, "IDENTITY.md"))).toBeDefined();
333
329
  });
334
330
 
331
+ test("start does not watch the skills directory", () => {
332
+ mkdirSync(join(WORKSPACE_DIR, "skills"), { recursive: true });
333
+ watcher.start();
334
+ expect(findWatcher(join(WORKSPACE_DIR, "skills"))).toBeUndefined();
335
+ });
336
+
335
337
  test("stop cancels pending debounce work, no eviction fires after", async () => {
336
338
  watcher.start();
337
339
  simulateFileChange(WORKSPACE_DIR, "SOUL.md");
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `evictConversationsForReload` drops in-memory conversations after a
3
- * config/prompt/skills reload so the next turn rebuilds them against the new
3
+ * config/prompt reload so the next turn rebuilds them against the new
4
4
  * config. Queued messages live only on the instance being disposed, so the
5
5
  * same "not idle while a queue is pending" rule the periodic evictor applies
6
6
  * has to hold here: `isProcessing()` reads false in the window between a turn
@@ -60,7 +60,7 @@ function makeConfig(overrides: Partial<AssistantConfig> = {}): AssistantConfig {
60
60
  return {
61
61
  skills: {
62
62
  entries: {},
63
- load: { extraDirs: [], watch: true, watchDebounceMs: 250 },
63
+ load: { extraDirs: [] },
64
64
  install: { nodeManager: "npm" },
65
65
  allowBundled: null,
66
66
  remoteProviders: {
@@ -28,7 +28,7 @@ function makeConfig(overrides: Partial<AssistantConfig> = {}): AssistantConfig {
28
28
  return {
29
29
  skills: {
30
30
  entries: {},
31
- load: { extraDirs: [], watch: true, watchDebounceMs: 250 },
31
+ load: { extraDirs: [] },
32
32
  install: { nodeManager: "npm" },
33
33
  allowBundled: null,
34
34
  remoteProviders: {
@@ -233,7 +233,7 @@ describe("resolveSkillStates with feature flags", () => {
233
233
  const config = makeConfig({
234
234
  skills: {
235
235
  entries: { [DECLARED_SKILL_ID]: { enabled: true } },
236
- load: { extraDirs: [], watch: true, watchDebounceMs: 250 },
236
+ load: { extraDirs: [] },
237
237
  install: { nodeManager: "npm" },
238
238
  allowBundled: null,
239
239
  remoteProviders: {
@@ -8,9 +8,16 @@
8
8
 
9
9
  import { describe, expect, mock, test } from "bun:test";
10
10
 
11
- import type { AuthMethod, InitializeResponse } from "@agentclientprotocol/sdk";
11
+ import type {
12
+ AuthMethod,
13
+ InitializeResponse,
14
+ SessionConfigOption,
15
+ } from "@agentclientprotocol/sdk";
16
+ import * as acp from "@agentclientprotocol/sdk";
12
17
 
13
18
  import { AcpAgentProcess } from "../agent-process.js";
19
+ import { AcpConfigOptionRefusedError } from "../types.js";
20
+ import { modelOption } from "./helpers/acp-model-option.js";
14
21
 
15
22
  function makeProcess(): AcpAgentProcess {
16
23
  return new AcpAgentProcess(
@@ -160,6 +167,139 @@ describe("AcpAgentProcess loadSession/resumeSession", () => {
160
167
  });
161
168
  });
162
169
 
170
+ describe("AcpAgentProcess config options", () => {
171
+ /** Injects a connection whose session calls report `configOptions`. */
172
+ function stubSessionResponses(
173
+ proc: AcpAgentProcess,
174
+ configOptions: SessionConfigOption[] | null | undefined,
175
+ ): void {
176
+ (proc as unknown as { connection: unknown }).connection = {
177
+ newSession: () =>
178
+ Promise.resolve({ sessionId: "session-1", configOptions }),
179
+ loadSession: () => Promise.resolve({ configOptions }),
180
+ resumeSession: () => Promise.resolve({ configOptions }),
181
+ };
182
+ }
183
+
184
+ test("createSession returns the reported options", async () => {
185
+ const proc = makeProcess();
186
+ const options = [modelOption("sonnet")];
187
+ stubSessionResponses(proc, options);
188
+
189
+ await expect(proc.createSession("/tmp/project")).resolves.toEqual({
190
+ sessionId: "session-1",
191
+ configOptions: options,
192
+ });
193
+ });
194
+
195
+ test("loadSession returns the reported options", async () => {
196
+ const proc = makeProcess();
197
+ const options = [modelOption("sonnet")];
198
+ stubSessionResponses(proc, options);
199
+
200
+ await expect(
201
+ proc.loadSession("session-1", "/tmp/project"),
202
+ ).resolves.toEqual({ configOptions: options });
203
+ });
204
+
205
+ test("resumeSession returns the reported options", async () => {
206
+ const proc = makeProcess();
207
+ const options = [modelOption("opus")];
208
+ stubSessionResponses(proc, options);
209
+
210
+ await expect(
211
+ proc.resumeSession("session-1", "/tmp/project"),
212
+ ).resolves.toEqual({ configOptions: options });
213
+ });
214
+
215
+ test("a null or absent configOptions normalizes to an empty array", async () => {
216
+ const created = makeProcess();
217
+ stubSessionResponses(created, null);
218
+ await expect(created.createSession("/tmp/project")).resolves.toEqual({
219
+ sessionId: "session-1",
220
+ configOptions: [],
221
+ });
222
+
223
+ const loaded = makeProcess();
224
+ stubSessionResponses(loaded, null);
225
+ await expect(
226
+ loaded.loadSession("session-1", "/tmp/project"),
227
+ ).resolves.toEqual({ configOptions: [] });
228
+
229
+ const resumed = makeProcess();
230
+ stubSessionResponses(resumed, undefined);
231
+ await expect(
232
+ resumed.resumeSession("session-1", "/tmp/project"),
233
+ ).resolves.toEqual({ configOptions: [] });
234
+ });
235
+
236
+ test("setConfigOption forwards the value and returns the refreshed set", async () => {
237
+ const proc = makeProcess();
238
+ const calls: unknown[] = [];
239
+ const refreshed = [modelOption("opus")];
240
+ (proc as unknown as { connection: unknown }).connection = {
241
+ setSessionConfigOption: (params: unknown) => {
242
+ calls.push(params);
243
+ return Promise.resolve({ configOptions: refreshed });
244
+ },
245
+ };
246
+
247
+ await expect(
248
+ proc.setConfigOption("session-1", "model", "opus"),
249
+ ).resolves.toEqual(refreshed);
250
+
251
+ expect(calls).toEqual([
252
+ { sessionId: "session-1", configId: "model", value: "opus" },
253
+ ]);
254
+ });
255
+
256
+ test("setConfigOption sends the boolean request variant", async () => {
257
+ const proc = makeProcess();
258
+ const calls: unknown[] = [];
259
+ const refreshed = [modelOption("opus")];
260
+ (proc as unknown as { connection: unknown }).connection = {
261
+ setSessionConfigOption: (params: unknown) => {
262
+ calls.push(params);
263
+ return Promise.resolve({ configOptions: refreshed });
264
+ },
265
+ };
266
+
267
+ await proc.setConfigOption("session-1", "thinking", true);
268
+
269
+ expect(calls).toEqual([
270
+ {
271
+ sessionId: "session-1",
272
+ configId: "thinking",
273
+ type: "boolean",
274
+ value: true,
275
+ },
276
+ ]);
277
+ });
278
+
279
+ test("setConfigOption omits the discriminator for string values", async () => {
280
+ const proc = makeProcess();
281
+ const calls: unknown[] = [];
282
+ (proc as unknown as { connection: unknown }).connection = {
283
+ setSessionConfigOption: (params: unknown) => {
284
+ calls.push(params);
285
+ return Promise.resolve({ configOptions: [modelOption("opus")] });
286
+ },
287
+ };
288
+
289
+ await proc.setConfigOption("session-1", "model", "opus");
290
+
291
+ expect(calls[0]).not.toHaveProperty("type");
292
+ });
293
+
294
+ test("setConfigOption throws when the process is not spawned", async () => {
295
+ const proc = makeProcess();
296
+
297
+ await expect(
298
+ proc.setConfigOption("session-1", "model", "opus"),
299
+ ).rejects.toThrow('ACP agent "test-agent" is not spawned');
300
+ });
301
+ });
302
+
163
303
  describe("AcpAgentProcess auth_required retry", () => {
164
304
  // spawnedEnv is injected the way spawn() builds it ({ ...process.env,
165
305
  // ...config.env }), so the advertised var names use a VELLUM_TEST_ prefix
@@ -196,17 +336,19 @@ describe("AcpAgentProcess auth_required retry", () => {
196
336
  * initialize() so the advertised authMethods are captured.
197
337
  */
198
338
  async function setupAuthProcess(options: {
339
+ command?: string;
199
340
  env?: Record<string, string>;
200
341
  authMethods?: AuthMethod[];
201
342
  /** Rejections to throw from successive newSession calls before succeeding. */
202
343
  newSessionRejections?: unknown[];
203
344
  loadSessionRejections?: unknown[];
204
345
  resumeSessionRejections?: unknown[];
346
+ setConfigOptionRejections?: unknown[];
205
347
  promptRejections?: unknown[];
206
348
  }) {
207
349
  const proc = new AcpAgentProcess(
208
350
  "codex",
209
- { command: "echo", args: [], env: options.env },
351
+ { command: options.command ?? "echo", args: [], env: options.env },
210
352
  () => {
211
353
  throw new Error("client factory should not be called in this test");
212
354
  },
@@ -234,6 +376,10 @@ describe("AcpAgentProcess auth_required retry", () => {
234
376
  options.resumeSessionRejections ?? [],
235
377
  {},
236
378
  );
379
+ const setSessionConfigOption = failThenSucceed(
380
+ options.setConfigOptionRejections ?? [],
381
+ { configOptions: [] },
382
+ );
237
383
  const prompt = failThenSucceed(options.promptRejections ?? [], {
238
384
  stopReason: "end_turn",
239
385
  });
@@ -252,6 +398,7 @@ describe("AcpAgentProcess auth_required retry", () => {
252
398
  newSession,
253
399
  loadSession,
254
400
  resumeSession,
401
+ setSessionConfigOption,
255
402
  prompt,
256
403
  authenticate,
257
404
  };
@@ -264,18 +411,112 @@ describe("AcpAgentProcess auth_required retry", () => {
264
411
  newSession,
265
412
  loadSession,
266
413
  resumeSession,
414
+ setSessionConfigOption,
267
415
  prompt,
268
416
  authenticate,
269
417
  internals,
270
418
  };
271
419
  }
272
420
 
421
+ // An adapter that throws a plain Error reaches the client as a generic
422
+ // "Internal error" carrying the real sentence in `data.details`, which is
423
+ // the shape these fixtures reproduce.
424
+ test("setConfigOption surfaces the adapter's error answer as a refusal", async () => {
425
+ const { proc } = await setupAuthProcess({
426
+ setConfigOptionRejections: [
427
+ new acp.RequestError(-32603, "Internal error", {
428
+ details: "Invalid value for config option model: nope",
429
+ }),
430
+ ],
431
+ });
432
+
433
+ const refusal = await proc
434
+ .setConfigOption("session-1", "model", "nope")
435
+ .catch((err: unknown) => err);
436
+
437
+ expect(refusal).toBeInstanceOf(AcpConfigOptionRefusedError);
438
+ // The adapter's sentence, not the message the SDK framed it in.
439
+ expect((refusal as Error).message).toBe(
440
+ "Invalid value for config option model: nope",
441
+ );
442
+ });
443
+
444
+ test("setConfigOption serializes a payload that names no reason", async () => {
445
+ const { proc } = await setupAuthProcess({
446
+ setConfigOptionRejections: [
447
+ new acp.RequestError(-32603, "Internal error", { code: 7 }),
448
+ ],
449
+ });
450
+
451
+ await expect(
452
+ proc.setConfigOption("session-1", "model", "nope"),
453
+ ).rejects.toThrow('{"code":7}');
454
+ });
455
+
456
+ test("setConfigOption falls back to the message when there is no payload", async () => {
457
+ const { proc } = await setupAuthProcess({
458
+ setConfigOptionRejections: [
459
+ new acp.RequestError(-32603, "Session not found"),
460
+ ],
461
+ });
462
+
463
+ await expect(
464
+ proc.setConfigOption("session-1", "model", "nope"),
465
+ ).rejects.toThrow("Session not found");
466
+ });
467
+
468
+ test.each(["claude-agent-acp", "/usr/local/bin/claude-agent-acp"])(
469
+ "setConfigOption leaves Claude's message-shaped auth failure untyped (%s)",
470
+ async (command) => {
471
+ const expired = new acp.RequestError(-32603, "Internal error", {
472
+ details: "Not logged in",
473
+ });
474
+ const { proc } = await setupAuthProcess({
475
+ command,
476
+ setConfigOptionRejections: [expired],
477
+ });
478
+
479
+ await expect(
480
+ proc.setConfigOption("session-1", "model", "opus"),
481
+ ).rejects.toBe(expired);
482
+ },
483
+ );
484
+
485
+ test("setConfigOption reads Claude's login wording from another adapter as a refusal", async () => {
486
+ const { proc } = await setupAuthProcess({
487
+ command: "codex-acp",
488
+ setConfigOptionRejections: [
489
+ new acp.RequestError(-32603, "Internal error", {
490
+ details: "Not logged in",
491
+ }),
492
+ ],
493
+ });
494
+
495
+ const refusal = await proc
496
+ .setConfigOption("session-1", "model", "gpt-5")
497
+ .catch((err: unknown) => err);
498
+
499
+ expect(refusal).toBeInstanceOf(AcpConfigOptionRefusedError);
500
+ expect((refusal as Error).message).toBe("Not logged in");
501
+ });
502
+
503
+ test("setConfigOption leaves a transport failure untyped", async () => {
504
+ const closed = new Error("ACP connection closed");
505
+ const { proc } = await setupAuthProcess({
506
+ setConfigOptionRejections: [closed],
507
+ });
508
+
509
+ await expect(
510
+ proc.setConfigOption("session-1", "model", "opus"),
511
+ ).rejects.toBe(closed);
512
+ });
513
+
273
514
  test("createSession does not authenticate when newSession succeeds", async () => {
274
515
  const { proc, newSession, authenticate } = await setupAuthProcess({
275
516
  env: { [OPENAI_VAR]: "sk-test" },
276
517
  });
277
518
 
278
- const sessionId = await proc.createSession("/tmp/project");
519
+ const { sessionId } = await proc.createSession("/tmp/project");
279
520
 
280
521
  expect(sessionId).toBe("session-1");
281
522
  expect(newSession).toHaveBeenCalledTimes(1);
@@ -288,7 +529,7 @@ describe("AcpAgentProcess auth_required retry", () => {
288
529
  newSessionRejections: [authRequiredError],
289
530
  });
290
531
 
291
- const sessionId = await proc.createSession("/tmp/project");
532
+ const { sessionId } = await proc.createSession("/tmp/project");
292
533
 
293
534
  expect(sessionId).toBe("session-1");
294
535
  expect(authenticate).toHaveBeenCalledTimes(1);
@@ -387,6 +628,19 @@ describe("AcpAgentProcess auth_required retry", () => {
387
628
  expect(resumeSession).toHaveBeenCalledTimes(2);
388
629
  });
389
630
 
631
+ test("setConfigOption authenticates and retries on auth_required", async () => {
632
+ const { proc, setSessionConfigOption, authenticate } =
633
+ await setupAuthProcess({
634
+ env: { [OPENAI_VAR]: "sk-test" },
635
+ setConfigOptionRejections: [authRequiredError],
636
+ });
637
+
638
+ await proc.setConfigOption("session-1", "model", "opus");
639
+
640
+ expect(authenticate).toHaveBeenCalledWith({ methodId: "openai-api-key" });
641
+ expect(setSessionConfigOption).toHaveBeenCalledTimes(2);
642
+ });
643
+
390
644
  test("prompt authenticates and retries on auth_required, returning the response", async () => {
391
645
  const { proc, prompt, authenticate } = await setupAuthProcess({
392
646
  env: { [OPENAI_VAR]: "sk-test" },
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
8
 
9
+ import { RequestError } from "@agentclientprotocol/sdk";
10
+
9
11
  import { AcpAuthRequiredEventSchema } from "../../api/events/acp-auth-required.js";
10
12
  import { AcpSessionErrorEventSchema } from "../../api/events/acp-session-error.js";
11
13
  import {
@@ -15,6 +17,7 @@ import {
15
17
  CLAUDE_ACP_COMMAND,
16
18
  isAcpAuthRequired,
17
19
  isClaudeAuthFailureMessage,
20
+ requestErrorReason,
18
21
  } from "../auth-required.js";
19
22
 
20
23
  describe("isAcpAuthRequired", () => {
@@ -89,6 +92,99 @@ describe("isClaudeAuthFailureMessage", () => {
89
92
  });
90
93
  });
91
94
 
95
+ describe("requestErrorReason", () => {
96
+ test("decodes the sentence the SDK moved into the payload", () => {
97
+ const rejection = new RequestError(-32603, "Internal error", {
98
+ details: "Failed to authenticate. Please run /login",
99
+ });
100
+
101
+ expect(requestErrorReason(rejection)).toBe(
102
+ "Failed to authenticate. Please run /login",
103
+ );
104
+ // The point of decoding: the generic message never matches.
105
+ expect(isClaudeAuthFailureMessage(rejection.message)).toBe(false);
106
+ expect(isClaudeAuthFailureMessage(requestErrorReason(rejection))).toBe(
107
+ true,
108
+ );
109
+ });
110
+
111
+ test("reads the message off a rejection that carries no payload", () => {
112
+ // Straight off the wire: a plain object, not an instance of any class we
113
+ // control, and with the auth text where the SDK leaves a framed one.
114
+ const rejection = {
115
+ code: -32603,
116
+ message: "Failed to authenticate. Please run /login",
117
+ };
118
+
119
+ expect(requestErrorReason(rejection)).toBe(
120
+ "Failed to authenticate. Please run /login",
121
+ );
122
+ expect(isClaudeAuthFailureMessage(requestErrorReason(rejection))).toBe(
123
+ true,
124
+ );
125
+ });
126
+
127
+ test("keeps the rejection's own message when the payload names no reason", () => {
128
+ expect(requestErrorReason(new RequestError(-32603, "boom", { c: 7 }))).toBe(
129
+ "boom",
130
+ );
131
+ expect(
132
+ requestErrorReason(
133
+ RequestError.methodNotFound("session/set_config_option"),
134
+ ),
135
+ ).toBe('"Method not found": session/set_config_option');
136
+ expect(requestErrorReason(new Error("Not logged in"))).toBe(
137
+ "Not logged in",
138
+ );
139
+ });
140
+
141
+ test("keeps a specific rejection message ahead of supplemental details", () => {
142
+ expect(
143
+ requestErrorReason(
144
+ new RequestError(-32603, "Model unavailable", {
145
+ details: "The selected deployment is temporarily unavailable",
146
+ }),
147
+ ),
148
+ ).toBe("Model unavailable");
149
+ });
150
+
151
+ test("serializes the payload behind a bare Internal error that names no reason", () => {
152
+ expect(
153
+ requestErrorReason(new RequestError(-32603, "Internal error", { c: 7 })),
154
+ ).toBe('{"c":7}');
155
+ });
156
+
157
+ test("serializes the payload behind a generic Invalid params", () => {
158
+ // How the agent-side SDK answers a request its schema rejects.
159
+ expect(
160
+ requestErrorReason(
161
+ RequestError.invalidParams({ _errors: ["model: Invalid option"] }),
162
+ ),
163
+ ).toBe('{"_errors":["model: Invalid option"]}');
164
+ });
165
+
166
+ test("decodes claude-agent-acp's prompt-time 401 from its message", () => {
167
+ // RequestError.internalError({ errorKind }, cliText) as the adapter raises
168
+ // it: the CLI's text rides the message and the payload only names a kind.
169
+ const rejection = new RequestError(
170
+ -32603,
171
+ "Internal error: Failed to authenticate. API Error: 401 OAuth access token has expired.",
172
+ { errorKind: "authentication_failed" },
173
+ );
174
+
175
+ expect(requestErrorReason(rejection)).toBe(rejection.message);
176
+ expect(isClaudeAuthFailureMessage(requestErrorReason(rejection))).toBe(
177
+ true,
178
+ );
179
+ });
180
+
181
+ test("stringifies a rejection that is not an object at all", () => {
182
+ expect(requestErrorReason("plain string")).toBe("plain string");
183
+ expect(requestErrorReason(-32603)).toBe("-32603");
184
+ expect(requestErrorReason(null)).toBe("null");
185
+ });
186
+ });
187
+
92
188
  describe("AcpAuthRequiredError", () => {
93
189
  test("is an Error that carries the agent id and its message", () => {
94
190
  const err = new AcpAuthRequiredError("claude", "needs a reconnect");