@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.
Files changed (100) hide show
  1. package/openapi.yaml +87 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  4. package/src/__tests__/compaction-events.test.ts +139 -2
  5. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  6. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  7. package/src/__tests__/conversation-error.test.ts +102 -2
  8. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  9. package/src/__tests__/conversation-queue.test.ts +46 -0
  10. package/src/__tests__/conversation-slash.test.ts +4 -2
  11. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  12. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  13. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  14. package/src/__tests__/document-find-replace.test.ts +2 -1
  15. package/src/__tests__/document-tool-security.test.ts +2 -1
  16. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  17. package/src/__tests__/document-workspace-file.test.ts +467 -0
  18. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  19. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  20. package/src/__tests__/llm-resolver.test.ts +3 -9
  21. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  22. package/src/__tests__/notification-platform-adapter.test.ts +30 -3
  23. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  24. package/src/__tests__/notification-vellum-adapter.test.ts +6 -2
  25. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  26. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  27. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  28. package/src/__tests__/visible-app-context.test.ts +189 -0
  29. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  30. package/src/api/events/context-window-usage.ts +31 -0
  31. package/src/api/events/notification-intent.ts +3 -0
  32. package/src/api/index.ts +6 -0
  33. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  34. package/src/approvals/guardian-decision-primitive.ts +4 -0
  35. package/src/calls/__tests__/voice-session-bridge.test.ts +187 -8
  36. package/src/calls/voice-session-bridge.ts +167 -25
  37. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  38. package/src/config/call-site-defaults.ts +3 -2
  39. package/src/config/default-profile-catalog.ts +75 -70
  40. package/src/config/default-profile-names.ts +13 -28
  41. package/src/config/llm-resolver.ts +2 -2
  42. package/src/config/schemas/llm.ts +1 -9
  43. package/src/config/seed-inference-profiles.ts +19 -5
  44. package/src/daemon/conversation-agent-loop.ts +13 -0
  45. package/src/daemon/conversation-error.ts +123 -24
  46. package/src/daemon/conversation-process.ts +15 -2
  47. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  48. package/src/daemon/conversation-store.ts +36 -1
  49. package/src/daemon/conversation.ts +111 -27
  50. package/src/daemon/message-types/conversations.ts +8 -0
  51. package/src/documents/document-store.ts +247 -10
  52. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +171 -20
  53. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  54. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  55. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  56. package/src/live-voice/activity-label.ts +35 -0
  57. package/src/live-voice/live-voice-session.ts +114 -42
  58. package/src/messaging/providers/slack/withdraw.ts +6 -14
  59. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  60. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  61. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  62. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  63. package/src/notifications/__tests__/broadcaster.test.ts +26 -0
  64. package/src/notifications/adapters/macos.ts +2 -0
  65. package/src/notifications/adapters/platform.ts +62 -18
  66. package/src/notifications/adapters/telegram.ts +6 -4
  67. package/src/notifications/broadcaster.ts +12 -1
  68. package/src/notifications/types.ts +8 -0
  69. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  70. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  71. package/src/persistence/schema/documents.ts +26 -11
  72. package/src/persistence/steps.ts +2 -0
  73. package/src/plugin-api/model-profiles.test.ts +6 -2
  74. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  75. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  76. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  77. package/src/plugins/types.ts +17 -0
  78. package/src/prompts/templates/system-sections.ts +2 -2
  79. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  80. package/src/providers/inference/adapter-factory.ts +70 -12
  81. package/src/providers/model-intents.ts +11 -0
  82. package/src/providers/registry.ts +8 -0
  83. package/src/providers/retry.ts +85 -17
  84. package/src/providers/types.ts +1 -0
  85. package/src/runtime/channel-approval-types.ts +25 -0
  86. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  87. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  88. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  89. package/src/runtime/routes/conversation-management-routes.ts +8 -1
  90. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  91. package/src/runtime/routes/conversation-routes.ts +49 -1
  92. package/src/runtime/routes/documents-routes.ts +207 -12
  93. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  94. package/src/runtime/routes/workspace-routes.ts +11 -12
  95. package/src/runtime/routes/workspace-utils.ts +49 -2
  96. package/src/tools/workflows/run-workflow.test.ts +1 -0
  97. package/src/util/errors.ts +12 -0
  98. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  99. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  100. 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). Fails with 409 if
14683
- a connection with this name already exists.
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. For the Vellum-managed connection
14793
- (vellum) the auth is locked to platform; label remains editable.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.1-dev.202608032226.351a4e2",
3
+ "version": "0.11.1-dev.202608040007.969b6ee",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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.6-luna");
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("Speed");
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("Speed");
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
- "Couldn't refresh assistant credentials.",
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).toContain("personal");
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
+ });