@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321

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 (209) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  5. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  6. package/openapi.yaml +97 -4
  7. package/package.json +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +4 -2
  9. package/src/__tests__/call-routes-http.test.ts +4 -2
  10. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  13. package/src/__tests__/conversation-pairing.test.ts +10 -0
  14. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  15. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  16. package/src/__tests__/gemini-provider.test.ts +31 -0
  17. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  18. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  19. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  20. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  21. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  22. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  23. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  24. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  25. package/src/__tests__/pricing.test.ts +33 -0
  26. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  27. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  28. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  29. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  30. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  31. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  32. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  33. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  34. package/src/__tests__/skill-load-tool.test.ts +42 -11
  35. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  36. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  37. package/src/__tests__/validate-input.test.ts +73 -0
  38. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  39. package/src/api/ask-question-tool.ts +50 -0
  40. package/src/api/events/tool-result.ts +11 -0
  41. package/src/api/index.ts +7 -0
  42. package/src/api/requests/dictation.ts +2 -2
  43. package/src/api/responses/home.ts +27 -0
  44. package/src/avatar/notification-avatar.test.ts +42 -34
  45. package/src/avatar/notification-avatar.ts +1 -0
  46. package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
  47. package/src/calls/voice-escalation-judge.ts +6 -5
  48. package/src/calls/voice-screen-annotation.ts +44 -0
  49. package/src/calls/voice-session-bridge.ts +22 -0
  50. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  51. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  52. package/src/cli/commands/platform/index.help.ts +2 -1
  53. package/src/cli/commands/platform/index.ts +8 -2
  54. package/src/cli/commands/plugins.ts +16 -0
  55. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  56. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
  57. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  58. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  59. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  60. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  62. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  63. package/src/cli/lib/bundled-marketplace.json +4 -4
  64. package/src/cli/lib/inspect-plugin.ts +5 -0
  65. package/src/cli/lib/plugin-catalog-cache.ts +41 -4
  66. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  67. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  68. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  69. package/src/cli/lib/plugin-details.ts +10 -7
  70. package/src/cli/lib/search-plugins.ts +2 -0
  71. package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
  72. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  73. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  74. package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
  75. package/src/config/call-site-defaults.ts +17 -11
  76. package/src/config/default-profile-catalog.ts +118 -19
  77. package/src/config/default-profile-names.ts +69 -0
  78. package/src/config/feature-flag-registry.json +25 -9
  79. package/src/config/inference-profile-validation.ts +7 -6
  80. package/src/config/llm-resolver.ts +16 -0
  81. package/src/config/profile-text-generation.test.ts +9 -0
  82. package/src/config/schemas/call-site-catalog.ts +8 -1
  83. package/src/config/schemas/llm.ts +70 -9
  84. package/src/config/sidebar-done-gate.ts +27 -0
  85. package/src/config/sync-gated-profiles.ts +96 -44
  86. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  87. package/src/daemon/auto-profile-router.ts +220 -0
  88. package/src/daemon/conversation-agent-loop.ts +77 -4
  89. package/src/daemon/conversation-slash.ts +2 -2
  90. package/src/daemon/conversation-turn-finalize.ts +12 -0
  91. package/src/daemon/dictation-text-processing.ts +4 -4
  92. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  93. package/src/daemon/handlers/config-channels.ts +3 -1
  94. package/src/daemon/handlers/conversation-history.ts +11 -1
  95. package/src/daemon/handlers/conversations.ts +118 -10
  96. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  97. package/src/daemon/pkb-reminder-builder.ts +1 -1
  98. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  99. package/src/home/feed-types.ts +2 -0
  100. package/src/inbound/platform-callback-registration.ts +19 -6
  101. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  102. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  103. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  104. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  105. package/src/live-voice/continuation-judge.ts +1 -1
  106. package/src/live-voice/live-voice-session.ts +7 -6
  107. package/src/live-voice/protocol.ts +16 -5
  108. package/src/messaging/providers/gmail/client.ts +4 -0
  109. package/src/notifications/README.md +11 -3
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  111. package/src/notifications/conversation-pairing.ts +23 -8
  112. package/src/notifications/home-feed-mirror.ts +39 -0
  113. package/src/notifications/home-feed-side-effect.ts +46 -19
  114. package/src/oauth/AGENTS.md +7 -1
  115. package/src/permissions/question-prompter.ts +14 -4
  116. package/src/persistence/conversation-crud.ts +114 -3
  117. package/src/persistence/conversation-queries.ts +48 -3
  118. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
  119. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  120. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  121. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  122. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  123. package/src/persistence/embeddings/embedding-local.ts +72 -17
  124. package/src/persistence/embeddings/embedding-types.ts +0 -19
  125. package/src/persistence/jobs-store.ts +229 -0
  126. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  127. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  128. package/src/persistence/steps.ts +12 -0
  129. package/src/platform/sync-avatar.test.ts +29 -0
  130. package/src/plugin-api/model-profiles.ts +5 -3
  131. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  132. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  133. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  134. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  135. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  136. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  137. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  138. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  139. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  140. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  141. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  142. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  143. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  144. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  145. package/src/providers/anthropic/client.ts +2 -2
  146. package/src/providers/gemini/client.ts +17 -1
  147. package/src/providers/inference/auth.ts +25 -0
  148. package/src/providers/jev/ask.test.ts +66 -0
  149. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
  150. package/src/providers/malformed-tool-call.ts +55 -0
  151. package/src/providers/model-catalog.ts +257 -4
  152. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  153. package/src/providers/openai/chat-completions-provider.ts +30 -0
  154. package/src/providers/openai/codex-models.ts +2 -0
  155. package/src/providers/platform-proxy/constants.ts +5 -0
  156. package/src/providers/retry.ts +68 -36
  157. package/src/providers/search-provider-catalog.ts +10 -0
  158. package/src/providers/vellum-model-routing.test.ts +1 -0
  159. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  160. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  161. package/src/runtime/agent-wake.ts +9 -2
  162. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  163. package/src/runtime/auth/middleware.ts +15 -11
  164. package/src/runtime/middleware/auth.ts +7 -1
  165. package/src/runtime/pending-interactions.ts +8 -8
  166. package/src/runtime/question-resolution.ts +54 -8
  167. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  168. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  169. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  170. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  171. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  172. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  173. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  174. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  175. package/src/runtime/routes/conversation-routes.ts +4 -2
  176. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  177. package/src/runtime/routes/global-search-routes.ts +22 -7
  178. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  179. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  180. package/src/runtime/routes/platform-routes.ts +6 -1
  181. package/src/runtime/routes/plugins-routes.ts +5 -2
  182. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  183. package/src/runtime/sync/resource-sync-events.ts +11 -0
  184. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  185. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  186. package/src/runtime/verification-outbound-actions.ts +2 -2
  187. package/src/skills/validate-input.ts +75 -11
  188. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  189. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  190. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  191. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  192. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  193. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  194. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  195. package/src/tools/network/web-fetch.ts +30 -9
  196. package/src/tools/network/web-search.ts +235 -4
  197. package/src/tools/skills/execute.ts +1 -1
  198. package/src/tools/skills/format-input-schema.ts +15 -0
  199. package/src/tools/skills/load.ts +15 -43
  200. package/src/tools/skills/scaffold-managed.ts +86 -71
  201. package/src/tools/skills/skill-tool-factory.ts +2 -1
  202. package/src/tools/workflows/manage-workflows.ts +4 -2
  203. package/src/util/worker-pipe.test.ts +146 -0
  204. package/src/util/worker-pipe.ts +49 -0
  205. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  206. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  207. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -0,0 +1,289 @@
