@vellumai/assistant 0.10.3-staging.2 → 0.10.4-staging.1
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/openapi.yaml +73 -56
- package/package.json +1 -1
- package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
- package/src/__tests__/assistant-stream-state.test.ts +3 -76
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
- package/src/__tests__/channel-approval-routes.test.ts +21 -26
- package/src/__tests__/channel-delivery-store.test.ts +28 -0
- package/src/__tests__/channel-guardian.test.ts +82 -32
- package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
- package/src/__tests__/channel-reply-delivery.test.ts +6 -2
- package/src/__tests__/compaction-ledger-store.test.ts +128 -0
- package/src/__tests__/config-loader-backfill.test.ts +148 -0
- package/src/__tests__/consult-deadline.test.ts +60 -0
- package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
- package/src/__tests__/contact-store-user-file.test.ts +7 -10
- package/src/__tests__/contacts-relay-reads.test.ts +6 -9
- package/src/__tests__/contacts-write.test.ts +0 -2
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
- package/src/__tests__/conversation-agent-loop.test.ts +98 -7
- package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
- package/src/__tests__/conversation-error.test.ts +18 -0
- package/src/__tests__/conversation-fork-crud.test.ts +354 -24
- package/src/__tests__/conversation-title-service.test.ts +222 -201
- package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
- package/src/__tests__/delete-propagation.test.ts +5 -3
- package/src/__tests__/dm-backfill.test.ts +6 -4
- package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
- package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
- package/src/__tests__/guardian-dispatch.test.ts +50 -5
- package/src/__tests__/guardian-routing-state.test.ts +6 -10
- package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
- package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
- package/src/__tests__/helpers/mock-logger.ts +1 -0
- package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
- package/src/__tests__/invite-redemption-service.test.ts +273 -53
- package/src/__tests__/invite-routes-http.test.ts +34 -0
- package/src/__tests__/invite-service-ipc.test.ts +65 -2
- package/src/__tests__/list-messages-page-latest.test.ts +173 -4
- package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
- package/src/__tests__/non-member-access-request.test.ts +15 -13
- package/src/__tests__/onboarding-persona-write.test.ts +52 -22
- package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
- package/src/__tests__/persona-resolver.test.ts +75 -45
- package/src/__tests__/plugin-bootstrap.test.ts +13 -5
- package/src/__tests__/plugin-disabled-state.test.ts +190 -0
- package/src/__tests__/provider-usage-tracking.test.ts +1 -1
- package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
- package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
- package/src/__tests__/reaction-persistence.test.ts +51 -4
- package/src/__tests__/relay-server.test.ts +88 -31
- package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
- package/src/__tests__/settings-routes.test.ts +32 -0
- package/src/__tests__/slack-block-formatting.test.ts +1 -38
- package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
- package/src/__tests__/stt-hints.test.ts +6 -3
- package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
- package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
- package/src/__tests__/subagent-role-registry.test.ts +17 -4
- package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
- package/src/__tests__/subagent-tools.test.ts +398 -3
- package/src/__tests__/thread-backfill.test.ts +3 -3
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
- package/src/__tests__/tool-start-timestamp.test.ts +4 -3
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
- package/src/__tests__/trusted-contact-verification.test.ts +79 -54
- package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
- package/src/__tests__/voice-invite-redemption.test.ts +183 -20
- package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
- package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
- package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
- package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
- package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
- package/src/agent/loop-exclusive-tool.test.ts +19 -15
- package/src/agent/loop-native-web-search.test.ts +200 -0
- package/src/agent/loop.ts +108 -1
- package/src/api/responses/conversation-message.ts +9 -0
- package/src/approvals/guardian-request-resolvers.ts +16 -4
- package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
- package/src/calls/guardian-dispatch.ts +14 -11
- package/src/calls/inbound-trust-reader.ts +7 -1
- package/src/calls/relay-access-wait.ts +6 -6
- package/src/calls/relay-server.ts +22 -2
- package/src/calls/relay-setup-router.ts +10 -10
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
- package/src/cli/commands/contacts.ts +10 -7
- package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
- package/src/cli/commands/memory/worker.ts +97 -30
- package/src/cli/commands/plugins.ts +3 -146
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
- package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
- package/src/cli/lib/publish-plugin.ts +231 -1
- package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
- package/src/config/bundled-skills/subagent/SKILL.md +16 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
- package/src/config/call-site-defaults.ts +0 -6
- package/src/config/llm-resolver.ts +0 -3
- package/src/config/schemas/call-site-catalog.ts +0 -7
- package/src/config/schemas/heartbeat.ts +2 -5
- package/src/config/schemas/llm.ts +3 -12
- package/src/config/schemas/memory-lifecycle.ts +1 -1
- package/src/config/seed-inference-profiles.ts +76 -35
- package/src/config/sync-gated-profiles.ts +0 -3
- package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
- package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
- package/src/contacts/contact-store.ts +27 -237
- package/src/contacts/contacts-write.ts +18 -58
- package/src/contacts/gateway-channel-read.ts +51 -0
- package/src/contacts/member-write-relay.ts +25 -31
- package/src/contacts/types.ts +3 -15
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
- package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
- package/src/daemon/conversation-agent-loop.ts +68 -61
- package/src/daemon/conversation-error.ts +7 -10
- package/src/daemon/conversation-tool-setup.ts +0 -10
- package/src/daemon/conversation.ts +10 -0
- package/src/daemon/external-plugins-bootstrap.ts +8 -2
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
- package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
- package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
- package/src/daemon/handlers/config-channels.ts +14 -29
- package/src/daemon/lifecycle.ts +16 -4
- package/src/daemon/message-types/surfaces.ts +2 -0
- package/src/heartbeat/heartbeat-service.ts +5 -0
- package/src/home/relationship-state-writer.ts +5 -0
- package/src/memory/__tests__/embedding-cache.test.ts +136 -0
- package/src/memory/compaction-ledger-store.ts +107 -0
- package/src/memory/conversation-crud.ts +136 -61
- 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/memory/jobs-worker.ts +75 -29
- package/src/memory/memory-retrospective-job.ts +5 -0
- package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
- package/src/memory/migrations/302-create-compaction-events.ts +107 -0
- package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
- package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
- package/src/memory/schema/contacts.ts +6 -2
- package/src/memory/schema/conversations.ts +39 -0
- package/src/memory/steps.ts +1090 -367
- package/src/memory/worker-control.ts +104 -18
- package/src/memory/worker-process.ts +17 -0
- package/src/messaging/channel-binding-metadata.ts +31 -0
- package/src/messaging/channel-binding-schema.ts +51 -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/binding-metadata.ts +62 -0
- 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/notifications/__tests__/broadcaster.test.ts +0 -8
- package/src/notifications/__tests__/connected-channels.test.ts +8 -36
- package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
- package/src/notifications/destination-resolver.ts +7 -23
- package/src/notifications/emit-signal.ts +5 -11
- package/src/plugins/defaults/index.ts +0 -35
- 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/plugins/disabled-state.ts +31 -0
- package/src/plugins/registry.ts +55 -12
- package/src/prompts/persona-resolver.ts +43 -11
- package/src/providers/call-site-routing.ts +41 -0
- package/src/providers/provider-send-message.ts +6 -0
- package/src/providers/ratelimit.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/providers/retry.ts +6 -0
- package/src/providers/types.ts +13 -0
- package/src/providers/usage-tracking.ts +6 -0
- package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
- package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
- package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
- package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
- package/src/runtime/access-request-helper.ts +1 -2
- package/src/runtime/actor-trust-resolver.ts +44 -17
- package/src/runtime/anchored-guardian.test.ts +7 -54
- package/src/runtime/anchored-guardian.ts +4 -53
- package/src/runtime/assistant-stream-state.ts +12 -74
- package/src/runtime/channel-reply-delivery.ts +3 -8
- package/src/runtime/guardian-vellum-migration.ts +18 -16
- package/src/runtime/invite-redemption-service.ts +25 -10
- package/src/runtime/local-actor-identity.test.ts +108 -0
- package/src/runtime/local-actor-identity.ts +27 -20
- package/src/runtime/member-verdict-cache.ts +0 -0
- package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
- package/src/runtime/routes/contact-routes.ts +40 -25
- package/src/runtime/routes/conversation-list-routes.ts +1 -29
- package/src/runtime/routes/conversation-routes.ts +27 -7
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
- package/src/runtime/routes/settings-routes.ts +8 -3
- package/src/runtime/services/conversation-serializer.ts +6 -49
- package/src/runtime/slack-block-formatting.ts +0 -15
- package/src/runtime/trust-verdict-consumer.ts +36 -41
- package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
- package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
- package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
- package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
- package/src/subagent/index.ts +1 -1
- package/src/subagent/manager.ts +245 -33
- package/src/subagent/types.ts +8 -1
- package/src/tools/registry.ts +10 -3
- package/src/tools/subagent/consult-deadline.ts +49 -0
- package/src/tools/subagent/spawn.ts +234 -5
- package/src/util/logger.ts +9 -0
- package/src/util/platform.ts +14 -0
- package/src/workspace/migrations/031-drop-user-md.ts +232 -148
- package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
- package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
- package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
- package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
- package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
- package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
- package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
- package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
- package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
- package/src/plugins/defaults/advisor/config.ts +0 -21
- package/src/plugins/defaults/advisor/consult.ts +0 -197
- package/src/plugins/defaults/advisor/context-pack.ts +0 -288
- package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
- package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
- package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
- package/src/plugins/defaults/advisor/package.json +0 -14
- package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
|
@@ -31,20 +31,18 @@
|
|
|
31
31
|
* sized generously enough that a typical refresh round-trip (~1-3s)
|
|
32
32
|
* is well within window.
|
|
33
33
|
*
|
|
34
|
-
* Persisted
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* the
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* process assigns -- never ambiguous against it. The map is LRU-bounded; an
|
|
47
|
-
* evicted conversation reports no seq and the client cold-starts.
|
|
34
|
+
* Persisted seq: alongside the live counter and ring, the `seq` of the last
|
|
35
|
+
* event whose content is durably committed to a conversation's message rows
|
|
36
|
+
* is stored on the `conversations.seq` column (see `conversation-crud`). The
|
|
37
|
+
* `/messages` snapshot returns it so a client can align the snapshot with the
|
|
38
|
+
* stream: "these rows reflect all of this conversation's events through
|
|
39
|
+
* `seq = S`." It is written at each persistence flush (assistant rows persist
|
|
40
|
+
* incrementally, debounced, so the snapshot can lag the live counter) -- never
|
|
41
|
+
* the live counter itself, which would over-claim events that have streamed
|
|
42
|
+
* but not yet been written. Because it lives in the database it survives a
|
|
43
|
+
* restart; and because the counter resumes above the persisted reservation, a
|
|
44
|
+
* value written by a previous process could only ever be lower than any seq
|
|
45
|
+
* the new process assigns -- never ambiguous against it.
|
|
48
46
|
*/
|
|
49
47
|
|
|
50
48
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -66,16 +64,6 @@ const RING_COUNT_LIMIT = SSE_REPLAY_RING_COUNT_LIMIT;
|
|
|
66
64
|
const RING_SIZE_LIMIT_BYTES = 256 * 1024;
|
|
67
65
|
const RING_AGE_LIMIT_MS = SSE_REPLAY_RING_AGE_LIMIT_MS;
|
|
68
66
|
|
|
69
|
-
/**
|
|
70
|
-
* Cap on how many conversations retain a persisted-seq entry. Unlike the
|
|
71
|
-
* ring (which the live stream needs only briefly), the persisted-seq map
|
|
72
|
-
* grows with the number of conversations that have ever streamed in this
|
|
73
|
-
* process. Bound it LRU so it can't grow without limit; an evicted
|
|
74
|
-
* conversation simply reports no seq on its next `/messages` and the
|
|
75
|
-
* client cold-starts, which is harmless.
|
|
76
|
-
*/
|
|
77
|
-
const PERSISTED_SEQ_CONVERSATION_LIMIT = 1024;
|
|
78
|
-
|
|
79
67
|
/**
|
|
80
68
|
* How many seq values are reserved per persisted write. The counter can
|
|
81
69
|
* hand out seqs up to the persisted ceiling without touching disk, so
|
|
@@ -141,13 +129,6 @@ interface AssistantStreamState {
|
|
|
141
129
|
firstStampedSeq: number;
|
|
142
130
|
ring: RingEntry[];
|
|
143
131
|
totalSizeBytes: number;
|
|
144
|
-
/**
|
|
145
|
-
* Per-conversation `seq` of the last event durably committed to the
|
|
146
|
-
* message rows. Insertion order is maintained as an LRU recency list:
|
|
147
|
-
* the oldest key is evicted first once the map exceeds
|
|
148
|
-
* {@link PERSISTED_SEQ_CONVERSATION_LIMIT}.
|
|
149
|
-
*/
|
|
150
|
-
persistedSeqByConversation: Map<string, number>;
|
|
151
132
|
}
|
|
152
133
|
|
|
153
134
|
// ── State ────────────────────────────────────────────────────────────
|
|
@@ -159,7 +140,6 @@ const state: AssistantStreamState = {
|
|
|
159
140
|
firstStampedSeq: 0,
|
|
160
141
|
ring: [],
|
|
161
142
|
totalSizeBytes: 0,
|
|
162
|
-
persistedSeqByConversation: new Map(),
|
|
163
143
|
};
|
|
164
144
|
|
|
165
145
|
// ── Public API ───────────────────────────────────────────────────────
|
|
@@ -270,46 +250,6 @@ export function getCurrentSeq(): number {
|
|
|
270
250
|
return state.nextSeq - 1;
|
|
271
251
|
}
|
|
272
252
|
|
|
273
|
-
/**
|
|
274
|
-
* Record that conversation `conversationId` has durably persisted all of
|
|
275
|
-
* its events through `seq`. Called at each persistence flush with the
|
|
276
|
-
* `seq` of the last event whose content the write committed.
|
|
277
|
-
*
|
|
278
|
-
* Monotonic: a lower `seq` never regresses a higher one (out-of-order
|
|
279
|
-
* async commits are clamped). LRU-bounded by
|
|
280
|
-
* {@link PERSISTED_SEQ_CONVERSATION_LIMIT}: re-recording refreshes
|
|
281
|
-
* recency, and the oldest conversation is evicted once the cap is
|
|
282
|
-
* exceeded. Non-positive or non-finite `seq` values are ignored.
|
|
283
|
-
*/
|
|
284
|
-
export function recordPersistedSeq(conversationId: string, seq: number): void {
|
|
285
|
-
if (!Number.isFinite(seq) || seq <= 0) return;
|
|
286
|
-
|
|
287
|
-
const map = state.persistedSeqByConversation;
|
|
288
|
-
const prev = map.get(conversationId);
|
|
289
|
-
if (prev !== undefined) {
|
|
290
|
-
// Re-insert to move this key to the most-recently-used end.
|
|
291
|
-
map.delete(conversationId);
|
|
292
|
-
map.set(conversationId, Math.max(prev, seq));
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
map.set(conversationId, seq);
|
|
297
|
-
if (map.size > PERSISTED_SEQ_CONVERSATION_LIMIT) {
|
|
298
|
-
const oldestKey = map.keys().next().value;
|
|
299
|
-
if (oldestKey !== undefined) map.delete(oldestKey);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Highest `seq` durably persisted for `conversationId`, or `null` when
|
|
305
|
-
* none has been recorded in this process (cold conversation, or evicted
|
|
306
|
-
* from the LRU map). Returned by `/messages` so a client can align the
|
|
307
|
-
* snapshot with the live stream.
|
|
308
|
-
*/
|
|
309
|
-
export function getPersistedSeq(conversationId: string): number | null {
|
|
310
|
-
return state.persistedSeqByConversation.get(conversationId) ?? null;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
253
|
/**
|
|
314
254
|
* Reset all stream state. Test-only.
|
|
315
255
|
*/
|
|
@@ -323,7 +263,6 @@ export function _resetStreamStateForTesting(): void {
|
|
|
323
263
|
state.firstStampedSeq = 0;
|
|
324
264
|
state.ring = [];
|
|
325
265
|
state.totalSizeBytes = 0;
|
|
326
|
-
state.persistedSeqByConversation.clear();
|
|
327
266
|
}
|
|
328
267
|
|
|
329
268
|
/**
|
|
@@ -337,7 +276,6 @@ export function _simulateRestartForTesting(): void {
|
|
|
337
276
|
state.firstStampedSeq = 0;
|
|
338
277
|
state.ring = [];
|
|
339
278
|
state.totalSizeBytes = 0;
|
|
340
|
-
state.persistedSeqByConversation.clear();
|
|
341
279
|
}
|
|
342
280
|
|
|
343
281
|
/**
|
|
@@ -12,10 +12,6 @@ import { getLogger } from "../util/logger.js";
|
|
|
12
12
|
import type { ChannelDeliveryResult } from "./gateway-client.js";
|
|
13
13
|
import { deliverChannelReply } from "./gateway-client.js";
|
|
14
14
|
import type { RuntimeAttachmentMetadata } from "./http-types.js";
|
|
15
|
-
import {
|
|
16
|
-
isSlackCallbackUrl,
|
|
17
|
-
textToSlackBlocks,
|
|
18
|
-
} from "./slack-block-formatting.js";
|
|
19
15
|
|
|
20
16
|
const log = getLogger("channel-reply-delivery");
|
|
21
17
|
|
|
@@ -167,8 +163,6 @@ export async function deliverRenderedReplyViaCallback(
|
|
|
167
163
|
return;
|
|
168
164
|
}
|
|
169
165
|
|
|
170
|
-
const isSlack = isSlackCallbackUrl(callbackUrl);
|
|
171
|
-
|
|
172
166
|
// Only the first segment uses messageTs for in-place update;
|
|
173
167
|
// subsequent segments are posted as new messages.
|
|
174
168
|
let currentMessageTs = messageTs;
|
|
@@ -177,13 +171,14 @@ export async function deliverRenderedReplyViaCallback(
|
|
|
177
171
|
const isLastSegment = i === deliverableSegments.length - 1;
|
|
178
172
|
const isFirstSegment = i === startFromSegment;
|
|
179
173
|
const segmentText = deliverableSegments[i];
|
|
180
|
-
const blocks = isSlack ? textToSlackBlocks(segmentText) : undefined;
|
|
181
174
|
const result: ChannelDeliveryResult = await deliverChannelReply(
|
|
182
175
|
callbackUrl,
|
|
183
176
|
{
|
|
184
177
|
chatId,
|
|
185
178
|
text: segmentText,
|
|
186
|
-
|
|
179
|
+
// Ask the channel to render richly; each channel's adapter decides how
|
|
180
|
+
// (Slack → Block Kit). Channels without rich rendering send plain text.
|
|
181
|
+
useBlocks: true,
|
|
187
182
|
attachments: isLastSegment ? replyAttachments : undefined,
|
|
188
183
|
assistantId,
|
|
189
184
|
ephemeral,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ChannelId } from "../channels/types.js";
|
|
11
11
|
import {
|
|
12
|
-
|
|
12
|
+
findContactByAddress,
|
|
13
13
|
updateContactPrincipalAndChannel,
|
|
14
14
|
} from "../contacts/contact-store.js";
|
|
15
15
|
import {
|
|
@@ -44,8 +44,7 @@ const log = getLogger("guardian-vellum-migration");
|
|
|
44
44
|
* Returns true if healing occurred, false otherwise.
|
|
45
45
|
*
|
|
46
46
|
* The gateway binding supplies the authoritative principal; the local
|
|
47
|
-
* assistant-mirror row is repaired
|
|
48
|
-
* principal — even when the gateway binding already matches — because the
|
|
47
|
+
* assistant-mirror row is repaired to match the JWT principal because the
|
|
49
48
|
* /v1/messages trust path still resolves against the local mirror in this
|
|
50
49
|
* plan. A stale mirror must be repaired or valid guardians stay `unknown`.
|
|
51
50
|
*/
|
|
@@ -62,28 +61,31 @@ export async function healGuardianBindingDrift(
|
|
|
62
61
|
if (!guardian) return false;
|
|
63
62
|
|
|
64
63
|
const currentPrincipalId = guardian.principalId;
|
|
64
|
+
// Only repair auto-generated principals — never overwrite a real one.
|
|
65
65
|
if (!currentPrincipalId?.startsWith("vellum-principal-")) return false;
|
|
66
|
+
// No-op when the principal already matches the JWT principal.
|
|
67
|
+
if (currentPrincipalId === incomingPrincipalId) return false;
|
|
66
68
|
|
|
67
|
-
// Resolve the assistant-mirror row
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
69
|
+
// Resolve the assistant-mirror row to repair so local trust resolution
|
|
70
|
+
// converges on the JWT principal. The gateway delivery supplies the guardian
|
|
71
|
+
// identity (channel + address) but not the local channel UUID write target,
|
|
72
|
+
// so resolve that locally by the guardian's vellum-channel address.
|
|
73
|
+
const localContact = findContactByAddress("vellum", guardian.address);
|
|
74
|
+
const localChannel = localContact?.channels.find(
|
|
75
|
+
(c) => c.type === "vellum",
|
|
76
|
+
);
|
|
77
|
+
if (!localContact || !localChannel) return false;
|
|
76
78
|
|
|
77
79
|
const updated = updateContactPrincipalAndChannel(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
localContact.id,
|
|
81
|
+
localChannel.id,
|
|
80
82
|
incomingPrincipalId,
|
|
81
83
|
);
|
|
82
84
|
|
|
83
85
|
if (!updated) {
|
|
84
86
|
log.warn(
|
|
85
87
|
{
|
|
86
|
-
oldPrincipalId:
|
|
88
|
+
oldPrincipalId: currentPrincipalId,
|
|
87
89
|
newPrincipalId: incomingPrincipalId,
|
|
88
90
|
},
|
|
89
91
|
"Skipped guardian binding drift heal — address collision on contact_channels",
|
|
@@ -93,7 +95,7 @@ export async function healGuardianBindingDrift(
|
|
|
93
95
|
|
|
94
96
|
log.info(
|
|
95
97
|
{
|
|
96
|
-
oldPrincipalId:
|
|
98
|
+
oldPrincipalId: currentPrincipalId,
|
|
97
99
|
newPrincipalId: incomingPrincipalId,
|
|
98
100
|
},
|
|
99
101
|
"Healed vellum guardian binding drift — updated local mirror principalId to match JWT actor",
|
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
} from "../calls/inbound-trust-reader.js";
|
|
14
14
|
import type { ChannelId } from "../channels/types.js";
|
|
15
15
|
import { findContactChannel, getContact } from "../contacts/contact-store.js";
|
|
16
|
+
import { gatewayContactChannelState } from "../contacts/gateway-channel-read.js";
|
|
16
17
|
import { activateMemberChannel } from "../contacts/member-write-relay.js";
|
|
17
|
-
import type { ChannelStatus } from "../contacts/types.js";
|
|
18
|
+
import type { ChannelStatus, ContactChannel } from "../contacts/types.js";
|
|
18
19
|
import { ipcCallPersistent } from "../ipc/gateway-client.js";
|
|
19
20
|
import {
|
|
20
21
|
findActiveVoiceInvites,
|
|
@@ -33,19 +34,33 @@ const log = getLogger("invite-redemption-service");
|
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Resolve the sender's existing member status for the already_member/blocked
|
|
36
|
-
* gate from the gateway trust verdict. Falls back to the
|
|
37
|
-
* when the verdict is absent or carries no resolvable member status (e.g.
|
|
38
|
-
* externalChatId-only match or a resolutionFailed verdict), so a
|
|
39
|
-
*
|
|
37
|
+
* gate from the gateway trust verdict. Falls back to the gateway-sourced channel
|
|
38
|
+
* status when the verdict is absent or carries no resolvable member status (e.g.
|
|
39
|
+
* an externalChatId-only match or a resolutionFailed verdict), so a blocked
|
|
40
|
+
* contact can't bypass the gate.
|
|
40
41
|
*/
|
|
41
42
|
export async function resolveMemberGateStatus(
|
|
42
43
|
verdict: Awaited<ReturnType<typeof getInboundTrustVerdict>>,
|
|
43
|
-
|
|
44
|
+
fallbackStatus: ChannelStatus | null,
|
|
44
45
|
): Promise<ChannelStatus | null> {
|
|
45
46
|
const memberStatus = verdict
|
|
46
47
|
? verdictMemberFromVerdict(verdict)?.status
|
|
47
48
|
: null;
|
|
48
|
-
return memberStatus ??
|
|
49
|
+
return memberStatus ?? fallbackStatus;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Gateway-sourced status for an existing local channel, used as the gate-status
|
|
54
|
+
* fallback when the verdict resolves no member. The local row is only located by
|
|
55
|
+
* identity; its status is read from the gateway (ACL source of truth), never the
|
|
56
|
+
* local column.
|
|
57
|
+
*/
|
|
58
|
+
async function gatewayFallbackStatus(
|
|
59
|
+
channel: Pick<ContactChannel, "contactId" | "type" | "address"> | null,
|
|
60
|
+
): Promise<ChannelStatus | null> {
|
|
61
|
+
if (!channel) return null;
|
|
62
|
+
const state = await gatewayContactChannelState(channel);
|
|
63
|
+
return (state?.status as ChannelStatus | undefined) ?? null;
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
// ---------------------------------------------------------------------------
|
|
@@ -245,7 +260,7 @@ export async function redeemInvite(params: {
|
|
|
245
260
|
channelType: sourceChannel as ChannelId,
|
|
246
261
|
actorExternalId: canonicalUserId,
|
|
247
262
|
}),
|
|
248
|
-
existingChannel
|
|
263
|
+
await gatewayFallbackStatus(existingChannel),
|
|
249
264
|
);
|
|
250
265
|
|
|
251
266
|
if (existingChannel && gateStatus === "active" && !targetMismatch) {
|
|
@@ -481,7 +496,7 @@ export async function redeemVoiceInviteCode(params: {
|
|
|
481
496
|
|
|
482
497
|
const gateStatus = await resolveMemberGateStatus(
|
|
483
498
|
await getPhoneCallerVerdict(canonicalCallerId),
|
|
484
|
-
existingVoiceChannel
|
|
499
|
+
await gatewayFallbackStatus(existingVoiceChannel),
|
|
485
500
|
);
|
|
486
501
|
|
|
487
502
|
if (existingVoiceChannel && gateStatus === "active" && !targetMismatch) {
|
|
@@ -657,7 +672,7 @@ export async function redeemInviteByCode(params: {
|
|
|
657
672
|
channelType: sourceChannel as ChannelId,
|
|
658
673
|
actorExternalId: canonicalUserId,
|
|
659
674
|
}),
|
|
660
|
-
existingChannel
|
|
675
|
+
await gatewayFallbackStatus(existingChannel),
|
|
661
676
|
);
|
|
662
677
|
|
|
663
678
|
if (existingChannel && gateStatus === "active" && !targetMismatch) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the local-actor-identity cache-warm path.
|
|
3
|
+
*
|
|
4
|
+
* The SSE eager-subscribe path resolves the local actor principal
|
|
5
|
+
* synchronously from the IO-free guardian-delivery cache snapshot. A cold
|
|
6
|
+
* cache returns undefined, so the daemon warms it at startup
|
|
7
|
+
* (`warmLocalGuardianPrincipalCache`) before clients register. These tests pin
|
|
8
|
+
* that the sync read is cold before the warm and resolves the gateway-owned
|
|
9
|
+
* principal after it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
import type { GuardianDelivery } from "@vellumai/gateway-client";
|
|
15
|
+
|
|
16
|
+
// ── Controllable IPC mock ──────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
type IpcHandler = (params?: Record<string, unknown>) => unknown;
|
|
19
|
+
const ipcHandlers = new Map<string, IpcHandler>();
|
|
20
|
+
|
|
21
|
+
mock.module("../ipc/gateway-client.js", () => ({
|
|
22
|
+
ipcCall: async (method: string, params?: Record<string, unknown>) => {
|
|
23
|
+
const handler = ipcHandlers.get(method);
|
|
24
|
+
return handler ? handler(params) : undefined;
|
|
25
|
+
},
|
|
26
|
+
ipcCallPersistent: async () => undefined,
|
|
27
|
+
resetPersistentClient: () => {},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
let httpAuthDisabled = false;
|
|
31
|
+
mock.module("../config/env.js", () => ({
|
|
32
|
+
isHttpAuthDisabled: () => httpAuthDisabled,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { __resetGuardianDeliveryCacheForTest } from "../contacts/guardian-delivery-reader.js";
|
|
36
|
+
import {
|
|
37
|
+
findLocalGuardianPrincipalIdFromStore,
|
|
38
|
+
resolveActorPrincipalIdForLocalGuardianSync,
|
|
39
|
+
warmLocalGuardianPrincipalCache,
|
|
40
|
+
} from "./local-actor-identity.js";
|
|
41
|
+
|
|
42
|
+
const METHOD = "resolve_guardian_delivery";
|
|
43
|
+
|
|
44
|
+
const vellumGuardian: GuardianDelivery = {
|
|
45
|
+
channelType: "vellum",
|
|
46
|
+
contactId: "contact-1",
|
|
47
|
+
address: "self",
|
|
48
|
+
status: "active",
|
|
49
|
+
principalId: "principal-abc",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("warmLocalGuardianPrincipalCache", () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
__resetGuardianDeliveryCacheForTest();
|
|
55
|
+
ipcHandlers.clear();
|
|
56
|
+
httpAuthDisabled = false;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
__resetGuardianDeliveryCacheForTest();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("sync read is cold before warming", () => {
|
|
64
|
+
ipcHandlers.set(METHOD, () => ({ guardians: [vellumGuardian] }));
|
|
65
|
+
|
|
66
|
+
// No warm yet — the cache snapshot is empty.
|
|
67
|
+
expect(findLocalGuardianPrincipalIdFromStore()).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("warming populates the cache for the sync read", async () => {
|
|
71
|
+
ipcHandlers.set(METHOD, () => ({ guardians: [vellumGuardian] }));
|
|
72
|
+
|
|
73
|
+
await warmLocalGuardianPrincipalCache();
|
|
74
|
+
|
|
75
|
+
expect(findLocalGuardianPrincipalIdFromStore()).toBe("principal-abc");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("cold-start SSE registration resolves the principal after warm", async () => {
|
|
79
|
+
httpAuthDisabled = true;
|
|
80
|
+
ipcHandlers.set(METHOD, () => ({ guardians: [vellumGuardian] }));
|
|
81
|
+
|
|
82
|
+
// Cold cache: dev-bypass header resolves to no principal.
|
|
83
|
+
expect(
|
|
84
|
+
resolveActorPrincipalIdForLocalGuardianSync("dev-bypass"),
|
|
85
|
+
).toBeUndefined();
|
|
86
|
+
|
|
87
|
+
await warmLocalGuardianPrincipalCache();
|
|
88
|
+
|
|
89
|
+
// Warmed: the SSE sync path now resolves the gateway-owned principal.
|
|
90
|
+
expect(resolveActorPrincipalIdForLocalGuardianSync("dev-bypass")).toBe(
|
|
91
|
+
"principal-abc",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("warm tolerates an unreachable gateway without caching a failure", async () => {
|
|
96
|
+
ipcHandlers.set(METHOD, () => {
|
|
97
|
+
throw new Error("gateway down");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await warmLocalGuardianPrincipalCache();
|
|
101
|
+
|
|
102
|
+
// Failure not cached; a later successful read warms the cache.
|
|
103
|
+
expect(findLocalGuardianPrincipalIdFromStore()).toBeUndefined();
|
|
104
|
+
ipcHandlers.set(METHOD, () => ({ guardians: [vellumGuardian] }));
|
|
105
|
+
await warmLocalGuardianPrincipalCache();
|
|
106
|
+
expect(findLocalGuardianPrincipalIdFromStore()).toBe("principal-abc");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
import type { ChannelId } from "../channels/types.js";
|
|
15
15
|
import { isHttpAuthDisabled } from "../config/env.js";
|
|
16
|
-
import { findGuardianForChannel } from "../contacts/contact-store.js";
|
|
17
16
|
import {
|
|
18
17
|
getGuardianDelivery,
|
|
19
18
|
guardianForChannel,
|
|
@@ -54,24 +53,37 @@ export function buildLocalAuthContext(conversationId: string): AuthContext {
|
|
|
54
53
|
*
|
|
55
54
|
* The gateway owns guardian binding; this reads it through the cached
|
|
56
55
|
* `getGuardianDelivery` reader (PR-3 TTL + single-flight) so hot paths don't
|
|
57
|
-
* storm the IPC.
|
|
58
|
-
* first-run window where the gateway has no guardian yet or is unreachable
|
|
59
|
-
* (the reader returns `null`).
|
|
56
|
+
* storm the IPC.
|
|
60
57
|
*
|
|
61
|
-
* Returns `undefined` when no vellum guardian binding exists
|
|
62
|
-
*
|
|
63
|
-
* "not yet available" and
|
|
58
|
+
* Returns `undefined` when no vellum guardian binding exists (e.g. fresh
|
|
59
|
+
* install before bootstrap, or the gateway is unreachable). Callers should
|
|
60
|
+
* treat that case as "not yet available" and proceed without a principalId.
|
|
64
61
|
*/
|
|
65
62
|
export async function findLocalGuardianPrincipalId(): Promise<
|
|
66
63
|
string | undefined
|
|
67
64
|
> {
|
|
68
65
|
const list = await getGuardianDelivery({ channelTypes: ["vellum"] });
|
|
69
|
-
if (list)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
66
|
+
if (!list) return undefined;
|
|
67
|
+
return guardianForChannel(list, "vellum")?.principalId ?? undefined;
|
|
68
|
+
}
|
|
73
69
|
|
|
74
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Eagerly warm the gateway guardian-delivery cache for the vellum channel.
|
|
72
|
+
*
|
|
73
|
+
* The SSE eager-subscribe path resolves the actor principal synchronously via
|
|
74
|
+
* {@link findLocalGuardianPrincipalIdFromStore}, which reads only the IO-free
|
|
75
|
+
* cache snapshot. On a cold cache (auth-disabled / local startup, before any
|
|
76
|
+
* async `getGuardianDelivery` has run) it returns undefined, so the FIRST SSE
|
|
77
|
+
* registration would carry no `actorPrincipalId` and host-proxy same-user
|
|
78
|
+
* targeting would regress until a later reconnect warms the cache.
|
|
79
|
+
*
|
|
80
|
+
* Called during daemon startup (after the gateway IPC is reachable) so the
|
|
81
|
+
* cache is populated before clients register. Best-effort: a cold gateway
|
|
82
|
+
* leaves the cache empty (failures aren't cached), and the async hot paths
|
|
83
|
+
* warm it on their next read.
|
|
84
|
+
*/
|
|
85
|
+
export async function warmLocalGuardianPrincipalCache(): Promise<void> {
|
|
86
|
+
await findLocalGuardianPrincipalId();
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
/**
|
|
@@ -82,17 +94,12 @@ export async function findLocalGuardianPrincipalId(): Promise<
|
|
|
82
94
|
* Reads the same gateway-owned binding as the async path via a sync, IO-free
|
|
83
95
|
* snapshot of the guardian-delivery cache (kept fresh by the async hot paths
|
|
84
96
|
* and event-driven invalidation), so SSE registers the SAME principal the
|
|
85
|
-
* send/result routes resolve.
|
|
86
|
-
* cold — the same fallback the async path lands on during bootstrap.
|
|
97
|
+
* send/result routes resolve.
|
|
87
98
|
*/
|
|
88
99
|
export function findLocalGuardianPrincipalIdFromStore(): string | undefined {
|
|
89
100
|
const cached = peekCachedGuardianDelivery({ channelTypes: ["vellum"] });
|
|
90
|
-
if (cached)
|
|
91
|
-
|
|
92
|
-
if (principalId) return principalId;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return findGuardianForChannel("vellum")?.contact.principalId ?? undefined;
|
|
101
|
+
if (!cached) return undefined;
|
|
102
|
+
return guardianForChannel(cached, "vellum")?.principalId ?? undefined;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
/**
|
|
Binary file
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
15
15
|
|
|
16
16
|
import { IpcCallError } from "@vellumai/gateway-client/ipc-client";
|
|
17
|
+
import { z } from "zod";
|
|
17
18
|
|
|
18
19
|
let ipcCalls: { method: string; params?: Record<string, unknown> }[] = [];
|
|
19
20
|
let ipcResult: unknown = {};
|
|
@@ -44,16 +45,53 @@ const contactStoreReadGuard = mock(() => {
|
|
|
44
45
|
);
|
|
45
46
|
});
|
|
46
47
|
|
|
48
|
+
// Filtered/native reads (search) legitimately go to the assistant DB. Drive
|
|
49
|
+
// them deterministically so the daemon-native response shape can be asserted.
|
|
50
|
+
let searchContactsResult: unknown[] = [];
|
|
51
|
+
const searchContactsMock = mock(() => searchContactsResult);
|
|
52
|
+
|
|
47
53
|
mock.module("../../../contacts/contact-store.js", () => ({
|
|
48
54
|
...actualContactStore,
|
|
49
55
|
getContact: contactStoreReadGuard,
|
|
50
56
|
listContacts: contactStoreReadGuard,
|
|
51
57
|
getAssistantContactMetadata: contactStoreReadGuard,
|
|
58
|
+
searchContacts: searchContactsMock,
|
|
52
59
|
}));
|
|
53
60
|
|
|
54
|
-
const { handleListContacts, handleGetContact, ROUTES } =
|
|
55
|
-
"../contact-routes.js"
|
|
56
|
-
|
|
61
|
+
const { handleListContacts, handleGetContact, ROUTES } =
|
|
62
|
+
await import("../contact-routes.js");
|
|
63
|
+
|
|
64
|
+
// Daemon-native contact: INFO is hydrated locally; channel-level ACL fields
|
|
65
|
+
// (status/policy/verification) are gateway-owned and absent on native reads.
|
|
66
|
+
// Contact-level `role` is stored locally (NOT NULL) and always returned.
|
|
67
|
+
const nativeContact = {
|
|
68
|
+
id: "ct_2",
|
|
69
|
+
displayName: "Bob",
|
|
70
|
+
notes: null,
|
|
71
|
+
role: "contact",
|
|
72
|
+
contactType: "human",
|
|
73
|
+
lastInteraction: 4200,
|
|
74
|
+
interactionCount: 4,
|
|
75
|
+
createdAt: 1000,
|
|
76
|
+
updatedAt: 1500,
|
|
77
|
+
userFile: "bob.md",
|
|
78
|
+
channels: [
|
|
79
|
+
{
|
|
80
|
+
id: "ch_2",
|
|
81
|
+
contactId: "ct_2",
|
|
82
|
+
type: "phone",
|
|
83
|
+
address: "+15550200",
|
|
84
|
+
isPrimary: true,
|
|
85
|
+
externalChatId: null,
|
|
86
|
+
inviteId: null,
|
|
87
|
+
lastSeenAt: 4100,
|
|
88
|
+
interactionCount: 4,
|
|
89
|
+
lastInteraction: 4200,
|
|
90
|
+
updatedAt: 1500,
|
|
91
|
+
createdAt: 1000,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
57
95
|
|
|
58
96
|
const gatewayChannel = {
|
|
59
97
|
id: "ch_1",
|
|
@@ -76,7 +114,7 @@ const gatewayChannel = {
|
|
|
76
114
|
const gatewayContact = {
|
|
77
115
|
id: "ct_1",
|
|
78
116
|
displayName: "Alice",
|
|
79
|
-
role: "
|
|
117
|
+
role: "guardian",
|
|
80
118
|
notes: "a note",
|
|
81
119
|
contactType: "human",
|
|
82
120
|
lastInteraction: 1900,
|
|
@@ -93,6 +131,8 @@ describe("contacts read API relays from the gateway", () => {
|
|
|
93
131
|
ipcError = undefined;
|
|
94
132
|
ipcCallPersistentMock.mockClear();
|
|
95
133
|
contactStoreReadGuard.mockClear();
|
|
134
|
+
searchContactsResult = [];
|
|
135
|
+
searchContactsMock.mockClear();
|
|
96
136
|
});
|
|
97
137
|
|
|
98
138
|
test("list relays to contacts_list_rich and serializes the gateway ACL fields", async () => {
|
|
@@ -108,7 +148,7 @@ describe("contacts read API relays from the gateway", () => {
|
|
|
108
148
|
|
|
109
149
|
const [contact] = result.contacts;
|
|
110
150
|
// ACL fields are gateway-sourced and reach the web client unchanged.
|
|
111
|
-
expect(contact.role).toBe("
|
|
151
|
+
expect((contact as { role?: string }).role).toBe("guardian");
|
|
112
152
|
expect(contact.interactionCount).toBe(7);
|
|
113
153
|
expect(contact.lastInteraction).toBe(1900);
|
|
114
154
|
const channel = contact.channels[0] as Record<string, unknown>;
|
|
@@ -159,7 +199,7 @@ describe("contacts read API relays from the gateway", () => {
|
|
|
159
199
|
{ method: "contacts_get_rich", params: { contactId: "ct_1" } },
|
|
160
200
|
]);
|
|
161
201
|
expect(result.ok).toBe(true);
|
|
162
|
-
expect(result.contact.role).toBe("
|
|
202
|
+
expect(result.contact.role).toBe("guardian");
|
|
163
203
|
expect(result.contact.interactionCount).toBe(7);
|
|
164
204
|
const channel = result.contact.channels[0] as Record<string, unknown>;
|
|
165
205
|
expect(channel.status).toBe("active");
|
|
@@ -204,9 +244,62 @@ describe("contacts read API relays from the gateway", () => {
|
|
|
204
244
|
expect(ipcCalls).toEqual([
|
|
205
245
|
{ method: "contacts_list_rich", params: { limit: 50 } },
|
|
206
246
|
]);
|
|
207
|
-
expect(contacts[0].role).toBe("
|
|
247
|
+
expect(contacts[0].role).toBe("guardian");
|
|
208
248
|
expect(contacts[0].interactionCount).toBe(7);
|
|
209
249
|
expect(contacts[0].channels[0].status).toBe("active");
|
|
210
250
|
expect(contactStoreReadGuard).not.toHaveBeenCalled();
|
|
211
251
|
});
|
|
212
252
|
});
|
|
253
|
+
|
|
254
|
+
describe("filtered/native contact reads stay daemon-native", () => {
|
|
255
|
+
const listRoute = ROUTES.find((r) => r.operationId === "listContacts")!;
|
|
256
|
+
const listResponseSchema = listRoute.responseBody as z.ZodTypeAny;
|
|
257
|
+
const searchRoute = ROUTES.find((r) => r.operationId === "search_contacts")!;
|
|
258
|
+
const searchResponseSchema = searchRoute.responseBody as z.ZodTypeAny;
|
|
259
|
+
|
|
260
|
+
beforeEach(() => {
|
|
261
|
+
ipcCalls = [];
|
|
262
|
+
ipcResult = {};
|
|
263
|
+
ipcError = undefined;
|
|
264
|
+
ipcCallPersistentMock.mockClear();
|
|
265
|
+
contactStoreReadGuard.mockClear();
|
|
266
|
+
searchContactsResult = [];
|
|
267
|
+
searchContactsMock.mockClear();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("query-filtered list serves daemon-native INFO and validates against the response schema", async () => {
|
|
271
|
+
searchContactsResult = [nativeContact];
|
|
272
|
+
|
|
273
|
+
const result = await handleListContacts({ query: "Bob", limit: "10" });
|
|
274
|
+
|
|
275
|
+
// No gateway relay for a true search.
|
|
276
|
+
expect(ipcCalls).toEqual([]);
|
|
277
|
+
expect(searchContactsMock).toHaveBeenCalled();
|
|
278
|
+
|
|
279
|
+
const [contact] = result.contacts;
|
|
280
|
+
// INFO telemetry is present (re-hydrated locally, not dropped).
|
|
281
|
+
expect(contact.interactionCount).toBe(4);
|
|
282
|
+
expect(contact.lastInteraction).toBe(4200);
|
|
283
|
+
const channel = contact.channels[0] as Record<string, unknown>;
|
|
284
|
+
expect(channel.interactionCount).toBe(4);
|
|
285
|
+
expect(channel.lastSeenAt).toBe(4100);
|
|
286
|
+
expect(channel.externalUserId).toBe("+15550200");
|
|
287
|
+
// Contact-level `role` is locally stored (NOT NULL) and always present.
|
|
288
|
+
expect((contact as { role: string }).role).toBe("contact");
|
|
289
|
+
// Channel-level ACL fields (status/policy) are gateway-owned and absent.
|
|
290
|
+
expect("status" in channel).toBe(false);
|
|
291
|
+
expect(() => listResponseSchema.parse(result)).not.toThrow();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("POST search with a filter validates against the response schema", async () => {
|
|
295
|
+
searchContactsResult = [nativeContact];
|
|
296
|
+
|
|
297
|
+
const contacts = (await searchRoute.handler({
|
|
298
|
+
body: { query: "Bob" },
|
|
299
|
+
})) as unknown[];
|
|
300
|
+
|
|
301
|
+
expect(ipcCalls).toEqual([]);
|
|
302
|
+
expect(searchContactsMock).toHaveBeenCalled();
|
|
303
|
+
expect(() => searchResponseSchema.parse(contacts)).not.toThrow();
|
|
304
|
+
});
|
|
305
|
+
});
|