@vellumai/assistant 0.11.1-dev.202608032226.351a4e2 → 0.11.1-dev.202608040007.969b6ee
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/openapi.yaml +87 -4
- package/package.json +1 -1
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-error.test.ts +102 -2
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +46 -0
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/llm-resolver.test.ts +3 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +30 -3
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +6 -2
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +3 -0
- package/src/api/index.ts +6 -0
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +187 -8
- package/src/calls/voice-session-bridge.ts +167 -25
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/llm-resolver.ts +2 -2
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/daemon/conversation-agent-loop.ts +13 -0
- package/src/daemon/conversation-error.ts +123 -24
- package/src/daemon/conversation-process.ts +15 -2
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation.ts +111 -27
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/documents/document-store.ts +247 -10
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +171 -20
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +35 -0
- package/src/live-voice/live-voice-session.ts +114 -42
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/notifications/__tests__/broadcaster.test.ts +26 -0
- package/src/notifications/adapters/macos.ts +2 -0
- package/src/notifications/adapters/platform.ts +62 -18
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/broadcaster.ts +12 -1
- package/src/notifications/types.ts +8 -0
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +1 -0
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/conversation-management-routes.ts +8 -1
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/conversation-routes.ts +49 -1
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/errors.ts +12 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +2 -0
package/openapi.yaml
CHANGED
|
@@ -10711,6 +10711,11 @@ paths:
|
|
|
10711
10711
|
type: number
|
|
10712
10712
|
updatedAt:
|
|
10713
10713
|
type: number
|
|
10714
|
+
workspacePath:
|
|
10715
|
+
anyOf:
|
|
10716
|
+
- type: string
|
|
10717
|
+
- type: "null"
|
|
10718
|
+
description: Workspace-relative file this document is bound to, or null
|
|
10714
10719
|
required:
|
|
10715
10720
|
- success
|
|
10716
10721
|
- surfaceId
|
|
@@ -10720,6 +10725,7 @@ paths:
|
|
|
10720
10725
|
- wordCount
|
|
10721
10726
|
- createdAt
|
|
10722
10727
|
- updatedAt
|
|
10728
|
+
- workspacePath
|
|
10723
10729
|
additionalProperties: false
|
|
10724
10730
|
/v1/documents/{id}/comments:
|
|
10725
10731
|
get:
|
|
@@ -11137,6 +11143,74 @@ paths:
|
|
|
11137
11143
|
schema:
|
|
11138
11144
|
type: string
|
|
11139
11145
|
format: binary
|
|
11146
|
+
/v1/documents/for-workspace-file:
|
|
11147
|
+
post:
|
|
11148
|
+
operationId: documents_forworkspacefile_post
|
|
11149
|
+
summary: Get or create the document for a workspace markdown file
|
|
11150
|
+
description:
|
|
11151
|
+
"Return the document bound to a workspace markdown file, creating it on first open. The file is the source
|
|
11152
|
+
of truth at open time: stored content that has drifted from the file is refreshed from disk before the document
|
|
11153
|
+
is returned."
|
|
11154
|
+
tags:
|
|
11155
|
+
- documents
|
|
11156
|
+
requestBody:
|
|
11157
|
+
required: true
|
|
11158
|
+
content:
|
|
11159
|
+
application/json:
|
|
11160
|
+
schema:
|
|
11161
|
+
type: object
|
|
11162
|
+
properties:
|
|
11163
|
+
path:
|
|
11164
|
+
type: string
|
|
11165
|
+
description: Workspace-relative path to a .md/.markdown file
|
|
11166
|
+
conversationId:
|
|
11167
|
+
type: string
|
|
11168
|
+
description: Conversation the document is opened from
|
|
11169
|
+
required:
|
|
11170
|
+
- path
|
|
11171
|
+
- conversationId
|
|
11172
|
+
responses:
|
|
11173
|
+
"200":
|
|
11174
|
+
description: Successful response
|
|
11175
|
+
content:
|
|
11176
|
+
application/json:
|
|
11177
|
+
schema:
|
|
11178
|
+
type: object
|
|
11179
|
+
properties:
|
|
11180
|
+
success:
|
|
11181
|
+
type: boolean
|
|
11182
|
+
surfaceId:
|
|
11183
|
+
type: string
|
|
11184
|
+
conversationId:
|
|
11185
|
+
type: string
|
|
11186
|
+
title:
|
|
11187
|
+
type: string
|
|
11188
|
+
content:
|
|
11189
|
+
type: string
|
|
11190
|
+
wordCount:
|
|
11191
|
+
type: number
|
|
11192
|
+
createdAt:
|
|
11193
|
+
type: number
|
|
11194
|
+
updatedAt:
|
|
11195
|
+
type: number
|
|
11196
|
+
workspacePath:
|
|
11197
|
+
anyOf:
|
|
11198
|
+
- type: string
|
|
11199
|
+
- type: "null"
|
|
11200
|
+
description: Workspace-relative file this document is bound to, or null
|
|
11201
|
+
required:
|
|
11202
|
+
- success
|
|
11203
|
+
- surfaceId
|
|
11204
|
+
- conversationId
|
|
11205
|
+
- title
|
|
11206
|
+
- content
|
|
11207
|
+
- wordCount
|
|
11208
|
+
- createdAt
|
|
11209
|
+
- updatedAt
|
|
11210
|
+
- workspacePath
|
|
11211
|
+
additionalProperties: false
|
|
11212
|
+
"422":
|
|
11213
|
+
description: Path is not a markdown file
|
|
11140
11214
|
/v1/domain/register:
|
|
11141
11215
|
post:
|
|
11142
11216
|
operationId: domain_register_post
|
|
@@ -14679,8 +14753,9 @@ paths:
|
|
|
14679
14753
|
summary: Create a provider connection
|
|
14680
14754
|
description:
|
|
14681
14755
|
Create a new named provider connection. When auth is omitted it is derived from the provider (keyless
|
|
14682
|
-
providers get none, vellum gets platform, everything else needs credential for api_key auth).
|
|
14683
|
-
|
|
14756
|
+
providers get none, vellum gets platform, everything else needs credential for api_key auth). An explicit auth
|
|
14757
|
+
object must agree with the provider; platform auth belongs to vellum and only to vellum. Fails with 409 if a
|
|
14758
|
+
connection with this name already exists.
|
|
14684
14759
|
tags:
|
|
14685
14760
|
- inference
|
|
14686
14761
|
requestBody:
|
|
@@ -14789,8 +14864,9 @@ paths:
|
|
|
14789
14864
|
summary: Update a provider connection
|
|
14790
14865
|
description:
|
|
14791
14866
|
Update an existing connection. Cannot rename or change the provider. Omitting auth keeps the stored auth;
|
|
14792
|
-
passing credential alone rotates the key via provider-derived api_key auth.
|
|
14793
|
-
|
|
14867
|
+
passing credential alone rotates the key via provider-derived api_key auth. An explicit auth object must agree
|
|
14868
|
+
with the connection's provider; platform auth belongs to vellum and only to vellum. For the Vellum-managed
|
|
14869
|
+
connection (vellum) the auth is locked to platform; label remains editable.
|
|
14794
14870
|
tags:
|
|
14795
14871
|
- inference
|
|
14796
14872
|
parameters:
|
|
@@ -19763,6 +19839,13 @@ paths:
|
|
|
19763
19839
|
Client OS surface ("web" | "ios" | "macos" | "android"), reported separately from `interface`. Drives the
|
|
19764
19840
|
per-turn `client_os` context only; does not affect transport/host-proxy capabilities.
|
|
19765
19841
|
type: string
|
|
19842
|
+
visibleAppId:
|
|
19843
|
+
description:
|
|
19844
|
+
'Id of the app the client currently has open on screen (app viewer or the app-editing split). Drives the
|
|
19845
|
+
per-turn `visible_app:` context line so the assistant can resolve "the app" to what the user is
|
|
19846
|
+
looking at. View state only: it never affects transport, routing, or tool gating, and is omitted
|
|
19847
|
+
whenever no app is in view.'
|
|
19848
|
+
type: string
|
|
19766
19849
|
clientMessageId:
|
|
19767
19850
|
type: string
|
|
19768
19851
|
description:
|
package/package.json
CHANGED
|
@@ -1092,4 +1092,47 @@ describe("ensureByokDefaultProfiles", () => {
|
|
|
1092
1092
|
|
|
1093
1093
|
expect(readFileSync(configPath(), "utf-8")).toBe(before);
|
|
1094
1094
|
});
|
|
1095
|
+
|
|
1096
|
+
// The hatch wrote each copy's model by resolving the cost profile's intent
|
|
1097
|
+
// at hatch time. These are the ids that resolved per provider, pinned as
|
|
1098
|
+
// literals rather than derived from the template: a test that materializes
|
|
1099
|
+
// the template agrees with itself no matter which intent the template names,
|
|
1100
|
+
// and so cannot catch a template whose model comparison has drifted off what
|
|
1101
|
+
// is on disk.
|
|
1102
|
+
test.each([
|
|
1103
|
+
["openrouter", "anthropic/claude-haiku-4.5"],
|
|
1104
|
+
["gemini", "gemini-3.1-flash-lite"],
|
|
1105
|
+
["openai", "gpt-5.6-luna"],
|
|
1106
|
+
] as const)(
|
|
1107
|
+
"an unedited %s cost copy still converts",
|
|
1108
|
+
(provider, hatchModel) => {
|
|
1109
|
+
writeConfig({
|
|
1110
|
+
llm: {
|
|
1111
|
+
defaultProvider: { provider },
|
|
1112
|
+
activeProfile: "custom-cost-optimized",
|
|
1113
|
+
profiles: {
|
|
1114
|
+
"custom-cost-optimized": {
|
|
1115
|
+
...(hatchBody("cost-optimized", provider) as Record<
|
|
1116
|
+
string,
|
|
1117
|
+
unknown
|
|
1118
|
+
>),
|
|
1119
|
+
model: hatchModel,
|
|
1120
|
+
},
|
|
1121
|
+
"custom-balanced": hatchBody("balanced", provider),
|
|
1122
|
+
"custom-quality-optimized": hatchBody(
|
|
1123
|
+
"quality-optimized",
|
|
1124
|
+
provider,
|
|
1125
|
+
),
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
ensureByokDefaultProfiles(workspaceDir);
|
|
1131
|
+
|
|
1132
|
+
expect(profiles()["custom-cost-optimized"]).toBeUndefined();
|
|
1133
|
+
expect(profiles()["cost-optimized"]).toBeUndefined();
|
|
1134
|
+
expect(llm().activeProfile).toBe("cost-optimized");
|
|
1135
|
+
expectSecondRunNoop();
|
|
1136
|
+
},
|
|
1137
|
+
);
|
|
1095
1138
|
});
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* turn. The `context_compacted` event is the single source of truth for the
|
|
8
8
|
* indicator — the paired `usage_update` intentionally omits
|
|
9
9
|
* `contextWindowTokens` to avoid a redundant SwiftUI invalidation.
|
|
10
|
+
*
|
|
11
|
+
* Also covers the `context_window_usage` push, which carries the provider
|
|
12
|
+
* tokenizer's post-compaction count so the web indicator matches the numbers
|
|
13
|
+
* on the `/compact` result card.
|
|
10
14
|
*/
|
|
11
15
|
import { describe, expect, mock, test } from "bun:test";
|
|
12
16
|
|
|
@@ -218,7 +222,8 @@ import { Conversation } from "../daemon/conversation.js";
|
|
|
218
222
|
// Helpers
|
|
219
223
|
// ---------------------------------------------------------------------------
|
|
220
224
|
|
|
221
|
-
function makeProvider() {
|
|
225
|
+
function makeProvider(counts?: number[]) {
|
|
226
|
+
const pending = counts ? [...counts] : null;
|
|
222
227
|
return {
|
|
223
228
|
name: "mock-provider",
|
|
224
229
|
async sendMessage(): Promise<ProviderResponse> {
|
|
@@ -229,16 +234,20 @@ function makeProvider() {
|
|
|
229
234
|
stopReason: "end_turn",
|
|
230
235
|
};
|
|
231
236
|
},
|
|
237
|
+
// Present only when the test supplies counts, so the other cases keep
|
|
238
|
+
// exercising the local-estimate fallback.
|
|
239
|
+
...(pending ? { countInputTokens: async () => pending.shift() ?? 0 } : {}),
|
|
232
240
|
};
|
|
233
241
|
}
|
|
234
242
|
|
|
235
243
|
function makeConversation(
|
|
236
244
|
collected: AssistantEvent[],
|
|
237
245
|
id = "conv-compact-events",
|
|
246
|
+
counts?: number[],
|
|
238
247
|
): Conversation {
|
|
239
248
|
return new Conversation(
|
|
240
249
|
id,
|
|
241
|
-
makeProvider(),
|
|
250
|
+
makeProvider(counts),
|
|
242
251
|
"system prompt",
|
|
243
252
|
(msg) => {
|
|
244
253
|
collected.push(msg);
|
|
@@ -383,6 +392,134 @@ describe("forceCompact event emission", () => {
|
|
|
383
392
|
});
|
|
384
393
|
});
|
|
385
394
|
|
|
395
|
+
describe("forceCompact context-window usage push", () => {
|
|
396
|
+
test("pushes the post-compaction tokenizer count the result card reports", async () => {
|
|
397
|
+
const collected: AssistantEvent[] = [];
|
|
398
|
+
mockCompactResult = {
|
|
399
|
+
messages: [],
|
|
400
|
+
compacted: true,
|
|
401
|
+
// Estimate-based figures the compaction pipeline records internally.
|
|
402
|
+
// The user-facing numbers come from the provider tokenizer instead.
|
|
403
|
+
previousEstimatedInputTokens: 43_000,
|
|
404
|
+
estimatedInputTokens: 12_000,
|
|
405
|
+
maxInputTokens: 200_000,
|
|
406
|
+
thresholdTokens: 160_000,
|
|
407
|
+
compactedMessages: 10,
|
|
408
|
+
compactedPersistedMessages: 5,
|
|
409
|
+
summaryCalls: 1,
|
|
410
|
+
summaryInputTokens: 500,
|
|
411
|
+
summaryOutputTokens: 200,
|
|
412
|
+
summaryModel: "test-model",
|
|
413
|
+
summaryText: "summary text",
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const conversation = makeConversation(
|
|
417
|
+
collected,
|
|
418
|
+
"conv-compact-usage",
|
|
419
|
+
[56_000, 18_000],
|
|
420
|
+
);
|
|
421
|
+
const result = await conversation.forceCompact();
|
|
422
|
+
|
|
423
|
+
expect(result.previousEstimatedInputTokens).toBe(56_000);
|
|
424
|
+
expect(result.estimatedInputTokens).toBe(18_000);
|
|
425
|
+
|
|
426
|
+
const usage = collected.filter((m) => m.type === "context_window_usage");
|
|
427
|
+
expect(usage.length).toBe(1);
|
|
428
|
+
const usageEvent = usage[0] as Extract<
|
|
429
|
+
AssistantEvent,
|
|
430
|
+
{ type: "context_window_usage" }
|
|
431
|
+
>;
|
|
432
|
+
expect(usageEvent.conversationId).toBe("conv-compact-usage");
|
|
433
|
+
// Same figures the `/compact` card renders, so the indicator and the card
|
|
434
|
+
// cannot disagree.
|
|
435
|
+
expect(usageEvent.tokens).toBe(result.estimatedInputTokens);
|
|
436
|
+
expect(usageEvent.maxTokens).toBe(200_000);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("pushes to the caller's sink when the conversation's own sender is dead", async () => {
|
|
440
|
+
// A `/compact` draining behind an interactive turn: `process-message.ts`
|
|
441
|
+
// resets `sendToClient` to a no-op in its `finally`, while the queued item
|
|
442
|
+
// still carries the live sink its result card streams on. The push has to
|
|
443
|
+
// follow the card, not the dead sender.
|
|
444
|
+
const stranded: AssistantEvent[] = [];
|
|
445
|
+
const cardSink: AssistantEvent[] = [];
|
|
446
|
+
mockCompactResult = {
|
|
447
|
+
messages: [],
|
|
448
|
+
compacted: true,
|
|
449
|
+
previousEstimatedInputTokens: 43_000,
|
|
450
|
+
estimatedInputTokens: 12_000,
|
|
451
|
+
maxInputTokens: 200_000,
|
|
452
|
+
thresholdTokens: 160_000,
|
|
453
|
+
compactedMessages: 10,
|
|
454
|
+
compactedPersistedMessages: 5,
|
|
455
|
+
summaryCalls: 1,
|
|
456
|
+
summaryInputTokens: 500,
|
|
457
|
+
summaryOutputTokens: 200,
|
|
458
|
+
summaryModel: "test-model",
|
|
459
|
+
summaryText: "summary text",
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const conversation = makeConversation(
|
|
463
|
+
stranded,
|
|
464
|
+
"conv-compact-dead-sender",
|
|
465
|
+
[56_000, 18_000],
|
|
466
|
+
);
|
|
467
|
+
conversation.updateClient(() => {}, true);
|
|
468
|
+
|
|
469
|
+
const result = await conversation.forceCompact((msg) => cardSink.push(msg));
|
|
470
|
+
|
|
471
|
+
const usage = cardSink.filter((m) => m.type === "context_window_usage");
|
|
472
|
+
expect(usage.length).toBe(1);
|
|
473
|
+
expect(
|
|
474
|
+
(usage[0] as Extract<AssistantEvent, { type: "context_window_usage" }>)
|
|
475
|
+
.tokens,
|
|
476
|
+
).toBe(result.estimatedInputTokens);
|
|
477
|
+
expect(
|
|
478
|
+
stranded.filter((m) => m.type === "context_window_usage").length,
|
|
479
|
+
).toBe(0);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("pushes the current count when there was nothing to compact", async () => {
|
|
483
|
+
const collected: AssistantEvent[] = [];
|
|
484
|
+
mockCompactResult = {
|
|
485
|
+
messages: [],
|
|
486
|
+
compacted: false,
|
|
487
|
+
previousEstimatedInputTokens: 0,
|
|
488
|
+
estimatedInputTokens: 0,
|
|
489
|
+
maxInputTokens: 200_000,
|
|
490
|
+
thresholdTokens: 160_000,
|
|
491
|
+
compactedMessages: 0,
|
|
492
|
+
compactedPersistedMessages: 0,
|
|
493
|
+
summaryCalls: 0,
|
|
494
|
+
summaryInputTokens: 0,
|
|
495
|
+
summaryOutputTokens: 0,
|
|
496
|
+
summaryModel: "",
|
|
497
|
+
summaryText: "",
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const conversation = makeConversation(
|
|
501
|
+
collected,
|
|
502
|
+
"conv-compact-usage-noop",
|
|
503
|
+
// A no-op re-counts nothing, so the single count stands as before and
|
|
504
|
+
// after.
|
|
505
|
+
[56_000],
|
|
506
|
+
);
|
|
507
|
+
const result = await conversation.forceCompact();
|
|
508
|
+
|
|
509
|
+
expect(result.previousEstimatedInputTokens).toBe(56_000);
|
|
510
|
+
expect(result.estimatedInputTokens).toBe(56_000);
|
|
511
|
+
|
|
512
|
+
const usage = collected.filter((m) => m.type === "context_window_usage");
|
|
513
|
+
expect(usage.length).toBe(1);
|
|
514
|
+
const usageEvent = usage[0] as Extract<
|
|
515
|
+
AssistantEvent,
|
|
516
|
+
{ type: "context_window_usage" }
|
|
517
|
+
>;
|
|
518
|
+
expect(usageEvent.tokens).toBe(56_000);
|
|
519
|
+
expect(usageEvent.maxTokens).toBe(200_000);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
386
523
|
describe("maybeCompact gate sizing", () => {
|
|
387
524
|
test("default sizing resolves mainAgent's window; wake sizing resolves the wake's call-site window", async () => {
|
|
388
525
|
// The auto-threshold gate derives its trip point from the
|
|
@@ -864,7 +864,7 @@ describe("loadConfig startup behavior", () => {
|
|
|
864
864
|
expect(effective["quality-optimized"]?.provider).toBe("openai");
|
|
865
865
|
expect(effective["quality-optimized"]?.model).toBe("gpt-5.4");
|
|
866
866
|
expect(effective["cost-optimized"]?.provider).toBe("openai");
|
|
867
|
-
expect(effective["cost-optimized"]?.model).toBe("gpt-5.
|
|
867
|
+
expect(effective["cost-optimized"]?.model).toBe("gpt-5.4-nano");
|
|
868
868
|
});
|
|
869
869
|
|
|
870
870
|
test("off-platform hatch with a provider outside the named matrix columns resolves through the shared BYOK templates", () => {
|
|
@@ -1471,7 +1471,7 @@ describe("seedInferenceProfiles BYOK-mode default profiles", () => {
|
|
|
1471
1471
|
);
|
|
1472
1472
|
expect(effective.balanced?.label).toBe("Balanced");
|
|
1473
1473
|
expect(effective["quality-optimized"]?.label).toBe("Quality");
|
|
1474
|
-
expect(effective["cost-optimized"]?.label).toBe("
|
|
1474
|
+
expect(effective["cost-optimized"]?.label).toBe("Cost");
|
|
1475
1475
|
expect("status" in (effective.balanced ?? {})).toBe(false);
|
|
1476
1476
|
expect("status" in (effective["quality-optimized"] ?? {})).toBe(false);
|
|
1477
1477
|
expect("status" in (effective["cost-optimized"] ?? {})).toBe(false);
|
|
@@ -1741,7 +1741,39 @@ describe("seedInferenceProfiles BYOK-mode default profiles", () => {
|
|
|
1741
1741
|
const effective = getEffectiveProfiles(raw.llm.profiles);
|
|
1742
1742
|
expect(effective.balanced?.label).toBe("Balanced");
|
|
1743
1743
|
expect(effective["quality-optimized"]?.label).toBe("Quality");
|
|
1744
|
-
expect(effective["cost-optimized"]?.label).toBe("
|
|
1744
|
+
expect(effective["cost-optimized"]?.label).toBe("Cost");
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
test("a managed key missing from profileOrder lands beside its siblings", () => {
|
|
1748
|
+
// An install that predates a newly shipped managed profile, with a custom
|
|
1749
|
+
// profile already in its order. The new key belongs with the other
|
|
1750
|
+
// managed profiles, not after whatever the user has arranged below them.
|
|
1751
|
+
writeConfig({
|
|
1752
|
+
llm: {
|
|
1753
|
+
default: { provider: "anthropic", model: "claude-opus-4-7" },
|
|
1754
|
+
profiles: {
|
|
1755
|
+
"my-custom": { source: "user", provider: "anthropic", model: "x" },
|
|
1756
|
+
},
|
|
1757
|
+
profileOrder: [
|
|
1758
|
+
"balanced",
|
|
1759
|
+
"quality-optimized",
|
|
1760
|
+
"cost-optimized",
|
|
1761
|
+
"my-custom",
|
|
1762
|
+
],
|
|
1763
|
+
activeProfile: "balanced",
|
|
1764
|
+
},
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
mergeDefaultConfigAndSeedInferenceProfiles();
|
|
1768
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
1769
|
+
|
|
1770
|
+
expect(raw.llm.profileOrder).toEqual([
|
|
1771
|
+
"balanced",
|
|
1772
|
+
"quality-optimized",
|
|
1773
|
+
"cost-optimized",
|
|
1774
|
+
"latency-optimized",
|
|
1775
|
+
"my-custom",
|
|
1776
|
+
]);
|
|
1745
1777
|
});
|
|
1746
1778
|
|
|
1747
1779
|
test("boot leaves legacy bare labels on managed entries untouched", () => {
|
|
@@ -215,3 +215,33 @@ describe("runAgentLoopImpl fatal-failure cleanup (ATL-1009)", () => {
|
|
|
215
215
|
expect(drainQueue).toHaveBeenCalledWith("loop_complete");
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Turn-scoped view state
|
|
221
|
+
//
|
|
222
|
+
// The app the client has on screen rides in on a message's transport. Nothing
|
|
223
|
+
// tells the daemon when the user closes it, so leaving the value on the
|
|
224
|
+
// conversation would let a later turn with no fresh client transport (a
|
|
225
|
+
// scheduled wake, a background follow-up) assert the user is looking at an app
|
|
226
|
+
// they closed. This suite reuses the loop harness above because the clear
|
|
227
|
+
// lives in the loop's per-turn teardown.
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
describe("runAgentLoopImpl per-turn view state", () => {
|
|
231
|
+
test("clears the reported visible app at turn end so a later turn cannot inherit it", async () => {
|
|
232
|
+
const events: AssistantEvent[] = [];
|
|
233
|
+
const ctx = makeCtx({
|
|
234
|
+
visibleAppId: "app-on-screen",
|
|
235
|
+
commitTurnChanges: mock(
|
|
236
|
+
async () => {},
|
|
237
|
+
) as unknown as Context["commitTurnChanges"],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await runAgentLoopImpl(ctx, "background task", "msg-visible-app", (event) =>
|
|
241
|
+
events.push(event),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(ctx.visibleAppId).toBeUndefined();
|
|
245
|
+
expect(ctx.currentTurnVisibleAppId).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -777,6 +777,9 @@ describe("classifyConversationError", () => {
|
|
|
777
777
|
expect(result.code).toBe("PROVIDER_INVALID_KEY");
|
|
778
778
|
expect(result.retryable).toBe(false);
|
|
779
779
|
expect(result.errorCategory).toBe("provider_invalid_key");
|
|
780
|
+
expect(result.userMessage).toBe(
|
|
781
|
+
"Your personal Anthropic API key was rejected by Anthropic. Update that key in Settings → Models & Services.",
|
|
782
|
+
);
|
|
780
783
|
});
|
|
781
784
|
|
|
782
785
|
it("classifies managed-proxy auth failures as managed credential refresh failures", () => {
|
|
@@ -791,12 +794,30 @@ describe("classifyConversationError", () => {
|
|
|
791
794
|
|
|
792
795
|
expect(result.code).toBe("MANAGED_KEY_INVALID");
|
|
793
796
|
expect(result.userMessage).toBe(
|
|
794
|
-
"
|
|
797
|
+
"Vellum's managed inference credential was rejected. This isn't a personal provider API key — Vellum provisions this one, so there's nothing to update in Settings.",
|
|
795
798
|
);
|
|
796
799
|
expect(result.retryable).toBe(false);
|
|
797
800
|
expect(result.errorCategory).toBe("managed_key_invalid");
|
|
798
801
|
});
|
|
799
802
|
|
|
803
|
+
it("keeps credential-shaped 4xx failures on the request's managed route", () => {
|
|
804
|
+
providerRoutingSources.anthropic = "user-key";
|
|
805
|
+
const err = new ProviderError(
|
|
806
|
+
"Authentication error: invalid API key",
|
|
807
|
+
"anthropic",
|
|
808
|
+
400,
|
|
809
|
+
);
|
|
810
|
+
err.attachRouteAttribution({ credentialSource: "vellum-managed" });
|
|
811
|
+
|
|
812
|
+
const result = classifyConversationError(err, baseCtx);
|
|
813
|
+
|
|
814
|
+
expect(result.code).toBe("MANAGED_KEY_INVALID");
|
|
815
|
+
expect(result.errorCategory).toBe("managed_key_invalid");
|
|
816
|
+
expect(result.userMessage).toBe(
|
|
817
|
+
"Vellum's managed inference credential was rejected. This isn't a personal provider API key — Vellum provisions this one, so there's nothing to update in Settings.",
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
|
|
800
821
|
it("classifies ProviderError 401 with 'invalid x-api-key' message as PROVIDER_INVALID_KEY", () => {
|
|
801
822
|
// Regex-match branch — Anthropic's standard 401 wording.
|
|
802
823
|
const err = new ProviderError(
|
|
@@ -814,6 +835,8 @@ describe("classifyConversationError", () => {
|
|
|
814
835
|
const result = classifyConversationError(err, baseCtx);
|
|
815
836
|
expect(result.code).toBe("PROVIDER_INVALID_KEY");
|
|
816
837
|
expect(result.errorCategory).toBe("provider_invalid_key");
|
|
838
|
+
expect(result.userMessage).toContain("personal OpenAI API key");
|
|
839
|
+
expect(result.userMessage).toContain("rejected by OpenAI");
|
|
817
840
|
});
|
|
818
841
|
|
|
819
842
|
it("includes connection/profile attribution in PROVIDER_INVALID_KEY when provided", () => {
|
|
@@ -826,7 +849,84 @@ describe("classifyConversationError", () => {
|
|
|
826
849
|
expect(result.code).toBe("PROVIDER_INVALID_KEY");
|
|
827
850
|
expect(result.connectionName).toBe("my-anthropic");
|
|
828
851
|
expect(result.profileName).toBe("personal");
|
|
829
|
-
expect(result.userMessage).
|
|
852
|
+
expect(result.userMessage).toBe(
|
|
853
|
+
'Your personal Anthropic API key for profile "personal" (connection "my-anthropic") was rejected by Anthropic. Update that key in Settings → Models & Services.',
|
|
854
|
+
);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("uses the request's BYOK attribution when the same provider also has a managed route", () => {
|
|
858
|
+
providerRoutingSources.anthropic = "managed-proxy";
|
|
859
|
+
const err = new ProviderError("Unauthorized", "anthropic", 401);
|
|
860
|
+
err.attachRouteAttribution({
|
|
861
|
+
credentialSource: "byok",
|
|
862
|
+
connectionName: "anthropic-personal",
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
const result = classifyConversationError(err, baseCtx);
|
|
866
|
+
|
|
867
|
+
expect(result.code).toBe("PROVIDER_INVALID_KEY");
|
|
868
|
+
expect(result.connectionName).toBe("anthropic-personal");
|
|
869
|
+
expect(result.userMessage).toBe(
|
|
870
|
+
'Your personal Anthropic API key for connection "anthropic-personal" was rejected by Anthropic. Update that key in Settings → Models & Services.',
|
|
871
|
+
);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it("uses the request's managed attribution when the same provider also has a BYOK route", () => {
|
|
875
|
+
providerRoutingSources.anthropic = "user-key";
|
|
876
|
+
const err = new ProviderError("Unauthorized", "anthropic", 401);
|
|
877
|
+
err.attachRouteAttribution({
|
|
878
|
+
credentialSource: "vellum-managed",
|
|
879
|
+
connectionName: "vellum",
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const result = classifyConversationError(err, baseCtx);
|
|
883
|
+
|
|
884
|
+
expect(result.code).toBe("MANAGED_KEY_INVALID");
|
|
885
|
+
expect(result.userMessage).toBe(
|
|
886
|
+
"Vellum's managed inference credential was rejected. This isn't a personal provider API key — Vellum provisions this one, so there's nothing to update in Settings.",
|
|
887
|
+
);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("uses subscription-specific recovery when the same provider also has a managed route", () => {
|
|
891
|
+
providerRoutingSources.openai = "managed-proxy";
|
|
892
|
+
const err = new ProviderError("Unauthorized", "openai", 401, {
|
|
893
|
+
reason: "invalid_credentials",
|
|
894
|
+
});
|
|
895
|
+
err.attachRouteAttribution({
|
|
896
|
+
credentialSource: "oauth-subscription",
|
|
897
|
+
connectionName: "chatgpt-subscription",
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const result = classifyConversationError(err, baseCtx);
|
|
901
|
+
|
|
902
|
+
expect(result.code).toBe("PROVIDER_API");
|
|
903
|
+
expect(result.errorCategory).toBe("provider_subscription_auth");
|
|
904
|
+
expect(result.retryable).toBe(false);
|
|
905
|
+
expect(result.connectionName).toBe("chatgpt-subscription");
|
|
906
|
+
expect(result.userMessage).toBe(
|
|
907
|
+
'Your OpenAI subscription login for connection "chatgpt-subscription" was rejected by OpenAI. Reconnect that account in Settings → Models & Services.',
|
|
908
|
+
);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("explains when a no-auth endpoint rejects an unauthenticated request", () => {
|
|
912
|
+
providerRoutingSources["openai-compatible"] = "managed-proxy";
|
|
913
|
+
const err = new ProviderError("Unauthorized", "openai-compatible", 401, {
|
|
914
|
+
reason: "invalid_credentials",
|
|
915
|
+
});
|
|
916
|
+
err.attachRouteAttribution({
|
|
917
|
+
credentialSource: "no-auth",
|
|
918
|
+
connectionName: "local-model-server",
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const result = classifyConversationError(err, baseCtx);
|
|
922
|
+
|
|
923
|
+
expect(result.code).toBe("PROVIDER_API");
|
|
924
|
+
expect(result.errorCategory).toBe("provider_endpoint_auth_required");
|
|
925
|
+
expect(result.retryable).toBe(false);
|
|
926
|
+
expect(result.connectionName).toBe("local-model-server");
|
|
927
|
+
expect(result.userMessage).toBe(
|
|
928
|
+
'The OpenAI-compatible endpoint for connection "local-model-server" requires authentication, but that connection is configured without credentials. Configure authentication for that endpoint in Settings → Models & Services.',
|
|
929
|
+
);
|
|
830
930
|
});
|
|
831
931
|
|
|
832
932
|
it("classifies direct ProviderError with 402 as provider_billing (non-retryable)", () => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `withoutTurnScopedTransport`, the guard that keeps per-message view
|
|
3
|
+
* state out of the durable `conversationOptions` map.
|
|
4
|
+
*
|
|
5
|
+
* `getOrCreateConversation` re-applies the stored transport whenever it rebuilds
|
|
6
|
+
* an evicted or stale conversation. Anything describing what the client had on
|
|
7
|
+
* screen for one message must be stripped before it is stored, or a rebuild
|
|
8
|
+
* hours later (a scheduled wake, a background follow-up) would resurrect an app
|
|
9
|
+
* the user has since closed and tell the model it is on screen.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
import { withoutTurnScopedTransport } from "../daemon/conversation-store.js";
|
|
15
|
+
import type { ConversationCreateOptions } from "../daemon/handlers/shared.js";
|
|
16
|
+
|
|
17
|
+
describe("withoutTurnScopedTransport", () => {
|
|
18
|
+
test("drops the visible app while keeping durable transport fields", () => {
|
|
19
|
+
const options: ConversationCreateOptions = {
|
|
20
|
+
transport: {
|
|
21
|
+
channelId: "vellum",
|
|
22
|
+
interfaceId: "web",
|
|
23
|
+
clientOs: "macos",
|
|
24
|
+
clientTimezone: "America/Denver",
|
|
25
|
+
visibleAppId: "app-on-screen",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const stored = withoutTurnScopedTransport(options);
|
|
30
|
+
|
|
31
|
+
expect(stored.transport?.visibleAppId).toBeUndefined();
|
|
32
|
+
expect(stored.transport?.clientOs).toBe("macos");
|
|
33
|
+
expect(stored.transport?.clientTimezone).toBe("America/Denver");
|
|
34
|
+
expect(stored.transport?.interfaceId).toBe("web");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not mutate the caller's options, which still drive this turn", () => {
|
|
38
|
+
const options: ConversationCreateOptions = {
|
|
39
|
+
transport: {
|
|
40
|
+
channelId: "vellum",
|
|
41
|
+
interfaceId: "web",
|
|
42
|
+
visibleAppId: "app-on-screen",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
withoutTurnScopedTransport(options);
|
|
47
|
+
|
|
48
|
+
expect(options.transport?.visibleAppId).toBe("app-on-screen");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("passes options through untouched when there is no app in view", () => {
|
|
52
|
+
const options: ConversationCreateOptions = {
|
|
53
|
+
transport: { channelId: "vellum", interfaceId: "web", clientOs: "ios" },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
expect(withoutTurnScopedTransport(options)).toBe(options);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("passes options through untouched when there is no transport", () => {
|
|
60
|
+
const options: ConversationCreateOptions = {};
|
|
61
|
+
|
|
62
|
+
expect(withoutTurnScopedTransport(options)).toBe(options);
|
|
63
|
+
});
|
|
64
|
+
});
|