@vellumai/assistant 0.10.3-dev.202606260318.2a238d5 → 0.10.3-dev.202606261107.ffa4dca

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606260318.2a238d5",
3
+ "version": "0.10.3-dev.202606261107.ffa4dca",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,15 +1,45 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- const mockRunBtwSidechain = mock(async (_params: Record<string, unknown>) => ({
4
- text: "Project kickoff",
5
- hadTextDeltas: true,
6
- response: {
7
- content: [{ type: "text", text: "Project kickoff" }],
3
+ const TITLE_TOOL_NAME = "record_conversation_title";
4
+
5
+ /** A forced-tool response: the model called `record_conversation_title`. */
6
+ function toolResponse(title: string) {
7
+ return {
8
+ content: [
9
+ {
10
+ type: "tool_use",
11
+ id: "toolu_title",
12
+ name: TITLE_TOOL_NAME,
13
+ input: { title },
14
+ },
15
+ ],
16
+ model: "test-model",
17
+ usage: { inputTokens: 10, outputTokens: 5 },
18
+ stopReason: "tool_use",
19
+ };
20
+ }
21
+
22
+ /** A plain-text response: the model ignored the forced tool and emitted text. */
23
+ function textResponse(text: string) {
24
+ return {
25
+ content: [{ type: "text", text }],
8
26
  model: "test-model",
9
27
  usage: { inputTokens: 10, outputTokens: 5 },
10
28
  stopReason: "end_turn",
11
- },
12
- }));
29
+ };
30
+ }
31
+
32
+ function makeProvider(
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock returns
34
+ // partial ProviderResponse shapes; `any` keeps the stub assignable to Provider.
35
+ impl: (messages: any, options: any) => any = async () =>
36
+ toolResponse("Project kickoff"),
37
+ ) {
38
+ return {
39
+ name: "test-provider",
40
+ sendMessage: mock(impl),
41
+ };
42
+ }
13
43
 
