@vellumai/assistant 0.11.3-dev.202608110527.eba8e13 → 0.11.3-dev.202608111429.ed4a99b
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-delivery-store.test.ts +14 -14
- package/src/__tests__/edit-propagation.test.ts +0 -7
- package/src/__tests__/mcp-list-plugin-servers.test.ts +2 -2
- package/src/__tests__/run-conversation-turn-persistence.test.ts +222 -0
- package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
- package/src/cli/commands/memory/index.help.ts +43 -1
- package/src/cli/commands/memory/memory-v3.ts +64 -0
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/config/schemas/mcp.ts +23 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
- package/src/daemon/mcp-reload-service.ts +36 -6
- package/src/daemon/providers-setup.ts +6 -3
- package/src/mcp/__tests__/effective-config.test.ts +238 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
- package/src/mcp/client.ts +12 -4
- package/src/mcp/effective-config.ts +113 -0
- package/src/mcp/manager.ts +11 -6
- package/src/persistence/delivery-crud.ts +31 -29
- package/src/persistence/external-conversation-store.ts +32 -4
- package/src/plugin-api/conversation-turn.ts +168 -5
- package/src/plugin-api/index.ts +1 -0
- package/src/plugins/__tests__/mcp-servers.test.ts +5 -4
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
- package/src/plugins/mcp-servers.ts +26 -8
- package/src/plugins/mtime-cache.ts +12 -0
- package/src/runtime/routes/inbound-message-handler.ts +12 -17
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +0 -1
- package/src/runtime/routes/mcp-auth-routes.ts +16 -9
package/package.json
CHANGED
|
@@ -504,23 +504,23 @@ describe("channel-delivery-store", () => {
|
|
|
504
504
|
expect(r1.conversationId).not.toBe(r2.conversationId);
|
|
505
505
|
});
|
|
506
506
|
|
|
507
|
-
test("same chat
|
|
508
|
-
const r1 = recordInbound("telegram", "chat-1", "msg-1"
|
|
509
|
-
|
|
510
|
-
});
|
|
511
|
-
const r2 = recordInbound("telegram", "chat-1", "msg-2", {
|
|
512
|
-
assistantId: "asst-B",
|
|
513
|
-
});
|
|
507
|
+
test("the same chat keeps the same conversation", () => {
|
|
508
|
+
const r1 = recordInbound("telegram", "chat-1", "msg-1");
|
|
509
|
+
const r2 = recordInbound("telegram", "chat-1", "msg-2");
|
|
514
510
|
|
|
515
|
-
expect(r1.conversationId).
|
|
511
|
+
expect(r1.conversationId).toBe(r2.conversationId);
|
|
516
512
|
});
|
|
517
513
|
|
|
518
|
-
test("
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
514
|
+
test("binds under the self-scoped key", () => {
|
|
515
|
+
// The prefix is a stored format, not an internal detail. It reads like a
|
|
516
|
+
// leftover of the assistant id that used to be interpolated into it, so
|
|
517
|
+
// pin it: rows already written under `asst:self:` stop resolving if a
|
|
518
|
+
// later tidy-up drops it.
|
|
519
|
+
const { conversationId } = recordInbound("telegram", "chat-1", "msg-1");
|
|
520
|
+
|
|
521
|
+
expect(
|
|
522
|
+
getConversationByKey("asst:self:telegram:chat-1")?.conversationId,
|
|
523
|
+
).toBe(conversationId);
|
|
524
524
|
});
|
|
525
525
|
|
|
526
526
|
test("external bindings allow multiple Slack thread anchors per channel", () => {
|
|
@@ -145,7 +145,6 @@ describe("Slack edit propagation", () => {
|
|
|
145
145
|
conversationExternalId: seeded.conversationExternalId,
|
|
146
146
|
externalMessageId: nextEditEventId(),
|
|
147
147
|
sourceMessageId: seeded.channelTs,
|
|
148
|
-
canonicalAssistantId: "self",
|
|
149
148
|
assistantId: "self",
|
|
150
149
|
content: "new text",
|
|
151
150
|
});
|
|
@@ -187,7 +186,6 @@ describe("Slack edit propagation", () => {
|
|
|
187
186
|
externalMessageId: nextEditEventId(),
|
|
188
187
|
sourceMessageId: seeded.channelTs,
|
|
189
188
|
sourceThreadId: threadTs,
|
|
190
|
-
canonicalAssistantId: "self",
|
|
191
189
|
assistantId: "self",
|
|
192
190
|
content: "new text",
|
|
193
191
|
});
|
|
@@ -216,7 +214,6 @@ describe("Slack edit propagation", () => {
|
|
|
216
214
|
conversationExternalId: seeded.conversationExternalId,
|
|
217
215
|
externalMessageId: nextEditEventId(),
|
|
218
216
|
sourceMessageId: seeded.channelTs,
|
|
219
|
-
canonicalAssistantId: "self",
|
|
220
217
|
assistantId: "self",
|
|
221
218
|
content: "first edit",
|
|
222
219
|
});
|
|
@@ -239,7 +236,6 @@ describe("Slack edit propagation", () => {
|
|
|
239
236
|
conversationExternalId: seeded.conversationExternalId,
|
|
240
237
|
externalMessageId: nextEditEventId(),
|
|
241
238
|
sourceMessageId: seeded.channelTs,
|
|
242
|
-
canonicalAssistantId: "self",
|
|
243
239
|
assistantId: "self",
|
|
244
240
|
content: "second edit",
|
|
245
241
|
});
|
|
@@ -272,7 +268,6 @@ describe("Slack edit propagation", () => {
|
|
|
272
268
|
conversationExternalId: seeded.conversationExternalId,
|
|
273
269
|
externalMessageId: nextEditEventId(),
|
|
274
270
|
sourceMessageId: seeded.channelTs,
|
|
275
|
-
canonicalAssistantId: "self",
|
|
276
271
|
assistantId: "self",
|
|
277
272
|
// Same text as stored -- simulates a Slack unfurl `message_changed`
|
|
278
273
|
// where only attachments changed.
|
|
@@ -313,7 +308,6 @@ describe("Slack edit propagation", () => {
|
|
|
313
308
|
conversationExternalId: seeded.conversationExternalId,
|
|
314
309
|
externalMessageId: nextEditEventId(),
|
|
315
310
|
sourceMessageId: seeded.channelTs,
|
|
316
|
-
canonicalAssistantId: "self",
|
|
317
311
|
assistantId: "self",
|
|
318
312
|
content: "edited searchable text",
|
|
319
313
|
});
|
|
@@ -341,7 +335,6 @@ describe("Slack edit propagation", () => {
|
|
|
341
335
|
// sourceMessageId points at a ts that was never stored.
|
|
342
336
|
externalMessageId: nextEditEventId(),
|
|
343
337
|
sourceMessageId: "9999.0000",
|
|
344
|
-
canonicalAssistantId: "self",
|
|
345
338
|
assistantId: "self",
|
|
346
339
|
content: "new text",
|
|
347
340
|
});
|
|
@@ -204,12 +204,12 @@ describe("internal_mcp_list, plugin-declared servers", () => {
|
|
|
204
204
|
expect(workspace.hasStaticAuth).toBe(true);
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
-
test("plugin servers default to
|
|
207
|
+
test("plugin servers default to low risk", async () => {
|
|
208
208
|
writePlugin("unabyss", unabyssManifest());
|
|
209
209
|
|
|
210
210
|
const servers = await listServers();
|
|
211
211
|
expect(servers.find((s) => s.id === "unabyss")!.defaultRiskLevel).toEqual(
|
|
212
|
-
"
|
|
212
|
+
"low",
|
|
213
213
|
);
|
|
214
214
|
});
|
|
215
215
|
|
|
@@ -5,12 +5,15 @@ import {
|
|
|
5
5
|
ensureConversationExists,
|
|
6
6
|
getConversation,
|
|
7
7
|
} from "../persistence/conversation-crud.js";
|
|
8
|
+
import { getConversationByKey } from "../persistence/conversation-key-store.js";
|
|
8
9
|
import {
|
|
9
10
|
isEchoSuppressedUserMessage,
|
|
10
11
|
isReplyPushIneligibleUserMessage,
|
|
11
12
|
} from "../persistence/conversation-types.js";
|
|
12
13
|
import { getDb } from "../persistence/db-connection.js";
|
|
13
14
|
import { initializeDb } from "../persistence/db-init.js";
|
|
15
|
+
import { buildScopedConversationKey } from "../persistence/delivery-crud.js";
|
|
16
|
+
import { getBindingByConversation } from "../persistence/external-conversation-store.js";
|
|
14
17
|
|
|
15
18
|
await initializeDb();
|
|
16
19
|
|
|
@@ -37,6 +40,7 @@ mock.module("../runtime/sync/resource-sync-events.js", () => ({
|
|
|
37
40
|
let lastProcessMessageConversationId: string | undefined;
|
|
38
41
|
let lastProcessMessageOptions: Record<string, unknown> | undefined;
|
|
39
42
|
let lastEnqueueOptions: Record<string, unknown> | undefined;
|
|
43
|
+
let lastTurnChannelContext: unknown;
|
|
40
44
|
let conversationIsProcessing = false;
|
|
41
45
|
mock.module("../daemon/conversation-store.js", () => ({
|
|
42
46
|
getOrCreateConversation: async (
|
|
@@ -57,6 +61,9 @@ mock.module("../daemon/conversation-store.js", () => ({
|
|
|
57
61
|
}
|
|
58
62
|
return {
|
|
59
63
|
abortController: undefined,
|
|
64
|
+
setTurnChannelContext: (ctx: unknown) => {
|
|
65
|
+
lastTurnChannelContext = ctx;
|
|
66
|
+
},
|
|
60
67
|
isProcessing: () => conversationIsProcessing,
|
|
61
68
|
async processMessage(processOptions: Record<string, unknown>) {
|
|
62
69
|
// The row must already exist by the time the turn persists its user
|
|
@@ -95,6 +102,7 @@ describe("runConversationTurn persistence", () => {
|
|
|
95
102
|
lastProcessMessageConversationId = undefined;
|
|
96
103
|
lastProcessMessageOptions = undefined;
|
|
97
104
|
lastEnqueueOptions = undefined;
|
|
105
|
+
lastTurnChannelContext = undefined;
|
|
98
106
|
conversationIsProcessing = false;
|
|
99
107
|
});
|
|
100
108
|
|
|
@@ -193,3 +201,217 @@ describe("runConversationTurn provenance", () => {
|
|
|
193
201
|
expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
|
|
194
202
|
});
|
|
195
203
|
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Addressing a turn by chat rather than by conversation id.
|
|
207
|
+
*
|
|
208
|
+
* The point of these is not that the turn runs (that is the same code path
|
|
209
|
+
* either way) but that the conversation it runs in is the one the rest of
|
|
210
|
+
* the assistant already addresses by those coordinates. A caller keeping its
|
|
211
|
+
* own chat-to-conversation map would pass every test above and still be
|
|
212
|
+
* invisible to conversation reset, to the deny lanes, and to the channel
|
|
213
|
+
* metadata the conversation list renders.
|
|
214
|
+
*/
|
|
215
|
+
describe("runConversationTurn channel binding", () => {
|
|
216
|
+
const CHANNEL = {
|
|
217
|
+
sourceChannel: "plugin" as const,
|
|
218
|
+
externalChatId: "imessage:+12025550142",
|
|
219
|
+
displayName: "Ada",
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
const db = getDb();
|
|
224
|
+
db.run("DELETE FROM messages");
|
|
225
|
+
db.run("DELETE FROM external_conversation_bindings");
|
|
226
|
+
db.run("DELETE FROM conversation_keys");
|
|
227
|
+
db.run("DELETE FROM conversations");
|
|
228
|
+
listChangedCalls.length = 0;
|
|
229
|
+
lastProcessMessageOptions = undefined;
|
|
230
|
+
lastEnqueueOptions = undefined;
|
|
231
|
+
lastTurnChannelContext = undefined;
|
|
232
|
+
conversationIsProcessing = false;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("resolves the chat to the conversation inbound would have used", async () => {
|
|
236
|
+
const result = await runConversationTurn({
|
|
237
|
+
channel: CHANNEL,
|
|
238
|
+
content: [{ type: "text", text: "hello" }],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// The public name for this conversation, which is what reset and the deny
|
|
242
|
+
// lanes address it by.
|
|
243
|
+
expect(
|
|
244
|
+
getConversationByKey(
|
|
245
|
+
buildScopedConversationKey(
|
|
246
|
+
CHANNEL.sourceChannel,
|
|
247
|
+
CHANNEL.externalChatId,
|
|
248
|
+
),
|
|
249
|
+
)?.conversationId,
|
|
250
|
+
).toBe(result.conversationId);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("keeps the same chat in the same conversation across turns", async () => {
|
|
254
|
+
const first = await runConversationTurn({
|
|
255
|
+
channel: CHANNEL,
|
|
256
|
+
content: [{ type: "text", text: "hello" }],
|
|
257
|
+
});
|
|
258
|
+
const second = await runConversationTurn({
|
|
259
|
+
channel: CHANNEL,
|
|
260
|
+
content: [{ type: "text", text: "still me" }],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(second.conversationId).toBe(first.conversationId);
|
|
264
|
+
// Only the first turn minted a conversation.
|
|
265
|
+
expect(listChangedCalls).toEqual([
|
|
266
|
+
{ kind: "created", conversationId: first.conversationId },
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("gives a different chat its own conversation", async () => {
|
|
271
|
+
const ada = await runConversationTurn({
|
|
272
|
+
channel: CHANNEL,
|
|
273
|
+
content: [{ type: "text", text: "hello" }],
|
|
274
|
+
});
|
|
275
|
+
const grace = await runConversationTurn({
|
|
276
|
+
channel: { ...CHANNEL, externalChatId: "imessage:+12025550188" },
|
|
277
|
+
content: [{ type: "text", text: "hello" }],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(grace.conversationId).not.toBe(ada.conversationId);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("records the channel metadata the conversation list reads", async () => {
|
|
284
|
+
const result = await runConversationTurn({
|
|
285
|
+
channel: { ...CHANNEL, externalUserId: "+12025550142" },
|
|
286
|
+
content: [{ type: "text", text: "hello" }],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const binding = getBindingByConversation(result.conversationId);
|
|
290
|
+
expect(binding).toMatchObject({
|
|
291
|
+
sourceChannel: "plugin",
|
|
292
|
+
externalChatId: CHANNEL.externalChatId,
|
|
293
|
+
externalUserId: "+12025550142",
|
|
294
|
+
displayName: "Ada",
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("follows the vendor when a display name changes", async () => {
|
|
299
|
+
await runConversationTurn({
|
|
300
|
+
channel: CHANNEL,
|
|
301
|
+
content: [{ type: "text", text: "hello" }],
|
|
302
|
+
});
|
|
303
|
+
const result = await runConversationTurn({
|
|
304
|
+
channel: { ...CHANNEL, displayName: "Ada L." },
|
|
305
|
+
content: [{ type: "text", text: "renamed" }],
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(getBindingByConversation(result.conversationId)?.displayName).toBe(
|
|
309
|
+
"Ada L.",
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("runs the turn on the channel it was addressed by", async () => {
|
|
314
|
+
// Runtime assembly reads the per-turn channel first and only then the
|
|
315
|
+
// conversation's origin, so a turn into a conversation whose origin was
|
|
316
|
+
// never recorded would otherwise run as `vellum`. That is the wrong
|
|
317
|
+
// channel for the message row and for the permission cascade the tools
|
|
318
|
+
// are approved against.
|
|
319
|
+
await runConversationTurn({
|
|
320
|
+
channel: CHANNEL,
|
|
321
|
+
content: [{ type: "text", text: "hello" }],
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(lastTurnChannelContext).toEqual({
|
|
325
|
+
userMessageChannel: "plugin",
|
|
326
|
+
assistantMessageChannel: "plugin",
|
|
327
|
+
});
|
|
328
|
+
expect(lastProcessMessageOptions?.metadata).toMatchObject({
|
|
329
|
+
userMessageChannel: "plugin",
|
|
330
|
+
assistantMessageChannel: "plugin",
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("a queued turn carries its own channel rather than inheriting one", async () => {
|
|
335
|
+
// The drain happens after this call returns and reads the channel off the
|
|
336
|
+
// queued message, falling back to whichever turn was in flight. On a
|
|
337
|
+
// conversation shared with another channel that fallback is someone
|
|
338
|
+
// else's channel.
|
|
339
|
+
conversationIsProcessing = true;
|
|
340
|
+
|
|
341
|
+
const result = await runConversationTurn({
|
|
342
|
+
channel: CHANNEL,
|
|
343
|
+
content: [{ type: "text", text: "hello" }],
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(result.queued).toBe(true);
|
|
347
|
+
expect(lastEnqueueOptions?.metadata).toMatchObject({
|
|
348
|
+
userMessageChannel: "plugin",
|
|
349
|
+
assistantMessageChannel: "plugin",
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("leaves the channel unset when the turn names none", async () => {
|
|
354
|
+
// Clearing rather than leaving whatever the last turn set: a stale
|
|
355
|
+
// context would report this turn on a channel it has nothing to do with.
|
|
356
|
+
await runConversationTurn({
|
|
357
|
+
content: [{ type: "text", text: "hello" }],
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(lastTurnChannelContext).toBeNull();
|
|
361
|
+
expect(lastProcessMessageOptions?.metadata).toEqual({ automated: true });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("keeps a known sender when a later turn omits it", async () => {
|
|
365
|
+
// A plugin that knows only the chat coordinates this time is silent about
|
|
366
|
+
// the sender, not asserting it has none. Erasing the stored name on that
|
|
367
|
+
// silence drops it from the conversation and session APIs.
|
|
368
|
+
await runConversationTurn({
|
|
369
|
+
channel: { ...CHANNEL, externalUserId: "+12025550142" },
|
|
370
|
+
content: [{ type: "text", text: "hello" }],
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const result = await runConversationTurn({
|
|
374
|
+
channel: {
|
|
375
|
+
sourceChannel: CHANNEL.sourceChannel,
|
|
376
|
+
externalChatId: CHANNEL.externalChatId,
|
|
377
|
+
},
|
|
378
|
+
content: [{ type: "text", text: "still me" }],
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(getBindingByConversation(result.conversationId)).toMatchObject({
|
|
382
|
+
externalUserId: "+12025550142",
|
|
383
|
+
displayName: "Ada",
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("clears a sender the caller explicitly says is gone", async () => {
|
|
388
|
+
await runConversationTurn({
|
|
389
|
+
channel: CHANNEL,
|
|
390
|
+
content: [{ type: "text", text: "hello" }],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const result = await runConversationTurn({
|
|
394
|
+
channel: { ...CHANNEL, displayName: null },
|
|
395
|
+
content: [{ type: "text", text: "anonymous now" }],
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
expect(
|
|
399
|
+
getBindingByConversation(result.conversationId)?.displayName,
|
|
400
|
+
).toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("an explicit conversation id wins over the address", async () => {
|
|
404
|
+
// Two different conversations were named. Re-resolving would overrule the
|
|
405
|
+
// caller, so the explicit one is honoured and nothing is bound behind it.
|
|
406
|
+
const existing = createConversation({ title: "chosen" });
|
|
407
|
+
|
|
408
|
+
const result = await runConversationTurn({
|
|
409
|
+
conversationId: existing.id,
|
|
410
|
+
channel: CHANNEL,
|
|
411
|
+
content: [{ type: "text", text: "hello" }],
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(result.conversationId).toBe(existing.id);
|
|
415
|
+
expect(getBindingByConversation(existing.id)).toBeNull();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Tests for the `assistant memory v3` CLI subgroup (section-lane model).
|
|
3
3
|
*
|
|
4
4
|
* Validates:
|
|
5
|
-
* - Subcommand registration (rebuild-index, backfill-sections)
|
|
6
|
-
* `memory v3`.
|
|
7
|
-
* -
|
|
5
|
+
* - Subcommand registration (rebuild-index, backfill-sections, gate-stats)
|
|
6
|
+
* under `memory v3`.
|
|
7
|
+
* - `rebuild-index` and `backfill-sections` route through IPC to the daemon.
|
|
8
8
|
* - `backfill-sections` passes a long IPC timeout (the one-time full-corpus
|
|
9
|
-
* embed easily outlasts the default 60s)
|
|
10
|
-
* -
|
|
9
|
+
* embed easily outlasts the default 60s).
|
|
10
|
+
* - `gate-stats` calls handleMemoryV3GateStats directly — no daemon required.
|
|
11
|
+
* - Error paths return a non-zero exit code without throwing.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -39,6 +40,47 @@ let mockIpcResult: {
|
|
|
39
40
|
/** Captured log output for assertion. */
|
|
40
41
|
let logOutput: string[] = [];
|
|
41
42
|
|
|
43
|
+
/** Shared fake gate-stats payload used across gate-stats tests. */
|
|
44
|
+
const fakeGateStats = {
|
|
45
|
+
lookbackDays: 30,
|
|
46
|
+
totalRuns: 10,
|
|
47
|
+
buckets: [
|
|
48
|
+
{
|
|
49
|
+
pageCountRange: "0–9",
|
|
50
|
+
total: 2,
|
|
51
|
+
scored: 1,
|
|
52
|
+
passed: 2,
|
|
53
|
+
scoredPassRate: 1,
|
|
54
|
+
reasons: { dense_pass: 1, dense_disabled: 1 },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
pageCountRange: "10–49",
|
|
58
|
+
total: 5,
|
|
59
|
+
scored: 4,
|
|
60
|
+
passed: 3,
|
|
61
|
+
scoredPassRate: 0.75,
|
|
62
|
+
reasons: { dense_pass: 3, fail_no_signal: 1 },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
pageCountRange: "50–199",
|
|
66
|
+
total: 2,
|
|
67
|
+
scored: 2,
|
|
68
|
+
passed: 1,
|
|
69
|
+
scoredPassRate: 0.5,
|
|
70
|
+
reasons: { dense_pass: 1, fail_no_signal: 1 },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
pageCountRange: "200+",
|
|
74
|
+
total: 1,
|
|
75
|
+
scored: 1,
|
|
76
|
+
passed: 0,
|
|
77
|
+
scoredPassRate: 0,
|
|
78
|
+
reasons: { fail_no_signal: 1 },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
unknownPageCount: { total: 0, passed: 0, reasons: {} },
|
|
82
|
+
};
|
|
83
|
+
|
|
42
84
|
// ---------------------------------------------------------------------------
|
|
43
85
|
// Mocks
|
|
44
86
|
// ---------------------------------------------------------------------------
|
|
@@ -68,6 +110,33 @@ mock.module("../../../../util/logger.js", () => ({
|
|
|
68
110
|
getCliLogger: () => fakeLogger,
|
|
69
111
|
}));
|
|
70
112
|
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// gate-stats direct-invocation mocks
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Args captured from the last handleMemoryV3GateStats call. */
|
|
118
|
+
let lastGateStatsArgs: { lookbackDays: number; db: unknown } | null = null;
|
|
119
|
+
|
|
120
|
+
/** What handleMemoryV3GateStats returns, or an error to throw. */
|
|
121
|
+
let gateStatsImpl: (() => unknown) | null = null;
|
|
122
|
+
|
|
123
|
+
mock.module(
|
|
124
|
+
"../../../../plugins/defaults/memory/src/memory-v3-routes.js",
|
|
125
|
+
() => ({
|
|
126
|
+
handleMemoryV3GateStats: (lookbackDays: number, db: unknown) => {
|
|
127
|
+
lastGateStatsArgs = { lookbackDays, db };
|
|
128
|
+
if (gateStatsImpl) {
|
|
129
|
+
return gateStatsImpl();
|
|
130
|
+
}
|
|
131
|
+
return fakeGateStats;
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
mock.module("../../../../persistence/db-connection.js", () => ({
|
|
137
|
+
getTelemetrySqlite: () => ({}),
|
|
138
|
+
}));
|
|
139
|
+
|
|
71
140
|
// ---------------------------------------------------------------------------
|
|
72
141
|
// Import modules under test (after mocks)
|
|
73
142
|
// ---------------------------------------------------------------------------
|
|
@@ -135,6 +204,8 @@ beforeEach(() => {
|
|
|
135
204
|
ok: true,
|
|
136
205
|
result: { articles: 3, sections: 12, failures: 0 },
|
|
137
206
|
};
|
|
207
|
+
lastGateStatsArgs = null;
|
|
208
|
+
gateStatsImpl = null;
|
|
138
209
|
logOutput = [];
|
|
139
210
|
process.exitCode = 0;
|
|
140
211
|
});
|
|
@@ -155,6 +226,7 @@ describe("subcommand registration", () => {
|
|
|
155
226
|
"backfill-sections",
|
|
156
227
|
"eval",
|
|
157
228
|
"eval-tally",
|
|
229
|
+
"gate-stats",
|
|
158
230
|
"rebuild-index",
|
|
159
231
|
]);
|
|
160
232
|
});
|
|
@@ -236,3 +308,54 @@ describe("memory v3 backfill-sections", () => {
|
|
|
236
308
|
expect(exitCode).toBe(1);
|
|
237
309
|
});
|
|
238
310
|
});
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// gate-stats (direct invocation — no daemon required)
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
describe("memory v3 gate-stats", () => {
|
|
317
|
+
test("calls handleMemoryV3GateStats directly with default lookback (no IPC)", async () => {
|
|
318
|
+
const { exitCode } = await runCommand(["memory", "v3", "gate-stats"]);
|
|
319
|
+
|
|
320
|
+
expect(exitCode).toBe(0);
|
|
321
|
+
// Must NOT have gone through IPC.
|
|
322
|
+
expect(lastIpcCall).toBeNull();
|
|
323
|
+
// Must have called the handler directly with the default lookback.
|
|
324
|
+
expect(lastGateStatsArgs!.lookbackDays).toBe(30);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("forwards --lookback-days directly to handleMemoryV3GateStats", async () => {
|
|
328
|
+
await runCommand(["memory", "v3", "gate-stats", "--lookback-days", "7"]);
|
|
329
|
+
|
|
330
|
+
expect(lastIpcCall).toBeNull();
|
|
331
|
+
expect(lastGateStatsArgs!.lookbackDays).toBe(7);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("prints formatted table on success (no --json)", async () => {
|
|
335
|
+
await runCommand(["memory", "v3", "gate-stats"]);
|
|
336
|
+
|
|
337
|
+
expect(logOutput.some((l) => l.includes("Gate-stats"))).toBe(true);
|
|
338
|
+
expect(logOutput.some((l) => l.includes("10–49"))).toBe(true);
|
|
339
|
+
expect(logOutput.some((l) => l.includes("75.0%"))).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("emits raw JSON with --json", async () => {
|
|
343
|
+
await runCommand(["memory", "v3", "gate-stats", "--json"]);
|
|
344
|
+
|
|
345
|
+
const raw = logOutput.join("");
|
|
346
|
+
const parsed = JSON.parse(raw) as typeof fakeGateStats;
|
|
347
|
+
expect(parsed.totalRuns).toBe(10);
|
|
348
|
+
expect(parsed.buckets).toHaveLength(4);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("exits with code 1 when handleMemoryV3GateStats throws", async () => {
|
|
352
|
+
gateStatsImpl = () => {
|
|
353
|
+
throw new Error("telemetry DB locked");
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const { exitCode } = await runCommand(["memory", "v3", "gate-stats"]);
|
|
357
|
+
|
|
358
|
+
expect(exitCode).toBe(1);
|
|
359
|
+
expect(logOutput.some((l) => l.includes("telemetry DB locked"))).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -606,7 +606,9 @@ that live state safely.
|
|
|
606
606
|
|
|
607
607
|
Examples:
|
|
608
608
|
$ assistant memory v3 rebuild-index
|
|
609
|
-
$ assistant memory v3 backfill-sections
|
|
609
|
+
$ assistant memory v3 backfill-sections
|
|
610
|
+
$ assistant memory v3 gate-stats
|
|
611
|
+
$ assistant memory v3 gate-stats --lookback-days 7 --json`,
|
|
610
612
|
subcommands: [
|
|
611
613
|
{
|
|
612
614
|
name: "rebuild-index",
|
|
@@ -716,6 +718,46 @@ Examples:
|
|
|
716
718
|
$ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval
|
|
717
719
|
$ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval --turns-file .mv3/eval/key.json
|
|
718
720
|
$ assistant memory v3 eval --snapshot .mv3/snapshot/concepts --staging .mv3/staging --out .mv3/eval --exclude-conversation <migration-conv-id>`,
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
name: "gate-stats",
|
|
724
|
+
description:
|
|
725
|
+
"Show injection gate pass rates bucketed by corpus size (read-only)",
|
|
726
|
+
options: [
|
|
727
|
+
{
|
|
728
|
+
flags: "--lookback-days <n>",
|
|
729
|
+
description: "Days of telemetry to aggregate (1–90, default 30)",
|
|
730
|
+
defaultValue: "30",
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
flags: "--json",
|
|
734
|
+
description: "Emit raw JSON instead of a formatted table",
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
helpText: `
|
|
738
|
+
Reads the memory v3 injection gate telemetry outbox and prints pass rates
|
|
739
|
+
and reason distributions grouped by concept page count bucket
|
|
740
|
+
(0–9 / 10–49 / 50–199 / 200+). Scored runs (dense lane was available and
|
|
741
|
+
actually weighed scores) are reported separately from pass-open shortcuts
|
|
742
|
+
(dense disabled / unavailable / gate threw) so scoredPassRate reflects
|
|
743
|
+
only contested gate decisions — the signal relevant for threshold calibration.
|
|
744
|
+
|
|
745
|
+
Coverage is limited to runs still pending platform flush. In a healthy system
|
|
746
|
+
the outbox holds only the last few minutes to hours of events, so
|
|
747
|
+
--lookback-days is an upper bound, not a guarantee; long-window aggregation
|
|
748
|
+
lives on the platform side on top of flushed watchdog events.
|
|
749
|
+
|
|
750
|
+
Reads the telemetry database directly — the assistant does not need to be
|
|
751
|
+
running.
|
|
752
|
+
|
|
753
|
+
Intended use: verify that gate firing rates shift as expected after a
|
|
754
|
+
config or threshold change, and check whether the bm25-auto-calibration
|
|
755
|
+
flag is worth enabling for large corpora.
|
|
756
|
+
|
|
757
|
+
Examples:
|
|
758
|
+
$ assistant memory v3 gate-stats
|
|
759
|
+
$ assistant memory v3 gate-stats --lookback-days 7
|
|
760
|
+
$ assistant memory v3 gate-stats --json | jq '.buckets[] | select(.pageCountRange == "200+")'`,
|
|
719
761
|
},
|
|
720
762
|
{
|
|
721
763
|
name: "eval-tally",
|