@vellumai/assistant 0.3.15 → 0.3.18
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 +211 -12
- package/Dockerfile +1 -1
- package/README.md +11 -5
- package/docs/architecture/http-token-refresh.md +274 -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 +328 -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 +19 -15
- package/src/__tests__/checker.test.ts +103 -48
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
- package/src/__tests__/config-watcher.test.ts +356 -0
- package/src/__tests__/conversation-pairing.test.ts +127 -27
- 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 +425 -0
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-action-sweep.test.ts +9 -9
- package/src/__tests__/guardian-dispatch.test.ts +120 -0
- 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 +23 -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 +281 -0
- package/src/__tests__/notification-broadcaster.test.ts +115 -4
- package/src/__tests__/notification-decision-strategy.test.ts +138 -1
- package/src/__tests__/notification-deep-link.test.ts +44 -1
- package/src/__tests__/notification-guardian-path.test.ts +157 -0
- package/src/__tests__/notification-routing-intent.test.ts +11 -1
- package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
- 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 +38 -22
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +489 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +405 -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 +323 -0
- package/src/__tests__/update-template-contract.test.ts +24 -0
- package/src/__tests__/voice-session-bridge.test.ts +109 -9
- package/src/agent/loop.ts +2 -2
- package/src/amazon/client.ts +2 -3
- package/src/calls/call-controller.ts +241 -39
- 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/guardian-dispatch.ts +8 -0
- 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 +8 -6
- package/src/cli/core-commands.ts +43 -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 +15 -0
- package/src/config/templates/USER.md +5 -1
- package/src/config/types.ts +1 -0
- package/src/config/update-bulletin-format.ts +54 -0
- package/src/config/update-bulletin-state.ts +49 -0
- package/src/config/update-bulletin-template-path.ts +6 -0
- package/src/config/update-bulletin.ts +97 -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 +4 -2
- package/src/daemon/connection-policy.ts +21 -1
- package/src/daemon/daemon-control.ts +219 -8
- 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/identity.ts +45 -25
- 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/workspace.ts +12 -1
- package/src/daemon/ipc-contract-inventory.json +6 -1
- package/src/daemon/ipc-contract.ts +5 -1
- package/src/daemon/lifecycle.ts +314 -266
- package/src/daemon/recording-intent.ts +0 -41
- package/src/daemon/response-tier.ts +2 -2
- package/src/daemon/server.ts +31 -9
- 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 +546 -59
- 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 +60 -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 +35 -567
- package/src/memory/channel-guardian-store.ts +63 -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 +44 -983
- package/src/memory/db-connection.ts +3 -0
- package/src/memory/db-init.ts +33 -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 +136 -0
- package/src/memory/guardian-action-store.ts +418 -5
- 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 +521 -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/032-guardian-delivery-conversation-index.ts +15 -0
- package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -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 +10 -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 +4 -0
- package/src/memory/schema.ts +31 -8
- package/src/memory/search/semantic.ts +8 -90
- package/src/notifications/README.md +159 -18
- package/src/notifications/broadcaster.ts +69 -33
- package/src/notifications/conversation-pairing.ts +99 -21
- package/src/notifications/decision-engine.ts +176 -8
- package/src/notifications/deliveries-store.ts +39 -8
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/preferences-store.ts +7 -7
- package/src/notifications/thread-candidates.ts +269 -0
- package/src/notifications/types.ts +19 -0
- package/src/permissions/checker.ts +1 -16
- package/src/permissions/defaults.ts +25 -5
- 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 +271 -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/version.ts +29 -2
- 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
|
@@ -727,6 +727,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
|
|
|
727
727
|
type: 'heartbeat_checklist_write',
|
|
728
728
|
content: '- [ ] Check email\n- [ ] Review PRs',
|
|
729
729
|
},
|
|
730
|
+
voice_config_update: {
|
|
731
|
+
type: 'voice_config_update',
|
|
732
|
+
activationKey: 'fn',
|
|
733
|
+
},
|
|
730
734
|
};
|
|
731
735
|
|
|
732
736
|
// ---------------------------------------------------------------------------
|
|
@@ -839,8 +843,8 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
839
843
|
session_list_response: {
|
|
840
844
|
type: 'session_list_response',
|
|
841
845
|
sessions: [
|
|
842
|
-
{ id: 'sess-001', title: 'First session', updatedAt: 1700000000, threadType: 'standard' },
|
|
843
|
-
{ id: 'sess-002', title: 'Second session', updatedAt: 1700001000, threadType: 'standard', assistantAttention: { hasUnseenLatestAssistantMessage: true, latestAssistantMessageAt: 1700001000, lastSeenConfidence: 'explicit', lastSeenSignalType: 'macos_notification_view' } },
|
|
846
|
+
{ id: 'sess-001', title: 'First session', createdAt: 1699999000, updatedAt: 1700000000, threadType: 'standard' },
|
|
847
|
+
{ id: 'sess-002', title: 'Second session', createdAt: 1700000000, updatedAt: 1700001000, threadType: 'standard', assistantAttention: { hasUnseenLatestAssistantMessage: true, latestAssistantMessageAt: 1700001000, lastSeenConfidence: 'explicit', lastSeenSignalType: 'macos_notification_view' } },
|
|
844
848
|
],
|
|
845
849
|
},
|
|
846
850
|
sessions_clear_response: {
|
|
@@ -1775,9 +1779,6 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1775
1779
|
tasks_changed: {
|
|
1776
1780
|
type: 'tasks_changed',
|
|
1777
1781
|
},
|
|
1778
|
-
open_tasks_window: {
|
|
1779
|
-
type: 'open_tasks_window',
|
|
1780
|
-
},
|
|
1781
1782
|
task_run_thread_created: {
|
|
1782
1783
|
type: 'task_run_thread_created',
|
|
1783
1784
|
conversationId: 'conv-task-run-001',
|
|
@@ -2001,6 +2002,23 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
2001
2002
|
type: 'heartbeat_checklist_write_response',
|
|
2002
2003
|
success: true,
|
|
2003
2004
|
},
|
|
2005
|
+
navigate_settings: {
|
|
2006
|
+
type: 'navigate_settings',
|
|
2007
|
+
tab: 'general',
|
|
2008
|
+
},
|
|
2009
|
+
client_settings_update: {
|
|
2010
|
+
type: 'client_settings_update',
|
|
2011
|
+
key: 'activationKey',
|
|
2012
|
+
value: 'fn',
|
|
2013
|
+
},
|
|
2014
|
+
identity_changed: {
|
|
2015
|
+
type: 'identity_changed',
|
|
2016
|
+
name: 'Vellum',
|
|
2017
|
+
role: 'assistant',
|
|
2018
|
+
personality: 'friendly',
|
|
2019
|
+
emoji: '',
|
|
2020
|
+
home: '',
|
|
2021
|
+
},
|
|
2004
2022
|
};
|
|
2005
2023
|
|
|
2006
2024
|
// ---------------------------------------------------------------------------
|
|
@@ -188,7 +188,7 @@ describe('Story E2E: selfie yesterday -> generated image today', () => {
|
|
|
188
188
|
let selfieId: string;
|
|
189
189
|
let selfieAttachment: ReturnType<typeof uploadAttachment>;
|
|
190
190
|
|
|
191
|
-
beforeEach(() => {
|
|
191
|
+
beforeEach(async () => {
|
|
192
192
|
resetTables();
|
|
193
193
|
// Clear sandbox so stale files from prior tests don't mask regressions
|
|
194
194
|
rmSync(sandboxDir, { recursive: true, force: true });
|
|
@@ -216,7 +216,7 @@ describe('Story E2E: selfie yesterday -> generated image today', () => {
|
|
|
216
216
|
selfieAttachment = uploadAttachment('selfie.png', 'image/png', TINY_PNG_BASE64);
|
|
217
217
|
selfieId = selfieAttachment.id;
|
|
218
218
|
|
|
219
|
-
const msgA = addMessage(threadA.id, 'user', 'Here is my selfie from yesterday');
|
|
219
|
+
const msgA = await addMessage(threadA.id, 'user', 'Here is my selfie from yesterday');
|
|
220
220
|
linkAttachmentToMessage(msgA.id, selfieId, 0);
|
|
221
221
|
|
|
222
222
|
// Backdate the selfie to "yesterday" for realism
|
|
@@ -365,7 +365,7 @@ describe('Story E2E: selfie yesterday -> generated image today', () => {
|
|
|
365
365
|
generatedImageBase64,
|
|
366
366
|
);
|
|
367
367
|
|
|
368
|
-
const msgB = addMessage(threadB.id, 'assistant', 'Here is your generated portrait!');
|
|
368
|
+
const msgB = await addMessage(threadB.id, 'assistant', 'Here is your generated portrait!');
|
|
369
369
|
linkAttachmentToMessage(msgB.id, outputAttachment.id, 0);
|
|
370
370
|
|
|
371
371
|
// Verify the output attachment exists in the DB via raw search
|
|
@@ -475,7 +475,7 @@ describe('Private-thread variant: cross-thread media blocking', () => {
|
|
|
475
475
|
// Upload selfie in a private thread
|
|
476
476
|
const privateThread = createConversation({ title: 'Private selfie thread', threadType: 'private' });
|
|
477
477
|
const selfie = uploadAttachment('private-selfie.png', 'image/png', TINY_PNG_BASE64);
|
|
478
|
-
const msg = addMessage(privateThread.id, 'user', 'My private selfie');
|
|
478
|
+
const msg = await addMessage(privateThread.id, 'user', 'My private selfie');
|
|
479
479
|
linkAttachmentToMessage(msg.id, selfie.id, 0);
|
|
480
480
|
|
|
481
481
|
// Search from a standard thread
|
|
@@ -499,7 +499,7 @@ describe('Private-thread variant: cross-thread media blocking', () => {
|
|
|
499
499
|
const privateThread = createConversation({ title: 'Private selfie thread', threadType: 'private' });
|
|
500
500
|
const base64 = Buffer.from('private image data').toString('base64');
|
|
501
501
|
const selfie = uploadAttachment('private-selfie.png', 'image/png', base64);
|
|
502
|
-
const msg = addMessage(privateThread.id, 'user', 'My private selfie');
|
|
502
|
+
const msg = await addMessage(privateThread.id, 'user', 'My private selfie');
|
|
503
503
|
linkAttachmentToMessage(msg.id, selfie.id, 0);
|
|
504
504
|
|
|
505
505
|
// Try to materialize from a standard thread
|
|
@@ -523,7 +523,7 @@ describe('Private-thread variant: cross-thread media blocking', () => {
|
|
|
523
523
|
test('selfie in private thread IS accessible from the same private thread', async () => {
|
|
524
524
|
const privateThread = createConversation({ title: 'Private selfie thread', threadType: 'private' });
|
|
525
525
|
const selfie = uploadAttachment('private-selfie.png', 'image/png', TINY_PNG_BASE64);
|
|
526
|
-
const msg = addMessage(privateThread.id, 'user', 'My private selfie');
|
|
526
|
+
const msg = await addMessage(privateThread.id, 'user', 'My private selfie');
|
|
527
527
|
linkAttachmentToMessage(msg.id, selfie.id, 0);
|
|
528
528
|
|
|
529
529
|
// Search from the same private thread
|
|
@@ -552,7 +552,7 @@ describe('Private-thread variant: cross-thread media blocking', () => {
|
|
|
552
552
|
test('selfie in private thread A is NOT accessible from private thread B', async () => {
|
|
553
553
|
const privateThreadA = createConversation({ title: 'Private thread A', threadType: 'private' });
|
|
554
554
|
const selfie = uploadAttachment('thread-a-selfie.png', 'image/png', TINY_PNG_BASE64);
|
|
555
|
-
const msgA = addMessage(privateThreadA.id, 'user', 'Selfie in thread A');
|
|
555
|
+
const msgA = await addMessage(privateThreadA.id, 'user', 'Selfie in thread A');
|
|
556
556
|
linkAttachmentToMessage(msgA.id, selfie.id, 0);
|
|
557
557
|
|
|
558
558
|
// Search from a different private thread
|
|
@@ -3577,11 +3577,11 @@ describe('Memory regressions', () => {
|
|
|
3577
3577
|
});
|
|
3578
3578
|
|
|
3579
3579
|
// PR-17: addMessage() passes conversation scope to the indexer
|
|
3580
|
-
test('addMessage inherits private conversation scope on memory segments', () => {
|
|
3580
|
+
test('addMessage inherits private conversation scope on memory segments', async () => {
|
|
3581
3581
|
const conv = createConversation({ title: 'Private thread', threadType: 'private' });
|
|
3582
3582
|
expect(conv.memoryScopeId).toMatch(/^private:/);
|
|
3583
3583
|
|
|
3584
|
-
const msg = addMessage(conv.id, 'user', 'My secret project details for the private thread.');
|
|
3584
|
+
const msg = await addMessage(conv.id, 'user', 'My secret project details for the private thread.');
|
|
3585
3585
|
|
|
3586
3586
|
const db = getDb();
|
|
3587
3587
|
const segments = db
|
|
@@ -3596,11 +3596,11 @@ describe('Memory regressions', () => {
|
|
|
3596
3596
|
}
|
|
3597
3597
|
});
|
|
3598
3598
|
|
|
3599
|
-
test('addMessage uses default scope for standard conversations', () => {
|
|
3599
|
+
test('addMessage uses default scope for standard conversations', async () => {
|
|
3600
3600
|
const conv = createConversation({ title: 'Standard thread', threadType: 'standard' });
|
|
3601
3601
|
expect(conv.memoryScopeId).toBe('default');
|
|
3602
3602
|
|
|
3603
|
-
const msg = addMessage(conv.id, 'user', 'Normal conversation content for testing scope defaults.');
|
|
3603
|
+
const msg = await addMessage(conv.id, 'user', 'Normal conversation content for testing scope defaults.');
|
|
3604
3604
|
|
|
3605
3605
|
const db = getDb();
|
|
3606
3606
|
const segments = db
|
|
@@ -3910,6 +3910,7 @@ describe('Memory regressions', () => {
|
|
|
3910
3910
|
deferrals: 0,
|
|
3911
3911
|
runAfter: 0,
|
|
3912
3912
|
lastError: null,
|
|
3913
|
+
startedAt: Date.now(),
|
|
3913
3914
|
createdAt: Date.now(),
|
|
3914
3915
|
updatedAt: Date.now(),
|
|
3915
3916
|
};
|
|
@@ -3958,7 +3959,7 @@ describe('Memory regressions', () => {
|
|
|
3958
3959
|
payload: { conversationId: privConv.id },
|
|
3959
3960
|
status: 'running' as const,
|
|
3960
3961
|
attempts: 0, deferrals: 0, runAfter: 0, lastError: null,
|
|
3961
|
-
createdAt: now, updatedAt: now,
|
|
3962
|
+
startedAt: now, createdAt: now, updatedAt: now,
|
|
3962
3963
|
}, TEST_CONFIG);
|
|
3963
3964
|
|
|
3964
3965
|
// Create a standard conversation and build its summary
|
|
@@ -3990,7 +3991,7 @@ describe('Memory regressions', () => {
|
|
|
3990
3991
|
payload: { conversationId: stdConv.id },
|
|
3991
3992
|
status: 'running' as const,
|
|
3992
3993
|
attempts: 0, deferrals: 0, runAfter: 0, lastError: null,
|
|
3993
|
-
createdAt: now, updatedAt: now,
|
|
3994
|
+
startedAt: now, createdAt: now, updatedAt: now,
|
|
3994
3995
|
}, TEST_CONFIG);
|
|
3995
3996
|
|
|
3996
3997
|
// Query summaries scoped to 'default' — should only include the standard one
|
|
@@ -4018,7 +4019,7 @@ describe('Memory regressions', () => {
|
|
|
4018
4019
|
const privScope = getConversationMemoryScopeId(privConv.id);
|
|
4019
4020
|
expect(privScope).toMatch(/^private:/);
|
|
4020
4021
|
|
|
4021
|
-
const privMsg = addMessage(
|
|
4022
|
+
const privMsg = await addMessage(
|
|
4022
4023
|
privConv.id,
|
|
4023
4024
|
'user',
|
|
4024
4025
|
'I prefer using the Zephyr framework for all backend microservices.',
|
|
@@ -4095,7 +4096,7 @@ describe('Memory regressions', () => {
|
|
|
4095
4096
|
const stdScope = getConversationMemoryScopeId(stdConv.id);
|
|
4096
4097
|
expect(stdScope).toBe('default');
|
|
4097
4098
|
|
|
4098
|
-
const stdMsg = addMessage(
|
|
4099
|
+
const stdMsg = await addMessage(
|
|
4099
4100
|
stdConv.id,
|
|
4100
4101
|
'user',
|
|
4101
4102
|
'I prefer using the Obsidian editor for all my note-taking workflows.',
|
|
@@ -4417,6 +4418,7 @@ describe('Memory regressions', () => {
|
|
|
4417
4418
|
deferrals: 0,
|
|
4418
4419
|
runAfter: 0,
|
|
4419
4420
|
lastError: null,
|
|
4421
|
+
startedAt: Date.now(),
|
|
4420
4422
|
createdAt: Date.now(),
|
|
4421
4423
|
updatedAt: Date.now(),
|
|
4422
4424
|
};
|
|
@@ -4461,6 +4463,7 @@ describe('Memory regressions', () => {
|
|
|
4461
4463
|
deferrals: 0,
|
|
4462
4464
|
runAfter: 0,
|
|
4463
4465
|
lastError: null,
|
|
4466
|
+
startedAt: Date.now(),
|
|
4464
4467
|
createdAt: Date.now(),
|
|
4465
4468
|
updatedAt: Date.now(),
|
|
4466
4469
|
};
|
|
@@ -4535,6 +4538,7 @@ describe('Memory regressions', () => {
|
|
|
4535
4538
|
deferrals: 0,
|
|
4536
4539
|
runAfter: 0,
|
|
4537
4540
|
lastError: null,
|
|
4541
|
+
startedAt: Date.now(),
|
|
4538
4542
|
createdAt: Date.now(),
|
|
4539
4543
|
updatedAt: Date.now(),
|
|
4540
4544
|
};
|
|
@@ -4558,7 +4562,7 @@ describe('Memory regressions', () => {
|
|
|
4558
4562
|
|
|
4559
4563
|
// ── Provenance plumbing tests ────────────────────────────────────────
|
|
4560
4564
|
|
|
4561
|
-
test('provenance fields are preserved in stored message metadata', () => {
|
|
4565
|
+
test('provenance fields are preserved in stored message metadata', async () => {
|
|
4562
4566
|
const conv = createConversation('provenance-preserve');
|
|
4563
4567
|
const metadata = {
|
|
4564
4568
|
userMessageChannel: 'telegram' as const,
|
|
@@ -4567,7 +4571,7 @@ describe('Memory regressions', () => {
|
|
|
4567
4571
|
provenanceGuardianExternalUserId: 'guardian-123',
|
|
4568
4572
|
provenanceRequesterIdentifier: 'Alice',
|
|
4569
4573
|
};
|
|
4570
|
-
const msg = addMessage(conv.id, 'user', 'Hello from telegram', metadata);
|
|
4574
|
+
const msg = await addMessage(conv.id, 'user', 'Hello from telegram', metadata);
|
|
4571
4575
|
|
|
4572
4576
|
const db = getDb();
|
|
4573
4577
|
const stored = db
|
|
@@ -4628,7 +4632,7 @@ describe('Memory regressions', () => {
|
|
|
4628
4632
|
expect(result.provenanceRequesterIdentifier).toBe('Charlie');
|
|
4629
4633
|
});
|
|
4630
4634
|
|
|
4631
|
-
test('indexMessageNow receives provenanceActorRole when metadata includes it', () => {
|
|
4635
|
+
test('indexMessageNow receives provenanceActorRole when metadata includes it', async () => {
|
|
4632
4636
|
const conv = createConversation('provenance-indexer');
|
|
4633
4637
|
const metadata = {
|
|
4634
4638
|
provenanceActorRole: 'non-guardian' as const,
|
|
@@ -4636,7 +4640,7 @@ describe('Memory regressions', () => {
|
|
|
4636
4640
|
};
|
|
4637
4641
|
// addMessage parses metadata and passes provenanceActorRole to indexMessageNow.
|
|
4638
4642
|
// We verify indirectly: the message is persisted with metadata and segments are indexed.
|
|
4639
|
-
const msg = addMessage(conv.id, 'user', 'Test provenance indexing message with enough content to segment', metadata);
|
|
4643
|
+
const msg = await addMessage(conv.id, 'user', 'Test provenance indexing message with enough content to segment', metadata);
|
|
4640
4644
|
expect(msg.id).toBeTruthy();
|
|
4641
4645
|
|
|
4642
4646
|
// Verify segments were created (indexMessageNow was called successfully)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the non-member access request notification flow.
|
|
3
|
+
*
|
|
4
|
+
* When a non-member messages the assistant on a channel, the system should:
|
|
5
|
+
* 1. Deny the message with the standard rejection reply
|
|
6
|
+
* 2. Notify the guardian (if a guardian binding exists)
|
|
7
|
+
* 3. Create a guardian approval request for the access request
|
|
8
|
+
* 4. Deduplicate: don't create duplicate requests for repeated messages
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Test isolation: in-memory SQLite via temp directory
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const testDir = mkdtempSync(join(tmpdir(), 'non-member-access-request-test-'));
|
|
21
|
+
|
|
22
|
+
mock.module('../util/platform.js', () => ({
|
|
23
|
+
getRootDir: () => testDir,
|
|
24
|
+
getDataDir: () => testDir,
|
|
25
|
+
isMacOS: () => process.platform === 'darwin',
|
|
26
|
+
isLinux: () => process.platform === 'linux',
|
|
27
|
+
isWindows: () => process.platform === 'win32',
|
|
28
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
29
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
30
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
31
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
32
|
+
ensureDataDir: () => {},
|
|
33
|
+
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
34
|
+
readHttpToken: () => 'test-bearer-token',
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
mock.module('../util/logger.js', () => ({
|
|
38
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
39
|
+
get: () => () => {},
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Mock security check to always pass
|
|
44
|
+
mock.module('../security/secret-ingress.js', () => ({
|
|
45
|
+
checkIngressForSecrets: () => ({ blocked: false }),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// Mock ingress member store: findMember always returns null (non-member),
|
|
49
|
+
// updateLastSeen is a no-op.
|
|
50
|
+
mock.module('../memory/ingress-member-store.js', () => ({
|
|
51
|
+
findMember: () => null,
|
|
52
|
+
updateLastSeen: () => {},
|
|
53
|
+
upsertMember: () => {},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
mock.module('../config/env.js', () => ({
|
|
57
|
+
getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830',
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// Track emitNotificationSignal calls
|
|
61
|
+
const emitSignalCalls: Array<Record<string, unknown>> = [];
|
|
62
|
+
mock.module('../notifications/emit-signal.js', () => ({
|
|
63
|
+
emitNotificationSignal: async (params: Record<string, unknown>) => {
|
|
64
|
+
emitSignalCalls.push(params);
|
|
65
|
+
return {
|
|
66
|
+
signalId: 'mock-signal-id',
|
|
67
|
+
deduplicated: false,
|
|
68
|
+
dispatched: true,
|
|
69
|
+
reason: 'mock',
|
|
70
|
+
deliveryResults: [],
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
// Track deliverChannelReply calls
|
|
76
|
+
const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
|
|
77
|
+
mock.module('../runtime/gateway-client.js', () => ({
|
|
78
|
+
deliverChannelReply: async (url: string, payload: Record<string, unknown>) => {
|
|
79
|
+
deliverReplyCalls.push({ url, payload });
|
|
80
|
+
},
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
import {
|
|
84
|
+
createBinding,
|
|
85
|
+
findPendingAccessRequestForRequester,
|
|
86
|
+
} from '../memory/channel-guardian-store.js';
|
|
87
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
88
|
+
import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
|
|
89
|
+
|
|
90
|
+
initializeDb();
|
|
91
|
+
|
|
92
|
+
afterAll(() => {
|
|
93
|
+
resetDb();
|
|
94
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Helpers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const TEST_BEARER_TOKEN = 'test-token';
|
|
102
|
+
|
|
103
|
+
function resetState(): void {
|
|
104
|
+
const db = getDb();
|
|
105
|
+
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
106
|
+
db.run('DELETE FROM channel_guardian_bindings');
|
|
107
|
+
db.run('DELETE FROM channel_inbound_events');
|
|
108
|
+
db.run('DELETE FROM conversations');
|
|
109
|
+
db.run('DELETE FROM notification_events');
|
|
110
|
+
emitSignalCalls.length = 0;
|
|
111
|
+
deliverReplyCalls.length = 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
115
|
+
const body: Record<string, unknown> = {
|
|
116
|
+
sourceChannel: 'telegram',
|
|
117
|
+
interface: 'telegram',
|
|
118
|
+
externalChatId: 'chat-123',
|
|
119
|
+
externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
120
|
+
content: 'Hello, can I use this assistant?',
|
|
121
|
+
senderExternalUserId: 'user-unknown-456',
|
|
122
|
+
senderName: 'Alice Unknown',
|
|
123
|
+
senderUsername: 'alice_unknown',
|
|
124
|
+
replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
|
|
125
|
+
...overrides,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return new Request('http://localhost:8080/channels/inbound', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify(body),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Tests
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
describe('non-member access request notification', () => {
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
resetState();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('non-member message is denied with rejection reply', async () => {
|
|
148
|
+
const req = buildInboundRequest();
|
|
149
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
150
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
151
|
+
|
|
152
|
+
expect(json.denied).toBe(true);
|
|
153
|
+
expect(json.reason).toBe('not_a_member');
|
|
154
|
+
|
|
155
|
+
// Rejection reply was delivered
|
|
156
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
157
|
+
expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("you haven't been approved");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('guardian is notified when a non-member messages and a guardian binding exists', async () => {
|
|
161
|
+
// Set up a guardian binding for this channel
|
|
162
|
+
createBinding({
|
|
163
|
+
assistantId: 'self',
|
|
164
|
+
channel: 'telegram',
|
|
165
|
+
guardianExternalUserId: 'guardian-user-789',
|
|
166
|
+
guardianDeliveryChatId: 'guardian-chat-789',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const req = buildInboundRequest();
|
|
170
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
171
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
172
|
+
|
|
173
|
+
// Message is still denied
|
|
174
|
+
expect(json.denied).toBe(true);
|
|
175
|
+
expect(json.reason).toBe('not_a_member');
|
|
176
|
+
|
|
177
|
+
// Rejection reply was delivered
|
|
178
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
179
|
+
|
|
180
|
+
// A notification signal was emitted
|
|
181
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
182
|
+
expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
|
|
183
|
+
expect(emitSignalCalls[0].sourceChannel).toBe('telegram');
|
|
184
|
+
const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
|
|
185
|
+
expect(payload.senderExternalUserId).toBe('user-unknown-456');
|
|
186
|
+
expect(payload.senderName).toBe('Alice Unknown');
|
|
187
|
+
|
|
188
|
+
// An approval request was created
|
|
189
|
+
const pending = findPendingAccessRequestForRequester(
|
|
190
|
+
'self',
|
|
191
|
+
'telegram',
|
|
192
|
+
'user-unknown-456',
|
|
193
|
+
'ingress_access_request',
|
|
194
|
+
);
|
|
195
|
+
expect(pending).not.toBeNull();
|
|
196
|
+
expect(pending!.status).toBe('pending');
|
|
197
|
+
expect(pending!.requesterExternalUserId).toBe('user-unknown-456');
|
|
198
|
+
expect(pending!.guardianExternalUserId).toBe('guardian-user-789');
|
|
199
|
+
expect(pending!.toolName).toBe('ingress_access_request');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('no duplicate approval requests for repeated messages from same non-member', async () => {
|
|
203
|
+
createBinding({
|
|
204
|
+
assistantId: 'self',
|
|
205
|
+
channel: 'telegram',
|
|
206
|
+
guardianExternalUserId: 'guardian-user-789',
|
|
207
|
+
guardianDeliveryChatId: 'guardian-chat-789',
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// First message
|
|
211
|
+
const req1 = buildInboundRequest();
|
|
212
|
+
await handleChannelInbound(req1, undefined, TEST_BEARER_TOKEN);
|
|
213
|
+
|
|
214
|
+
// Second message from the same user
|
|
215
|
+
const req2 = buildInboundRequest({
|
|
216
|
+
externalMessageId: `msg-second-${Date.now()}`,
|
|
217
|
+
content: 'Please let me in!',
|
|
218
|
+
});
|
|
219
|
+
await handleChannelInbound(req2, undefined, TEST_BEARER_TOKEN);
|
|
220
|
+
|
|
221
|
+
// Both messages should be denied with rejection replies
|
|
222
|
+
expect(deliverReplyCalls.length).toBe(2);
|
|
223
|
+
|
|
224
|
+
// Only one notification signal should be emitted (second is deduplicated)
|
|
225
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
226
|
+
|
|
227
|
+
// Only one approval request should exist
|
|
228
|
+
const pending = findPendingAccessRequestForRequester(
|
|
229
|
+
'self',
|
|
230
|
+
'telegram',
|
|
231
|
+
'user-unknown-456',
|
|
232
|
+
'ingress_access_request',
|
|
233
|
+
);
|
|
234
|
+
expect(pending).not.toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('deny works without error when no guardian binding exists', async () => {
|
|
238
|
+
// No guardian binding — should deny without notification
|
|
239
|
+
const req = buildInboundRequest();
|
|
240
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
241
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
242
|
+
|
|
243
|
+
expect(json.denied).toBe(true);
|
|
244
|
+
expect(json.reason).toBe('not_a_member');
|
|
245
|
+
|
|
246
|
+
// Rejection reply was still delivered
|
|
247
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
248
|
+
|
|
249
|
+
// No notification signal was emitted
|
|
250
|
+
expect(emitSignalCalls.length).toBe(0);
|
|
251
|
+
|
|
252
|
+
// No approval request was created
|
|
253
|
+
const pending = findPendingAccessRequestForRequester(
|
|
254
|
+
'self',
|
|
255
|
+
'telegram',
|
|
256
|
+
'user-unknown-456',
|
|
257
|
+
'ingress_access_request',
|
|
258
|
+
);
|
|
259
|
+
expect(pending).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('no notification when senderExternalUserId is absent', async () => {
|
|
263
|
+
createBinding({
|
|
264
|
+
assistantId: 'self',
|
|
265
|
+
channel: 'telegram',
|
|
266
|
+
guardianExternalUserId: 'guardian-user-789',
|
|
267
|
+
guardianDeliveryChatId: 'guardian-chat-789',
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Message without senderExternalUserId — can't identify the requester.
|
|
271
|
+
// The ACL check requires senderExternalUserId to look up members,
|
|
272
|
+
// so without it the non-member gate is bypassed entirely.
|
|
273
|
+
const req = buildInboundRequest({
|
|
274
|
+
senderExternalUserId: undefined,
|
|
275
|
+
});
|
|
276
|
+
await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
277
|
+
|
|
278
|
+
// No access request notification should fire (no identity to notify about)
|
|
279
|
+
expect(emitSignalCalls.length).toBe(0);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - Handles missing adapters gracefully
|
|
7
7
|
* - Falls back to copy-composer when decision copy is missing
|
|
8
8
|
* - Reports delivery results per channel
|
|
9
|
+
* - Emits notification_thread_created only when a new conversation is created
|
|
10
|
+
* - Does NOT emit notification_thread_created when reusing an existing thread
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { describe, expect, mock, test } from 'bun:test';
|
|
@@ -36,6 +38,32 @@ mock.module('../notifications/deliveries-store.js', () => ({
|
|
|
36
38
|
updateDeliveryStatus: () => {},
|
|
37
39
|
}));
|
|
38
40
|
|
|
41
|
+
// Configurable mock for conversation-pairing.
|
|
42
|
+
// By default returns a "new conversation" result with a stable UUID.
|
|
43
|
+
// Set `nextPairingResult` to override the return value for a single call.
|
|
44
|
+
let nextPairingResult: import('../notifications/conversation-pairing.js').PairingResult | null = null;
|
|
45
|
+
let pairingCallCount = 0;
|
|
46
|
+
|
|
47
|
+
mock.module('../notifications/conversation-pairing.js', () => ({
|
|
48
|
+
pairDeliveryWithConversation: async () => {
|
|
49
|
+
if (nextPairingResult) {
|
|
50
|
+
const result = nextPairingResult;
|
|
51
|
+
nextPairingResult = null;
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
// Default: simulate creating a new conversation with a unique ID
|
|
55
|
+
const id = `mock-conv-${++pairingCallCount}`;
|
|
56
|
+
return {
|
|
57
|
+
conversationId: id,
|
|
58
|
+
messageId: `mock-msg-${pairingCallCount}`,
|
|
59
|
+
strategy: 'start_new_conversation' as const,
|
|
60
|
+
createdNewConversation: true,
|
|
61
|
+
threadDecisionFallbackUsed: false,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
import type { ThreadCreatedInfo } from '../notifications/broadcaster.js';
|
|
39
67
|
import { NotificationBroadcaster } from '../notifications/broadcaster.js';
|
|
40
68
|
import type { NotificationSignal } from '../notifications/signal.js';
|
|
41
69
|
import type {
|
|
@@ -167,10 +195,17 @@ describe('notification broadcaster', () => {
|
|
|
167
195
|
await broadcaster.broadcastDecision(signal, decision);
|
|
168
196
|
|
|
169
197
|
expect(vellumAdapter.sent).toHaveLength(1);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
198
|
+
// The broadcaster overwrites deepLinkTarget.conversationId with the
|
|
199
|
+
// paired conversation ID, so the original 'conv-123' is replaced.
|
|
200
|
+
// Verify the structure is correct and that conversationId comes from
|
|
201
|
+
// the pairing result, not the pre-pairing placeholder.
|
|
202
|
+
const deepLink = vellumAdapter.sent[0].deepLinkTarget;
|
|
203
|
+
expect(deepLink).toBeDefined();
|
|
204
|
+
expect(deepLink!.screen).toBe('thread');
|
|
205
|
+
expect(deepLink!.conversationId).toBeDefined();
|
|
206
|
+
expect(deepLink!.conversationId).not.toBe('conv-123');
|
|
207
|
+
// Should be the paired conversation ID from conversation-pairing
|
|
208
|
+
expect(deepLink!.conversationId).toMatch(/^mock-conv-\d+$/);
|
|
174
209
|
});
|
|
175
210
|
|
|
176
211
|
test('multiple channels receive independent copy from the decision', async () => {
|
|
@@ -253,4 +288,80 @@ describe('notification broadcaster', () => {
|
|
|
253
288
|
expect(results).toHaveLength(0);
|
|
254
289
|
expect(vellumAdapter.sent).toHaveLength(0);
|
|
255
290
|
});
|
|
291
|
+
|
|
292
|
+
// ── Thread-created IPC emission ─────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
test('fires onThreadCreated when a new vellum conversation is created (start_new)', async () => {
|
|
295
|
+
const vellumAdapter = new MockAdapter('vellum');
|
|
296
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
297
|
+
const threadCreatedCalls: ThreadCreatedInfo[] = [];
|
|
298
|
+
broadcaster.setOnThreadCreated((info) => threadCreatedCalls.push(info));
|
|
299
|
+
|
|
300
|
+
const signal = makeSignal();
|
|
301
|
+
// No threadActions means default start_new behavior
|
|
302
|
+
const decision = makeDecision();
|
|
303
|
+
|
|
304
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
305
|
+
|
|
306
|
+
// Pairing creates a new conversation by default, so onThreadCreated should fire
|
|
307
|
+
expect(threadCreatedCalls).toHaveLength(1);
|
|
308
|
+
expect(threadCreatedCalls[0].sourceEventName).toBe('test.event');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('fires per-dispatch onThreadCreated callback on new conversation', async () => {
|
|
312
|
+
const vellumAdapter = new MockAdapter('vellum');
|
|
313
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
314
|
+
const dispatchCalls: ThreadCreatedInfo[] = [];
|
|
315
|
+
|
|
316
|
+
const signal = makeSignal();
|
|
317
|
+
const decision = makeDecision();
|
|
318
|
+
|
|
319
|
+
await broadcaster.broadcastDecision(signal, decision, {
|
|
320
|
+
onThreadCreated: (info) => dispatchCalls.push(info),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(dispatchCalls).toHaveLength(1);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('does NOT fire class-level onThreadCreated when reusing an existing thread', async () => {
|
|
327
|
+
const vellumAdapter = new MockAdapter('vellum');
|
|
328
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
329
|
+
const ipcCalls: ThreadCreatedInfo[] = [];
|
|
330
|
+
const dispatchCalls: ThreadCreatedInfo[] = [];
|
|
331
|
+
broadcaster.setOnThreadCreated((info) => ipcCalls.push(info));
|
|
332
|
+
|
|
333
|
+
// Simulate a successful reuse by injecting a pairing result with
|
|
334
|
+
// createdNewConversation=false. This bypasses the real conversation
|
|
335
|
+
// store (which would fall back to creating a new conversation since
|
|
336
|
+
// the target does not exist in the test DB).
|
|
337
|
+
nextPairingResult = {
|
|
338
|
+
conversationId: 'conv-reused-456',
|
|
339
|
+
messageId: 'msg-reused-789',
|
|
340
|
+
strategy: 'start_new_conversation',
|
|
341
|
+
createdNewConversation: false,
|
|
342
|
+
threadDecisionFallbackUsed: false,
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const signal = makeSignal();
|
|
346
|
+
const decision = makeDecision({
|
|
347
|
+
threadActions: {
|
|
348
|
+
vellum: { action: 'reuse_existing', conversationId: 'conv-existing-123' },
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await broadcaster.broadcastDecision(signal, decision, {
|
|
353
|
+
onThreadCreated: (info) => dispatchCalls.push(info),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// The class-level IPC callback should NOT fire because
|
|
357
|
+
// createdNewConversation is false — the client already knows about
|
|
358
|
+
// the reused conversation.
|
|
359
|
+
expect(ipcCalls).toHaveLength(0);
|
|
360
|
+
|
|
361
|
+
// The per-dispatch callback SHOULD fire for both new and reused
|
|
362
|
+
// pairings (used by callers like dispatchGuardianQuestion for
|
|
363
|
+
// delivery bookkeeping).
|
|
364
|
+
expect(dispatchCalls).toHaveLength(1);
|
|
365
|
+
expect(dispatchCalls[0].conversationId).toBe('conv-reused-456');
|
|
366
|
+
});
|
|
256
367
|
});
|