@vellumai/assistant 0.3.15 → 0.3.16
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/ARCHITECTURE.md +142 -0
- package/Dockerfile +1 -1
- package/README.md +5 -5
- package/docs/architecture/http-token-refresh.md +252 -0
- package/docs/architecture/memory.md +5 -4
- package/docs/architecture/scheduling.md +4 -88
- package/docs/runbook-trusted-contacts.md +283 -0
- package/docs/trusted-contact-access.md +247 -0
- package/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
- package/src/__tests__/access-request-decision.test.ts +331 -0
- package/src/__tests__/asset-materialize-tool.test.ts +7 -7
- package/src/__tests__/asset-search-tool.test.ts +15 -15
- package/src/__tests__/attachments-store.test.ts +13 -13
- package/src/__tests__/call-controller.test.ts +150 -4
- package/src/__tests__/call-conversation-messages.test.ts +2 -2
- package/src/__tests__/call-pointer-messages.test.ts +28 -0
- package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
- package/src/__tests__/channel-approval-routes.test.ts +108 -12
- package/src/__tests__/channel-guardian.test.ts +16 -14
- package/src/__tests__/checker.test.ts +24 -0
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
- package/src/__tests__/config-watcher.test.ts +358 -0
- package/src/__tests__/conversation-pairing.test.ts +24 -24
- package/src/__tests__/conversation-store.test.ts +36 -36
- package/src/__tests__/date-context.test.ts +179 -1
- package/src/__tests__/db-migration-rollback.test.ts +4 -7
- package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
- package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
- package/src/__tests__/gateway-only-guard.test.ts +188 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
- package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
- package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
- package/src/__tests__/guardian-action-sweep.test.ts +9 -9
- package/src/__tests__/guardian-outbound-http.test.ts +194 -2
- package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
- package/src/__tests__/handlers-telegram-config.test.ts +6 -6
- package/src/__tests__/hooks-runner.test.ts +13 -4
- package/src/__tests__/ingress-routes-http.test.ts +443 -0
- package/src/__tests__/intent-routing.test.ts +14 -0
- package/src/__tests__/ipc-snapshot.test.ts +2 -5
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-regressions.test.ts +16 -12
- package/src/__tests__/non-member-access-request.test.ts +282 -0
- package/src/__tests__/notification-decision-strategy.test.ts +136 -0
- package/src/__tests__/notification-routing-intent.test.ts +11 -1
- package/src/__tests__/notification-thread-candidates.test.ts +166 -0
- package/src/__tests__/recording-intent.test.ts +1 -0
- package/src/__tests__/recording-state-machine.test.ts +328 -17
- package/src/__tests__/registry.test.ts +17 -8
- package/src/__tests__/relay-server.test.ts +105 -0
- package/src/__tests__/reminder.test.ts +13 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
- package/src/__tests__/scheduler-recurrence.test.ts +50 -0
- package/src/__tests__/server-history-render.test.ts +8 -8
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-runtime-assembly.test.ts +49 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
- package/src/__tests__/slack-channel-config.test.ts +230 -0
- package/src/__tests__/subagent-manager-notify.test.ts +4 -4
- package/src/__tests__/swarm-session-integration.test.ts +2 -2
- package/src/__tests__/system-prompt.test.ts +43 -0
- package/src/__tests__/task-management-tools.test.ts +3 -3
- package/src/__tests__/task-tools.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +17 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
- package/src/__tests__/trusted-contact-verification.test.ts +360 -0
- package/src/__tests__/update-bulletin-format.test.ts +119 -0
- package/src/__tests__/update-bulletin-state.test.ts +129 -0
- package/src/__tests__/update-bulletin.test.ts +260 -0
- package/src/__tests__/update-template-contract.test.ts +29 -0
- package/src/agent/loop.ts +2 -2
- package/src/amazon/client.ts +2 -3
- package/src/calls/call-controller.ts +115 -34
- package/src/calls/call-conversation-messages.ts +2 -2
- package/src/calls/call-domain.ts +10 -3
- package/src/calls/call-pointer-messages.ts +17 -5
- package/src/calls/guardian-action-sweep.ts +77 -36
- package/src/calls/relay-server.ts +51 -12
- package/src/calls/twilio-routes.ts +3 -1
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -4
- package/src/cli/core-commands.ts +3 -3
- package/src/cli/map.ts +8 -5
- package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
- package/src/config/bundled-skills/tasks/SKILL.md +1 -1
- package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
- package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
- package/src/config/computer-use-prompt.ts +1 -0
- package/src/config/core-schema.ts +16 -0
- package/src/config/env-registry.ts +1 -0
- package/src/config/env.ts +16 -1
- package/src/config/memory-schema.ts +5 -0
- package/src/config/schema.ts +4 -0
- package/src/config/system-prompt.ts +69 -2
- package/src/config/templates/BOOTSTRAP.md +1 -1
- package/src/config/templates/IDENTITY.md +8 -4
- package/src/config/templates/SOUL.md +14 -0
- package/src/config/templates/UPDATES.md +16 -0
- package/src/config/templates/USER.md +5 -1
- package/src/config/types.ts +1 -0
- package/src/config/update-bulletin-format.ts +52 -0
- package/src/config/update-bulletin-state.ts +49 -0
- package/src/config/update-bulletin.ts +82 -0
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
- package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
- package/src/context/window-manager.ts +43 -3
- package/src/daemon/config-watcher.ts +1 -0
- package/src/daemon/connection-policy.ts +21 -1
- package/src/daemon/daemon-control.ts +164 -7
- package/src/daemon/date-context.ts +174 -1
- package/src/daemon/guardian-action-generators.ts +175 -0
- package/src/daemon/guardian-verification-intent.ts +120 -0
- package/src/daemon/handlers/apps.ts +1 -3
- package/src/daemon/handlers/config-channels.ts +2 -2
- package/src/daemon/handlers/config-heartbeat.ts +1 -1
- package/src/daemon/handlers/config-inbox.ts +55 -159
- package/src/daemon/handlers/config-ingress.ts +1 -1
- package/src/daemon/handlers/config-integrations.ts +1 -1
- package/src/daemon/handlers/config-platform.ts +1 -1
- package/src/daemon/handlers/config-scheduling.ts +2 -2
- package/src/daemon/handlers/config-slack-channel.ts +190 -0
- package/src/daemon/handlers/config-telegram.ts +1 -1
- package/src/daemon/handlers/config-twilio.ts +1 -1
- package/src/daemon/handlers/config-voice.ts +100 -0
- package/src/daemon/handlers/config.ts +3 -0
- package/src/daemon/handlers/misc.ts +83 -5
- package/src/daemon/handlers/navigate-settings.ts +27 -0
- package/src/daemon/handlers/recording.ts +270 -144
- package/src/daemon/handlers/sessions.ts +100 -17
- package/src/daemon/handlers/subagents.ts +3 -3
- package/src/daemon/handlers/work-items.ts +10 -7
- package/src/daemon/ipc-contract/integrations.ts +9 -1
- package/src/daemon/ipc-contract/messages.ts +4 -0
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/settings.ts +26 -0
- package/src/daemon/ipc-contract/shared.ts +2 -0
- package/src/daemon/ipc-contract/work-items.ts +1 -7
- package/src/daemon/ipc-contract-inventory.json +5 -1
- package/src/daemon/ipc-contract.ts +5 -1
- package/src/daemon/lifecycle.ts +306 -266
- package/src/daemon/recording-intent.ts +0 -41
- package/src/daemon/response-tier.ts +2 -2
- package/src/daemon/server.ts +6 -6
- package/src/daemon/session-agent-loop-handlers.ts +34 -9
- package/src/daemon/session-agent-loop.ts +15 -8
- package/src/daemon/session-history.ts +3 -2
- package/src/daemon/session-media-retry.ts +3 -0
- package/src/daemon/session-messaging.ts +38 -4
- package/src/daemon/session-notifiers.ts +2 -2
- package/src/daemon/session-process.ts +256 -23
- package/src/daemon/session-queue-manager.ts +2 -0
- package/src/daemon/session-runtime-assembly.ts +39 -0
- package/src/daemon/session-skill-tools.ts +13 -4
- package/src/daemon/session-tool-setup.ts +5 -6
- package/src/daemon/session.ts +19 -8
- package/src/daemon/tls-certs.ts +55 -13
- package/src/daemon/tool-side-effects.ts +13 -5
- package/src/gallery/default-gallery.ts +32 -9
- package/src/influencer/client.ts +2 -1
- package/src/memory/channel-delivery-store.ts +37 -567
- package/src/memory/channel-guardian-store.ts +66 -1317
- package/src/memory/conflict-store.ts +4 -4
- package/src/memory/conversation-attention-store.ts +0 -3
- package/src/memory/conversation-crud.ts +668 -0
- package/src/memory/conversation-queries.ts +361 -0
- package/src/memory/conversation-store.ts +45 -983
- package/src/memory/db-connection.ts +3 -0
- package/src/memory/db-init.ts +25 -0
- package/src/memory/delivery-channels.ts +175 -0
- package/src/memory/delivery-crud.ts +211 -0
- package/src/memory/delivery-status.ts +199 -0
- package/src/memory/embedding-backend.ts +70 -4
- package/src/memory/embedding-local.ts +12 -2
- package/src/memory/entity-extractor.ts +3 -8
- package/src/memory/fts-reconciler.ts +121 -0
- package/src/memory/guardian-action-store.ts +366 -3
- package/src/memory/guardian-approvals.ts +569 -0
- package/src/memory/guardian-bindings.ts +130 -0
- package/src/memory/guardian-rate-limits.ts +196 -0
- package/src/memory/guardian-verification.ts +520 -0
- package/src/memory/job-handlers/index-maintenance.ts +2 -1
- package/src/memory/job-utils.ts +8 -5
- package/src/memory/jobs-store.ts +66 -6
- package/src/memory/jobs-worker.ts +23 -1
- package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
- package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
- package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
- package/src/memory/migrations/100-core-tables.ts +1 -1
- package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
- package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
- package/src/memory/migrations/112-assistant-inbox.ts +1 -1
- package/src/memory/migrations/113-late-migrations.ts +1 -1
- package/src/memory/migrations/116-messages-fts.ts +13 -0
- package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
- package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
- package/src/memory/migrations/index.ts +8 -3
- package/src/memory/migrations/validate-migration-state.ts +114 -15
- package/src/memory/qdrant-circuit-breaker.ts +105 -0
- package/src/memory/retriever.ts +46 -13
- package/src/memory/schema-migration.ts +3 -0
- package/src/memory/schema.ts +25 -7
- package/src/memory/search/semantic.ts +8 -90
- package/src/notifications/README.md +1 -1
- package/src/notifications/broadcaster.ts +20 -2
- package/src/notifications/conversation-pairing.ts +3 -3
- package/src/notifications/decision-engine.ts +173 -8
- package/src/notifications/deliveries-store.ts +27 -8
- package/src/notifications/preferences-store.ts +7 -7
- package/src/notifications/thread-candidates.ts +234 -0
- package/src/notifications/types.ts +18 -0
- package/src/permissions/defaults.ts +11 -1
- package/src/permissions/prompter.ts +17 -0
- package/src/permissions/trust-store.ts +2 -0
- package/src/providers/failover.ts +19 -0
- package/src/providers/registry.ts +46 -1
- package/src/runtime/approval-message-composer.ts +1 -1
- package/src/runtime/channel-guardian-service.ts +15 -3
- package/src/runtime/channel-retry-sweep.ts +7 -2
- package/src/runtime/guardian-action-conversation-turn.ts +85 -0
- package/src/runtime/guardian-action-followup-executor.ts +301 -0
- package/src/runtime/guardian-action-message-composer.ts +245 -0
- package/src/runtime/guardian-outbound-actions.ts +26 -6
- package/src/runtime/guardian-verification-templates.ts +15 -9
- package/src/runtime/http-errors.ts +93 -0
- package/src/runtime/http-server.ts +133 -44
- package/src/runtime/http-types.ts +53 -0
- package/src/runtime/ingress-service.ts +237 -0
- package/src/runtime/middleware/error-handler.ts +4 -3
- package/src/runtime/middleware/rate-limiter.ts +160 -0
- package/src/runtime/middleware/request-logger.ts +71 -0
- package/src/runtime/middleware/twilio-validation.ts +7 -6
- package/src/runtime/pending-interactions.ts +12 -0
- package/src/runtime/routes/access-request-decision.ts +215 -0
- package/src/runtime/routes/app-routes.ts +25 -18
- package/src/runtime/routes/approval-routes.ts +18 -47
- package/src/runtime/routes/attachment-routes.ts +15 -41
- package/src/runtime/routes/call-routes.ts +20 -20
- package/src/runtime/routes/channel-delivery-routes.ts +6 -5
- package/src/runtime/routes/contact-routes.ts +4 -9
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +26 -57
- package/src/runtime/routes/debug-routes.ts +71 -0
- package/src/runtime/routes/events-routes.ts +3 -2
- package/src/runtime/routes/guardian-approval-interception.ts +221 -0
- package/src/runtime/routes/identity-routes.ts +14 -10
- package/src/runtime/routes/inbound-conversation.ts +3 -2
- package/src/runtime/routes/inbound-message-handler.ts +527 -62
- package/src/runtime/routes/ingress-routes.ts +174 -0
- package/src/runtime/routes/integration-routes.ts +78 -16
- package/src/runtime/routes/pairing-routes.ts +11 -10
- package/src/runtime/routes/secret-routes.ts +10 -18
- package/src/runtime/verification-rate-limiter.ts +83 -0
- package/src/schedule/schedule-store.ts +13 -1
- package/src/schedule/scheduler.ts +1 -1
- package/src/security/secret-ingress.ts +5 -2
- package/src/security/secret-scanner.ts +72 -6
- package/src/subagent/manager.ts +6 -4
- package/src/swarm/plan-validator.ts +4 -1
- package/src/tasks/task-runner.ts +3 -1
- package/src/tools/browser/api-map.ts +9 -6
- package/src/tools/calls/call-start.ts +20 -0
- package/src/tools/executor.ts +50 -568
- package/src/tools/permission-checker.ts +272 -0
- package/src/tools/registry.ts +14 -6
- package/src/tools/reminder/reminder-store.ts +7 -7
- package/src/tools/reminder/reminder.ts +6 -3
- package/src/tools/secret-detection-handler.ts +301 -0
- package/src/tools/subagent/message.ts +1 -1
- package/src/tools/system/voice-config.ts +62 -0
- package/src/tools/tasks/index.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +3 -3
- package/src/tools/tasks/work-item-update.ts +4 -5
- package/src/tools/tool-approval-handler.ts +192 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/watcher/watcher-store.ts +9 -9
- package/src/work-items/work-item-runner.ts +9 -6
- /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
- /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
|
@@ -232,10 +232,10 @@ describe('attachment orphan cleanup', () => {
|
|
|
232
232
|
db.run('DELETE FROM conversations');
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
-
test('deleteLastExchange cleans up orphaned attachments', () => {
|
|
235
|
+
test('deleteLastExchange cleans up orphaned attachments', async () => {
|
|
236
236
|
const conv = createConversation('test');
|
|
237
|
-
addMessage(conv.id, 'user', 'hello');
|
|
238
|
-
const assistantMsg = addMessage(conv.id, 'assistant', 'Here is a file');
|
|
237
|
+
await addMessage(conv.id, 'user', 'hello');
|
|
238
|
+
const assistantMsg = await addMessage(conv.id, 'assistant', 'Here is a file');
|
|
239
239
|
|
|
240
240
|
const stored = uploadAttachment('chart.png', 'image/png', 'iVBOR');
|
|
241
241
|
linkAttachmentToMessage(assistantMsg.id, stored.id, 0);
|
|
@@ -252,11 +252,11 @@ describe('attachment orphan cleanup', () => {
|
|
|
252
252
|
expect(remaining.c).toBe(0);
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
-
test('deleteLastExchange preserves attachments still linked to other messages', () => {
|
|
255
|
+
test('deleteLastExchange preserves attachments still linked to other messages', async () => {
|
|
256
256
|
const conv = createConversation('test');
|
|
257
|
-
const msg1 = addMessage(conv.id, 'assistant', 'first');
|
|
258
|
-
addMessage(conv.id, 'user', 'question');
|
|
259
|
-
const msg2 = addMessage(conv.id, 'assistant', 'second');
|
|
257
|
+
const msg1 = await addMessage(conv.id, 'assistant', 'first');
|
|
258
|
+
await addMessage(conv.id, 'user', 'question');
|
|
259
|
+
const msg2 = await addMessage(conv.id, 'assistant', 'second');
|
|
260
260
|
|
|
261
261
|
const shared = uploadAttachment('shared.png', 'image/png', 'AAAA');
|
|
262
262
|
linkAttachmentToMessage(msg1.id, shared.id, 0);
|
|
@@ -271,9 +271,9 @@ describe('attachment orphan cleanup', () => {
|
|
|
271
271
|
expect(remaining.c).toBe(1);
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
-
test('clearAll removes all attachments', () => {
|
|
274
|
+
test('clearAll removes all attachments', async () => {
|
|
275
275
|
const conv = createConversation('test');
|
|
276
|
-
const msg = addMessage(conv.id, 'assistant', 'file');
|
|
276
|
+
const msg = await addMessage(conv.id, 'assistant', 'file');
|
|
277
277
|
const stored = uploadAttachment('doc.pdf', 'application/pdf', 'JVBER');
|
|
278
278
|
linkAttachmentToMessage(msg.id, stored.id, 0);
|
|
279
279
|
|
|
@@ -286,10 +286,10 @@ describe('attachment orphan cleanup', () => {
|
|
|
286
286
|
expect(linkCount.c).toBe(0);
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
test('deleteLastExchange does not delete unlinked user uploads', () => {
|
|
289
|
+
test('deleteLastExchange does not delete unlinked user uploads', async () => {
|
|
290
290
|
const conv = createConversation('test');
|
|
291
|
-
addMessage(conv.id, 'user', 'hello');
|
|
292
|
-
const assistantMsg = addMessage(conv.id, 'assistant', 'Here is a file');
|
|
291
|
+
await addMessage(conv.id, 'user', 'hello');
|
|
292
|
+
const assistantMsg = await addMessage(conv.id, 'assistant', 'Here is a file');
|
|
293
293
|
|
|
294
294
|
// An attachment linked to the assistant message (should be cleaned up)
|
|
295
295
|
const linked = uploadAttachment('chart.png', 'image/png', 'iVBOR');
|
|
@@ -438,15 +438,15 @@ describe('attachment reuse across thread lifecycles', () => {
|
|
|
438
438
|
db.run('DELETE FROM conversations');
|
|
439
439
|
});
|
|
440
440
|
|
|
441
|
-
test('attachment uploaded in conversation A is retrievable by ID without any conversation reference', () => {
|
|
441
|
+
test('attachment uploaded in conversation A is retrievable by ID without any conversation reference', async () => {
|
|
442
442
|
const convA = createConversation('Thread A');
|
|
443
|
-
const msgA = addMessage(convA.id, 'assistant', 'Here is a file');
|
|
443
|
+
const msgA = await addMessage(convA.id, 'assistant', 'Here is a file');
|
|
444
444
|
const stored = uploadAttachment('report.pdf', 'application/pdf', 'JVBER');
|
|
445
445
|
linkAttachmentToMessage(msgA.id, stored.id, 0);
|
|
446
446
|
|
|
447
447
|
// Create a completely separate conversation
|
|
448
448
|
const convB = createConversation('Thread B');
|
|
449
|
-
addMessage(convB.id, 'user', 'hello');
|
|
449
|
+
await addMessage(convB.id, 'user', 'hello');
|
|
450
450
|
|
|
451
451
|
// The attachment is retrievable by ID regardless of which conversation is active.
|
|
452
452
|
const fetched = getAttachmentById(stored.id);
|
|
@@ -456,12 +456,12 @@ describe('attachment reuse across thread lifecycles', () => {
|
|
|
456
456
|
expect(fetched!.dataBase64).toBe('JVBER');
|
|
457
457
|
});
|
|
458
458
|
|
|
459
|
-
test('attachment can be linked to messages in different conversations', () => {
|
|
459
|
+
test('attachment can be linked to messages in different conversations', async () => {
|
|
460
460
|
const convA = createConversation('Thread A');
|
|
461
461
|
const convB = createConversation('Thread B');
|
|
462
462
|
|
|
463
|
-
const msgA = addMessage(convA.id, 'assistant', 'Original file');
|
|
464
|
-
const msgB = addMessage(convB.id, 'assistant', 'Reused file');
|
|
463
|
+
const msgA = await addMessage(convA.id, 'assistant', 'Original file');
|
|
464
|
+
const msgB = await addMessage(convB.id, 'assistant', 'Reused file');
|
|
465
465
|
|
|
466
466
|
// Upload once, link to both conversations
|
|
467
467
|
const stored = uploadAttachment('shared.png', 'image/png', 'iVBORw0K');
|
|
@@ -478,16 +478,16 @@ describe('attachment reuse across thread lifecycles', () => {
|
|
|
478
478
|
expect(linkedB[0].id).toBe(stored.id);
|
|
479
479
|
});
|
|
480
480
|
|
|
481
|
-
test('deleting conversation A does not orphan attachment reused in conversation B', () => {
|
|
481
|
+
test('deleting conversation A does not orphan attachment reused in conversation B', async () => {
|
|
482
482
|
const convA = createConversation('Thread A');
|
|
483
483
|
const convB = createConversation('Thread B');
|
|
484
484
|
|
|
485
485
|
// deleteLastExchange deletes from the last user message onward,
|
|
486
486
|
// so we need a user message before the assistant message that carries the attachment.
|
|
487
|
-
addMessage(convA.id, 'user', 'Please generate a chart');
|
|
488
|
-
const msgA = addMessage(convA.id, 'assistant', 'Original');
|
|
489
|
-
addMessage(convB.id, 'user', 'Show me the chart');
|
|
490
|
-
const msgB = addMessage(convB.id, 'assistant', 'Reused');
|
|
487
|
+
await addMessage(convA.id, 'user', 'Please generate a chart');
|
|
488
|
+
const msgA = await addMessage(convA.id, 'assistant', 'Original');
|
|
489
|
+
await addMessage(convB.id, 'user', 'Show me the chart');
|
|
490
|
+
const msgB = await addMessage(convB.id, 'assistant', 'Reused');
|
|
491
491
|
|
|
492
492
|
const stored = uploadAttachment('chart.png', 'image/png', 'AAAA');
|
|
493
493
|
linkAttachmentToMessage(msgA.id, stored.id, 0);
|
|
@@ -506,12 +506,12 @@ describe('attachment reuse across thread lifecycles', () => {
|
|
|
506
506
|
expect(linkedB[0].id).toBe(stored.id);
|
|
507
507
|
});
|
|
508
508
|
|
|
509
|
-
test('content-hash dedup works across conversations', () => {
|
|
509
|
+
test('content-hash dedup works across conversations', async () => {
|
|
510
510
|
const convA = createConversation('Thread A');
|
|
511
511
|
const convB = createConversation('Thread B');
|
|
512
512
|
|
|
513
|
-
addMessage(convA.id, 'user', 'upload in A');
|
|
514
|
-
addMessage(convB.id, 'user', 'upload in B');
|
|
513
|
+
await addMessage(convA.id, 'user', 'upload in A');
|
|
514
|
+
await addMessage(convB.id, 'user', 'upload in B');
|
|
515
515
|
|
|
516
516
|
// Same content uploaded in two different conversation contexts
|
|
517
517
|
const first = uploadAttachment('photo.png', 'image/png', 'DEDUPCROSS');
|
|
@@ -535,11 +535,11 @@ describe('no private-thread attachment visibility boundary', () => {
|
|
|
535
535
|
db.run('DELETE FROM conversations');
|
|
536
536
|
});
|
|
537
537
|
|
|
538
|
-
test('attachment from a private thread is visible via getAttachmentById (no thread scoping)', () => {
|
|
538
|
+
test('attachment from a private thread is visible via getAttachmentById (no thread scoping)', async () => {
|
|
539
539
|
const privateConv = createConversation({ title: 'Secret', threadType: 'private' });
|
|
540
540
|
expect(privateConv.threadType).toBe('private');
|
|
541
541
|
|
|
542
|
-
const msg = addMessage(privateConv.id, 'assistant', 'Private content');
|
|
542
|
+
const msg = await addMessage(privateConv.id, 'assistant', 'Private content');
|
|
543
543
|
const stored = uploadAttachment('secret.pdf', 'application/pdf', 'JVBER');
|
|
544
544
|
linkAttachmentToMessage(msg.id, stored.id, 0);
|
|
545
545
|
|
|
@@ -549,12 +549,12 @@ describe('no private-thread attachment visibility boundary', () => {
|
|
|
549
549
|
expect(fetched!.originalFilename).toBe('secret.pdf');
|
|
550
550
|
});
|
|
551
551
|
|
|
552
|
-
test('attachment from private thread can be linked to a standard thread message', () => {
|
|
552
|
+
test('attachment from private thread can be linked to a standard thread message', async () => {
|
|
553
553
|
const privateConv = createConversation({ title: 'Private', threadType: 'private' });
|
|
554
554
|
const standardConv = createConversation({ title: 'Standard', threadType: 'standard' });
|
|
555
555
|
|
|
556
|
-
const privateMsg = addMessage(privateConv.id, 'assistant', 'Private file');
|
|
557
|
-
const standardMsg = addMessage(standardConv.id, 'assistant', 'Reusing private file');
|
|
556
|
+
const privateMsg = await addMessage(privateConv.id, 'assistant', 'Private file');
|
|
557
|
+
const standardMsg = await addMessage(standardConv.id, 'assistant', 'Reusing private file');
|
|
558
558
|
|
|
559
559
|
const stored = uploadAttachment('private-doc.png', 'image/png', 'PRIVDATA');
|
|
560
560
|
linkAttachmentToMessage(privateMsg.id, stored.id, 0);
|
|
@@ -569,9 +569,9 @@ describe('no private-thread attachment visibility boundary', () => {
|
|
|
569
569
|
expect(linkedStandard[0].id).toBe(stored.id);
|
|
570
570
|
});
|
|
571
571
|
|
|
572
|
-
test('getAttachmentsForMessage returns private thread attachments', () => {
|
|
572
|
+
test('getAttachmentsForMessage returns private thread attachments', async () => {
|
|
573
573
|
const privateConv = createConversation({ title: 'Private', threadType: 'private' });
|
|
574
|
-
const msg = addMessage(privateConv.id, 'assistant', 'Private media');
|
|
574
|
+
const msg = await addMessage(privateConv.id, 'assistant', 'Private media');
|
|
575
575
|
const stored = uploadAttachment('photo.jpg', 'image/jpeg', 'AAAA');
|
|
576
576
|
linkAttachmentToMessage(msg.id, stored.id, 0);
|
|
577
577
|
|
|
@@ -592,12 +592,12 @@ describe('no private-thread attachment visibility boundary', () => {
|
|
|
592
592
|
expect(fromStandard.id).toBe(fromPrivate.id);
|
|
593
593
|
});
|
|
594
594
|
|
|
595
|
-
test('clearAll removes attachments from both private and standard threads', () => {
|
|
595
|
+
test('clearAll removes attachments from both private and standard threads', async () => {
|
|
596
596
|
const privateConv = createConversation({ title: 'Private', threadType: 'private' });
|
|
597
597
|
const standardConv = createConversation({ title: 'Standard', threadType: 'standard' });
|
|
598
598
|
|
|
599
|
-
const privateMsg = addMessage(privateConv.id, 'assistant', 'Private file');
|
|
600
|
-
const standardMsg = addMessage(standardConv.id, 'assistant', 'Standard file');
|
|
599
|
+
const privateMsg = await addMessage(privateConv.id, 'assistant', 'Private file');
|
|
600
|
+
const standardMsg = await addMessage(standardConv.id, 'assistant', 'Standard file');
|
|
601
601
|
|
|
602
602
|
const att1 = uploadAttachment('private.png', 'image/png', 'PRIV');
|
|
603
603
|
const att2 = uploadAttachment('standard.png', 'image/png', 'STD');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect,test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { buildTemporalContext } from '../daemon/date-context.js';
|
|
3
|
+
import { buildTemporalContext, extractUserTimeZoneFromDynamicProfile } from '../daemon/date-context.js';
|
|
4
4
|
|
|
5
5
|
// Fixed timestamps for deterministic assertions (all UTC midday to avoid DST edge cases).
|
|
6
6
|
|
|
@@ -40,11 +40,181 @@ describe('buildTemporalContext', () => {
|
|
|
40
40
|
expect(result).toContain('Timezone: America/New_York');
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test('includes current local time as ISO 8601 with offset', () => {
|
|
44
|
+
const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'UTC' });
|
|
45
|
+
expect(result).toContain('Current local time: 2026-02-18T12:00:00+00:00');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('includes current UTC time from assistant host clock', () => {
|
|
49
|
+
const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'UTC' });
|
|
50
|
+
expect(result).toContain('Current UTC time: 2026-02-18T12:00:00.000Z');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('documents assistant host as the authoritative clock source', () => {
|
|
54
|
+
const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'UTC' });
|
|
55
|
+
expect(result).toContain('Clock source: assistant host machine');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('uses user timezone when provided and records source metadata', () => {
|
|
59
|
+
const result = buildTemporalContext({
|
|
60
|
+
nowMs: WED_FEB_18,
|
|
61
|
+
hostTimeZone: 'UTC',
|
|
62
|
+
userTimeZone: 'America/New_York',
|
|
63
|
+
});
|
|
64
|
+
expect(result).toContain('Timezone: America/New_York');
|
|
65
|
+
expect(result).toContain('Current local time: 2026-02-18T07:00:00-05:00');
|
|
66
|
+
expect(result).toContain('Assistant host timezone: UTC');
|
|
67
|
+
expect(result).toContain('User timezone: America/New_York');
|
|
68
|
+
expect(result).toContain('Timezone source: user_profile_memory');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('uses configured user timezone when profile timezone is unavailable', () => {
|
|
72
|
+
const result = buildTemporalContext({
|
|
73
|
+
nowMs: WED_FEB_18,
|
|
74
|
+
hostTimeZone: 'UTC',
|
|
75
|
+
configuredUserTimeZone: 'America/Chicago',
|
|
76
|
+
userTimeZone: null,
|
|
77
|
+
});
|
|
78
|
+
expect(result).toContain('Timezone: America/Chicago');
|
|
79
|
+
expect(result).toContain('Current local time: 2026-02-18T06:00:00-06:00');
|
|
80
|
+
expect(result).toContain('User timezone: America/Chicago');
|
|
81
|
+
expect(result).toContain('Timezone source: user_settings');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('configured user timezone takes precedence over profile timezone', () => {
|
|
85
|
+
const result = buildTemporalContext({
|
|
86
|
+
nowMs: WED_FEB_18,
|
|
87
|
+
hostTimeZone: 'UTC',
|
|
88
|
+
configuredUserTimeZone: 'America/Los_Angeles',
|
|
89
|
+
userTimeZone: 'America/New_York',
|
|
90
|
+
});
|
|
91
|
+
expect(result).toContain('Timezone: America/Los_Angeles');
|
|
92
|
+
expect(result).toContain('Current local time: 2026-02-18T04:00:00-08:00');
|
|
93
|
+
expect(result).toContain('User timezone: America/Los_Angeles');
|
|
94
|
+
expect(result).toContain('Timezone source: user_settings');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('falls back to host timezone when user timezone is unavailable', () => {
|
|
98
|
+
const result = buildTemporalContext({
|
|
99
|
+
nowMs: WED_FEB_18,
|
|
100
|
+
hostTimeZone: 'UTC',
|
|
101
|
+
userTimeZone: null,
|
|
102
|
+
});
|
|
103
|
+
expect(result).toContain('Timezone: UTC');
|
|
104
|
+
expect(result).toContain('User timezone: unknown');
|
|
105
|
+
expect(result).toContain('Timezone source: assistant_host_fallback');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('accepts UTC/GMT offset-style user timezone values', () => {
|
|
109
|
+
const result = buildTemporalContext({
|
|
110
|
+
nowMs: WED_FEB_18,
|
|
111
|
+
hostTimeZone: 'UTC',
|
|
112
|
+
userTimeZone: 'UTC+2',
|
|
113
|
+
});
|
|
114
|
+
expect(result).toContain('Timezone: Etc/GMT-2');
|
|
115
|
+
expect(result).toContain('Current local time: 2026-02-18T14:00:00+02:00');
|
|
116
|
+
expect(result).toContain('User timezone: Etc/GMT-2');
|
|
117
|
+
expect(result).toContain('Timezone source: user_profile_memory');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('accepts fractional UTC/GMT offset-style user timezone values', () => {
|
|
121
|
+
const result = buildTemporalContext({
|
|
122
|
+
nowMs: WED_FEB_18,
|
|
123
|
+
hostTimeZone: 'UTC',
|
|
124
|
+
userTimeZone: 'UTC+5:30',
|
|
125
|
+
});
|
|
126
|
+
expect(result).toContain('Timezone: +05:30');
|
|
127
|
+
expect(result).toContain('Current local time: 2026-02-18T17:30:00+05:30');
|
|
128
|
+
expect(result).toContain('User timezone: +05:30');
|
|
129
|
+
expect(result).toContain('Timezone source: user_profile_memory');
|
|
130
|
+
});
|
|
131
|
+
|
|
43
132
|
test('includes week definitions', () => {
|
|
44
133
|
const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'UTC' });
|
|
45
134
|
expect(result).toContain('work week = Monday–Friday');
|
|
46
135
|
expect(result).toContain('weekend = Saturday–Sunday');
|
|
47
136
|
});
|
|
137
|
+
|
|
138
|
+
test('formats midnight hours as 00 (never 24) in local ISO output', () => {
|
|
139
|
+
const justAfterMidnight = Date.UTC(2026, 1, 19, 0, 5, 0);
|
|
140
|
+
const result = buildTemporalContext({ nowMs: justAfterMidnight, timeZone: 'UTC' });
|
|
141
|
+
expect(result).toContain('Current local time: 2026-02-19T00:05:00+00:00');
|
|
142
|
+
expect(result).not.toContain('T24:05:00');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('extractUserTimeZoneFromDynamicProfile', () => {
|
|
147
|
+
test('extracts canonical timezone from explicit timezone profile line', () => {
|
|
148
|
+
const profile = [
|
|
149
|
+
'<dynamic-user-profile>',
|
|
150
|
+
'- timezone: Timezone is America/New_York.',
|
|
151
|
+
'</dynamic-user-profile>',
|
|
152
|
+
].join('\n');
|
|
153
|
+
expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('America/New_York');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('extracts timezone token from generic profile text when explicit line is absent', () => {
|
|
157
|
+
const profile = [
|
|
158
|
+
'<dynamic-user-profile>',
|
|
159
|
+
'- location: Travels often between Europe and Asia (currently Europe/Paris).',
|
|
160
|
+
'</dynamic-user-profile>',
|
|
161
|
+
].join('\n');
|
|
162
|
+
expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('Europe/Paris');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('returns null when no valid timezone is present', () => {
|
|
166
|
+
const profile = [
|
|
167
|
+
'<dynamic-user-profile>',
|
|
168
|
+
'- timezone: Pacific time',
|
|
169
|
+
'</dynamic-user-profile>',
|
|
170
|
+
].join('\n');
|
|
171
|
+
expect(extractUserTimeZoneFromDynamicProfile(profile)).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('extracts UTC/GMT offset tokens from explicit timezone profile line', () => {
|
|
175
|
+
const profile = [
|
|
176
|
+
'<dynamic-user-profile>',
|
|
177
|
+
'- timezone: UTC+2',
|
|
178
|
+
'</dynamic-user-profile>',
|
|
179
|
+
].join('\n');
|
|
180
|
+
expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('Etc/GMT-2');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('extracts GMT negative offset tokens from generic profile text', () => {
|
|
184
|
+
const profile = [
|
|
185
|
+
'<dynamic-user-profile>',
|
|
186
|
+
'- preferences: schedule notifications in GMT-5 whenever possible.',
|
|
187
|
+
'</dynamic-user-profile>',
|
|
188
|
+
].join('\n');
|
|
189
|
+
expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('Etc/GMT+5');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('extracts fractional UTC offset tokens from explicit timezone profile line', () => {
|
|
193
|
+
const profile = [
|
|
194
|
+
'<dynamic-user-profile>',
|
|
195
|
+
'- timezone: UTC+5:30',
|
|
196
|
+
'</dynamic-user-profile>',
|
|
197
|
+
].join('\n');
|
|
198
|
+
expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('+05:30');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('extracts fractional GMT offset tokens from generic profile text', () => {
|
|
202
|
+
const profile = [
|
|
203
|
+
'<dynamic-user-profile>',
|
|
204
|
+
'- preferences: default reminders to GMT+5:45.',
|
|
205
|
+
'</dynamic-user-profile>',
|
|
206
|
+
].join('\n');
|
|
207
|
+
expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('+05:45');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('prefers IANA timezone tokens over UTC/GMT offsets in the same profile line', () => {
|
|
211
|
+
const profile = [
|
|
212
|
+
'<dynamic-user-profile>',
|
|
213
|
+
'- timezone: UTC+1 (Europe/Paris)',
|
|
214
|
+
'</dynamic-user-profile>',
|
|
215
|
+
].join('\n');
|
|
216
|
+
expect(extractUserTimeZoneFromDynamicProfile(profile)).toBe('Europe/Paris');
|
|
217
|
+
});
|
|
48
218
|
});
|
|
49
219
|
|
|
50
220
|
// ---------------------------------------------------------------------------
|
|
@@ -186,6 +356,7 @@ describe('DST-safe timezone behavior', () => {
|
|
|
186
356
|
// Feb 18 12:00 UTC = Feb 18 07:00 EST (same calendar date)
|
|
187
357
|
const result = buildTemporalContext({ nowMs: WED_FEB_18, timeZone: 'America/New_York' });
|
|
188
358
|
expect(result).toContain('Today: 2026-02-18 (Wednesday)');
|
|
359
|
+
expect(result).toContain('Current local time: 2026-02-18T07:00:00-05:00');
|
|
189
360
|
});
|
|
190
361
|
|
|
191
362
|
test('date labels are correct in timezone ahead of UTC', () => {
|
|
@@ -234,6 +405,13 @@ describe('DST-safe timezone behavior', () => {
|
|
|
234
405
|
expect(result).toContain('2026-02-21 Saturday');
|
|
235
406
|
expect(result).toContain('2026-02-22 Sunday');
|
|
236
407
|
});
|
|
408
|
+
|
|
409
|
+
test('local offset tracks daylight saving changes', () => {
|
|
410
|
+
// Jul 1 12:00 UTC = Jul 1 08:00 EDT
|
|
411
|
+
const summer = Date.UTC(2026, 6, 1, 12, 0, 0);
|
|
412
|
+
const result = buildTemporalContext({ nowMs: summer, timeZone: 'America/New_York' });
|
|
413
|
+
expect(result).toContain('Current local time: 2026-07-01T08:00:00-04:00');
|
|
414
|
+
});
|
|
237
415
|
});
|
|
238
416
|
|
|
239
417
|
// ---------------------------------------------------------------------------
|
|
@@ -445,13 +445,10 @@ describe('schema-drift recovery: migration handles unexpected schema state', ()
|
|
|
445
445
|
VALUES ('migration_memory_items_scope_salted_fingerprints_v1', '1', ${now})
|
|
446
446
|
`);
|
|
447
447
|
|
|
448
|
-
// validateMigrationState
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
expect(result.dependencyViolations).toHaveLength(1);
|
|
453
|
-
expect(result.dependencyViolations[0].migration).toBe('migration_memory_items_scope_salted_fingerprints_v1');
|
|
454
|
-
expect(result.dependencyViolations[0].missingDependency).toBe('migration_memory_items_fingerprint_scope_unique_v1');
|
|
448
|
+
// validateMigrationState throws an IntegrityError on dependency violations
|
|
449
|
+
// to block daemon startup with an inconsistent schema.
|
|
450
|
+
expect(() => validateMigrationState(db)).toThrow('Migration dependency violations detected');
|
|
451
|
+
expect(() => validateMigrationState(db)).toThrow('migration_memory_items_fingerprint_scope_unique_v1');
|
|
455
452
|
|
|
456
453
|
// Sanity-check: confirm the registry also declares this dependency, so the
|
|
457
454
|
// violation detection is grounded in real schema intent.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for the deterministic verification control plane (M1).
|
|
3
3
|
*
|
|
4
4
|
* Verifies that:
|
|
5
|
-
* 1. Verification
|
|
5
|
+
* 1. Verification control messages (code replies, /start gv_<token>) never invoke
|
|
6
6
|
* the normal message pipeline — they produce only template-driven copy.
|
|
7
7
|
* 2. Call session mode metadata is persisted correctly for guardian verification calls.
|
|
8
8
|
* 3. TwiML generation includes guardian verification parameters when relevant.
|
|
@@ -208,8 +208,8 @@ describe('Call session mode metadata', () => {
|
|
|
208
208
|
// Guard test: verification commands must not reach processMessage
|
|
209
209
|
// ---------------------------------------------------------------------------
|
|
210
210
|
|
|
211
|
-
describe('Verification
|
|
212
|
-
test('handleChannelInbound does not call processMessage for
|
|
211
|
+
describe('Verification control messages are deterministic (guard)', () => {
|
|
212
|
+
test('handleChannelInbound does not call processMessage for verification code replies', async () => {
|
|
213
213
|
const { createHash } = await import('node:crypto');
|
|
214
214
|
const { handleChannelInbound } = await import('../runtime/routes/inbound-message-handler.js');
|
|
215
215
|
const {
|
|
@@ -217,7 +217,7 @@ describe('Verification commands are deterministic (guard)', () => {
|
|
|
217
217
|
} = await import('../memory/channel-guardian-store.js');
|
|
218
218
|
|
|
219
219
|
// Set up a pending challenge
|
|
220
|
-
const secret = '
|
|
220
|
+
const secret = '123456';
|
|
221
221
|
const challengeHash = createHash('sha256').update(secret).digest('hex');
|
|
222
222
|
createChallenge({
|
|
223
223
|
id: 'challenge-guard-test',
|
|
@@ -260,7 +260,7 @@ describe('Verification commands are deterministic (guard)', () => {
|
|
|
260
260
|
interface: 'telegram',
|
|
261
261
|
externalChatId: 'chat-123',
|
|
262
262
|
externalMessageId: `msg-guard-${Date.now()}`,
|
|
263
|
-
content:
|
|
263
|
+
content: secret,
|
|
264
264
|
senderExternalUserId: 'user-123',
|
|
265
265
|
senderName: 'Test User',
|
|
266
266
|
replyCallbackUrl: 'http://localhost/callback',
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
const evaluateSignalMock = mock();
|
|
4
|
+
const enforceRoutingIntentMock = mock();
|
|
5
|
+
const updateDecisionMock = mock();
|
|
6
|
+
const runDeterministicChecksMock = mock();
|
|
7
|
+
const createEventMock = mock();
|
|
8
|
+
const updateEventDedupeKeyMock = mock();
|
|
9
|
+
const dispatchDecisionMock = mock();
|
|
10
|
+
|
|
11
|
+
mock.module('../util/logger.js', () => ({
|
|
12
|
+
getLogger: () =>
|
|
13
|
+
new Proxy({} as Record<string, unknown>, {
|
|
14
|
+
get: () => () => {},
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
mock.module('../channels/config.js', () => ({
|
|
19
|
+
getDeliverableChannels: () => ['vellum', 'telegram'],
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
mock.module('../memory/channel-guardian-store.js', () => ({
|
|
23
|
+
getActiveBinding: (_assistantId: string, channel: string) =>
|
|
24
|
+
channel === 'telegram'
|
|
25
|
+
? {
|
|
26
|
+
guardianDeliveryChatId: 'guardian-chat-123',
|
|
27
|
+
guardianExternalUserId: 'guardian-user-123',
|
|
28
|
+
}
|
|
29
|
+
: null,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
mock.module('../notifications/adapters/macos.js', () => ({
|
|
33
|
+
VellumAdapter: class {
|
|
34
|
+
constructor(_broadcastFn: unknown) {}
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
mock.module('../notifications/adapters/sms.js', () => ({
|
|
39
|
+
SmsAdapter: class {},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
mock.module('../notifications/adapters/telegram.js', () => ({
|
|
43
|
+
TelegramAdapter: class {},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
mock.module('../notifications/broadcaster.js', () => ({
|
|
47
|
+
NotificationBroadcaster: class {
|
|
48
|
+
constructor(_adapters: unknown[]) {}
|
|
49
|
+
setOnThreadCreated(_fn: unknown) {}
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
mock.module('../notifications/decision-engine.js', () => ({
|
|
54
|
+
evaluateSignal: (...args: unknown[]) => evaluateSignalMock(...args),
|
|
55
|
+
enforceRoutingIntent: (...args: unknown[]) => enforceRoutingIntentMock(...args),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
mock.module('../notifications/decisions-store.js', () => ({
|
|
59
|
+
updateDecision: (...args: unknown[]) => updateDecisionMock(...args),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
mock.module('../notifications/deterministic-checks.js', () => ({
|
|
63
|
+
runDeterministicChecks: (...args: unknown[]) => runDeterministicChecksMock(...args),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
mock.module('../notifications/events-store.js', () => ({
|
|
67
|
+
createEvent: (...args: unknown[]) => createEventMock(...args),
|
|
68
|
+
updateEventDedupeKey: (...args: unknown[]) => updateEventDedupeKeyMock(...args),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
mock.module('../notifications/runtime-dispatch.js', () => ({
|
|
72
|
+
dispatchDecision: (...args: unknown[]) => dispatchDecisionMock(...args),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
76
|
+
|
|
77
|
+
describe('emitNotificationSignal routing intent re-persistence', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
evaluateSignalMock.mockReset();
|
|
80
|
+
enforceRoutingIntentMock.mockReset();
|
|
81
|
+
updateDecisionMock.mockReset();
|
|
82
|
+
runDeterministicChecksMock.mockReset();
|
|
83
|
+
createEventMock.mockReset();
|
|
84
|
+
updateEventDedupeKeyMock.mockReset();
|
|
85
|
+
dispatchDecisionMock.mockReset();
|
|
86
|
+
|
|
87
|
+
createEventMock.mockReturnValue({ id: 'evt-1' });
|
|
88
|
+
runDeterministicChecksMock.mockResolvedValue({ passed: true });
|
|
89
|
+
dispatchDecisionMock.mockResolvedValue({
|
|
90
|
+
dispatched: true,
|
|
91
|
+
reason: 'ok',
|
|
92
|
+
deliveryResults: [],
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('re-persists selectedChannels/reasoningSummary when enforcement changes the decision', async () => {
|
|
97
|
+
const preDecision = {
|
|
98
|
+
shouldNotify: true,
|
|
99
|
+
selectedChannels: ['vellum'],
|
|
100
|
+
reasoningSummary: 'LLM selected vellum only',
|
|
101
|
+
renderedCopy: {
|
|
102
|
+
vellum: { title: 'Reminder', body: 'Take out trash' },
|
|
103
|
+
},
|
|
104
|
+
dedupeKey: 'dedupe-rem-1',
|
|
105
|
+
confidence: 0.9,
|
|
106
|
+
fallbackUsed: false,
|
|
107
|
+
persistedDecisionId: 'dec-1',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const enforcedDecision = {
|
|
111
|
+
...preDecision,
|
|
112
|
+
selectedChannels: ['vellum', 'telegram'],
|
|
113
|
+
reasoningSummary: `${preDecision.reasoningSummary} [routing_intent=all_channels enforced: vellum, telegram]`,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
evaluateSignalMock.mockResolvedValue(preDecision);
|
|
117
|
+
enforceRoutingIntentMock.mockReturnValue(enforcedDecision);
|
|
118
|
+
|
|
119
|
+
const result = await emitNotificationSignal({
|
|
120
|
+
sourceEventName: 'reminder.fired',
|
|
121
|
+
sourceChannel: 'scheduler',
|
|
122
|
+
sourceSessionId: 'rem-1',
|
|
123
|
+
attentionHints: {
|
|
124
|
+
requiresAction: true,
|
|
125
|
+
urgency: 'high',
|
|
126
|
+
isAsyncBackground: false,
|
|
127
|
+
visibleInSourceNow: false,
|
|
128
|
+
},
|
|
129
|
+
contextPayload: { reminderId: 'rem-1' },
|
|
130
|
+
routingIntent: 'all_channels',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result.dispatched).toBe(true);
|
|
134
|
+
expect(updateDecisionMock).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(updateDecisionMock).toHaveBeenCalledWith('dec-1', {
|
|
136
|
+
selectedChannels: ['vellum', 'telegram'],
|
|
137
|
+
reasoningSummary: `${preDecision.reasoningSummary} [routing_intent=all_channels enforced: vellum, telegram]`,
|
|
138
|
+
validationResults: {
|
|
139
|
+
dedupeKey: 'dedupe-rem-1',
|
|
140
|
+
channelCount: 2,
|
|
141
|
+
hasCopy: true,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('does not re-persist when enforcement leaves the decision unchanged', async () => {
|
|
147
|
+
const decision = {
|
|
148
|
+
shouldNotify: true,
|
|
149
|
+
selectedChannels: ['vellum'],
|
|
150
|
+
reasoningSummary: 'No routing override needed',
|
|
151
|
+
renderedCopy: {
|
|
152
|
+
vellum: { title: 'Reminder', body: 'Drink water' },
|
|
153
|
+
},
|
|
154
|
+
dedupeKey: 'dedupe-rem-2',
|
|
155
|
+
confidence: 0.8,
|
|
156
|
+
fallbackUsed: false,
|
|
157
|
+
persistedDecisionId: 'dec-2',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
evaluateSignalMock.mockResolvedValue(decision);
|
|
161
|
+
enforceRoutingIntentMock.mockImplementation((inputDecision: unknown) => inputDecision);
|
|
162
|
+
|
|
163
|
+
await emitNotificationSignal({
|
|
164
|
+
sourceEventName: 'reminder.fired',
|
|
165
|
+
sourceChannel: 'scheduler',
|
|
166
|
+
sourceSessionId: 'rem-2',
|
|
167
|
+
attentionHints: {
|
|
168
|
+
requiresAction: false,
|
|
169
|
+
urgency: 'medium',
|
|
170
|
+
isAsyncBackground: false,
|
|
171
|
+
visibleInSourceNow: false,
|
|
172
|
+
},
|
|
173
|
+
contextPayload: { reminderId: 'rem-2' },
|
|
174
|
+
routingIntent: 'single_channel',
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(updateDecisionMock).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
});
|