14
44
  const mockGetConversation = mock(
15
45
  (_conversationId: string) =>
@@ -29,21 +59,36 @@ const mockGetMessages = mock(() => [
29
59
  const mockUpdateConversationTitle = mock(() => {});
30
60
  const mockGetConfiguredProvider = mock(async () => null);
31
61
 
32
- mock.module("../runtime/btw-sidechain.js", () => ({
33
- runBtwSidechain: mockRunBtwSidechain,
34
- }));
35
-
36
62
  mock.module("../memory/conversation-crud.js", () => ({
37
- setConversationProcessingStartedAt: () => {},
38
- isConversationProcessing: () => false,
63
+ setConversationProcessingStartedAt: () => {},
64
+ isConversationProcessing: () => false,
39
65
  getConversation: mockGetConversation,
40
66
  getMessages: mockGetMessages,
41
67
  updateConversationTitle: mockUpdateConversationTitle,
42
68
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
43
69
  }));
44
70
 
71
+ // The title service imports `getConfiguredProvider` plus the pure response
72
+ // helpers (`createTimeout`, `userMessage`, `extractToolUse`, `extractAllText`)
73
+ // from this module. Replacing the module means we must re-provide working
74
+ // implementations of those helpers — they are stubbed here to mirror the real
75
+ // behavior the service depends on.
45
76
  mock.module("../providers/provider-send-message.js", () => ({
46
77
  getConfiguredProvider: mockGetConfiguredProvider,
78
+ createTimeout: () => ({
79
+ signal: new AbortController().signal,
80
+ cleanup: () => {},
81
+ }),
82
+ userMessage: (text: string) => ({ role: "user", content: text }),
83
+ extractToolUse: (response: { content?: Array<{ type: string }> }) =>
84
+ response?.content?.find((b) => b.type === "tool_use"),
85
+ extractAllText: (response: {
86
+ content?: Array<{ type: string; text?: string }>;
87
+ }) =>
88
+ (response?.content ?? [])
89
+ .filter((b) => b.type === "text")
90
+ .map((b) => b.text ?? "")
91
+ .join(" "),
47
92
  }));
48
93
 
49
94
  mock.module("../util/logger.js", () => ({
@@ -62,6 +107,7 @@ mock.module("../runtime/sync/resource-sync-events.js", () => ({
62
107
 
63
108
  import {
64
109
  AUTO_TITLE_DETERMINISTIC,
110
+ AUTO_TITLE_LLM,
65
111
  generateAndPersistConversationTitle,
66
112
  queueGenerateConversationTitle,
67
113
  regenerateConversationTitle,
@@ -70,19 +116,6 @@ import {
70
116
 
71
117
  describe("conversation-title-service", () => {
72
118
  beforeEach(() => {
73
- mockRunBtwSidechain.mockClear();
74
- mockRunBtwSidechain.mockImplementation(
75
- async (_params: Record<string, unknown>) => ({
76
- text: "Project kickoff",
77
- hadTextDeltas: true,
78
- response: {
79
- content: [{ type: "text", text: "Project kickoff" }],
80
- model: "test-model",
81
- usage: { inputTokens: 10, outputTokens: 5 },
82
- stopReason: "end_turn",
83
- },
84
- }),
85
- );
86
119
  mockGetConversation.mockClear();
87
120
  mockGetConversation.mockImplementation(
88
121
  (_conversationId: string) =>
@@ -106,13 +139,8 @@ describe("conversation-title-service", () => {
106
139
  mockPublishConversationTitleChanged.mockClear();
107
140
  });
108
141
 
109
- test("uses the BTW side-chain helper for initial title generation", async () => {
110
- const provider = {
111
- name: "test-provider",
112
- sendMessage: mock(async () => {
113
- throw new Error("provider.sendMessage should not be called directly");
114
- }),
115
- };
142
+ test("forces the title tool and persists the extracted title", async () => {
143
+ const provider = makeProvider();
116
144
 
117
145
  const result = await generateAndPersistConversationTitle({
118
146
  conversationId: "conv-1",
@@ -121,20 +149,29 @@ describe("conversation-title-service", () => {
121
149
  });
122
150
 
123
151
  expect(result).toEqual({ title: "Project kickoff", updated: true });
124
- expect(mockRunBtwSidechain).toHaveBeenCalledTimes(1);
125
- expect(mockRunBtwSidechain).toHaveBeenCalledWith(
126
- expect.objectContaining({
127
- provider,
128
- systemPrompt: expect.stringContaining("conversation titles"),
129
- tools: [],
130
- callSite: "conversationTitle",
131
- timeoutMs: 15_000,
132
- }),
133
- );
152
+ expect(provider.sendMessage).toHaveBeenCalledTimes(1);
153
+
154
+ const [, options] = provider.sendMessage.mock.calls[0] as [
155
+ unknown,
156
+ {
157
+ tools: Array<{ name: string }>;
158
+ systemPrompt: string;
159
+ config: { callSite: string; tool_choice: unknown };
160
+ },
161
+ ];
162
+ expect(options.config.callSite).toBe("conversationTitle");
163
+ expect(options.config.tool_choice).toEqual({
164
+ type: "tool",
165
+ name: TITLE_TOOL_NAME,
166
+ });
167
+ expect(options.tools).toHaveLength(1);
168
+ expect(options.tools[0].name).toBe(TITLE_TOOL_NAME);
169
+ expect(options.systemPrompt).toContain("conversation titles");
170
+
134
171
  expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
135
172
  "conv-1",
136
173
  "Project kickoff",
137
- 1,
174
+ AUTO_TITLE_LLM,
138
175
  );
139
176
  // Emit is service-native: persisting a title broadcasts the update so
140
177
  // every title origin (agent loop, bootstrap, voice) updates clients live.
@@ -165,21 +202,15 @@ describe("conversation-title-service", () => {
165
202
  },
166
203
  ]);
167
204
 
168
- const provider = {
169
- name: "test-provider",
170
- sendMessage: mock(async () => {
171
- throw new Error("should not call directly");
172
- }),
173
- };
205
+ const provider = makeProvider();
174
206
 
175
207
  await regenerateConversationTitle({ conversationId: "conv-1", provider });
176
208
 
177
- // The prompt sent to the sidechain should contain plain text, not raw JSON
178
- const prompt = (mockRunBtwSidechain.mock.calls[0] as any)?.[0]
209
+ // The prompt sent to the model should contain plain text, not raw JSON.
210
+ const prompt = (provider.sendMessage.mock.calls[0] as any)?.[0]?.[0]
179
211
  ?.content as string;
180
212
  expect(prompt).not.toContain('"type":"text"');
181
213
  expect(prompt).not.toContain('"type":"tool_use"');
182
- // Tool metadata should NOT appear in the title prompt
183
214
  expect(prompt).not.toContain("Tool use");
184
215
  expect(prompt).not.toContain("web_search");
185
216
  expect(prompt).toContain("Help me plan the kickoff");
@@ -213,31 +244,20 @@ describe("conversation-title-service", () => {
213
244
  },
214
245
  ]);
215
246
 
216
- const provider = {
217
- name: "test-provider",
218
- sendMessage: mock(async () => {
219
- throw new Error("should not call directly");
220
- }),
221
- };
247
+ const provider = makeProvider();
222
248
 
223
249
  await regenerateConversationTitle({ conversationId: "conv-1", provider });
224
250
 
225
- const prompt = (mockRunBtwSidechain.mock.calls[0] as any)?.[0]
251
+ const prompt = (provider.sendMessage.mock.calls[0] as any)?.[0]?.[0]
226
252
  ?.content as string;
227
253
  expect(prompt).not.toContain('"type":"tool_result"');
228
- // Tool-only assistant message should be skipped entirely
229
254
  expect(prompt).not.toContain("Tool use");
230
255
  expect(prompt).toContain("Search for restaurants");
231
256
  expect(prompt).toContain("Found 3 restaurants nearby");
232
257
  });
233
258
 
234
- test("uses the BTW side-chain helper for title regeneration", async () => {
235
- const provider = {
236
- name: "test-provider",
237
- sendMessage: mock(async () => {
238
- throw new Error("provider.sendMessage should not be called directly");
239
- }),
240
- };
259
+ test("forces the title tool for regeneration", async () => {
260
+ const provider = makeProvider();
241
261
 
242
262
  const result = await regenerateConversationTitle({
243
263
  conversationId: "conv-1",
@@ -245,20 +265,20 @@ describe("conversation-title-service", () => {
245
265
  });
246
266
 
247
267
  expect(result).toEqual({ title: "Project kickoff", updated: true });
248
- expect(mockRunBtwSidechain).toHaveBeenCalledTimes(1);
249
- expect(mockRunBtwSidechain).toHaveBeenCalledWith(
250
- expect.objectContaining({
251
- provider,
252
- systemPrompt: expect.stringContaining("conversation titles"),
253
- tools: [],
254
- callSite: "conversationTitle",
255
- timeoutMs: 15_000,
256
- }),
257
- );
268
+ expect(provider.sendMessage).toHaveBeenCalledTimes(1);
269
+ const [, options] = provider.sendMessage.mock.calls[0] as [
270
+ unknown,
271
+ { config: { callSite: string; tool_choice: unknown } },
272
+ ];
273
+ expect(options.config.callSite).toBe("conversationTitle");
274
+ expect(options.config.tool_choice).toEqual({
275
+ type: "tool",
276
+ name: TITLE_TOOL_NAME,
277
+ });
258
278
  expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
259
279
  "conv-1",
260
280
  "Project kickoff",
261
- 1,
281
+ AUTO_TITLE_LLM,
262
282
  );
263
283
  });
264
284
 
@@ -268,12 +288,7 @@ describe("conversation-title-service", () => {
268
288
  isAutoTitle: 1,
269
289
  });
270
290
 
271
- const provider = {
272
- name: "test-provider",
273
- sendMessage: mock(async () => {
274
- throw new Error("should not call directly");
275
- }),
276
- };
291
+ const provider = makeProvider();
277
292
 
278
293
  const result = await regenerateConversationTitle({
279
294
  conversationId: "conv-1",
@@ -282,28 +297,12 @@ describe("conversation-title-service", () => {
282
297
  });
283
298
 
284
299
  expect(result).toEqual({ title: "Project kickoff", updated: false });
285
- expect(mockRunBtwSidechain).not.toHaveBeenCalled();
300
+ expect(provider.sendMessage).not.toHaveBeenCalled();
286
301
  expect(mockUpdateConversationTitle).not.toHaveBeenCalled();
287
302
  });
288
303
 
289
304
  test("rejects meta-failure outputs like 'Missing Context' and uses fallback", async () => {
290
- mockRunBtwSidechain.mockImplementationOnce(async () => ({
291
- text: "Missing Context",
292
- hadTextDeltas: true,
293
- response: {
294
- content: [{ type: "text", text: "Missing Context" }],
295
- model: "test-model",
296
- usage: { inputTokens: 10, outputTokens: 5 },
297
- stopReason: "end_turn",
298
- },
299
- }));
300
-
301
- const provider = {
302
- name: "test-provider",
303
- sendMessage: mock(async () => {
304
- throw new Error("should not call directly");
305
- }),
306
- };
305
+ const provider = makeProvider(async () => toolResponse("Missing Context"));
307
306
 
308
307
  const result = await generateAndPersistConversationTitle({
309
308
  conversationId: "conv-1",
@@ -327,23 +326,7 @@ describe("conversation-title-service", () => {
327
326
  "No Topic",
328
327
  "Empty Conversation",
329
328
  ])("rejects meta-failure variant: %s", async (bad) => {
330
- mockRunBtwSidechain.mockImplementationOnce(async () => ({
331
- text: bad,
332
- hadTextDeltas: true,
333
- response: {
334
- content: [{ type: "text", text: bad }],
335
- model: "test-model",
336
- usage: { inputTokens: 10, outputTokens: 5 },
337
- stopReason: "end_turn",
338
- },
339
- }));
340
-
341
- const provider = {
342
- name: "test-provider",
343
- sendMessage: mock(async () => {
344
- throw new Error("should not call directly");
345
- }),
346
- };
329
+ const provider = makeProvider(async () => toolResponse(bad));
347
330
 
348
331
  const result = await generateAndPersistConversationTitle({
349
332
  conversationId: "conv-1",
@@ -354,6 +337,96 @@ describe("conversation-title-service", () => {
354
337
  expect(result.title).toBe("Untitled Conversation");
355
338
  });
356
339
 
340
+ // The core bug this PR fixes: weak title models emit their reasoning or
341
+ // continue the conversation, and that prose used to get persisted verbatim.
342
+ // These are real leaked titles observed in production.
343
+ test.each([
344
+ "I need to generate a",
345
+ "I'll work through these 22 files systematically.",
346
+ "The user wants a title",
347
+ "The conversation is about cooking",
348
+ "The assistant should summarize this",
349
+ "The title for this chat is unclear",
350
+ "Let me look at the new results",
351
+ "Based on the conversation, this is about cooking.",
352
+ "Here is a title for the conversation",
353
+ "Sure, here's a good title",
354
+ "User: hey baby Assistant: hi",
355
+ "Knowledge base updated.\n\nGenerate a 2-6 word title",
356
+ ])("rejects leaked-prose title from the forced tool: %s", async (prose) => {
357
+ const provider = makeProvider(async () => toolResponse(prose));
358
+
359
+ const result = await generateAndPersistConversationTitle({
360
+ conversationId: "conv-1",
361
+ provider,
362
+ userMessage: "hey baby",
363
+ });
364
+
365
+ expect(result.title).toBe("Untitled Conversation");
366
+ expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
367
+ "conv-1",
368
+ "Untitled Conversation",
369
+ AUTO_TITLE_DETERMINISTIC,
370
+ );
371
+ });
372
+
373
+ test.each([
374
+ "Auth Middleware Rewrite",
375
+ "Docker Volume Mounts",
376
+ "Onboarding Flow",
377
+ "Morning Check-In",
378
+ "T-Shirt Discussion",
379
+ // Bare noun-phrase titles whose opening words ("the user", "the
380
+ // conversation", "the assistant", "the title") must not be mistaken for
381
+ // leaked reasoning prose. They are legitimate topics and must be accepted.
382
+ "The User Interface Redesign",
383
+ "The Conversation API",
384
+ "The Assistant Onboarding",
385
+ "The Title Bar Bug",
386
+ ])("accepts a clean noun-phrase title: %s", async (good) => {
387
+ const provider = makeProvider(async () => toolResponse(good));
388
+
389
+ const result = await generateAndPersistConversationTitle({
390
+ conversationId: "conv-1",
391
+ provider,
392
+ userMessage: "x",
393
+ });
394
+
395
+ expect(result).toEqual({ title: good, updated: true });
396
+ expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
397
+ "conv-1",
398
+ good,
399
+ AUTO_TITLE_LLM,
400
+ );
401
+ });
402
+
403
+ test("falls back to response text when the model skips the forced tool", async () => {
404
+ // Provider returned plain text (forced tool ignored) with a compliant title.
405
+ const provider = makeProvider(async () => textResponse("Kickoff Planning"));
406
+
407
+ const result = await generateAndPersistConversationTitle({
408
+ conversationId: "conv-1",
409
+ provider,
410
+ userMessage: "x",
411
+ });
412
+
413
+ expect(result).toEqual({ title: "Kickoff Planning", updated: true });
414
+ });
415
+
416
+ test("rejects prose in the text-fallback path", async () => {
417
+ const provider = makeProvider(async () =>
418
+ textResponse("I need to generate a title for this conversation"),
419
+ );
420
+
421
+ const result = await generateAndPersistConversationTitle({
422
+ conversationId: "conv-1",
423
+ provider,
424
+ userMessage: "x",
425
+ });
426
+
427
+ expect(result.title).toBe("Untitled Conversation");
428
+ });
429
+
357
430
  test("regeneration skips LLM call when recent messages have no extractable text", async () => {
358
431
  mockGetMessages.mockReturnValueOnce([
359
432
  {
@@ -385,30 +458,20 @@ describe("conversation-title-service", () => {
385
458
  isAutoTitle: 1,
386
459
  });
387
460
 
388
- const provider = {
389
- name: "test-provider",
390
- sendMessage: mock(async () => {
391
- throw new Error("should not call directly");
392
- }),
393
- };
461
+ const provider = makeProvider();
394
462
 
395
463
  const result = await regenerateConversationTitle({
396
464
  conversationId: "conv-1",
397
465
  provider,
398
466
  });
399
467
 
400
- expect(mockRunBtwSidechain).not.toHaveBeenCalled();
468
+ expect(provider.sendMessage).not.toHaveBeenCalled();
401
469
  expect(mockUpdateConversationTitle).not.toHaveBeenCalled();
402
470
  expect(result).toEqual({ title: "Existing Title", updated: false });
403
471
  });
404
472
 
405
473
  test("title prompt content does not contain generation instructions", async () => {
406
- const provider = {
407
- name: "test-provider",
408
- sendMessage: mock(async () => {
409
- throw new Error("provider.sendMessage should not be called directly");
410
- }),
411
- };
474
+ const provider = makeProvider();
412
475
 
413
476
  await generateAndPersistConversationTitle({
414
477
  conversationId: "conv-1",
@@ -416,14 +479,15 @@ describe("conversation-title-service", () => {
416
479
  userMessage: "Help me plan the kickoff",
417
480
  });
418
481
 
419
- const call = mockRunBtwSidechain.mock.calls[0]![0] as {
420
- content: string;
421
- systemPrompt: string;
422
- };
423
- // Instructions should be in systemPrompt, not in content
424
- expect(call.content).not.toContain("Generate a very short title");
425
- expect(call.content).not.toContain("do NOT respond");
426
- expect(call.systemPrompt).toContain("Do NOT respond");
482
+ const [messages, options] = provider.sendMessage.mock.calls[0] as [
483
+ Array<{ content: string }>,
484
+ { systemPrompt: string },
485
+ ];
486
+ const content = messages[0].content;
487
+ // Instructions should be in systemPrompt, not in the user content.
488
+ expect(content).not.toContain("Generate a very short title");
489
+ expect(content).not.toContain("do NOT respond");
490
+ expect(options.systemPrompt).toContain("Do NOT respond");
427
491
  });
428
492
 
429
493
  test("queueGenerateConversationTitle serializes concurrent calls", async () => {
@@ -433,46 +497,20 @@ describe("conversation-title-service", () => {
433
497
  resolveFirst = r;
434
498
  });
435
499
 
436
- // First call: blocks until we release it
437
- mockRunBtwSidechain.mockImplementationOnce(async () => {
500
+ const provider = makeProvider();
501
+ // First call: blocks until released.
502
+ provider.sendMessage.mockImplementationOnce(async () => {
438
503
  callOrder.push("first:start");
439
504
  await firstBlocked;
440
505
  callOrder.push("first:end");
441
- return {
442
- text: "Title One",
443
- hadTextDeltas: true,
444
- response: {
445
- content: [{ type: "text", text: "Title One" }],
446
- model: "test-model",
447
- usage: { inputTokens: 10, outputTokens: 5 },
448
- stopReason: "end_turn",
449
- },
450
- };
506
+ return toolResponse("Title One");
451
507
  });
452
-
453
- // Second call: resolves immediately
454
- mockRunBtwSidechain.mockImplementationOnce(async () => {
508
+ // Second call: resolves immediately.
509
+ provider.sendMessage.mockImplementationOnce(async () => {
455
510
  callOrder.push("second:start");
456
- return {
457
- text: "Title Two",
458
- hadTextDeltas: true,
459
- response: {
460
- content: [{ type: "text", text: "Title Two" }],
461
- model: "test-model",
462
- usage: { inputTokens: 10, outputTokens: 5 },
463
- stopReason: "end_turn",
464
- },
465
- };
511
+ return toolResponse("Title Two");
466
512
  });
467
513
 
468
- const provider = {
469
- name: "test-provider",
470
- sendMessage: mock(async () => {
471
- throw new Error("should not call directly");
472
- }),
473
- };
474
-
475
- // Fire both calls — without serialization both would start immediately
476
514
  queueGenerateConversationTitle({
477
515
  conversationId: "conv-1",
478
516
  provider,
@@ -484,42 +522,26 @@ describe("conversation-title-service", () => {
484
522
  userMessage: "second message",
485
523
  });
486
524
 
487
- // Let microtasks settle — only the first call should have started
525
+ // Let microtasks settle — only the first call should have started.
488
526
  await new Promise((r) => setTimeout(r, 10));
489
527
  expect(callOrder).toEqual(["first:start"]);
490
528
 
491
- // Release the first call
492
529
  resolveFirst();
493
530
  await titleMutex.withLock(async () => {});
494
531
 
495
- // Second should have started only after first finished
496
532
  expect(callOrder).toEqual(["first:start", "first:end", "second:start"]);
497
533
  });
498
534
 
499
535
  test("queue continues processing after a failed call", async () => {
500
- // First call: throws
501
- mockRunBtwSidechain.mockImplementationOnce(async () => {
536
+ const provider = makeProvider();
537
+ // First call: throws.
538
+ provider.sendMessage.mockImplementationOnce(async () => {
502
539
  throw new Error("provider timeout");
503
540
  });
504
-
505
- // Second call: succeeds
506
- mockRunBtwSidechain.mockImplementationOnce(async () => ({
507
- text: "Recovery Title",
508
- hadTextDeltas: true,
509
- response: {
510
- content: [{ type: "text", text: "Recovery Title" }],
511
- model: "test-model",
512
- usage: { inputTokens: 10, outputTokens: 5 },
513
- stopReason: "end_turn",
514
- },
515
- }));
516
-
517
- const provider = {
518
- name: "test-provider",
519
- sendMessage: mock(async () => {
520
- throw new Error("should not call directly");
521
- }),
522
- };
541
+ // Second call: succeeds.
542
+ provider.sendMessage.mockImplementationOnce(async () =>
543
+ toolResponse("Recovery Title"),
544
+ );
523
545
 
524
546
  queueGenerateConversationTitle({
525
547
  conversationId: "conv-1",
@@ -534,8 +556,8 @@ describe("conversation-title-service", () => {
534
556
 
535
557
  await titleMutex.withLock(async () => {});
536
558
 
537
- // Both calls went through — failure didn't break the chain
538
- expect(mockRunBtwSidechain).toHaveBeenCalledTimes(2);
559
+ // Both calls went through — failure didn't break the chain.
560
+ expect(provider.sendMessage).toHaveBeenCalledTimes(2);
539
561
  const firstUpdate = (
540
562
  mockUpdateConversationTitle.mock.calls as unknown as Array<
541
563
  [string, string, number?]
@@ -546,7 +568,6 @@ describe("conversation-title-service", () => {
546
568
  "Untitled Conversation",
547
569
  AUTO_TITLE_DETERMINISTIC,
548
570
  ]);
549
- // Second conversation got a proper title
550
571
  const secondUpdate = (
551
572
  mockUpdateConversationTitle.mock.calls as unknown as string[][]
552
573
  ).find((c) => c[0] === "conv-2" && c[1] === "Recovery Title");
@@ -123,6 +123,7 @@ mock.module("../memory/embedding-backend.js", () => ({
123
123
  model: "test",
124
124
  vectors: [],
125
125
  }),
126
+ geminiCacheExtras: () => [],
126
127
  generateSparseEmbedding: () => ({ indices: [], values: [] }),
127
128
  getMemoryBackendStatus: async () => ({
128
129
  enabled: false,