@vellumai/assistant 0.10.4-dev.202607012136.5ef7865 → 0.10.4-dev.202607012335.d6d6958
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/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +16 -3
- package/openapi.yaml +80 -0
- package/package.json +1 -1
- package/src/__tests__/approval-interception-trust-gates.test.ts +148 -3
- package/src/__tests__/context-search-conversations-source.test.ts +33 -58
- package/src/__tests__/non-member-access-request.test.ts +224 -1
- package/src/__tests__/persona-resolver.test.ts +56 -2
- package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/slack-inbound-verification.test.ts +36 -0
- package/src/__tests__/trust-context-guards.test.ts +21 -10
- package/src/__tests__/trusted-contact-multichannel.test.ts +4 -0
- package/src/__tests__/workspace-migration-121-seed-default-user-guardrails.test.ts +118 -0
- package/src/approvals/guardian-request-resolvers.ts +21 -1
- package/src/calls/relay-server.ts +10 -0
- package/src/channels/channel-verification-sessions.ts +6 -1
- package/src/cli/lib/__tests__/toggle-plugin.test.ts +7 -0
- package/src/cli/lib/toggle-plugin.ts +21 -2
- package/src/config/assistant-feature-flags.ts +0 -22
- package/src/config/feature-flag-registry.json +0 -8
- package/src/contacts/__tests__/member-write-relay.test.ts +57 -1
- package/src/contacts/member-write-relay.ts +41 -0
- package/src/daemon/disk-pressure-policy.ts +2 -1
- package/src/daemon/message-provenance.ts +12 -14
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/persistence/__tests__/conversation-queries-search.test.ts +37 -41
- package/src/persistence/conversation-queries.ts +37 -88
- package/src/persistence/conversation-search-lexical.ts +1 -1
- package/src/persistence/job-handlers/cleanup.ts +3 -4
- package/src/persistence/migrations/312-drop-inbox-conversation-state-table.ts +15 -0
- package/src/persistence/schema/contacts.ts +0 -28
- package/src/persistence/steps.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +0 -20
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +22 -62
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +8 -0
- package/src/prompts/__tests__/system-prompt.test.ts +128 -1
- package/src/prompts/persona-resolver.ts +13 -9
- package/src/prompts/system-prompt.ts +24 -1
- package/src/prompts/templates/users/default.md +35 -0
- package/src/runtime/__tests__/channel-verification-service.test.ts +2 -2
- package/src/runtime/access-request-helper.ts +71 -2
- package/src/runtime/channel-verification-service.ts +3 -1
- package/src/runtime/local-actor-identity.test.ts +38 -0
- package/src/runtime/local-actor-identity.ts +14 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +360 -3
- package/src/runtime/routes/app-management-routes.ts +4 -7
- package/src/runtime/routes/guardian-approval-interception.ts +51 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +215 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +140 -27
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +2 -3
- package/src/runtime/routes/inbound-stages/escalation-intercept.test.ts +158 -0
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +22 -1
- package/src/runtime/routes/plugins-routes.ts +149 -1
- package/src/runtime/sync/resource-sync-events.ts +15 -0
- package/src/runtime/tool-grant-request-helper.ts +17 -1
- package/src/runtime/trust-class.test.ts +141 -0
- package/src/runtime/trust-class.ts +88 -0
- package/src/workspace/migrations/121-seed-default-user-guardrails.ts +82 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/config/__tests__/messages-search-backend.test.ts +0 -77
|
@@ -77,6 +77,7 @@ export type { AdmissionPolicy } from "./admission-policy-contract.js";
|
|
|
77
77
|
|
|
78
78
|
// Trust verdict contract (gateway → daemon) — Zod schemas + derived types
|
|
79
79
|
export {
|
|
80
|
+
isTrustClass,
|
|
80
81
|
makeResolutionFailedVerdict,
|
|
81
82
|
ResolveInboundTrustRequestSchema,
|
|
82
83
|
TRUST_CLASS_VALUES,
|
|
@@ -32,6 +32,18 @@ export const TrustClassSchema = z.enum(TRUST_CLASS_VALUES);
|
|
|
32
32
|
|
|
33
33
|
export type TrustClass = (typeof TRUST_CLASS_VALUES)[number];
|
|
34
34
|
|
|
35
|
+
const TRUST_CLASS_SET: ReadonlySet<string> = new Set(TRUST_CLASS_VALUES);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Type guard for wire-sourced trust classes. Consumers receive verdicts over
|
|
39
|
+
* IPC/HTTP, so a field statically typed {@link TrustClass} can still carry an
|
|
40
|
+
* out-of-contract value (version skew, malformed payload); this narrows it
|
|
41
|
+
* safely without casting.
|
|
42
|
+
*/
|
|
43
|
+
export function isTrustClass(value: string): value is TrustClass {
|
|
44
|
+
return TRUST_CLASS_SET.has(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
/**
|
|
36
48
|
* Per-actor trust verdict resolved by the gateway from its ACL DB. ACL +
|
|
37
49
|
* identity keys + minimal labels only. Guardian binding and member
|
|
@@ -42,9 +54,10 @@ export const TrustVerdictSchema = z.object({
|
|
|
42
54
|
trustClass: TrustClassSchema,
|
|
43
55
|
canonicalSenderId: z.string().nullable(),
|
|
44
56
|
|
|
45
|
-
// Present+true ⇒ gateway attempted resolution but
|
|
46
|
-
//
|
|
47
|
-
//
|
|
57
|
+
// Present+true ⇒ gateway attempted resolution but could not produce a
|
|
58
|
+
// usable verdict (DB error, or a sender who maps to the guardian contact
|
|
59
|
+
// whose principal is unresolved); consumer treats it as "could not vouch",
|
|
60
|
+
// distinct from a real `unknown` stranger.
|
|
48
61
|
resolutionFailed: z.boolean().optional(),
|
|
49
62
|
|
|
50
63
|
// Guardian binding — present only when a guardian binding matches.
|
package/openapi.yaml
CHANGED
|
@@ -21119,6 +21119,11 @@ paths:
|
|
|
21119
21119
|
name:
|
|
21120
21120
|
type: string
|
|
21121
21121
|
description: Display name. Equal to `id` today.
|
|
21122
|
+
enabled:
|
|
21123
|
+
type: boolean
|
|
21124
|
+
description:
|
|
21125
|
+
Whether the plugin is active in this workspace. `false` when a `.disabled` sentinel is present under its
|
|
21126
|
+
directory; `true` otherwise.
|
|
21122
21127
|
description:
|
|
21123
21128
|
anyOf:
|
|
21124
21129
|
- type: string
|
|
@@ -21147,6 +21152,7 @@ paths:
|
|
|
21147
21152
|
required:
|
|
21148
21153
|
- id
|
|
21149
21154
|
- name
|
|
21155
|
+
- enabled
|
|
21150
21156
|
- description
|
|
21151
21157
|
- version
|
|
21152
21158
|
additionalProperties: false
|
|
@@ -21430,6 +21436,80 @@ paths:
|
|
|
21430
21436
|
description: The install recorded no commit or no fingerprint to re-materialize and verify a baseline from.
|
|
21431
21437
|
"503":
|
|
21432
21438
|
description: The plugin source (GitHub) was temporarily unavailable; the diff is retryable.
|
|
21439
|
+
/v1/plugins/{name}/disable:
|
|
21440
|
+
post:
|
|
21441
|
+
operationId: plugins_by_name_disable_post
|
|
21442
|
+
summary: Disable a plugin
|
|
21443
|
+
description:
|
|
21444
|
+
Disable a plugin in this workspace by dropping a `.disabled` sentinel, mirroring the CLI's `assistant
|
|
21445
|
+
plugins disable <name>`. The change is honored live at read time by every tool / injector / hook gate — no
|
|
21446
|
+
restart required. Broadcasts a `sync_changed` invalidation carrying the `plugins:list` tag so other clients
|
|
21447
|
+
refetch `GET /v1/plugins`. An already-disabled plugin returns 409; a user plugin with no directory returns 404
|
|
21448
|
+
(default plugins are stubbed on demand via the `default-` prefix); a malformed name returns 400.
|
|
21449
|
+
tags:
|
|
21450
|
+
- plugins
|
|
21451
|
+
parameters:
|
|
21452
|
+
- name: name
|
|
21453
|
+
in: path
|
|
21454
|
+
required: true
|
|
21455
|
+
schema:
|
|
21456
|
+
type: string
|
|
21457
|
+
responses:
|
|
21458
|
+
"200":
|
|
21459
|
+
description: Successful response
|
|
21460
|
+
content:
|
|
21461
|
+
application/json:
|
|
21462
|
+
schema:
|
|
21463
|
+
type: object
|
|
21464
|
+
properties:
|
|
21465
|
+
ok:
|
|
21466
|
+
type: boolean
|
|
21467
|
+
required:
|
|
21468
|
+
- ok
|
|
21469
|
+
additionalProperties: false
|
|
21470
|
+
"400":
|
|
21471
|
+
description: The plugin name failed validation (not kebab-case alphanumerics).
|
|
21472
|
+
"404":
|
|
21473
|
+
description: No plugin directory exists with the given name (user plugins must already be installed).
|
|
21474
|
+
"409":
|
|
21475
|
+
description: The plugin is already disabled.
|
|
21476
|
+
/v1/plugins/{name}/enable:
|
|
21477
|
+
post:
|
|
21478
|
+
operationId: plugins_by_name_enable_post
|
|
21479
|
+
summary: Enable a plugin
|
|
21480
|
+
description:
|
|
21481
|
+
Enable a plugin in this workspace by removing its `.disabled` sentinel, mirroring the CLI's `assistant
|
|
21482
|
+
plugins enable <name>`. The change is honored live at read time by every tool / injector / hook gate — no
|
|
21483
|
+
restart required. Broadcasts a `sync_changed` invalidation carrying the `plugins:list` tag so other clients
|
|
21484
|
+
refetch `GET /v1/plugins`. An already-enabled plugin returns 409; a name with no plugin directory returns 404
|
|
21485
|
+
(prefix a default plugin with `default-`); a malformed name returns 400.
|
|
21486
|
+
tags:
|
|
21487
|
+
- plugins
|
|
21488
|
+
parameters:
|
|
21489
|
+
- name: name
|
|
21490
|
+
in: path
|
|
21491
|
+
required: true
|
|
21492
|
+
schema:
|
|
21493
|
+
type: string
|
|
21494
|
+
responses:
|
|
21495
|
+
"200":
|
|
21496
|
+
description: Successful response
|
|
21497
|
+
content:
|
|
21498
|
+
application/json:
|
|
21499
|
+
schema:
|
|
21500
|
+
type: object
|
|
21501
|
+
properties:
|
|
21502
|
+
ok:
|
|
21503
|
+
type: boolean
|
|
21504
|
+
required:
|
|
21505
|
+
- ok
|
|
21506
|
+
additionalProperties: false
|
|
21507
|
+
"400":
|
|
21508
|
+
description: The plugin name failed validation (not kebab-case alphanumerics).
|
|
21509
|
+
"404":
|
|
21510
|
+
description: No plugin directory exists with the given name.
|
|
21511
|
+
"409":
|
|
21512
|
+
description: The plugin is already enabled.
|
|
21433
21513
|
/v1/plugins/{name}/inspect:
|
|
21434
21514
|
get:
|
|
21435
21515
|
operationId: plugins_by_name_inspect_get
|
package/package.json
CHANGED
|
@@ -24,8 +24,14 @@ mock.module("../daemon/conversation-registry.js", () => ({
|
|
|
24
24
|
findConversation: (id: string) => _conversationMocks.get(id),
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
|
+
// Anchored guardian principal, driven per-test to exercise the guardian
|
|
28
|
+
// principal gate (undefined = anchor unresolvable).
|
|
29
|
+
let _anchorPrincipalId: string | undefined;
|
|
30
|
+
mock.module("../runtime/local-actor-identity.js", () => ({
|
|
31
|
+
findLocalGuardianPrincipalId: async () => _anchorPrincipalId,
|
|
32
|
+
}));
|
|
33
|
+
|
|
27
34
|
import type { Conversation } from "../daemon/conversation.js";
|
|
28
|
-
import type { TrustContext } from "../daemon/trust-context.js";
|
|
29
35
|
import { initializeDb } from "../persistence/db-init.js";
|
|
30
36
|
import * as approvalMessageComposer from "../runtime/approval-message-composer.js";
|
|
31
37
|
import * as gatewayClient from "../runtime/gateway-client.js";
|
|
@@ -76,6 +82,7 @@ describe("approval interception trust-class gates", () => {
|
|
|
76
82
|
|
|
77
83
|
beforeEach(() => {
|
|
78
84
|
pendingInteractions.clear();
|
|
85
|
+
_anchorPrincipalId = undefined;
|
|
79
86
|
deliverSpy = spyOn(gatewayClient, "deliverChannelReply").mockResolvedValue({
|
|
80
87
|
ok: true,
|
|
81
88
|
});
|
|
@@ -105,7 +112,7 @@ describe("approval interception trust-class gates", () => {
|
|
|
105
112
|
trustClass: "unknown",
|
|
106
113
|
requesterExternalUserId: "intruder-user-1",
|
|
107
114
|
guardianExternalUserId: "guardian-1",
|
|
108
|
-
}
|
|
115
|
+
},
|
|
109
116
|
assistantId: ASSISTANT_ID,
|
|
110
117
|
});
|
|
111
118
|
|
|
@@ -117,6 +124,144 @@ describe("approval interception trust-class gates", () => {
|
|
|
117
124
|
composeSpy.mockRestore();
|
|
118
125
|
});
|
|
119
126
|
|
|
127
|
+
test("guardian apr: callback with a principal matching the anchor applies the decision", async () => {
|
|
128
|
+
_anchorPrincipalId = "guardian-principal-1";
|
|
129
|
+
const sessionMock = registerPendingInteraction(
|
|
130
|
+
"req-guardian-apply-1",
|
|
131
|
+
CONVERSATION_ID,
|
|
132
|
+
TOOL_NAME,
|
|
133
|
+
TOOL_INPUT,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const result = await handleApprovalInterception({
|
|
137
|
+
conversationId: CONVERSATION_ID,
|
|
138
|
+
callbackData: "apr:req-guardian-apply-1:approve_once",
|
|
139
|
+
content: "",
|
|
140
|
+
conversationExternalId: REQUESTER_CHAT,
|
|
141
|
+
sourceChannel: "telegram",
|
|
142
|
+
actorExternalId: "guardian-user-1",
|
|
143
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
144
|
+
trustCtx: {
|
|
145
|
+
sourceChannel: "telegram",
|
|
146
|
+
trustClass: "guardian",
|
|
147
|
+
requesterExternalUserId: "guardian-user-1",
|
|
148
|
+
guardianExternalUserId: "guardian-user-1",
|
|
149
|
+
guardianPrincipalId: "guardian-principal-1",
|
|
150
|
+
},
|
|
151
|
+
assistantId: ASSISTANT_ID,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(result.handled).toBe(true);
|
|
155
|
+
expect(result.type).toBe("decision_applied");
|
|
156
|
+
expect(sessionMock).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("guardian apr: callback with a principal NOT matching the anchor is rejected before any decision", async () => {
|
|
160
|
+
_anchorPrincipalId = "the-real-guardian-principal";
|
|
161
|
+
const sessionMock = registerPendingInteraction(
|
|
162
|
+
"req-guardian-mismatch-1",
|
|
163
|
+
CONVERSATION_ID,
|
|
164
|
+
TOOL_NAME,
|
|
165
|
+
TOOL_INPUT,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const result = await handleApprovalInterception({
|
|
169
|
+
conversationId: CONVERSATION_ID,
|
|
170
|
+
callbackData: "apr:req-guardian-mismatch-1:approve_once",
|
|
171
|
+
content: "",
|
|
172
|
+
conversationExternalId: REQUESTER_CHAT,
|
|
173
|
+
sourceChannel: "telegram",
|
|
174
|
+
actorExternalId: "stale-guardian-user",
|
|
175
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
176
|
+
trustCtx: {
|
|
177
|
+
sourceChannel: "telegram",
|
|
178
|
+
trustClass: "guardian",
|
|
179
|
+
requesterExternalUserId: "stale-guardian-user",
|
|
180
|
+
guardianExternalUserId: "stale-guardian-user",
|
|
181
|
+
guardianPrincipalId: "some-other-principal",
|
|
182
|
+
},
|
|
183
|
+
assistantId: ASSISTANT_ID,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(result.handled).toBe(true);
|
|
187
|
+
expect(result.type).toBe("stale_ignored");
|
|
188
|
+
expect(sessionMock).not.toHaveBeenCalled();
|
|
189
|
+
// Generic failure copy — no oracle about pending requests or permission.
|
|
190
|
+
expect(deliverSpy).toHaveBeenCalledWith(
|
|
191
|
+
"https://gateway.test/deliver",
|
|
192
|
+
expect.objectContaining({
|
|
193
|
+
text: "Sorry, I couldn't process that. Please try again.",
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("guardian without a resolved acting principal is rejected before any decision", async () => {
|
|
199
|
+
// Address-only guardian classification (e.g. a binding row with a null
|
|
200
|
+
// principal) must not authorize decisions.
|
|
201
|
+
_anchorPrincipalId = "the-real-guardian-principal";
|
|
202
|
+
const sessionMock = registerPendingInteraction(
|
|
203
|
+
"req-guardian-no-principal-1",
|
|
204
|
+
CONVERSATION_ID,
|
|
205
|
+
TOOL_NAME,
|
|
206
|
+
TOOL_INPUT,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const result = await handleApprovalInterception({
|
|
210
|
+
conversationId: CONVERSATION_ID,
|
|
211
|
+
callbackData: "apr:req-guardian-no-principal-1:approve_once",
|
|
212
|
+
content: "",
|
|
213
|
+
conversationExternalId: REQUESTER_CHAT,
|
|
214
|
+
sourceChannel: "telegram",
|
|
215
|
+
actorExternalId: "guardian-user-1",
|
|
216
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
217
|
+
trustCtx: {
|
|
218
|
+
sourceChannel: "telegram",
|
|
219
|
+
trustClass: "guardian",
|
|
220
|
+
requesterExternalUserId: "guardian-user-1",
|
|
221
|
+
guardianExternalUserId: "guardian-user-1",
|
|
222
|
+
},
|
|
223
|
+
assistantId: ASSISTANT_ID,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result.handled).toBe(true);
|
|
227
|
+
expect(result.type).toBe("stale_ignored");
|
|
228
|
+
expect(sessionMock).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("guardian decision defers to the verdict when the anchor read is unresolvable", async () => {
|
|
232
|
+
// Transient gateway miss on the anchor read: the gateway-stamped verdict
|
|
233
|
+
// (which already classified the actor guardian by principal) wins.
|
|
234
|
+
_anchorPrincipalId = undefined;
|
|
235
|
+
const sessionMock = registerPendingInteraction(
|
|
236
|
+
"req-guardian-anchor-miss-1",
|
|
237
|
+
CONVERSATION_ID,
|
|
238
|
+
TOOL_NAME,
|
|
239
|
+
TOOL_INPUT,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const result = await handleApprovalInterception({
|
|
243
|
+
conversationId: CONVERSATION_ID,
|
|
244
|
+
callbackData: "apr:req-guardian-anchor-miss-1:approve_once",
|
|
245
|
+
content: "",
|
|
246
|
+
conversationExternalId: REQUESTER_CHAT,
|
|
247
|
+
sourceChannel: "telegram",
|
|
248
|
+
actorExternalId: "guardian-user-1",
|
|
249
|
+
replyCallbackUrl: "https://gateway.test/deliver",
|
|
250
|
+
trustCtx: {
|
|
251
|
+
sourceChannel: "telegram",
|
|
252
|
+
trustClass: "guardian",
|
|
253
|
+
requesterExternalUserId: "guardian-user-1",
|
|
254
|
+
guardianExternalUserId: "guardian-user-1",
|
|
255
|
+
guardianPrincipalId: "guardian-principal-1",
|
|
256
|
+
},
|
|
257
|
+
assistantId: ASSISTANT_ID,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(result.handled).toBe(true);
|
|
261
|
+
expect(result.type).toBe("decision_applied");
|
|
262
|
+
expect(sessionMock).toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
120
265
|
test("unverified sender (no identity) auto-denies pending approval", async () => {
|
|
121
266
|
const sessionMock = registerPendingInteraction(
|
|
122
267
|
"req-unknown-auto-deny-1",
|
|
@@ -135,7 +280,7 @@ describe("approval interception trust-class gates", () => {
|
|
|
135
280
|
trustCtx: {
|
|
136
281
|
sourceChannel: "telegram",
|
|
137
282
|
trustClass: "unknown",
|
|
138
|
-
}
|
|
283
|
+
},
|
|
139
284
|
assistantId: ASSISTANT_ID,
|
|
140
285
|
});
|
|
141
286
|
|
|
@@ -2,13 +2,12 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import { writeSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
|
|
4
4
|
import type { MessageLexicalSearchResult } from "../persistence/embeddings/messages-lexical-index.js";
|
|
5
|
-
import { setOverridesForTesting } from "./feature-flag-test-helpers.js";
|
|
6
5
|
|
|
7
6
|
// Mutable stand-in for the Qdrant lexical candidate helper. The default
|
|
8
7
|
// throws so any qdrant-path test that forgets to set an implementation fails
|
|
9
8
|
// loudly rather than silently exercising an empty candidate set. FTS-path
|
|
10
|
-
// tests never reach it (
|
|
11
|
-
// there.
|
|
9
|
+
// tests never reach it (fts5 is used until the backfill completes), so its
|
|
10
|
+
// value is irrelevant there.
|
|
12
11
|
let lexicalMockImpl: (
|
|
13
12
|
query: string,
|
|
14
13
|
limit: number,
|
|
@@ -99,7 +98,10 @@ describe("searchConversationSource", () => {
|
|
|
99
98
|
});
|
|
100
99
|
});
|
|
101
100
|
|
|
102
|
-
test("
|
|
101
|
+
test("returns no evidence for short and non-ASCII queries (content matching is index-only)", async () => {
|
|
102
|
+
// Short/punctuation-only and CJK queries produce no usable ≥2-char match
|
|
103
|
+
// shape. There is no content-scan fallback, so such queries yield no
|
|
104
|
+
// conversation evidence even when an exact substring exists.
|
|
103
105
|
await seedConversation({
|
|
104
106
|
title: "C++ notes",
|
|
105
107
|
role: "user",
|
|
@@ -117,12 +119,8 @@ describe("searchConversationSource", () => {
|
|
|
117
119
|
5,
|
|
118
120
|
);
|
|
119
121
|
|
|
120
|
-
expect(shortResult.evidence
|
|
121
|
-
|
|
122
|
-
]);
|
|
123
|
-
expect(unicodeResult.evidence.map((item) => item.title)).toEqual([
|
|
124
|
-
"Unicode notes",
|
|
125
|
-
]);
|
|
122
|
+
expect(shortResult.evidence).toEqual([]);
|
|
123
|
+
expect(unicodeResult.evidence).toEqual([]);
|
|
126
124
|
});
|
|
127
125
|
|
|
128
126
|
test("does not return derived subagent, auto-analysis, or notification conversations", async () => {
|
|
@@ -203,27 +201,6 @@ describe("searchConversationSource", () => {
|
|
|
203
201
|
]);
|
|
204
202
|
});
|
|
205
203
|
|
|
206
|
-
test("excludes legacy private conversations through the LIKE fallback", async () => {
|
|
207
|
-
const visible = await seedConversation({
|
|
208
|
-
title: "Visible non-ASCII conversation",
|
|
209
|
-
content: "東京 appears in a normal conversation.",
|
|
210
|
-
});
|
|
211
|
-
const legacyPrivate = await seedConversation({
|
|
212
|
-
title: "Legacy private non-ASCII conversation",
|
|
213
|
-
content: "東京 appears in a private conversation.",
|
|
214
|
-
});
|
|
215
|
-
rawRun(
|
|
216
|
-
"UPDATE conversations SET conversation_type = 'private' WHERE id = ?",
|
|
217
|
-
legacyPrivate.conversation.id,
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
const result = await searchConversationSource("東京", makeContext(), 10);
|
|
221
|
-
|
|
222
|
-
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
223
|
-
`${visible.conversation.id}#${visible.message.id}`,
|
|
224
|
-
]);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
204
|
test("includes archived, scheduled, and background conversations", async () => {
|
|
228
205
|
const archived = await seedConversation({
|
|
229
206
|
title: "Archived conversation",
|
|
@@ -407,14 +384,13 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
407
384
|
getDb().run("DELETE FROM conversations");
|
|
408
385
|
lexicalCalls = [];
|
|
409
386
|
suppressIndexing = false;
|
|
410
|
-
//
|
|
411
|
-
//
|
|
387
|
+
// The qdrant backend is selected once the index is populated: not suppressed
|
|
388
|
+
// + backfill complete. These tests exercise that post-backfill path, so mark
|
|
389
|
+
// the backfill complete. The completion gate is covered by its own test.
|
|
412
390
|
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
413
|
-
setOverridesForTesting({ "messages-search-backend": true });
|
|
414
391
|
});
|
|
415
392
|
|
|
416
393
|
afterEach(() => {
|
|
417
|
-
setOverridesForTesting({});
|
|
418
394
|
suppressIndexing = false;
|
|
419
395
|
deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
|
|
420
396
|
lexicalMockImpl = () => {
|
|
@@ -424,7 +400,7 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
424
400
|
};
|
|
425
401
|
});
|
|
426
402
|
|
|
427
|
-
test("falls back to FTS when memory indexing is suppressed
|
|
403
|
+
test("falls back to FTS when memory indexing is suppressed", async () => {
|
|
428
404
|
const match = await seedConversation({
|
|
429
405
|
title: "Suppressed indexing notes",
|
|
430
406
|
content: "The suppressedtoken decision is recorded here.",
|
|
@@ -451,7 +427,7 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
451
427
|
]);
|
|
452
428
|
});
|
|
453
429
|
|
|
454
|
-
test("falls back to FTS until the backfill completion checkpoint is set
|
|
430
|
+
test("falls back to FTS until the backfill completion checkpoint is set", async () => {
|
|
455
431
|
const match = await seedConversation({
|
|
456
432
|
title: "Pre-backfill notes",
|
|
457
433
|
content: "The prebackfilltoken decision is recorded here.",
|
|
@@ -480,16 +456,17 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
480
456
|
]);
|
|
481
457
|
});
|
|
482
458
|
|
|
483
|
-
test("
|
|
484
|
-
|
|
459
|
+
test("short/punctuation-only queries return no evidence and never hit Qdrant", async () => {
|
|
460
|
+
await seedConversation({
|
|
485
461
|
title: "C++ notes",
|
|
486
462
|
role: "user",
|
|
487
463
|
content: "Use C++ when the example needs deterministic lifetime notes.",
|
|
488
464
|
});
|
|
489
465
|
|
|
490
|
-
// `C++` produces no usable ≥2-char FTS match shape
|
|
491
|
-
//
|
|
492
|
-
//
|
|
466
|
+
// `C++` produces no usable ≥2-char FTS match shape. The sparse encoder
|
|
467
|
+
// would still emit a noisy 1-char `c` token, so the source must return no
|
|
468
|
+
// evidence WITHOUT querying the index. The mock throws to prove it never
|
|
469
|
+
// runs.
|
|
493
470
|
lexicalMockImpl = async () => {
|
|
494
471
|
throw new Error("searchMessageIdsLexical must not run for a short query");
|
|
495
472
|
};
|
|
@@ -497,9 +474,7 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
497
474
|
const result = await searchConversationSource("C++", makeContext(), 5);
|
|
498
475
|
|
|
499
476
|
expect(lexicalCalls).toHaveLength(0);
|
|
500
|
-
expect(result.evidence
|
|
501
|
-
`${match.conversation.id}#${match.message.id}`,
|
|
502
|
-
]);
|
|
477
|
+
expect(result.evidence).toEqual([]);
|
|
503
478
|
});
|
|
504
479
|
|
|
505
480
|
test("over-fetches a wide candidate pool from Qdrant, not the FTS prefetch window", async () => {
|
|
@@ -622,10 +597,10 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
622
597
|
]);
|
|
623
598
|
});
|
|
624
599
|
|
|
625
|
-
test("
|
|
626
|
-
|
|
627
|
-
title: "
|
|
628
|
-
content: "The
|
|
600
|
+
test("returns no evidence when the Qdrant lookup throws", async () => {
|
|
601
|
+
await seedConversation({
|
|
602
|
+
title: "Failure notes",
|
|
603
|
+
content: "The qdranterrortoken decision is recorded here.",
|
|
629
604
|
});
|
|
630
605
|
|
|
631
606
|
lexicalMockImpl = async () => {
|
|
@@ -633,18 +608,18 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
633
608
|
};
|
|
634
609
|
|
|
635
610
|
const result = await searchConversationSource(
|
|
636
|
-
"
|
|
611
|
+
"qdranterrortoken",
|
|
637
612
|
makeContext(),
|
|
638
613
|
5,
|
|
639
614
|
);
|
|
640
615
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
]);
|
|
616
|
+
// Content matching is index-only: the failure is logged and the source
|
|
617
|
+
// yields no conversation evidence — no content-scan recovery.
|
|
618
|
+
expect(result.evidence).toEqual([]);
|
|
644
619
|
});
|
|
645
620
|
|
|
646
|
-
test("
|
|
647
|
-
|
|
621
|
+
test("returns no evidence when Qdrant returns no candidates", async () => {
|
|
622
|
+
await seedConversation({
|
|
648
623
|
title: "Empty candidate notes",
|
|
649
624
|
content: "The emptycandidatetoken decision is recorded here.",
|
|
650
625
|
});
|
|
@@ -657,9 +632,9 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
657
632
|
5,
|
|
658
633
|
);
|
|
659
634
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
]);
|
|
635
|
+
// The index is authoritative for content matching — an empty candidate
|
|
636
|
+
// set is an empty result, not a trigger for a table-scan fallback.
|
|
637
|
+
expect(result.evidence).toEqual([]);
|
|
663
638
|
});
|
|
664
639
|
});
|
|
665
640
|
|