@vellumai/assistant 0.12.3 → 0.12.4-staging.2

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 (103) 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/openapi.yaml +9 -3
  5. package/package.json +1 -1
  6. package/src/__tests__/approval-routes-http.test.ts +4 -2
  7. package/src/__tests__/call-routes-http.test.ts +4 -2
  8. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  9. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  10. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  11. package/src/__tests__/gemini-provider.test.ts +31 -0
  12. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  13. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  14. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  15. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  16. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  17. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  18. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  19. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  20. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  21. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  22. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  23. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  24. package/src/api/requests/dictation.ts +2 -2
  25. package/src/calls/__tests__/typesafe-noul.test.ts +66 -0
  26. package/src/calls/typesafe-noul.ts +8 -2
  27. package/src/calls/voice-escalation-judge.ts +5 -4
  28. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  29. package/src/cli/lib/bundled-marketplace.json +4 -4
  30. package/src/config/__tests__/default-profile-catalog.test.ts +33 -1
  31. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  32. package/src/config/call-site-defaults.ts +11 -11
  33. package/src/config/default-profile-catalog.ts +88 -18
  34. package/src/config/default-profile-names.ts +35 -0
  35. package/src/config/feature-flag-registry.json +8 -8
  36. package/src/config/inference-profile-validation.ts +7 -6
  37. package/src/config/llm-resolver.ts +16 -0
  38. package/src/config/profile-text-generation.test.ts +9 -0
  39. package/src/config/schemas/call-site-catalog.ts +1 -1
  40. package/src/config/schemas/llm.ts +54 -9
  41. package/src/config/sidebar-done-gate.ts +27 -0
  42. package/src/daemon/conversation-slash.ts +2 -2
  43. package/src/daemon/conversation-turn-finalize.ts +12 -0
  44. package/src/daemon/dictation-text-processing.ts +4 -4
  45. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  46. package/src/daemon/handlers/config-channels.ts +3 -1
  47. package/src/daemon/handlers/conversation-history.ts +11 -1
  48. package/src/daemon/handlers/conversations.ts +118 -10
  49. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  50. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  51. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  52. package/src/permissions/question-prompter.ts +14 -4
  53. package/src/persistence/conversation-crud.ts +114 -3
  54. package/src/persistence/conversation-queries.ts +48 -3
  55. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
  56. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  57. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  58. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  59. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  60. package/src/persistence/embeddings/embedding-local.ts +72 -17
  61. package/src/persistence/embeddings/embedding-types.ts +0 -19
  62. package/src/plugin-api/model-profiles.ts +5 -3
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  64. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  65. package/src/providers/gemini/client.ts +17 -1
  66. package/src/providers/malformed-tool-call.ts +55 -0
  67. package/src/providers/model-catalog.ts +23 -4
  68. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  69. package/src/providers/openai/chat-completions-provider.ts +30 -0
  70. package/src/providers/platform-proxy/constants.ts +5 -0
  71. package/src/providers/retry.ts +68 -36
  72. package/src/providers/vellum-model-routing.test.ts +1 -0
  73. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  74. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  75. package/src/runtime/agent-wake.ts +9 -2
  76. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  77. package/src/runtime/auth/middleware.ts +15 -11
  78. package/src/runtime/middleware/auth.ts +7 -1
  79. package/src/runtime/pending-interactions.ts +8 -8
  80. package/src/runtime/question-resolution.ts +54 -8
  81. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  82. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  83. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  84. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  85. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  86. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  87. package/src/runtime/routes/conversation-routes.ts +4 -2
  88. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  89. package/src/runtime/routes/global-search-routes.ts +22 -7
  90. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  91. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  92. package/src/runtime/sync/resource-sync-events.ts +11 -0
  93. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  94. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  95. package/src/runtime/verification-outbound-actions.ts +2 -2
  96. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  97. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  98. package/src/tools/workflows/manage-workflows.ts +4 -2
  99. package/src/util/worker-pipe.test.ts +100 -0
  100. package/src/util/worker-pipe.ts +44 -0
  101. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  102. package/src/workspace/migrations/registry.ts +2 -0
  103. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -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
