@vellumai/assistant 0.10.3-dev.202606260109.8a3d17b → 0.10.3-dev.202606260916.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 +1 -1
- package/src/__tests__/channel-reply-delivery.test.ts +6 -2
- package/src/__tests__/conversation-title-service.test.ts +222 -201
- package/src/__tests__/mcp-config-secret-boundary.test.ts +1 -0
- package/src/__tests__/slack-block-formatting.test.ts +1 -38
- package/src/memory/__tests__/embedding-cache.test.ts +136 -0
- package/src/memory/conversation-title-service.ts +173 -24
- package/src/memory/embedding-backend.ts +8 -1
- package/src/memory/embedding-cache.ts +139 -0
- package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
- package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
- package/src/messaging/providers/a2a/deliver.ts +5 -1
- package/src/messaging/providers/a2a/transport.ts +10 -0
- package/src/messaging/providers/callback-routing.ts +48 -0
- package/src/messaging/providers/channel-transport.ts +55 -0
- package/src/messaging/providers/index.ts +65 -241
- package/src/messaging/providers/slack/transport.ts +92 -0
- package/src/messaging/providers/telegram-bot/transport.ts +51 -0
- package/src/messaging/providers/whatsapp/transport.ts +38 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
- package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
- package/src/runtime/channel-reply-delivery.ts +3 -8
- package/src/runtime/slack-block-formatting.ts +0 -15
package/package.json
CHANGED
|
@@ -81,8 +81,8 @@ mock.module("../runtime/gateway-client.js", () => ({
|
|
|
81
81
|
}));
|
|
82
82
|
|
|
83
83
|
mock.module("../memory/conversation-crud.js", () => ({
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
setConversationProcessingStartedAt: () => {},
|
|
85
|
+
isConversationProcessing: () => false,
|
|
86
86
|
setConversationOriginChannelIfUnset: () => {},
|
|
87
87
|
updateConversationContextWindow: () => {},
|
|
88
88
|
deleteMessageById: () => {},
|
|
@@ -240,6 +240,7 @@ describe("channel-reply-delivery", () => {
|
|
|
240
240
|
payload: {
|
|
241
241
|
chatId: "chat-1",
|
|
242
242
|
text: "Before tool.",
|
|
243
|
+
useBlocks: true,
|
|
243
244
|
attachments: undefined,
|
|
244
245
|
assistantId: "assistant-1",
|
|
245
246
|
},
|
|
@@ -249,6 +250,7 @@ describe("channel-reply-delivery", () => {
|
|
|
249
250
|
payload: {
|
|
250
251
|
chatId: "chat-1",
|
|
251
252
|
text: "After tool.",
|
|
253
|
+
useBlocks: true,
|
|
252
254
|
attachments,
|
|
253
255
|
assistantId: "assistant-1",
|
|
254
256
|
},
|
|
@@ -307,12 +309,14 @@ describe("channel-reply-delivery", () => {
|
|
|
307
309
|
expect(deliveryCalls[0].payload).toEqual({
|
|
308
310
|
chatId: "chat-3",
|
|
309
311
|
text: "Before tool.",
|
|
312
|
+
useBlocks: true,
|
|
310
313
|
attachments: undefined,
|
|
311
314
|
assistantId: "assistant-2",
|
|
312
315
|
});
|
|
313
316
|
expect(deliveryCalls[1].payload).toEqual({
|
|
314
317
|
chatId: "chat-3",
|
|
315
318
|
text: "After tool.",
|
|
319
|
+
useBlocks: true,
|
|
316
320
|
attachments: [
|
|
317
321
|
{
|
|
318
322
|
id: "att-2",
|
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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("
|
|
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(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
tools:
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
const prompt = (
|
|
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 = (
|
|
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("
|
|
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(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
420
|
-
content: string
|
|
421
|
-
systemPrompt: string
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
expect(
|
|
426
|
-
expect(
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
536
|
+
const provider = makeProvider();
|
|
537
|
+
// First call: throws.
|
|
538
|
+
provider.sendMessage.mockImplementationOnce(async () => {
|
|
502
539
|
throw new Error("provider timeout");
|
|
503
540
|
});
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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(
|
|
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,
|