@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
@@ -265,14 +265,14 @@ export function getByConversation(
265
265
  * /v1/host-transfer-result after completing the operation, get a 404, and the
266
266
  * proxy timer would fire with a spurious timeout error.
267
267
  *
268
- * `question` interactions are also skipped: a new message supersedes an open
269
- * ask_question by steering to it (see the enqueue path in
270
- * conversation-routes.ts), which aborts the parked turn and settles the
271
- * question via its abort signal. Clearing the entry here instead would drop it
272
- * without settling the prompt's Promise (questions carry no `rpcResolve`
273
- * fallback like secrets do) and would strip the steer of the entry it needs to
274
- * fire — which can co-occur with a confirmation, since one model response can
275
- * open both tools concurrently.
268
+ * `question` interactions are also skipped: a new message settles an open
269
+ * ask_question at the enqueue path (see `daemon/handlers/conversations.ts`),
270
+ * either by answering it with the typed text or by steering to the message,
271
+ * which aborts the parked turn and settles the question via its abort signal.
272
+ * Clearing the entry here instead would drop it without settling the prompt's
273
+ * Promise (questions carry no `rpcResolve` fallback like secrets do) and would
274
+ * strip both paths of the entry they need, which can co-occur with a
275
+ * confirmation since one model response can open both tools concurrently.
276
276
  */
277
277
  export function removeByConversation(
278
278
  conversationId: string,
@@ -2,7 +2,7 @@
2
2
  * Shared resolution core for pending `ask_question` interactions.
3
3
  *
4
4
  * A pending `question` interaction (registered by {@link QuestionPrompter}) can
5
- * be resolved from two places:
5
+ * be resolved from three places:
6
6
  *
7
7
  * - `POST /v1/question-response` — an app/web client submits the user's
8
8
  * batched selection (see `routes/question-routes.ts`).
@@ -10,8 +10,11 @@
10
10
  * or bare-text answer routes through the guardian reply router to the
11
11
  * `pending_question` resolver, which builds the submission (see
12
12
  * `approvals/guardian-request-resolvers.ts`).
13
+ * - the enqueue path: a message typed into the chat while the card is open
14
+ * answers the parked question as free text (see
15
+ * `daemon/handlers/conversations.ts`).
13
16
  *
14
- * Both funnel through {@link resolvePendingQuestion} so the validate → consume
17
+ * All funnel through {@link resolvePendingQuestion} so the validate → consume
15
18
  * → `rpcResolve` sequence has a single implementation. The helper is
16
19
  * transport-agnostic: it returns a discriminated outcome rather than throwing
17
20
  * route errors, so each caller maps the outcome to its own surface (HTTP status
@@ -28,12 +31,20 @@ import {
28
31
  import * as pendingInteractions from "./pending-interactions.js";
29
32
 
30
33
  /**
31
- * How to resolve a pending question: a batched submission (one entry per
32
- * question) or a close (every question reported as `skipped`).
34
+ * How to resolve a pending question:
35
+ *
36
+ * - `submit`: a batched submission, one entry per question.
37
+ * - `close`: the card was dismissed; every question is reported as
38
+ * `skipped`.
39
+ * - `chat_reply`: the user typed a message into the chat while the card was
40
+ * open. The text answers the first question as free text and the rest stay
41
+ * `skipped`; the result is flagged so the tool tells the model where the
42
+ * answer came from.
33
43
  */
34
44
  export type QuestionResolutionInput =
35
45
  | { kind: "submit"; submissions: QuestionBatchSubmission[] }
36
- | { kind: "close" };
46
+ | { kind: "close" }
47
+ | { kind: "chat_reply"; text: string };
37
48
 
38
49
  /**
39
50
  * Outcome of {@link resolvePendingQuestion}.
@@ -57,9 +68,10 @@ export type QuestionResolutionOutcome =
57
68
  | { status: "invalid"; message: string };
58
69
 
59
70
  /**
60
- * Resolve a pending `question` interaction with a batched submission or a
61
- * close. Validation runs BEFORE the interaction is consumed, so an `invalid`
62
- * outcome leaves the pending prompt (and its timer) intact for a retry.
71
+ * Resolve a pending `question` interaction with a batched submission, a close,
72
+ * or a chat reply. Validation runs BEFORE the interaction is consumed, so an
73
+ * `invalid` outcome leaves the pending prompt (and its timer) intact for a
74
+ * retry.
63
75
  */
64
76
  export function resolvePendingQuestion(
65
77
  requestId: string,
@@ -81,6 +93,8 @@ export function resolvePendingQuestion(
81
93
  })),
82
94
  overall: "closed",
83
95
  };
96
+ } else if (input.kind === "chat_reply") {
97
+ result = buildChatReplyResult(input.text, interaction);
84
98
  } else {
85
99
  result = buildCompletedResult(input.submissions, interaction);
86
100
  }