1
+ /**
2
+ * A Done conversation comes back to the list when a message the user reads
3
+ * lands in it.
4
+ *
5
+ * Two seams enforce the rule: `insertMessageCore`, where every durable append
6
+ * lands, and the turn's finalize effect, where a streamed reply becomes
7
+ * readable. These tests drive the insert seam the way each ingest path does,
8
+ * with `addMessage` and that path's row shape, and drive the finalize seam
9
+ * through `resurfaceArchivedConversation` itself, which is all the finalize
10
+ * effect adds to the reserved row.
11
+ */
12
+
13
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
14
+
15
+ import { eq } from "drizzle-orm";
16
+
17
+ mock.module("../persistence/embeddings/qdrant-client.js", () => ({
18
+ getQdrantClient: () => ({
19
+ searchWithFilter: async () => [],
20
+ hybridSearch: async () => [],
21
+ upsertPoints: async () => {},
22
+ deletePoints: async () => {},
23
+ }),
24
+ initQdrantClient: () => {},
25
+ resolveQdrantUrl: () => "http://127.0.0.1:6333",
26
+ }));
27
+
28
+ let sidebarDoneEnabled = true;
29
+ mock.module("../config/sidebar-done-gate.js", () => ({
30
+ SIDEBAR_DONE_FLAG_KEY: "sidebar-done",
31
+ isSidebarDoneEnabled: () => sidebarDoneEnabled,
32
+ }));
33
+
34
+ const listInvalidations: Array<{ reason: string; conversationIds: unknown }> =
35
+ [];
36
+ const actualSyncEvents =
37
+ await import("../runtime/sync/resource-sync-events.js");
38
+ mock.module("../runtime/sync/resource-sync-events.js", () => ({
39
+ ...actualSyncEvents,
40
+ publishConversationListAndMetadataChanged: (
41
+ reason: string,
42
+ conversationIds: unknown,
43
+ ) => {
44
+ listInvalidations.push({ reason, conversationIds });
45
+ },
46
+ }));
47
+
48
+ import {
49
+ addMessage,
50
+ archiveConversation,
51
+ getConversation,
52
+ reserveMessage,
53
+ resurfaceArchivedConversation,
54
+ } from "../persistence/conversation-crud.js";
55
+ import { getDb } from "../persistence/db-connection.js";
56
+ import { initializeDb } from "../persistence/db-init.js";
57
+ import { conversations, messages } from "../persistence/schema/index.js";
58
+ import {
59
+ MEMORY_RETROSPECTIVE_INSTRUCTION_KIND,
60
+ SKILL_CARD_MESSAGE_KIND,
61
+ } from "../plugins/defaults/memory/memory-retrospective-constants.js";
62
+ import { setConfig } from "./helpers/set-config.js";
63
+
64
+ setConfig("memory", { extraction: { useLLM: false } });
65
+
66
+ await initializeDb();
67
+
68
+ const DONE_CONVERSATION_ID = "conv-done";
69
+ const RETRO_FORK_ID = "conv-done-retro-fork";
70
+
71
+ function insertConversation(id: string): void {
72
+ const now = Date.now();
73
+ getDb()
74
+ .insert(conversations)
75
+ .values({
76
+ id,
77
+ title: null,
78
+ createdAt: now,
79
+ updatedAt: now,
80
+ totalInputTokens: 0,
81
+ totalOutputTokens: 0,
82
+ totalEstimatedCost: 0,
83
+ contextSummary: null,
84
+ contextCompactedMessageCount: 0,
85
+ contextCompactedAt: null,
86
+ })
87
+ .run();
88
+ }
89
+
90
+ function resetTables(): void {
91
+ const db = getDb();
92
+ db.run("DELETE FROM messages");
93
+ db.run("DELETE FROM conversations");
94
+ insertConversation(DONE_CONVERSATION_ID);
95
+ insertConversation(RETRO_FORK_ID);
96
+ archiveConversation(DONE_CONVERSATION_ID);
97
+ listInvalidations.length = 0;
98
+ }
99
+
100
+ function archivedAtOf(id: string): number | null | undefined {
101
+ return getConversation(id)?.archivedAt;
102
+ }
103
+
104
+ function blocks(text: string): string {
105
+ return JSON.stringify([{ type: "text", text }]);
106
+ }
107
+
108
+ describe("Done conversations and new activity", () => {
109
+ beforeEach(() => {
110
+ sidebarDoneEnabled = true;
111
+ resetTables();
112
+ });
113
+
114
+ test("assistant output from a scheduled run brings the conversation back", async () => {
115
+ // The wake persists its `<background_event>` trigger first. That row is
116
+ // dropped from the transcript, so it must not resurface anything on its
117
+ // own: the run has produced nothing the user can read yet.
118
+ await addMessage(
119
+ DONE_CONVERSATION_ID,
120
+ "user",
121
+ blocks(
122
+ '<background_event source="schedule">Daily digest</background_event>',
123
+ ),
124
+ {
125
+ metadata: {
126
+ kind: "background-event",
127
+ backgroundEventSource: "schedule",
128
+ automated: true,
129
+ },
130
+ skipIndexing: true,
131
+ },
132
+ );
133
+
134
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
135
+ expect(listInvalidations).toHaveLength(0);
136
+
137
+ // The run's own output is what the user reads.
138
+ await addMessage(
139
+ DONE_CONVERSATION_ID,
140
+ "assistant",
141
+ blocks("Here is today's digest."),
142
+ { metadata: { automated: true }, skipIndexing: true },
143
+ );
144
+
145
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
146
+ expect(listInvalidations).toEqual([
147
+ { reason: "reordered", conversationIds: DONE_CONVERSATION_ID },
148
+ ]);
149
+ });
150
+
151
+ test("the empty row an LLM call reserves leaves the conversation Done", async () => {
152
+ const reserved = await reserveMessage(
153
+ DONE_CONVERSATION_ID,
154
+ "assistant",
155
+ undefined,
156
+ "inflight/ref.jsonl",
157
+ );
158
+
159
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
160
+ expect(listInvalidations).toHaveLength(0);
161
+
162
+ // The finalize seam is what resurfaces it, once the row holds content.
163
+ expect(
164
+ resurfaceArchivedConversation(DONE_CONVERSATION_ID, reserved.createdAt),
165
+ ).toBe(true);
166
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
167
+ expect(listInvalidations).toHaveLength(1);
168
+ });
169
+
170
+ test("a Done mark made after the activity wins over the resurface", () => {
171
+ const activityAt = Date.now() - 60_000;
172
+
173
+ expect(
174
+ resurfaceArchivedConversation(DONE_CONVERSATION_ID, activityAt),
175
+ ).toBe(false);
176
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
177
+ expect(listInvalidations).toHaveLength(0);
178
+ });
179
+
180
+ test("an inbound channel message from a person brings the conversation back", async () => {
181
+ await addMessage(DONE_CONVERSATION_ID, "user", "any updates?", {
182
+ metadata: {
183
+ userMessageChannel: "slack",
184
+ provenanceSourceChannel: "slack",
185
+ },
186
+ skipIndexing: true,
187
+ });
188
+
189
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
190
+ expect(listInvalidations).toHaveLength(1);
191
+ });
192
+
193
+ test("rows that never render leave the conversation Done", async () => {
194
+ await addMessage(DONE_CONVERSATION_ID, "user", "internal signal", {
195
+ metadata: { hidden: true },
196
+ skipIndexing: true,
197
+ });
198
+ await addMessage(DONE_CONVERSATION_ID, "user", "subagent finished", {
199
+ metadata: { subagentNotification: { subagentId: "sub-1" } },
200
+ skipIndexing: true,
201
+ });
202
+ await addMessage(DONE_CONVERSATION_ID, "user", "acp session update", {
203
+ metadata: { acpNotification: { sessionId: "acp-1" } },
204
+ skipIndexing: true,
205
+ });
206
+
207
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
208
+ expect(listInvalidations).toHaveLength(0);
209
+ });
210
+
211
+ test("a memory retrospective leaves the conversation it reviews Done", async () => {
212
+ // A retrospective runs in a fork: its instruction and its whole pass are
213
+ // appended to the fork, never to the conversation under review.
214
+ await addMessage(RETRO_FORK_ID, "user", "Review the transcript.", {
215
+ metadata: { kind: MEMORY_RETROSPECTIVE_INSTRUCTION_KIND, hidden: true },
216
+ skipIndexing: true,
217
+ });
218
+ await addMessage(RETRO_FORK_ID, "assistant", blocks("Saved one fact."), {
219
+ metadata: { automated: true },
220
+ skipIndexing: true,
221
+ });
222
+
223
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
224
+ expect(listInvalidations).toHaveLength(0);
225
+ });
226
+
227
+ test("a row its producer marked as bookkeeping leaves the conversation Done", async () => {
228
+ // The retrospective skill card, written the way
229
+ // `memory-retrospective-skill-card.ts` writes it. Persistence cannot
230
+ // import the memory plugin (`persistence-layering-guard`), so the producer
231
+ // declares the row instead of the seam recognising its kind.
232
+ await addMessage(
233
+ DONE_CONVERSATION_ID,
234
+ "assistant",
235
+ JSON.stringify([
236
+ {
237
+ type: "ui_surface",
238
+ surfaceId: "surface-1",
239
+ surfaceType: "skill_card",
240
+ title: "I just learned how to do expense filing",
241
+ display: "inline",
242
+ data: { skills: [] },
243
+ },
244
+ {
245
+ type: "text",
246
+ text: "I just learned how to do expense filing",
247
+ _surfaceFallback: true,
248
+ },
249
+ ]),
250
+ {
251
+ metadata: { kind: SKILL_CARD_MESSAGE_KIND, automated: true },
252
+ skipIndexing: true,
253
+ clientMessageId: "surface-1",
254
+ skipResurface: true,
255
+ },
256
+ );
257
+
258
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
259
+ expect(listInvalidations).toHaveLength(0);
260
+ });
261
+
262
+ test("the flag off keeps every append from clearing archived_at", async () => {
263
+ sidebarDoneEnabled = false;
264
+
265
+ await addMessage(DONE_CONVERSATION_ID, "user", "any updates?", {
266
+ metadata: {
267
+ userMessageChannel: "slack",
268
+ provenanceSourceChannel: "slack",
269
+ },
270
+ skipIndexing: true,
271
+ });
272
+ await addMessage(
273
+ DONE_CONVERSATION_ID,
274
+ "assistant",
275
+ blocks("Here is today's digest."),
276
+ { metadata: { automated: true }, skipIndexing: true },
277
+ );
278
+
279
+ expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
280
+ expect(listInvalidations).toHaveLength(0);
281
+ expect(
282
+ getDb()
283
+ .select()
284
+ .from(messages)
285
+ .where(eq(messages.conversationId, DONE_CONVERSATION_ID))
286
+ .all(),
287
+ ).toHaveLength(2);
288
+ });
289
+ });
@@ -116,6 +116,7 @@ describe("managed-column-only scoping", () => {
116
116
  expect(Object.keys(MANAGED_PROFILE_TEMPLATES)).toEqual([
117
117
  ...DEFAULT_PROFILE_KEYS,
118
118
  ...BACKUP_PROFILE_KEYS,
119
+ "jev-managed",
119
120
  ]);
120
121
  });
