@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
@@ -22,7 +22,6 @@ import {
22
22
  getConfiguredProvider,
23
23
  userMessage,
24
24
  } from "../../providers/provider-send-message.js";
25
- import { isMaxTokensStopReason } from "../../providers/stop-reasons.js";
26
25
  import { getLogger } from "../../util/logger.js";
27
26
  import { safeStringSlice } from "../../util/unicode.js";
28
27
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
@@ -73,56 +72,14 @@ function buildAppMetadataBlock(context: DictationContext): string {
73
72
  ].join("\n");
74
73
  }
75
74
 
76
- function buildCombinedDictationPrompt(
77
- body: DictationBody,
78
- stylePrompt?: string,
79
- ): string {
80
- // Every line here is read on every hold, so the prompt says each thing
81
- // once and leaves the model no reasoning to write.
82
- const sections = [
83
- "You are a voice input assistant. Given a speech transcription, classify it and, if it is dictation, clean it up.",
84
- "",
85
- "## Classification",
75
+ function buildDictationClassificationPrompt(body: DictationBody): string {
76
+ return [
77
+ "Classify the speech transcription without rewriting it.",
86
78
  "dictation: the user is composing text to be typed as-is.",
87
- "action: the user is asking an assistant to do something (send, message, open, search, create, schedule). Return the transcription unchanged.",
79
+ "action: the user is asking an assistant to do something (send, message, open, search, create, schedule).",
88
80
  `Cursor in text field: ${body.context.cursorInTextField ? "yes" : "no"}. If yes, lean toward dictation unless the intent to command is clear.`,
89
- "",
90
- "## Cleanup",
91
- "- Fix grammar, punctuation and capitalization; remove filler words",
92
- "- Rewrite hedging into clear statements, keeping the speaker's meaning",
93
- "- When the speaker enumerates items, lay them out as a list, one per line",
94
- "- Keep the user's natural voice; do not over-formalize casual speech",
95
- ];
96
-
97
- if (stylePrompt) {
98
- sections.push(
99
- "",
100
- "## User Style (highest priority)",
101
- "The user's own writing preferences. They override the tone guidance below.",
102
- "",
103
- stylePrompt,
104
- );
105
- }
106
-
107
- sections.push(
108
- "",
109
- "## Tone",
110
- stylePrompt
111
- ? "Fallback guidance where the User Style above is silent:"
112
- : "Adapt tone to the active application:",
113
- "- Email: professional but warm, greetings and sign-offs where they fit",
114
- "- Slack: casual and conversational",
115
- "- Code editors: technical and concise",
116
- "- Terminal: terse, command-like",
117
- "- Messages: very casual, short sentences",
118
- "- Notes and docs: neutral, clear writing",
119
- "- Otherwise: the user's natural voice",
120
- "The window title may name the recipient; adapt formality to the apparent relationship.",
121
- "",
122
81
  buildAppMetadataBlock(body.context),
123
- );
124
-
125
- return sections.join("\n");
82
+ ].join("\n");
126
83
  }
127
84
 
128
85
  function buildCommandPrompt(body: DictationBody, stylePrompt?: string): string {
@@ -184,44 +141,10 @@ function buildCommandPrompt(body: DictationBody, stylePrompt?: string): string {
184
141
 
185
142
  export function computeMaxTokens(inputLength: number): number {
186
143
  const estimatedInputTokens = Math.ceil(inputLength / 3);
187
- // The cleanup tool call carries the whole transcript back in its `text`
188
- // argument, plus a `reasoning` string and the JSON scaffolding around both,
189
- // so the budget has to cover well more than the input alone. `max_tokens` is
190
- // a ceiling rather than a target: a call that stops short of it costs no
191
- // extra latency.
144
+ // Leave room for the transformed selection and tool-call JSON.
192
145
  return Math.max(512, estimatedInputTokens * 2 + 256);
193
146
  }
194
147
 
195
- export type CleanupRejection = "truncated";
196
-
197
- /**
198
- * Decide whether the cleanup model's rewrite is safe to use as the payload.
199
- *
200
- * The rewrite replaces what the user actually said. A token-cap stop means the
201
- * tool JSON was cut mid-argument, so however complete the `text` argument
202
- * happens to look, it is a fragment; the raw transcript is the safer payload
203
- * then. Unpolished beats wrong.
204
- *
205
- * Whether a shorter rewrite still preserved the meaning is the model's call
206
- * (the prompt asks it to drop fillers and tighten phrasing), so there is no
207
- * length check here: a deterministic ratio cannot tell a concise cleanup from
208
- * a summary.
209
- */
210
- export function resolveCleanedDictation(
211
- raw: string,
212
- cleaned: string,
213
- stopReason: string | null | undefined,
214
- ): { text: string; rejected: CleanupRejection | null } {
215
- const trimmed = cleaned.trim();
216
- if (!trimmed) {
217
- return { text: raw, rejected: null };
218
- }
219
- if (isMaxTokensStopReason(stopReason)) {
220
- return { text: raw, rejected: "truncated" };
221
- }
222
- return { text: trimmed, rejected: null };
223
- }
224
-
225
148
  interface DictationResult {
226
149
  text: string;
227
150
  mode: DictationMode;
@@ -263,174 +186,88 @@ async function handleDictation(body: DictationBody): Promise<DictationResult> {
263
186
  return handleCommandMode(body, profile, profileMeta, stylePrompt);
264
187
  }
265
188
 
266
- // Non-command: single LLM call that classifies AND cleans in one shot
267
- const transcription = expandSnippets(body.transcription, profile.snippets);
189
+ const mode = await classifyDictation(body);
190
+ if (mode === "action") {
191
+ return {
192
+ text: body.transcription,
193
+ mode,
194
+ actionPlan: `User wants to: ${body.transcription}`,
195
+ ...profileMeta,
196
+ };
197
+ }
268
198
 
269
- // Covers provider resolution as well as the call, which is what the caller
270
- // waits for.
271
- const modelStartedAt = Date.now();
199
+ // Only explicit user-authored replacements may change dictated words.
200
+ const text = applyDictionary(
201
+ expandSnippets(body.transcription, profile.snippets),
202
+ profile.dictionary,
203
+ );
204
+ return { text, mode, ...profileMeta };
205
+ }
206
+
207
+ async function classifyDictation(
208
+ body: DictationBody,
209
+ ): Promise<"dictation" | "action"> {
272
210
  try {
273
211
  const provider = await getConfiguredProvider("interactionClassifier");
274
- if (!provider) {
275
- log.warn(
276
- "Dictation: no provider available, using heuristic + raw transcription",
212
+ if (provider) {
213
+ const { signal, cleanup } = createTimeout(
214
+ DICTATION_CLASSIFICATION_TIMEOUT_MS,
277
215
  );
278
- // Build a compatible msg for the heuristic
279
- const mode = detectDictationModeHeuristic({
280
- type: "dictation_request",
281
- transcription: body.transcription,
282
- context: body.context,
283
- } as DictationRequest);
284
- const normalizedText = applyDictionary(transcription, profile.dictionary);
285
- if (mode === "action") {
286
- return {
287
- text: body.transcription,
288
- mode: "action",
289
- actionPlan: `User wants to: ${body.transcription}`,
290
- ...profileMeta,
291
- };
292
- }
293
- return {
294
- text: normalizedText,
295
- mode,
296
- ...profileMeta,
297
- };
298
- }
299
-
300
- const systemPrompt = buildCombinedDictationPrompt(body, stylePrompt);
301
- const maxTokens = computeMaxTokens(transcription.length);
302
- const { signal, cleanup } = createTimeout(
303
- DICTATION_CLASSIFICATION_TIMEOUT_MS,
304
- );
305
-
306
- try {
307
- const response = await provider.sendMessage(
308
- [userMessage(`Transcription: "${transcription}"`)],
309
- {
310
- tools: [
311
- {
312
- name: "process_dictation",
313
- description: "Classify the voice input and return cleaned text",
314
- input_schema: {
315
- type: "object" as const,
316
- properties: {
317
- mode: {
318
- type: "string",
319
- enum: ["dictation", "action"],
320
- description:
321
- "dictation = user wants text inserted/cleaned up for typing. action = user wants the assistant to perform a task.",
322
- },
323
- text: {
324
- type: "string",
325
- description:
326
- "If dictation: the cleaned/formatted text ready for insertion. If action: the raw transcription unchanged.",
216
+ try {
217
+ const response = await provider.sendMessage(
218
+ [userMessage(body.transcription)],
219
+ {
220
+ tools: [
221
+ {
222
+ name: "process_dictation",
223
+ description: "Classify the voice input",
224
+ input_schema: {
225
+ type: "object" as const,
226
+ properties: {
227
+ mode: {
228
+ type: "string",
229
+ enum: ["dictation", "action"],
230
+ description:
231
+ "dictation = user wants text inserted. action = user wants the assistant to perform a task.",
232
+ },
327
233
  },
234
+ required: ["mode"],
328
235
  },
329
- required: ["mode", "text"],
330
236
  },
237
+ ],
238
+ systemPrompt: buildDictationClassificationPrompt(body),
239
+ config: {
240
+ callSite: "interactionClassifier",
241
+ max_tokens: 512,
242
+ tool_choice: { type: "tool" as const, name: "process_dictation" },
331
243
  },
332
- ],
333
- systemPrompt,
334
- config: {
335
- callSite: "interactionClassifier",
336
- max_tokens: maxTokens,
337
- tool_choice: {
338
- type: "tool" as const,
339
- name: "process_dictation",
340
- },
244
+ signal,
341
245
  },
342
- signal,
343
- },
344
- );
345
- cleanup();
346
-
347
- const toolBlock = extractToolUse(response);
348
- if (toolBlock) {
349
- const input = toolBlock.input as {
350
- mode?: string;
351
- text?: string;
352
- };
353
- const mode: DictationMode =
354
- input.mode === "action" ? "action" : "dictation";
355
- log.info(
356
- {
357
- mode,
358
- modelMs: Date.now() - modelStartedAt,
359
- inChars: transcription.length,
360
- outChars: input.text?.length ?? 0,
361
- },
362
- "LLM dictation classify+clean",
363
246
  );
364
-
365
- if (mode === "action") {
366
- return {
367
- text: body.transcription,
368
- mode: "action",
369
- actionPlan: `User wants to: ${body.transcription}`,
370
- ...profileMeta,
371
- };
247
+ const input = extractToolUse(response)?.input as
248
+ | { mode?: string }
249
+ | undefined;
250
+ if (input?.mode === "action" || input?.mode === "dictation") {
251
+ return input.mode;
372
252
  }
373
- const { text: cleanedText, rejected } = resolveCleanedDictation(
374
- transcription,
375
- input.text ?? "",
376
- response.stopReason,
377
- );
378
- if (rejected) {
379
- // Lengths only -- transcript content must never be logged.
380
- log.warn(
381
- {
382
- rejected,
383
- stopReason: response.stopReason,
384
- rawChars: transcription.length,
385
- cleanedChars: input.text?.trim().length ?? 0,
386
- },
387
- "Dictation cleanup rejected, using raw transcription",
388
- );
389
- }
390
- const normalizedText = applyDictionary(cleanedText, profile.dictionary);
391
- return {
392
- text: normalizedText,
393
- mode: "dictation",
394
- ...profileMeta,
395
- };
253
+ } finally {
254
+ cleanup();
396
255
  }
397
-
398
- log.warn(
399
- { modelMs: Date.now() - modelStartedAt },
400
- "No tool_use block in combined dictation call, using heuristic",
401
- );
402
- } finally {
403
- cleanup();
404
256
  }
405
257
  } catch (err) {
406
258
  const message = err instanceof Error ? err.message : String(err);
407
259
  log.warn(
408
- { err: message, modelMs: Date.now() - modelStartedAt },
409
- "Combined dictation LLM call failed, using heuristic",
260
+ { err: message },
261
+ "Dictation classification failed, using heuristic",
410
262
  );
411
263
  }
412
264
 
413
- // Heuristic fallback
414
- const fallbackMode = detectDictationModeHeuristic({
265
+ const mode = detectDictationModeHeuristic({
415
266
  type: "dictation_request",
416
267
  transcription: body.transcription,
417
268
  context: body.context,
418
269
  } as DictationRequest);
419
- log.info({ mode: fallbackMode }, "Using heuristic fallback");
420
- if (fallbackMode === "action") {
421
- return {
422
- text: body.transcription,
423
- mode: "action",
424
- actionPlan: `User wants to: ${body.transcription}`,
425
- ...profileMeta,
426
- };
427
- }
428
- const normalizedText = applyDictionary(transcription, profile.dictionary);
429
- return {
430
- text: normalizedText,
431
- mode: fallbackMode,
432
- ...profileMeta,
433
- };
270
+ return mode === "action" ? "action" : "dictation";
434
271
  }
435
272
 
436
273
  async function handleCommandMode(
@@ -560,7 +397,7 @@ export const ROUTES: RouteDefinition[] = [
560
397
  },
561
398
  summary: "Process dictation",
562
399
  description:
563
- "Classify voice input as dictation or action, clean up text, and apply user style preferences.",
400
+ "Preserve dictated words with explicit dictionary and snippet replacements, classify actions, or edit selected text when instructed.",
564
401
  tags: ["diagnostics"],
565
402
  requestBody: DictationRequestSchema,
566
403
  responseBody: z.object({
@@ -11,6 +11,7 @@
11
11
  import { z } from "zod";
12
12
 
13
13
  import { getConfig } from "../../config/loader.js";
14
+ import { isSidebarDoneEnabled } from "../../config/sidebar-done-gate.js";
14
15
  import { searchContacts } from "../../contacts/contact-store.js";
15
16
  import { searchConversations } from "../../persistence/conversation-queries.js";
16
17
  import { getMemorySqlite } from "../../persistence/db-connection.js";
@@ -123,23 +124,32 @@ function parseCategories(raw: string | undefined): Set<Category> {
123
124
  * cleaned term as a literal query, so unknown filters are visible to lexical
124
125
  * matching rather than silently dropped.
125
126
  *
126
- * Examples:
127
+ * `defaultArchived` is what an unfiltered query means. It is `false` by
128
+ * default and `true` under the `sidebar-done` flag, where a done chat is a
129
+ * filed chat rather than a hidden one and search is how the user reaches it.
130
+ * Either way both filters still say exactly what they say, so `is:unarchived`
131
+ * is the opt-out whenever the default is on.
132
+ *
133
+ * Examples (with `defaultArchived: false`):
127
134
  * "foo bar" -> { term: "foo bar", archived: false }
128
135
  * "is:archived foo" -> { term: "foo", archived: true }
129
136
  * "foo is:archived" -> { term: "foo", archived: true }
130
137
  * "is:archived" -> { term: "", archived: true }
131
138
  * "is:starred foo" -> { term: "is:starred foo", archived: false }
132
139
  */
133
- function parseSearchQuery(rawQ: string | undefined): {
140
+ function parseSearchQuery(
141
+ rawQ: string | undefined,
142
+ defaultArchived: boolean,
143
+ ): {
134
144
  term: string;
135
145
  archived: boolean;
136
146
  } {
137
147
  if (!rawQ) {
138
- return { term: "", archived: false };
148
+ return { term: "", archived: defaultArchived };
139
149
  }
140
150
  const tokens = rawQ.split(/\s+/).filter((t) => t.length > 0);
141
151
  const termTokens: string[] = [];
142
- let archived = false;
152
+ let archived = defaultArchived;
143
153
  for (const tok of tokens) {
144
154
  const lower = tok.toLowerCase();
145
155
  if (
@@ -291,11 +301,16 @@ async function handleGlobalSearch({
291
301
  throw new BadRequestError("q query parameter is required");
292
302
  }
293
303
 
294
- // Pull the archive opt-in out of the query string itself (`is:archived` /
304
+ // Pull the archive filter out of the query string itself (`is:archived` /
295
305
  // `archive:yes` / etc.) so the user controls it the same way they control
296
306
  // every other modifier: by typing it into the search box. The cleaned term
297
- // is what the backends actually search on.
298
- const { term, archived: includeArchived } = parseSearchQuery(rawQ);
307
+ // is what the backends actually search on. Under `sidebar-done` the
308
+ // unfiltered default flips to including done chats, and `is:unarchived`
309
+ // becomes the token that narrows back to the live set.
310
+ const { term, archived: includeArchived } = parseSearchQuery(
311
+ rawQ,
312
+ isSidebarDoneEnabled(),
313
+ );
299
314
 
300
315
  const limit = Math.max(1, Math.min(Number(queryParams.limit ?? 20), 100));
301
316
  const categories = parseCategories(queryParams.categories);
@@ -1,14 +1,16 @@
1
1
  import { z } from "zod";
2
2
 
3
- import { CALL_SITE_DEFAULTS } from "../../config/call-site-defaults.js";
4
3
  import { getUserSelectableProfilesForProvider } from "../../config/default-profile-catalog.js";
5
- import { resolveDefaultProfileKey } from "../../config/llm-resolver.js";
4
+ import {
5
+ resolveDefaultProfileKey,
6
+ resolveShippedDefaultProfileKey,
7
+ } from "../../config/llm-resolver.js";
6
8
  import { loadConfig } from "../../config/loader.js";
7
9
  import {
8
10
  CALL_SITE_CATALOG,
9
11
  CALL_SITE_DOMAINS,
10
12
  } from "../../config/schemas/call-site-catalog.js";
11
- import type { LLMCallSite } from "../../config/schemas/llm.js";
13
+ import type { LLMCallSite, LLMConfig } from "../../config/schemas/llm.js";
12
14
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
13
15
  import type { RouteDefinition } from "./types.js";
14
16
 
@@ -30,7 +32,8 @@ const callSiteEntrySchema = z.object({
30
32
  * `defaultProfile` would scatter pinned or remapped sites across their
31
33
  * winners. Profileless call sites report `balanced`: the resolver's
32
34
  * fallback anchor consults the balanced remap for them, so they follow
33
- * the Balanced tier like any balanced-keyed site.
35
+ * the Balanced tier like any balanced-keyed site. A managed-only tier is
36
+ * omitted when it has no body under the workspace's default provider.
34
37
  */
35
38
  shippedDefaultProfile: z.string().optional(),
36
39
  });
@@ -40,16 +43,22 @@ const callSiteCatalogResponseSchema = z.object({
40
43
  callSites: z.array(callSiteEntrySchema),
41
44
  });
42
45
 
46
+ export function buildCallSiteCatalog(llm: LLMConfig) {
47
+ return CALL_SITE_CATALOG.map((entry) => ({
48
+ ...entry,
49
+ defaultProfile: resolveDefaultProfileKey(entry.id as LLMCallSite, llm),
50
+ shippedDefaultProfile: resolveShippedDefaultProfileKey(
51
+ entry.id as LLMCallSite,
52
+ llm,
53
+ ),
54
+ }));
55
+ }
56
+
43
57
  async function handleGetCallSites() {
44
58
  const { llm } = loadConfig();
45
59
  return {
46
60
  domains: CALL_SITE_DOMAINS,
47
- callSites: CALL_SITE_CATALOG.map((entry) => ({
48
- ...entry,
49
- defaultProfile: resolveDefaultProfileKey(entry.id as LLMCallSite, llm),
50
- shippedDefaultProfile:
51
- CALL_SITE_DEFAULTS[entry.id]?.profile ?? "balanced",
52
- })),
61
+ callSites: buildCallSiteCatalog(llm),
53
62
  };
54
63
  }
55
64
 
@@ -1,22 +1,32 @@
1
1
  /**
2
- * A scheduled, heartbeat, or background turn runs its document tools in a
3
- * sidecar worker, whose local hub has no SSE subscriber. The documents-changed
4
- * broadcast has to be handed to the daemon there, or the document edited while
5
- * the user was not looking stays invisible on every client.
2
+ * A scheduled, heartbeat, or background turn runs its document tools and its
3
+ * conversation writes in a sidecar worker, whose local hub has no SSE
4
+ * subscriber. Those broadcasts have to be handed to the daemon there, or the
5
+ * document edited and the conversation changed while the user was not looking
6
+ * stay invisible on every client.
6
7
  */
7
8
 
8
9
  import { beforeEach, describe, expect, mock, test } from "bun:test";
9
10
 
10
11
  let notifyCount = 0;
12
+ const listHandoffs: Array<{ reason: string; conversationIds: string[] }> = [];
11
13
 
12
14
  mock.module("./worker-daemon-notify.js", () => ({
13
15
  NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD:
14
16
  "notify_conversation_persisted_externally",
17
+ NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD:
18
+ "notify_conversation_list_changed_externally",
15
19
  NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD: "notify_documents_changed_externally",
16
20
  NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD:
17
21
  "notify_activation_progress_changed_externally",
18
22
  notifyDaemonActivationProgressChanged: async () => {},
19
23
  notifyDaemonConversationPersisted: async () => {},
24
+ notifyDaemonConversationListChanged: async (
25
+ reason: string,
26
+ conversationIds: string[],
27
+ ) => {
28
+ listHandoffs.push({ reason, conversationIds });
29
+ },
20
30
  notifyDaemonDocumentsChanged: async () => {
21
31
  notifyCount++;
22
32
  },
@@ -30,6 +40,7 @@ import {
30
40
  } from "../assistant-stream-state.js";
31
41
  import {
32
42
  DOCUMENTS_CHANGED_COALESCE_MS,
43
+ publishConversationListAndMetadataChanged,
33
44
  publishDocumentsChanged,
34
45
  } from "./resource-sync-events.js";
35
46
 
@@ -66,6 +77,7 @@ describe("publishDocumentsChanged in a sidecar worker", () => {
66
77
  beforeEach(async () => {
67
78
  await settle();
68
79
  notifyCount = 0;
80
+ listHandoffs.length = 0;
69
81
  _resetStreamStateForTesting();
70
82
  });
71
83
 
@@ -102,3 +114,37 @@ describe("publishDocumentsChanged in a sidecar worker", () => {
102
114
  expect(events[0].message).toMatchObject({ type: "sync_changed" });
103
115
  });
104
116
  });
117
+
118
+ describe("publishConversationListAndMetadataChanged in a sidecar worker", () => {
119
+ beforeEach(async () => {
120
+ await settle();
121
+ listHandoffs.length = 0;
122
+ _resetStreamStateForTesting();
123
+ });
124
+
125
+ test("hands the list invalidation to the daemon instead of publishing into a void", async () => {
126
+ disableStreamSeqStamping();
127
+
128
+ const events = await captureLocalPublishes(() => {
129
+ publishConversationListAndMetadataChanged("reordered", "conv-1");
130
+ });
131
+
132
+ expect(listHandoffs).toEqual([
133
+ { reason: "reordered", conversationIds: ["conv-1"] },
134
+ ]);
135
+ expect(events).toEqual([]);
136
+ });
137
+
138
+ test("publishes on the local hub in the daemon, where subscribers live", async () => {
139
+ const events = await captureLocalPublishes(() => {
140
+ publishConversationListAndMetadataChanged("reordered", "conv-1");
141
+ });
142
+
143
+ expect(listHandoffs).toEqual([]);
144
+ expect(events).toHaveLength(1);
145
+ expect(events[0].message).toMatchObject({
146
+ type: "sync_changed",
147
+ tags: ["conversations:list", "conversation:conv-1:metadata"],
148
+ });
149
+ });
150
+ });
@@ -12,6 +12,7 @@ import { isStreamSeqStampingDisabled } from "../assistant-stream-state.js";
12
12
  import { publishSyncInvalidation } from "./sync-publisher.js";
13
13
  import {
14
14
  notifyDaemonActivationProgressChanged,
15
+ notifyDaemonConversationListChanged,
15
16
  notifyDaemonConversationPersisted,
16
17
  notifyDaemonDocumentsChanged,
17
18
  } from "./worker-daemon-notify.js";
@@ -236,6 +237,16 @@ export function publishConversationListAndMetadataChanged(
236
237
  ? conversationIds
237
238
  : [conversationIds];
238
239
 
240
+ // In a sidecar worker the local hub has no SSE subscribers, so a publish
241
+ // here reaches nobody and the sidebar keeps showing a list the worker has
242
+ // already changed. Hand off to the daemon, which republishes on the hub
243
+ // clients actually subscribe to. A background worker turn has no
244
+ // originating client, so no `originClientId` is forwarded.
245
+ if (isStreamSeqStampingDisabled()) {
246
+ void notifyDaemonConversationListChanged(reason, ids);
247
+ return;
248
+ }
249
+
239
250
  // Shape-changing reasons (`created`, `deleted`, `reordered`) add or
240
251
  // remove rows or change the order of the paginated window — web must
241
252
  // refetch the list, so include the `conversationsList` umbrella tag.
@@ -33,13 +33,43 @@ mock.module("../../ipc/cli-client.js", () => ({
33
33
 
34
34
  import {
35
35
  NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD,
36
+ NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
36
37
  NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD,
37
38
  NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD,
38
39
  notifyDaemonActivationProgressChanged,
40
+ notifyDaemonConversationListChanged,
39
41
  notifyDaemonConversationPersisted,
40
42
  notifyDaemonDocumentsChanged,
41
43
  } from "./worker-daemon-notify.js";
42
44
 
45
+ describe("notifyDaemonConversationListChanged", () => {
46
+ beforeEach(() => {
47
+ calls.length = 0;
48
+ nextResult = { ok: true };
49
+ shouldThrow = false;
50
+ });
51
+
52
+ test("hands the reason and ids to the daemon over the shared IPC method", async () => {
53
+ await notifyDaemonConversationListChanged("reordered", ["conv-1"]);
54
+
55
+ expect(calls).toHaveLength(1);
56
+ expect(calls[0]!.method).toBe(NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD);
57
+ expect(calls[0]!.params).toEqual({
58
+ body: { reason: "reordered", conversationIds: ["conv-1"] },
59
+ });
60
+ });
61
+
62
+ test("swallows a thrown IPC error (best-effort, no throw)", async () => {
63
+ shouldThrow = true;
64
+
65
+ const result = await notifyDaemonConversationListChanged("reordered", [
66
+ "conv-1",
67
+ ]);
68
+ expect(result).toBeUndefined();
69
+ expect(calls).toHaveLength(1);
70
+ });
71
+ });
72
+
43
73
  describe("notifyDaemonConversationPersisted", () => {
44
74
  beforeEach(() => {
45
75
  calls.length = 0;