@@ -136,6 +150,38 @@ function buildCompletedResult(
136
150
  return { entries, overall: "completed" };
137
151
  }
138
152
 
153
+ /**
154
+ * Build the result for a chat reply: the typed text answers the first question
155
+ * as free text, every other question stays `skipped`.
156
+ *
157
+ * The daemon holds no partial per-question state (the card keeps the user's
158
+ * in-progress answers locally until the whole batch is posted), so the first
159
+ * question is by construction the first unanswered one. Which question the
160
+ * text really speaks to is the model's call, not the daemon's: the flag on the
161
+ * result tells it the answer came from the chat rather than the card, and it
162
+ * maps the reply onto the batch from there.
163
+ */
164
+ function buildChatReplyResult(
165
+ text: string,
166
+ interaction: ReturnType<typeof pendingInteractions.get>,
167
+ ): QuestionPromptResult {
168
+ const { orderedIds } = readBatchMetadata(interaction);
169
+ if (orderedIds.length === 0) {
170
+ throw new QuestionBatchValidationError(
171
+ "No registered question ids for this batch",
172
+ );
173
+ }
174
+ return {
175
+ entries: orderedIds.map((id, index) =>
176
+ index === 0
177
+ ? { questionId: id, decision: "free_text" as const, text }
178
+ : { questionId: id, decision: "skipped" as const },
179
+ ),
180
+ overall: "completed",
181
+ answeredInChat: true,
182
+ };
183
+ }
184
+
139
185
  /**
140
186
  * Pull the prompter-stashed batch bookkeeping off a pending interaction.
141
187
  * Returns empty defaults if the metadata is absent.
@@ -1302,3 +1302,149 @@ describe("GET /v1/conversations/sections", () => {
1302
1302
  expect(sections.some((s) => s.kind === "pinned")).toBe(false);
1303
1303
  });
1304
1304
  });
1305
+
1306
+ describe("GET /v1/conversations, conversationType=all", () => {
1307
+ beforeEach(() => {
1308
+ clearConversations();
1309
+ });
1310
+
1311
+ function setLastMessageAt(id: string, at: number): void {
1312
+ rawRun(
1313
+ "test:setLastMessageAt",
1314
+ "UPDATE conversations SET last_message_at = ? WHERE id = ?",
1315
+ at,
1316
+ id,
1317
+ );
1318
+ }
1319
+
1320
+ test("returns the standard listing and the background umbrella together", async () => {
1321
+ createConversation("foreground-1");
1322
+ createConversation({ title: "bg-1", conversationType: "background" });
1323
+ createConversation({ title: "sched-1", conversationType: "scheduled" });
1324
+
1325
+ const result = (await invoke({ conversationType: "all" })) as ListResponse;
1326
+
1327
+ expect(result.conversations.map((c) => c.title).sort()).toEqual([
1328
+ "bg-1",
1329
+ "foreground-1",
1330
+ "sched-1",
1331
+ ]);
1332
+ });
1333
+
1334
+ test("with archiveStatus=all one page spans live and done rows of every type", async () => {
1335
+ createConversation("foreground-1");
1336
+ createConversation({ title: "bg-1", conversationType: "background" });
1337
+ seedArchived("archived-1");
1338
+ const archivedBackground = createConversation({
1339
+ title: "archived-bg",
1340
+ conversationType: "background",
1341
+ });
1342
+ rawRun(
1343
+ "test:archiveConversation",
1344
+ "UPDATE conversations SET archived_at = ? WHERE id = ?",
1345
+ Date.now(),
1346
+ archivedBackground.id,
1347
+ );
1348
+
1349
+ const result = (await invoke({
1350
+ conversationType: "all",
1351
+ archiveStatus: "all",
1352
+ })) as ListResponse;
1353
+
1354
+ expect(result.conversations.map((c) => c.title).sort()).toEqual([
1355
+ "archived-1",
1356
+ "archived-bg",
1357
+ "bg-1",
1358
+ "foreground-1",
1359
+ ]);
1360
+ expect(result.hasMore).toBe(false);
1361
+ });
1362
+
1363
+ test("orders by last message time descending across the types it merges", async () => {
1364
+ const oldest = createConversation("oldest-foreground");
1365
+ const middle = createConversation({
1366
+ title: "middle-background",
1367
+ conversationType: "background",
1368
+ });
1369
+ const newest = createConversation("newest-foreground");
1370
+ setLastMessageAt(oldest.id, 1000);
1371
+ setLastMessageAt(middle.id, 2000);
1372
+ setLastMessageAt(newest.id, 3000);
1373
+
1374
+ const result = (await invoke({ conversationType: "all" })) as ListResponse;
1375
+
1376
+ expect(result.conversations.map((c) => c.title)).toEqual([
1377
+ "newest-foreground",
1378
+ "middle-background",
1379
+ "oldest-foreground",
1380
+ ]);
1381
+ });
1382
+
1383
+ test("excludes legacy private rows and subagent runs", async () => {
1384
+ createConversation("foreground-1");
1385
+ const legacyPrivate = createConversation("legacy-private");
1386
+ rawRun(
1387
+ "test:legacyPrivateRow",
1388
+ "UPDATE conversations SET conversation_type = 'private', group_id = 'system:background' WHERE id = ?",
1389
+ legacyPrivate.id,
1390
+ );
1391
+ const subagent = createConversation({
1392
+ title: "subagent-run",
1393
+ conversationType: "background",
1394
+ });
1395
+ rawRun(
1396
+ "test:subagentRun",
1397
+ "UPDATE conversations SET source = 'subagent' WHERE id = ?",
1398
+ subagent.id,
1399
+ );
1400
+
1401
+ const result = (await invoke({ conversationType: "all" })) as ListResponse;
1402
+
1403
+ expect(result.conversations.map((c) => c.title)).toEqual(["foreground-1"]);
1404
+ });
1405
+
1406
+ test("never appends pinned rows outside the page", async () => {
1407
+ // Pinned injection is the compatibility shim for the default sidebar
1408
+ // read. A whole-history page already contains the pinned row in recency
1409
+ // order, so appending it would duplicate it and put hasMore out of step
1410
+ // with the count.
1411
+ const pinned = createConversation("pinned-1");
1412
+ rawRun(
1413
+ "test:pinConversation",
1414
+ "UPDATE conversations SET is_pinned = 1, group_id = 'system:pinned' WHERE id = ?",
1415
+ pinned.id,
1416
+ );
1417
+ const newer = createConversation("newer-1");
1418
+ setLastMessageAt(pinned.id, 1000);
1419
+ setLastMessageAt(newer.id, 2000);
1420
+
1421
+ const result = (await invoke({
1422
+ conversationType: "all",
1423
+ limit: "1",
1424
+ })) as ListResponse;
1425
+
1426
+ expect(result.conversations.map((c) => c.title)).toEqual(["newer-1"]);
1427
+ expect(result.hasMore).toBe(true);
1428
+ });
1429
+
1430
+ test("hasMore describes the combined total, so pagination drains it", async () => {
1431
+ createConversation("foreground-1");
1432
+ createConversation({ title: "bg-1", conversationType: "background" });
1433
+ createConversation({ title: "sched-1", conversationType: "scheduled" });
1434
+
1435
+ const first = (await invoke({
1436
+ conversationType: "all",
1437
+ limit: "2",
1438
+ })) as ListResponse;
1439
+ expect(first.conversations).toHaveLength(2);
1440
+ expect(first.hasMore).toBe(true);
1441
+
1442
+ const second = (await invoke({
1443
+ conversationType: "all",
1444
+ limit: "2",
1445
+ offset: String(first.nextOffset),
1446
+ })) as ListResponse;
1447
+ expect(second.conversations).toHaveLength(1);
1448
+ expect(second.hasMore).toBe(false);
1449
+ });
1450
+ });
@@ -0,0 +1,174 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { DictationProfile } from "../../../daemon/dictation-profile-store.js";
4
+
5
+ let profile: DictationProfile;
6
+ let nextToolInput: { mode?: string; text?: string } | null;
7
+ let providerAvailable: boolean;
8
+ let providerFails: boolean;
9
+ let providerResolutions: number;
10
+ let requestedProperties: Record<string, unknown> | undefined;
11
+
12
+ mock.module("../../../providers/provider-send-message.js", () => ({
13
+ getConfiguredProvider: async () => {
14
+ providerResolutions += 1;
15
+ if (!providerAvailable) {
16
+ return null;
17
+ }
18
+ return {
19
+ sendMessage: async (
20
+ _messages: unknown,
21
+ options: {
22
+ tools: Array<{
23
+ input_schema: { properties: Record<string, unknown> };
24
+ }>;
25
+ },
26
+ ) => {
27
+ if (providerFails) {
28
+ throw new Error("Provider unavailable");
29
+ }
30
+ requestedProperties = options.tools[0]?.input_schema.properties;
31
+ return {
32
+ content: nextToolInput
33
+ ? [{ type: "tool_use", input: nextToolInput }]
34
+ : [{ type: "text", text: "Rewritten text." }],
35
+ };
36
+ },
37
+ };
38
+ },
39
+ extractToolUse: (response: { content: Array<{ type: string }> }) =>
40
+ response.content.find((block) => block.type === "tool_use"),
41
+ userMessage: (text: string) => ({ role: "user", content: text }),
42
+ createTimeout: () => ({ signal: undefined, cleanup: () => undefined }),
43
+ }));
44
+
45
+ mock.module("../../../daemon/dictation-profile-store.js", () => ({
46
+ resolveProfile: () => ({ profile, source: "default" }),
47
+ }));
48
+
49
+ const { ROUTES, computeMaxTokens } = await import("../diagnostics-routes.js");
50
+ const route = ROUTES.find((entry) => entry.operationId === "dictation_post")!;
51
+
52
+ async function dictate(
53
+ transcription: string,
54
+ context: { cursorInTextField?: boolean; selectedText?: string } = {
55
+ cursorInTextField: true,
56
+ },
57
+ ) {
58
+ return (await route.handler({
59
+ body: { transcription, context },
60
+ } as unknown as Parameters<typeof route.handler>[0])) as {
61
+ text: string;
62
+ mode: string;
63
+ actionPlan?: string;
64
+ };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ profile = { id: "general", name: "General" };
69
+ nextToolInput = { mode: "dictation", text: "We should ship it." };
70
+ providerAvailable = true;
71
+ providerFails = false;
72
+ providerResolutions = 0;
73
+ requestedProperties = undefined;
74
+ });
75
+
76
+ describe("dictation preserves recognized words", () => {
77
+ test.each([
78
+ "I think we probably shouldn't ship it, at least not yet.",
79
+ "Um, I don't, I don't want to say yes. Maybe next week?",
80
+ "First onions, then tomatoes. Actually, no tomatoes.",
81
+ " Please don't send that.\nI mean it. ",
82
+ "send me the draft, but don't send it to anyone else",
83
+ ])(
84
+ "preserves text-field dictation despite an attempted model rewrite: %s",
85
+ async (words) => {
86
+ expect(await dictate(words)).toMatchObject({
87
+ mode: "dictation",
88
+ text: words,
89
+ });
90
+ expect(providerResolutions).toBe(1);
91
+ },
92
+ );
93
+
94
+ test("a style profile cannot automatically rewrite ordinary dictation", async () => {
95
+ profile.stylePrompt = "Make every sentence confident and professional.";
96
+ const words = "I guess maybe we shouldn't do this.";
97
+
98
+ expect(await dictate(words)).toMatchObject({ text: words });
99
+ expect(providerResolutions).toBe(1);
100
+ });
101
+
102
+ test("whitespace-only selection is ordinary dictation", async () => {
103
+ expect(
104
+ await dictate("Don't change my words.", {
105
+ cursorInTextField: true,
106
+ selectedText: " \n ",
107
+ }),
108
+ ).toMatchObject({ mode: "dictation", text: "Don't change my words." });
109
+ expect(providerResolutions).toBe(1);
110
+ });
111
+
112
+ test("keeps explicitly configured snippet and dictionary replacements", async () => {
113
+ profile.snippets = [
114
+ { trigger: "my sign off", expansion: "Thanks, user one" },
115
+ ];
116
+ profile.dictionary = [{ spoken: "user one", written: "Example User" }];
117
+
118
+ expect(await dictate("Maybe later. my sign off")).toMatchObject({
119
+ mode: "dictation",
120
+ text: "Maybe later. Thanks, Example User",
121
+ });
122
+ expect(providerResolutions).toBe(1);
123
+ });
124
+
125
+ test("classification outside a text field cannot supply replacement text", async () => {
126
+ const words = "I think we probably shouldn't ship it.";
127
+
128
+ expect(await dictate(words, {})).toMatchObject({
129
+ mode: "dictation",
130
+ text: words,
131
+ });
132
+ expect(providerResolutions).toBe(1);
133
+ expect(Object.keys(requestedProperties ?? {})).toEqual(["mode"]);
134
+ });
135
+
136
+ test.each([true, false])(
137
+ "action classification preserves the original command with cursorInTextField=%s",
138
+ async (cursorInTextField) => {
139
+ nextToolInput = { mode: "action", text: "Send the files." };
140
+ profile.snippets = [{ trigger: "files", expansion: "all files" }];
141
+ const words = "Send the files, but not the private ones.";
142
+
143
+ expect(await dictate(words, { cursorInTextField })).toMatchObject({
144
+ mode: "action",
145
+ text: words,
146
+ actionPlan: `User wants to: ${words}`,
147
+ });
148
+ },
149
+ );
150
+
151
+ test.each([
152
+ "missing-provider",
153
+ "provider-error",
154
+ "missing-tool",
155
+ "invalid-mode",
156
+ ])("classification fallback preserves words when %s", async (failure) => {
157
+ providerAvailable = failure !== "missing-provider";
158
+ providerFails = failure === "provider-error";
159
+ nextToolInput = failure === "missing-tool" ? null : { mode: "invalid" };
160
+ const words = "I don't think that's what I meant, you know?";
161
+
162
+ expect(await dictate(words, {})).toMatchObject({
163
+ mode: "dictation",
164
+ text: words,
165
+ });
166
+ });
167
+ });
168
+
169
+ describe("selected-text output budget", () => {
170
+ test("reserves space for the edit and tool-call JSON", () => {
171
+ expect(computeMaxTokens(900)).toBeGreaterThan(600);
172
+ expect(computeMaxTokens(5)).toBe(512);
173
+ });
174
+ });
@@ -8,7 +8,10 @@
8
8
  *
9
9
  * Also covers the search-query parser: `is:archived` and its synonyms in the
10
10
  * `q` string flip the conversation arm to `includeArchived: true`, and the
11
- * filter token is stripped from the term before any backend is called.
11
+ * filter token is stripped from the term before any backend is called. Under
12
+ * the `sidebar-done` flag the unfiltered default is `true` instead and
13
+ * `is:unarchived` is the opt-out, so the flag-on twins assert the mirror
14
+ * image of the default ones.
12
15
  */