+ });
@@ -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 }>;
@@ -31,16 +31,14 @@ import {
31
31
  import { resolveConversationId } from "../../persistence/conversation-key-store.js";
32
32
  import {
33
33
  type ConversationListFilter,
34
+ type ConversationListTypeFilter,
34
35
  countConversations,
35
36
  countConversationSections,
36
37
  countUnreadConversations,
37
38
  listConversations,
38
39
  listPinnedConversations,
39
40
  } from "../../persistence/conversation-queries.js";
40
- import {
41
- ASSISTANT_INITIATED_GROUP_ID,
42
- type ConversationType,
43
- } from "../../persistence/conversation-types.js";
41
+ import { ASSISTANT_INITIATED_GROUP_ID } from "../../persistence/conversation-types.js";
44
42
  import { getBindingsForConversations } from "../../persistence/external-conversation-store.js";
45
43
  import { listGroups } from "../../persistence/group-crud.js";
46
44
  import { UserError } from "../../util/errors.js";
@@ -252,12 +250,14 @@ function handleListConversations({
252
250
  const limit = Number(queryParams.limit ?? 50);
253
251
  const offset = Number(queryParams.offset ?? 0);
254
252
  // "background" is the back-compat umbrella (background + scheduled); newer
255
- // clients can pass "scheduled" to load only the Scheduled section. Absent
256
- // defaults to the standard foreground list.
257
- const conversationType: ConversationType =
253
+ // clients can pass "scheduled" to load only the Scheduled section, or "all"
254
+ // to page a user's whole history in one cursor. Absent defaults to the
255
+ // standard foreground list.
256
+ const conversationType: ConversationListTypeFilter =
258
257
  parseEnumQueryParam(queryParams, "conversationType", [
259
258
  "background",
260
259
  "scheduled",
260
+ "all",
261
261
  ]) ?? "standard";
262
262
  // Defaults to `active` so sidebar restores no longer pull archived rows.
263
263
  // The Archive page opts into `archived` to render only archived rows
@@ -294,7 +294,9 @@ function handleListConversations({
294
294
  read withholds them, so they appear in their own section and nowhere
295
295
  else. Confined to `standard`: the background and scheduled buckets are
296
296
  back-compat umbrellas addressed by conversation type, and narrowing them
297
- by source would drop rows their callers still page through. Confined to
297
+ by source would drop rows their callers still page through, while `all`
298
+ is a whole-history read with no second section to defer those rows to,
299
+ so withholding there would simply lose them. Confined to
298
300
  active reads too: the Archive view asks `archiveStatus=archived` with no
299
301
  `groupId`, and the section only ever lists active rows, so withholding
300
302
  there would leave an archived section thread visible nowhere. */
@@ -584,10 +586,7 @@ function handleMarkUnread({ body = {}, headers }: RouteHandlerArgs) {
584
586
  }
585
587
  }
586
588
 
587
- function handleGetConversation({
588
- pathParams = {},
589
- headers,
590
- }: RouteHandlerArgs) {
589
+ function handleGetConversation({ pathParams = {}, headers }: RouteHandlerArgs) {
591
590
  const detail = buildConversationDetailResponse(
592
591
  pathParams.id!,
593
592
  localeFromAcceptLanguage(headers?.["accept-language"]),
@@ -633,8 +632,11 @@ export const ROUTES: RouteDefinition[] = [
633
632
  type: "string",
634
633
  required: false,
635
634
  description:
636
- 'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella), or "scheduled" to list only scheduled conversations.',
637
- schema: { type: "string", enum: ["background", "scheduled"] },
635
+ 'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with archiveStatus=all for a whole-history read; rows are recency-ordered (COALESCE(last_message_at, updated_at) descending) like every other list read, and pinned rows are never appended to it.',
636
+ schema: {
637
+ type: "string",
638
+ enum: ["background", "scheduled", "all"],
639
+ },
638
640
  },
639
641
  {
640
642
  name: "archiveStatus",
@@ -65,6 +65,7 @@ import {
65
65
  } from "../../config/schemas/llm.js";
66
66
  import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
67
67
  import { ServiceModeSchema } from "../../config/schemas/services.js";
68
+ import { isSidebarDoneEnabled } from "../../config/sidebar-done-gate.js";
68
69
  import {
69
70
  describeShadowedConfigSet,
70
71
  findSubstrateShadowing,
@@ -2108,6 +2109,9 @@ async function handleSearchConversations({
2108
2109
  query: q,
2109
2110
  limit,
2110
2111
  maxMessagesPerConversation: maxMessages,
2112
+ // Under `sidebar-done` a done chat is filed rather than hidden, so the
2113
+ // agent's own history search reaches it the same way the user's does.
2114
+ includeArchived: isSidebarDoneEnabled(),
2111
2115
  });
2112
2116
  return { query: q, results };
2113
2117
  }
@@ -42,7 +42,7 @@ import {
42
42
  supportsHostProxy,
43
43
  } from "../../channels/types.js";
44
44
  import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
45
- import { getUserSelectableProfilesForProvider } from "../../config/default-profile-catalog.js";
45
+ import { getConversationProfilesForProvider } from "../../config/default-profile-catalog.js";
46
46
  import { isHttpAuthDisabled } from "../../config/env.js";
47
47
  import { getConfig } from "../../config/loader.js";
48
48
  import { isModeSessionRecoveryHealthy } from "../../config/session-groups-gate.js";
@@ -1754,7 +1754,9 @@ export async function handleSendMessage(
1754
1754
  }
1755
1755
  if (requestedInferenceProfile !== undefined) {
1756
1756
  const { llm } = getConfig();
1757
- const profiles = getUserSelectableProfilesForProvider(
1757
+ // The conversation view: a sticky pin runs the main agent's turns, so a
1758
+ // profile whose model returns verdicts rather than text is not offered.
1759
+ const profiles = getConversationProfilesForProvider(
1758
1760
  llm.profiles,
1759
1761
  llm.defaultProvider ?? null,
1760
1762
  );