121
122
 
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { MALFORMED_TOOL_CALL_MESSAGE } from "../providers/malformed-tool-call.js";
3
4
  import type {
4
5
  ContentBlock,
5
6
  Message,
@@ -209,6 +210,36 @@ describe("GeminiProvider", () => {
209
210
  expect(result.stopReason).toBe("STOP");
210
211
  });
211
212
 
213
+ test("throws a status-less malformed tool-call error on MALFORMED_FUNCTION_CALL", async () => {
214
+ fakeChunks = [
215
+ textChunk(",featuredImage:{_type:image},patch:true}]})"),
216
+ finishChunk("MALFORMED_FUNCTION_CALL", 10, 5),
217
+ ];
218
+
219
+ const error = await provider
220
+ .sendMessage([{ role: "user", content: [{ type: "text", text: "Hi" }] }])
221
+ .then(
222
+ () => undefined,
223
+ (e: unknown) => e,
224
+ );
225
+
226
+ expect(error).toBeInstanceOf(ProviderError);
227
+ const providerError = error as ProviderError;
228
+ expect(providerError.statusCode).toBeUndefined();
229
+ expect(providerError.message).toContain(MALFORMED_TOOL_CALL_MESSAGE);
230
+ expect(providerError.message).toContain("MALFORMED_FUNCTION_CALL");
231
+ });
232
+
233
+ test("throws a malformed tool-call error on UNEXPECTED_TOOL_CALL", async () => {
234
+ fakeChunks = [finishChunk("UNEXPECTED_TOOL_CALL", 10, 0)];
235
+
236
+ await expect(
237
+ provider.sendMessage([
238
+ { role: "user", content: [{ type: "text", text: "Hi" }] },
239
+ ]),
240
+ ).rejects.toThrow(MALFORMED_TOOL_CALL_MESSAGE);
241
+ });
242
+
212
243
  // -----------------------------------------------------------------------
213
244
  // Streaming events
214
245
  // -----------------------------------------------------------------------
@@ -0,0 +1,17 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { validateInferenceProfileKey } from "../config/inference-profile-validation.js";
4
+ import { setConfig } from "./helpers/set-config.js";
5
+
6
+ describe("validateInferenceProfileKey and the managed Jev profile", () => {
7
+ beforeEach(() => {
8
+ setConfig("llm", { profiles: {} });
9
+ });
10
+
11
+ test("a schedule or subagent turn cannot run on the verdict model", () => {
12
+ expect(validateInferenceProfileKey("balanced")).toBeNull();
13
+ expect(validateInferenceProfileKey("jev-managed")).toContain(
14
+ "is not defined in llm.profiles",
15
+ );
16
+ });
17
+ });
@@ -35,6 +35,7 @@ const MEMORY_JOB_TYPES = [
35
35
  "memory_retrospective",
36
36
  "memory_retrospective_sweep",
37
37
  "skill_card_insert",
38
+ "skill_update_receipt",
38
39
  "pkb_filing",
39
40
  "pkb_compaction",
40
41
  ].sort();
@@ -235,6 +235,10 @@ describe("LLM catalog parity: daemon vs client", () => {
235
235
 
236
236
  test("jev-latest opts out of chat text generation", () => {
237
237
  expect(catalogModelSupportsText("typesafe", "jev-latest")).toBe(false);
238
+ // A managed Jev profile is configured as `vellum`; the routing identity
239
+ // owns no catalog models, so the lookup must judge by the catalog owner.
240
+ expect(catalogModelSupportsText("vellum", "jev-latest")).toBe(false);
241
+ expect(catalogModelSupportsText("vellum", "claude-opus-5")).toBe(true);
238
242
  expect(catalogModelSupportsText("anthropic", "claude-opus-4-8")).toBe(true);
239
243
  expect(catalogModelSupportsText("openai-compatible", "local-model")).toBe(
240
244
  true,
@@ -225,6 +225,49 @@ describe("composeCallSiteTweak", () => {
225
225
  });
226
226
 
227
227
  describe("shipped call-site tuning", () => {
228
+ test("the escalation judge uses managed Jev by default", () => {
229
+ const resolved = resolveCallSiteConfig(
230
+ "voiceEscalationJudge",
231
+ LLMSchema.parse({}),
232
+ );
233
+
234
+ expect(resolved.provider).toBe("vellum");
235
+ expect(resolved.provider_connection).toBeUndefined();
236
+ expect(resolved.model).toBe("jev-latest");
237
+ });
238
+
239
+ test("the escalation judge falls through on a BYOK default provider", () => {
240
+ const resolved = resolveCallSiteConfig(
241
+ "voiceEscalationJudge",
242
+ LLMSchema.parse(anthropicDp),
243
+ );
244
+
245
+ expect(resolved.provider).toBe("anthropic");
246
+ expect(resolved.model).not.toBe("jev-latest");
247
+ });
248
+
249
+ test("a BYOK Jev call-site override replaces the managed default", () => {
250
+ const llm = LLMSchema.parse({
251
+ ...anthropicDp,
252
+ profiles: {
253
+ jev: {
254
+ ...completeCustom,
255
+ provider: "typesafe",
256
+ provider_connection: "typesafe-personal",
257
+ model: "jev-latest",
258
+ },
259
+ },
260
+ callSites: {
261
+ voiceEscalationJudge: { profile: "jev" },
262
+ },
263
+ });
264
+ const resolved = resolveCallSiteConfig("voiceEscalationJudge", llm);
265
+
266
+ expect(resolved.provider).toBe("typesafe");
267
+ expect(resolved.provider_connection).toBe("typesafe-personal");
268
+ expect(resolved.model).toBe("jev-latest");
269
+ });
270
+
228
271
  // A workspace entry that only repoints the profile must not cost the call
229
272
  // site the tuning it ships with. `recall` carries a full set: a token
230
273
  // budget, effort, sampling, thinking, and cache posture.
@@ -446,7 +446,7 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
446
446
  expect(JSON.stringify(messages)).not.toContain("prompt_cache_breakpoint");
447
447
  });
448
448
 
449
- test("catalog flags GPT-5.6 and GPT-6 Astra direct-openai rows", () => {
449
+ test("catalog flags GPT-5.6 and GPT-6 direct-openai rows", () => {
450
450
  const openai = PROVIDER_CATALOG.find((p) => p.id === "openai");
451
451
  const flagged = (openai?.models ?? [])
452
452
  .filter((m) => m.supportsPromptCacheBreakpoints)
@@ -457,6 +457,8 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
457
457
  "gpt-5.6-sol",
458
458
  "gpt-5.6-terra",
459
459
  "gpt-6-astra",
460
+ "gpt-6-luna",
461
+ "gpt-6-sol",
460
462
  ]);
461
463
  });
462
464
  });
@@ -11,6 +11,7 @@ let mockVelayWebhooksEnabled = false;
11
11
  let mockPlatformBaseUrl = "";
12
12
  let mockPlatformAssistantId = "";
13
13
  let mockSecureKeys: Record<string, string> = {};
14
+ let mockSecureKeysUnreachable = false;
14
15
  let mockConfig: { ingress?: { publicBaseUrl?: string; enabled?: boolean } } =
15
16
  {};
16
17
 
@@ -65,7 +66,12 @@ mock.module("../ipc/gateway-client.js", () => ({
65
66
  const actualSecureKeys = await import("../security/secure-keys.js");
66
67
  mock.module("../security/secure-keys.js", () => ({
67
68
  ...actualSecureKeys,
68
- getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? undefined,
69
+ getSecureKeyAsync: async (key: string) =>
70
+ mockSecureKeysUnreachable ? undefined : (mockSecureKeys[key] ?? undefined),
71
+ getSecureKeyResultAsync: async (key: string) =>
72
+ mockSecureKeysUnreachable
73
+ ? { value: undefined, unreachable: true }
74
+ : { value: mockSecureKeys[key] ?? undefined, unreachable: false },
69
75
  }));
70
76
 
71
77
  const actualLoader = await import("../config/loader.js");
@@ -92,6 +98,7 @@ describe("platform callback registration", () => {
92
98
  mockPlatformBaseUrl = "";
93
99
  mockPlatformAssistantId = "";
94
100
  mockSecureKeys = {};
101
+ mockSecureKeysUnreachable = false;
95
102
  mockConfig = {};
96
103
  setIngressPublicBaseUrl(undefined);
97
104
  delete process.env.ASSISTANT_API_KEY;
@@ -156,6 +163,43 @@ describe("platform callback registration", () => {
156
163
  expect(context.authHeader).toBe("Api-Key env-key");
157
164
  });
158
165
 
166
+ test("reports the key as missing when the store answers and holds none", async () => {
167
+ mockPlatformBaseUrl = "https://platform.example.com";
168
+ mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
169
+
170
+ const context = await resolvePlatformCallbackRegistrationContext();
171
+
172
+ expect(context.hasAssistantApiKey).toBe(false);
173
+ expect(context.authHeader).toBeNull();
174
+ expect(context.enabled).toBe(false);
175
+ });
176
+
177
+ test("reports the key state as unknown when the store did not answer", async () => {
178
+ // A client that provisions a key when it sees none must not be told
179
+ // "none" by a daemon whose store did not answer.
180
+ mockSecureKeysUnreachable = true;
181
+ mockPlatformBaseUrl = "https://platform.example.com";
182
+ mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
183
+
184
+ const context = await resolvePlatformCallbackRegistrationContext();
185
+
186
+ expect(context.hasAssistantApiKey).toBeNull();
187
+ expect(context.authHeader).toBeNull();
188
+ expect(context.enabled).toBe(false);
189
+ });
190
+
191
+ test("an environment key counts as present even when the store did not answer", async () => {
192
+ mockSecureKeysUnreachable = true;
193
+ process.env.ASSISTANT_API_KEY = "env-key";
194
+ mockPlatformBaseUrl = "https://platform.example.com";
195
+ mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
196
+
197
+ const context = await resolvePlatformCallbackRegistrationContext();
198
+
199
+ expect(context.hasAssistantApiKey).toBe(true);
200
+ expect(context.authHeader).toBe("Api-Key env-key");
201
+ });
202
+
159
203
  test("registerCallbackRoute falls back to assistant API key auth", async () => {
160
204
  mockSecureKeys[credentialKey("vellum", "platform_base_url")] =
161
205
  "https://platform.example.com";
@@ -402,6 +446,7 @@ describe("resolveCallbackUrl resolution order", () => {
402
446
  mockPlatformBaseUrl = "";
403
447
  mockPlatformAssistantId = "";
404
448
  mockSecureKeys = {};
449
+ mockSecureKeysUnreachable = false;
405
450
  mockConfig = {};
406
451
  setIngressPublicBaseUrl(undefined);
407
452
  delete process.env.ASSISTANT_API_KEY;
@@ -40,6 +40,13 @@ describe("getModelProfiles", () => {
40
40
  writeFixtureConfig({});
41
41
  });
42
42
 
43
+ test("omits the managed Jev profile, whose model returns verdicts rather than text", () => {
44
+ writeFixtureConfig({});
45
+ const keys = getModelProfiles().map((p) => p.key);
46
+ expect(keys).toContain("balanced");
47
+ expect(keys).not.toContain("jev-managed");
48
+ });
49
+
43
50
  test("orders profiles by profileOrder then the remaining keys alphabetically", () => {
44
51
  // GIVEN a workspace whose profileOrder names some keys, with a duplicate
45
52
  // ("balanced") and a key that resolves to no profile ("ghost").
@@ -196,6 +196,9 @@ const BASELINE: Record<string, readonly string[]> = {
196
196
  "../../../daemon/trust-context.js",
197
197
  "../../../home/job-handlers/conversation-starters.js",
198
198
  "../../../media/job-handlers/media-processing.js",
199
+ // The skill-update receipt job announces a burst through the
200
+ // notification pipeline's single entry point. No plugin-api equivalent.
201
+ "../../../notifications/emit-signal.js",
199
202
  "../../../permissions/types.js",
200
203
  "../../../persistence/auto-analysis-constants.js",
201
204
  "../../../persistence/checkpoints.js",
@@ -135,6 +135,39 @@ describe("resolvePricing", () => {
135
135
  expect(result.estimatedCostUsd).toBeCloseTo(0.5 + 0.625 + 0.05, 10);
136
136
  });
137
137
 
138
+ test("bills GPT-6 Sol at published short-context rates", () => {
139
+ const result = resolvePricingForUsage("openai", "gpt-6-sol", {
140
+ directInputTokens: 50_000,
141
+ outputTokens: 50_000,
142
+ cacheCreationInputTokens: 50_000,
143
+ cacheReadInputTokens: 50_000,
144
+ anthropicCacheCreation: null,
145
+ });
146
+
147
+ expect(result.pricingStatus).toBe("priced");
148
+ // 0.05M x $2 direct + 0.05M x $2.5 write + 0.05M x $0.2 read
149
+ // + 0.05M x $10 output.
150
+ expect(result.estimatedCostUsd).toBeCloseTo(0.1 + 0.125 + 0.01 + 0.5, 10);
151
+ });
152
+
153
+ test("bills GPT-6 Luna at the long-context tier above 272k", () => {
154
+ const result = resolvePricingForUsage("openai", "gpt-6-luna", {
155
+ directInputTokens: 500_000,
156
+ outputTokens: 1_000_000,
157
+ cacheCreationInputTokens: 500_000,
158
+ cacheReadInputTokens: 1_000_000,
159
+ anthropicCacheCreation: null,
160
+ });
161
+
162
+ expect(result.pricingStatus).toBe("priced");
163
+ // Tier rates: 0.5M x $0.2 direct + 0.5M x $0.25 write + 1M x $0.02 read
164
+ // + 1M x $0.75 output.
165
+ expect(result.estimatedCostUsd).toBeCloseTo(
166
+ 0.1 + 0.125 + 0.02 + 0.75,
167
+ 10,
168
+ );
169
+ });
170
+
138
171
  test("bills GPT-6 Astra at the long-context tier above 272k", () => {
139
172
  const result = resolvePricingForUsage("openai", "gpt-6-astra", {
140
173
  directInputTokens: 500_000,
@@ -118,6 +118,7 @@ mock.module("../util/retry.js", () => {
118
118
  };
119
119
  });
120
120
 
121
+ import { malformedToolCallError } from "../providers/malformed-tool-call.js";
121
122
  import { RetryProvider } from "../providers/retry.js";
122
123
  import { createStreamTimeout } from "../providers/stream-timeout.js";
123
124
  import {
@@ -941,6 +942,51 @@ describe("RetryProvider — streaming corruption retries", () => {
941
942
  }
942
943
  });
943
944
 
945
+ test("malformed tool-call finishes retry once with a corrective note", async () => {
946
+ const providerError = malformedToolCallError(
947
+ "openrouter",
948
+ "MALFORMED_FUNCTION_CALL",
949
+ );
950
+ // Providers wrap stream-loop throws in their generic catch.
951
+ const wrapped = new ProviderError(
952
+ `OpenRouter request failed: ${providerError.message}`,
953
+ "openrouter",
954
+ undefined,
955
+ { cause: providerError },
956
+ );
957
+ const inner = makeCapturingFlaky(1, wrapped);
958
+ const provider = new RetryProvider(inner);
959
+
960
+ await provider.sendMessage(MESSAGES);
961
+
962
+ expect(inner.calls).toBe(2);
963
+ expect(inner.seen[0]).toBe(MESSAGES);
964
+ const retried = inner.seen[1]!;
965
+ const lastMessage = retried[retried.length - 1]!;
966
+ expect(lastMessage.role).toBe("user");
967
+ expect(lastMessage.content).toHaveLength(2);
968
+ const hint = lastMessage.content[1] as { type: string; text: string };
969
+ expect(hint.type).toBe("text");
970
+ expect(hint.text).toContain("did not parse");
971
+ expect(MESSAGES[MESSAGES.length - 1]!.content).toHaveLength(1);
972
+ });
973
+
974
+ test("a malformed tool-call message with an HTTP status is not retried", async () => {
975
+ const inner = makeFailing(
976
+ new ProviderError(
977
+ malformedToolCallError("gemini", "MALFORMED_FUNCTION_CALL").message,
978
+ "gemini",
979
+ 400,
980
+ ),
981
+ );
982
+ const provider = new RetryProvider(inner);
983
+
984
+ await expect(provider.sendMessage(MESSAGES)).rejects.toThrow(
985
+ "malformed tool call",
986
+ );
987
+ expect(inner.calls).toBe(1);
988
+ });
989
+
944
990
  test("other stream-corruption retries resend the messages untouched", async () => {
945
991
  const inner = makeCapturingFlaky(
946
992
  1,