13
16
 
14
17
  import { afterEach, describe, expect, mock, test } from "bun:test";
@@ -30,9 +33,13 @@ const searchConversationsMock = mock(
30
33
  },
31
34
  );
32
35
 
36
+ let sidebarDoneEnabled = false;
37
+
33
38
  const actualContactStore = await import("../../../contacts/contact-store.js");
34
39
  const actualConvQueries =
35
40
  await import("../../../persistence/conversation-queries.js");
41
+ const actualSidebarDoneGate =
42
+ await import("../../../config/sidebar-done-gate.js");
36
43
 
37
44
  mock.module("../../../contacts/contact-store.js", () => ({
38
45
  ...actualContactStore,
@@ -42,6 +49,10 @@ mock.module("../../../persistence/conversation-queries.js", () => ({
42
49
  ...actualConvQueries,
43
50
  searchConversations: searchConversationsMock,
44
51
  }));
52
+ mock.module("../../../config/sidebar-done-gate.js", () => ({
53
+ ...actualSidebarDoneGate,
54
+ isSidebarDoneEnabled: () => sidebarDoneEnabled,
55
+ }));
45
56
 
46
57
  const { ROUTES } = await import("../global-search-routes.js");
47
58
 
@@ -69,6 +80,7 @@ afterEach(() => {
69
80
  searchConversationsCalls = [];
70
81
  searchConversationsResult = [];
71
82
  searchConversationsMock.mockClear();
83
+ sidebarDoneEnabled = false;
72
84
  });
73
85
 
74
86
  describe("global-search contacts recency source", () => {
@@ -268,3 +280,64 @@ describe("global-search q parser", () => {
268
280
  expect(result.queryTokens).toEqual(["alpha", "beta"]);
269
281
  });
270
282
  });
283
+
284
+ describe("global-search q parser under sidebar-done", () => {
285
+ test("plain term reaches done chats by default", async () => {
286
+ sidebarDoneEnabled = true;
287
+
288
+ await handler({
289
+ queryParams: { q: "shema", categories: "conversations" },
290
+ });
291
+
292
+ expect(searchConversationsCalls[0]?.query).toBe("shema");
293
+ expect(searchConversationsCalls[0]?.opts).toMatchObject({
294
+ includeArchived: true,
295
+ });
296
+ });
297
+
298
+ test("is:unarchived (and archive:no / archive:false) narrows back to live chats", async () => {
299
+ for (const filter of ["is:unarchived", "archive:no", "archive:false"]) {
300
+ sidebarDoneEnabled = true;
301
+ searchConversationsCalls = [];
302
+ searchConversationsMock.mockClear();
303
+
304
+ await handler({
305
+ queryParams: {
306
+ q: `${filter} shema`,
307
+ categories: "conversations",
308
+ },
309
+ });
310
+
311
+ expect(searchConversationsCalls[0]?.query).toBe("shema");
312
+ expect(searchConversationsCalls[0]?.opts).toMatchObject({
313
+ includeArchived: false,
314
+ });
315
+ }
316
+ });
317
+
318
+ test("is:archived stays a no-op the term is still stripped of", async () => {
319
+ sidebarDoneEnabled = true;
320
+
321
+ await handler({
322
+ queryParams: { q: "is:archived shema", categories: "conversations" },
323
+ });
324
+
325
+ expect(searchConversationsCalls[0]?.query).toBe("shema");
326
+ expect(searchConversationsCalls[0]?.opts).toMatchObject({
327
+ includeArchived: true,
328
+ });
329
+ });
330
+
331
+ test("unknown filter tokens stay in the term and do not narrow the default", async () => {
332
+ sidebarDoneEnabled = true;
333
+
334
+ await handler({
335
+ queryParams: { q: "is:starred shema", categories: "conversations" },
336
+ });
337
+
338
+ expect(searchConversationsCalls[0]?.query).toBe("is:starred shema");
339
+ expect(searchConversationsCalls[0]?.opts).toMatchObject({
340
+ includeArchived: true,
341
+ });
342
+ });
343
+ });
@@ -405,6 +405,66 @@ describe("POST inference/provider-connections (create)", () => {
405
405
  expect(result.auth).toEqual({ type: "platform" });
406
406
  });
407
407
 
408
+ test("stores an openai-compatible row pointed at opencode.ai as provider opencode", async () => {
409
+ const created = (await call(
410
+ findHandler("inference_provider_connections_create"),
411
+ {
412
+ body: {
413
+ name: "opencode-go",
414
+ provider: "openai-compatible",
415
+ credential: "vault/opencode/key",
416
+ base_url: "https://opencode.ai/zen/go/v1",
417
+ models: [{ id: "mimo-v2.6-flash" }],
418
+ },
419
+ },
420
+ )) as { provider: string; baseUrl: string | null };
421
+ expect(created.provider).toBe("opencode");
422
+ expect(created.baseUrl).toBe("https://opencode.ai/zen/go/v1");
423
+
424
+ const lookalike = (await call(
425
+ findHandler("inference_provider_connections_create"),
426
+ {
427
+ body: {
428
+ name: "lookalike",
429
+ provider: "openai-compatible",
430
+ base_url: "https://opencode.ai.evil.example/v1",
431
+ models: [{ id: "m" }],
432
+ },
433
+ },
434
+ )) as { provider: string };
435
+ expect(lookalike.provider).toBe("openai-compatible");
436
+ });
437
+
438
+ test("rejects a keyless openai-compatible row pointed at opencode.ai", async () => {
439
+ await expect(
440
+ call(findHandler("inference_provider_connections_create"), {
441
+ body: {
442
+ name: "opencode-keyless",
443
+ provider: "openai-compatible",
444
+ base_url: "https://opencode.ai/zen/v1",
445
+ models: [{ id: "m" }],
446
+ },
447
+ }),
448
+ ).rejects.toThrow(/Provider "opencode" requires an API key/);
449
+ });
450
+
451
+ test("rejects repointing an existing openai-compatible row at opencode.ai", async () => {
452
+ await call(findHandler("inference_provider_connections_create"), {
453
+ body: {
454
+ name: "my-proxy",
455
+ provider: "openai-compatible",
456
+ base_url: "http://localhost:8080/v1",
457
+ models: [{ id: "m" }],
458
+ },
459
+ });
460
+ await expect(
461
+ call(findHandler("inference_provider_connections_update"), {
462
+ pathParams: { name: "my-proxy" },
463
+ body: { base_url: "https://opencode.ai/zen/v1" },
464
+ }),
465
+ ).rejects.toThrow(/needs an "opencode" connection/);
466
+ });
467
+
408
468
  test("rejects a whitespace-only label", async () => {
409
469
  await expect(
410
470
  call(findHandler("inference_provider_connections_create"), {
@@ -4,8 +4,9 @@ import { CALL_SITE_DEFAULTS } from "../../../config/call-site-defaults.js";
4
4
  import {
5
5
  type LLMCallSite,
6
6
  LLMCallSiteEnum,
7
+ LLMSchema,
7
8
  } from "../../../config/schemas/llm.js";
8
- import { ROUTES } from "../llm-call-sites-routes.js";
9
+ import { buildCallSiteCatalog, ROUTES } from "../llm-call-sites-routes.js";
9
10
 
10
11
  const route = ROUTES.find((r) => r.operationId === "llm_call_sites_list")!;
11
12
 
@@ -89,6 +90,20 @@ describe("llm-call-sites-routes", () => {
89
90
  }
90
91
  });
91
92
 
93
+ test("omits an unavailable managed-only shipped default under BYOK", () => {
94
+ const managed = buildCallSiteCatalog(LLMSchema.parse({})).find(
95
+ ({ id }) => id === "voiceEscalationJudge",
96
+ );
97
+ expect(managed?.defaultProfile).toBe("jev-managed");
98
+ expect(managed?.shippedDefaultProfile).toBe("jev-managed");
99
+
100
+ const byok = buildCallSiteCatalog(
101
+ LLMSchema.parse({ defaultProvider: { provider: "anthropic" } }),
102
+ ).find(({ id }) => id === "voiceEscalationJudge");
103
+ expect(byok?.defaultProfile).toBeUndefined();
104
+ expect(byok?.shippedDefaultProfile).toBeUndefined();
105
+ });
106
+
92
107
  test("domains have non-empty id and displayName", async () => {
93
108
  const result = (await route.handler({})) as {
94
109
  domains: Array<{ id: string; displayName: string }>;