@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.
- package/ARCHITECTURE.md +5 -8
- package/openapi.yaml +49 -32
- package/package.json +1 -1
- package/src/__tests__/config-watcher.test.ts +6 -4
- package/src/__tests__/evict-conversations-for-reload.test.ts +1 -1
- package/src/__tests__/skill-feature-flags-integration.test.ts +1 -1
- package/src/__tests__/skill-feature-flags.test.ts +2 -2
- package/src/acp/__tests__/agent-process.test.ts +258 -4
- package/src/acp/__tests__/auth-required.test.ts +96 -0
- package/src/acp/__tests__/client-handler.test.ts +90 -2
- package/src/acp/__tests__/helpers/acp-model-option.ts +51 -0
- package/src/acp/__tests__/session-manager-persistence.test.ts +5 -1
- package/src/acp/__tests__/session-manager-resume.test.ts +270 -6
- package/src/acp/__tests__/session-manager-usage.test.ts +4 -2
- package/src/acp/__tests__/session-manager.test.ts +867 -4
- package/src/acp/agent-process.test.ts +4 -1
- package/src/acp/agent-process.ts +94 -9
- package/src/acp/auth-required.ts +50 -1
- package/src/acp/auto-install.test.ts +9 -7
- package/src/acp/client-handler.ts +16 -3
- package/src/acp/model-config.test.ts +166 -0
- package/src/acp/model-config.ts +138 -0
- package/src/acp/resolve-agent.test.ts +220 -6
- package/src/acp/resolve-agent.ts +88 -23
- package/src/acp/session-manager.test.ts +196 -3
- package/src/acp/session-manager.ts +435 -18
- package/src/acp/types.ts +50 -5
- package/src/api/events/acp-session-model-update.test.ts +88 -0
- package/src/api/events/acp-session-model-update.ts +46 -0
- package/src/api/index.ts +6 -0
- package/src/cli/commands/channels/__tests__/send.test.ts +218 -0
- package/src/cli/commands/channels/index.help.ts +70 -2
- package/src/cli/commands/channels/index.ts +16 -3
- package/src/cli/commands/channels/send.ts +128 -0
- package/src/config/__tests__/memory-retrospective-schema.test.ts +15 -1
- package/src/config/acp-agent-write.ts +43 -0
- package/src/config/acp-defaults.test.ts +13 -3
- package/src/config/acp-defaults.ts +23 -6
- package/src/config/acp-schema.test.ts +81 -0
- package/src/config/acp-schema.ts +30 -2
- package/src/config/bundled-skills/acp/SKILL.md +20 -14
- package/src/config/bundled-skills/acp/TOOLS.json +4 -0
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/memory-v3-gate.test.ts +31 -0
- package/src/config/memory-v3-gate.ts +20 -16
- package/src/config/schemas/memory-retrospective.ts +10 -0
- package/src/config/schemas/skills.ts +0 -14
- package/src/daemon/__tests__/skill-memory-refresh-mtime.test.ts +133 -0
- package/src/daemon/config-watcher.ts +4 -186
- package/src/daemon/conversation-skill-tools.ts +3 -3
- package/src/daemon/conversation-store.ts +1 -1
- package/src/daemon/conversation.ts +2 -1
- package/src/daemon/skill-memory-refresh.ts +64 -0
- package/src/messaging/providers/__tests__/proactive-address.test.ts +20 -65
- package/src/messaging/providers/channel-transport.ts +24 -18
- package/src/messaging/providers/discord/transport.ts +5 -11
- package/src/messaging/providers/slack/api.ts +0 -14
- package/src/messaging/providers/slack/transport.ts +5 -11
- package/src/messaging/providers/telegram-bot/transport.ts +2 -7
- package/src/messaging/providers/whatsapp/transport.ts +4 -4
- package/src/permissions/checker.test.ts +11 -6
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +13 -10
- package/src/plugins/defaults/memory/__tests__/shutdown-hook.test.ts +4 -0
- package/src/plugins/defaults/memory/hooks/shutdown.ts +2 -0
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +2 -2
- package/src/plugins/defaults/memory/skill-md-mtime-poll.ts +9 -0
- package/src/plugins/defaults/memory/startup.ts +6 -1
- package/src/plugins/defaults/memory/worker.ts +6 -0
- package/src/runtime/AGENTS.md +1 -1
- package/src/runtime/__tests__/channel-send.test.ts +7 -6
- package/src/runtime/channel-send.ts +4 -7
- package/src/runtime/routes/__tests__/acp-routes.test.ts +129 -4
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +108 -1
- package/src/runtime/routes/acp-routes.test.ts +15 -0
- package/src/runtime/routes/acp-routes.ts +54 -9
- package/src/runtime/routes/channel-send-routes.ts +12 -21
- package/src/runtime/routes/conversation-query-routes.ts +3 -0
- package/src/skills/__tests__/workspace-skill-md-signature.test.ts +60 -0
- package/src/skills/workspace-skill-md-signature.ts +50 -0
- package/src/tools/acp/list-agents.test.ts +3 -1
- package/src/tools/acp/spawn.test.ts +85 -7
- package/src/tools/acp/spawn.ts +21 -14
- package/src/tools/acp/status.test.ts +94 -0
- package/src/tools/acp/status.ts +40 -3
- package/src/tools/policy-context.ts +6 -7
- 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 "
|
|
1298
|
-
|
|
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 -->
|
|
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 -->
|
|
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,
|
|
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
|
@@ -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
|
|
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: []
|
|
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: []
|
|
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: []
|
|
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 {
|
|
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");
|