@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
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'guardian-action-late-reply-test-'));
|
|
8
|
+
|
|
9
|
+
mock.module('../util/platform.js', () => ({
|
|
10
|
+
getDataDir: () => testDir,
|
|
11
|
+
isMacOS: () => process.platform === 'darwin',
|
|
12
|
+
isLinux: () => process.platform === 'linux',
|
|
13
|
+
isWindows: () => process.platform === 'win32',
|
|
14
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
15
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
16
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
17
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
18
|
+
ensureDataDir: () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
mock.module('../util/logger.js', () => ({
|
|
22
|
+
getLogger: () =>
|
|
23
|
+
new Proxy({} as Record<string, unknown>, {
|
|
24
|
+
get: () => () => {},
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
mock.module('../runtime/gateway-client.js', () => ({
|
|
29
|
+
deliverChannelReply: async () => {},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
|
|
33
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
34
|
+
import {
|
|
35
|
+
createGuardianActionDelivery,
|
|
36
|
+
createGuardianActionRequest,
|
|
37
|
+
expireGuardianActionRequest,
|
|
38
|
+
getExpiredDeliveriesByConversation,
|
|
39
|
+
getExpiredDeliveriesByDestination,
|
|
40
|
+
getExpiredDeliveryByConversation,
|
|
41
|
+
getFollowupDeliveriesByConversation,
|
|
42
|
+
getGuardianActionRequest,
|
|
43
|
+
getPendingDeliveriesByConversation,
|
|
44
|
+
resolveGuardianActionRequest,
|
|
45
|
+
startFollowupFromExpiredRequest,
|
|
46
|
+
updateDeliveryStatus,
|
|
47
|
+
} from '../memory/guardian-action-store.js';
|
|
48
|
+
import { conversations } from '../memory/schema.js';
|
|
49
|
+
|
|
50
|
+
initializeDb();
|
|
51
|
+
|
|
52
|
+
function ensureConversation(id: string): void {
|
|
53
|
+
const db = getDb();
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
db.insert(conversations).values({
|
|
56
|
+
id,
|
|
57
|
+
title: `Conversation ${id}`,
|
|
58
|
+
createdAt: now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
}).run();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resetTables(): void {
|
|
64
|
+
const db = getDb();
|
|
65
|
+
db.run('DELETE FROM guardian_action_deliveries');
|
|
66
|
+
db.run('DELETE FROM guardian_action_requests');
|
|
67
|
+
db.run('DELETE FROM call_pending_questions');
|
|
68
|
+
db.run('DELETE FROM call_events');
|
|
69
|
+
db.run('DELETE FROM call_sessions');
|
|
70
|
+
db.run('DELETE FROM messages');
|
|
71
|
+
db.run('DELETE FROM conversations');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createExpiredRequest(convId: string, opts?: { chatId?: string; externalUserId?: string; conversationId?: string }) {
|
|
75
|
+
ensureConversation(convId);
|
|
76
|
+
const session = createCallSession({
|
|
77
|
+
conversationId: convId,
|
|
78
|
+
provider: 'twilio',
|
|
79
|
+
fromNumber: '+15550001111',
|
|
80
|
+
toNumber: '+15550002222',
|
|
81
|
+
});
|
|
82
|
+
const pq = createPendingQuestion(session.id, 'What is the gate code?');
|
|
83
|
+
const request = createGuardianActionRequest({
|
|
84
|
+
kind: 'ask_guardian',
|
|
85
|
+
sourceChannel: 'voice',
|
|
86
|
+
sourceConversationId: convId,
|
|
87
|
+
callSessionId: session.id,
|
|
88
|
+
pendingQuestionId: pq.id,
|
|
89
|
+
questionText: pq.questionText,
|
|
90
|
+
expiresAt: Date.now() - 10_000, // already expired
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Create delivery
|
|
94
|
+
const deliveryConvId = opts?.conversationId ?? `delivery-conv-${request.id}`;
|
|
95
|
+
if (opts?.conversationId) {
|
|
96
|
+
ensureConversation(opts.conversationId);
|
|
97
|
+
} else {
|
|
98
|
+
ensureConversation(deliveryConvId);
|
|
99
|
+
}
|
|
100
|
+
const delivery = createGuardianActionDelivery({
|
|
101
|
+
requestId: request.id,
|
|
102
|
+
destinationChannel: 'telegram',
|
|
103
|
+
destinationChatId: opts?.chatId ?? 'chat-123',
|
|
104
|
+
destinationExternalUserId: opts?.externalUserId ?? 'user-456',
|
|
105
|
+
destinationConversationId: deliveryConvId,
|
|
106
|
+
});
|
|
107
|
+
updateDeliveryStatus(delivery.id, 'sent');
|
|
108
|
+
|
|
109
|
+
// Expire the request and delivery
|
|
110
|
+
expireGuardianActionRequest(request.id, 'sweep_timeout');
|
|
111
|
+
|
|
112
|
+
return { request: getGuardianActionRequest(request.id)!, delivery, deliveryConvId };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('guardian-action-late-reply', () => {
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
resetTables();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterAll(() => {
|
|
121
|
+
resetDb();
|
|
122
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── getExpiredDeliveriesByDestination ──────────────────────────────
|
|
126
|
+
|
|
127
|
+
test('getExpiredDeliveriesByDestination returns expired deliveries for follow-up eligible requests', () => {
|
|
128
|
+
const { request } = createExpiredRequest('conv-late-1', { chatId: 'chat-abc', externalUserId: 'user-xyz' });
|
|
129
|
+
|
|
130
|
+
const deliveries = getExpiredDeliveriesByDestination('self', 'telegram', 'chat-abc');
|
|
131
|
+
expect(deliveries).toHaveLength(1);
|
|
132
|
+
expect(deliveries[0].requestId).toBe(request.id);
|
|
133
|
+
expect(deliveries[0].status).toBe('expired');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('getExpiredDeliveriesByDestination returns empty for non-matching channel', () => {
|
|
137
|
+
createExpiredRequest('conv-late-2', { chatId: 'chat-abc' });
|
|
138
|
+
|
|
139
|
+
const deliveries = getExpiredDeliveriesByDestination('self', 'sms', 'chat-abc');
|
|
140
|
+
expect(deliveries).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('getExpiredDeliveriesByDestination returns empty when followup already started', () => {
|
|
144
|
+
const { request } = createExpiredRequest('conv-late-3', { chatId: 'chat-started' });
|
|
145
|
+
|
|
146
|
+
// Start a follow-up, transitioning followup_state from 'none' to 'awaiting_guardian_choice'
|
|
147
|
+
startFollowupFromExpiredRequest(request.id, 'late answer text');
|
|
148
|
+
|
|
149
|
+
const deliveries = getExpiredDeliveriesByDestination('self', 'telegram', 'chat-started');
|
|
150
|
+
expect(deliveries).toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── getExpiredDeliveryByConversation ───────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('getExpiredDeliveryByConversation returns expired delivery for mac channel', () => {
|
|
156
|
+
const { delivery, deliveryConvId } = createExpiredRequest('conv-late-4', { conversationId: 'mac-conv-1' });
|
|
157
|
+
|
|
158
|
+
const found = getExpiredDeliveryByConversation(deliveryConvId);
|
|
159
|
+
expect(found).not.toBeNull();
|
|
160
|
+
expect(found!.id).toBe(delivery.id);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('getExpiredDeliveryByConversation returns null for non-matching conversation', () => {
|
|
164
|
+
createExpiredRequest('conv-late-5', { conversationId: 'mac-conv-2' });
|
|
165
|
+
|
|
166
|
+
const found = getExpiredDeliveryByConversation('nonexistent-conv');
|
|
167
|
+
expect(found).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('getExpiredDeliveryByConversation returns null when followup already started', () => {
|
|
171
|
+
const { request, deliveryConvId } = createExpiredRequest('conv-late-6', { conversationId: 'mac-conv-3' });
|
|
172
|
+
|
|
173
|
+
startFollowupFromExpiredRequest(request.id, 'already answered');
|
|
174
|
+
|
|
175
|
+
const found = getExpiredDeliveryByConversation(deliveryConvId);
|
|
176
|
+
expect(found).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── startFollowupFromExpiredRequest ───────────────────────────────
|
|
180
|
+
|
|
181
|
+
test('startFollowupFromExpiredRequest transitions to awaiting_guardian_choice and records late answer', () => {
|
|
182
|
+
const { request } = createExpiredRequest('conv-late-7');
|
|
183
|
+
|
|
184
|
+
const updated = startFollowupFromExpiredRequest(request.id, 'The gate code is 1234');
|
|
185
|
+
expect(updated).not.toBeNull();
|
|
186
|
+
expect(updated!.followupState).toBe('awaiting_guardian_choice');
|
|
187
|
+
expect(updated!.lateAnswerText).toBe('The gate code is 1234');
|
|
188
|
+
expect(updated!.lateAnsweredAt).toBeGreaterThan(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('startFollowupFromExpiredRequest returns null if followup already started', () => {
|
|
192
|
+
const { request } = createExpiredRequest('conv-late-8');
|
|
193
|
+
|
|
194
|
+
// First call succeeds
|
|
195
|
+
const first = startFollowupFromExpiredRequest(request.id, 'answer 1');
|
|
196
|
+
expect(first).not.toBeNull();
|
|
197
|
+
|
|
198
|
+
// Second call fails — already in awaiting_guardian_choice
|
|
199
|
+
const second = startFollowupFromExpiredRequest(request.id, 'answer 2');
|
|
200
|
+
expect(second).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('startFollowupFromExpiredRequest returns null for pending requests (not expired)', () => {
|
|
204
|
+
const convId = 'conv-late-9';
|
|
205
|
+
ensureConversation(convId);
|
|
206
|
+
const session = createCallSession({
|
|
207
|
+
conversationId: convId,
|
|
208
|
+
provider: 'twilio',
|
|
209
|
+
fromNumber: '+15550001111',
|
|
210
|
+
toNumber: '+15550002222',
|
|
211
|
+
});
|
|
212
|
+
const pq = createPendingQuestion(session.id, 'Still pending question');
|
|
213
|
+
const request = createGuardianActionRequest({
|
|
214
|
+
kind: 'ask_guardian',
|
|
215
|
+
sourceChannel: 'voice',
|
|
216
|
+
sourceConversationId: convId,
|
|
217
|
+
callSessionId: session.id,
|
|
218
|
+
pendingQuestionId: pq.id,
|
|
219
|
+
questionText: pq.questionText,
|
|
220
|
+
expiresAt: Date.now() + 60_000, // not expired
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const result = startFollowupFromExpiredRequest(request.id, 'late answer');
|
|
224
|
+
expect(result).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── Follow-up flow for already-answered requests ──────────────────
|
|
228
|
+
|
|
229
|
+
test('already-answered requests do not appear in expired delivery queries', () => {
|
|
230
|
+
const convId = 'conv-late-10';
|
|
231
|
+
ensureConversation(convId);
|
|
232
|
+
const session = createCallSession({
|
|
233
|
+
conversationId: convId,
|
|
234
|
+
provider: 'twilio',
|
|
235
|
+
fromNumber: '+15550001111',
|
|
236
|
+
toNumber: '+15550002222',
|
|
237
|
+
});
|
|
238
|
+
const pq = createPendingQuestion(session.id, 'Already answered question');
|
|
239
|
+
const request = createGuardianActionRequest({
|
|
240
|
+
kind: 'ask_guardian',
|
|
241
|
+
sourceChannel: 'voice',
|
|
242
|
+
sourceConversationId: convId,
|
|
243
|
+
callSessionId: session.id,
|
|
244
|
+
pendingQuestionId: pq.id,
|
|
245
|
+
questionText: pq.questionText,
|
|
246
|
+
expiresAt: Date.now() + 60_000,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const answeredConvId = 'answered-conv-1';
|
|
250
|
+
ensureConversation(answeredConvId);
|
|
251
|
+
const delivery = createGuardianActionDelivery({
|
|
252
|
+
requestId: request.id,
|
|
253
|
+
destinationChannel: 'telegram',
|
|
254
|
+
destinationChatId: 'chat-answered',
|
|
255
|
+
destinationExternalUserId: 'user-answered',
|
|
256
|
+
destinationConversationId: answeredConvId,
|
|
257
|
+
});
|
|
258
|
+
updateDeliveryStatus(delivery.id, 'sent');
|
|
259
|
+
|
|
260
|
+
// Answer the request (transitions to 'answered', not 'expired')
|
|
261
|
+
resolveGuardianActionRequest(request.id, 'the code is 5678', 'telegram', 'user-answered');
|
|
262
|
+
|
|
263
|
+
// Should not appear in expired queries
|
|
264
|
+
const expiredByDest = getExpiredDeliveriesByDestination('self', 'telegram', 'chat-answered');
|
|
265
|
+
expect(expiredByDest).toHaveLength(0);
|
|
266
|
+
|
|
267
|
+
const expiredByConv = getExpiredDeliveryByConversation(answeredConvId);
|
|
268
|
+
expect(expiredByConv).toBeNull();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── Composed follow-up text verification ──────────────────────────
|
|
272
|
+
|
|
273
|
+
test('composeGuardianActionMessageGenerative produces follow-up text for late answer scenario', async () => {
|
|
274
|
+
// The composer is tested directly rather than through the handler
|
|
275
|
+
const { composeGuardianActionMessageGenerative } = await import('../runtime/guardian-action-message-composer.js');
|
|
276
|
+
|
|
277
|
+
const text = await composeGuardianActionMessageGenerative({
|
|
278
|
+
scenario: 'guardian_late_answer_followup',
|
|
279
|
+
questionText: 'What is the gate code?',
|
|
280
|
+
lateAnswerText: 'The gate code is 1234',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// In test mode, the deterministic fallback is used
|
|
284
|
+
expect(text).toContain('called earlier');
|
|
285
|
+
expect(text).toContain('call them back');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('composeGuardianActionMessageGenerative produces stale text for expired scenario', async () => {
|
|
289
|
+
const { composeGuardianActionMessageGenerative } = await import('../runtime/guardian-action-message-composer.js');
|
|
290
|
+
|
|
291
|
+
const text = await composeGuardianActionMessageGenerative({
|
|
292
|
+
scenario: 'guardian_stale_expired',
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(text).toContain('expired');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── Multiple deliveries in one conversation (disambiguation) ──────
|
|
299
|
+
|
|
300
|
+
describe('multi-delivery disambiguation in reused conversations', () => {
|
|
301
|
+
// Helper to create a pending request with delivery in a shared conversation
|
|
302
|
+
function createPendingInSharedConv(sourceConvId: string, sharedDeliveryConvId: string) {
|
|
303
|
+
ensureConversation(sourceConvId);
|
|
304
|
+
const session = createCallSession({
|
|
305
|
+
conversationId: sourceConvId,
|
|
306
|
+
provider: 'twilio',
|
|
307
|
+
fromNumber: '+15550001111',
|
|
308
|
+
toNumber: '+15550002222',
|
|
309
|
+
});
|
|
310
|
+
const pq = createPendingQuestion(session.id, `Question from ${sourceConvId}`);
|
|
311
|
+
const request = createGuardianActionRequest({
|
|
312
|
+
kind: 'ask_guardian',
|
|
313
|
+
sourceChannel: 'voice',
|
|
314
|
+
sourceConversationId: sourceConvId,
|
|
315
|
+
callSessionId: session.id,
|
|
316
|
+
pendingQuestionId: pq.id,
|
|
317
|
+
questionText: pq.questionText,
|
|
318
|
+
expiresAt: Date.now() + 60_000,
|
|
319
|
+
});
|
|
320
|
+
const delivery = createGuardianActionDelivery({
|
|
321
|
+
requestId: request.id,
|
|
322
|
+
destinationChannel: 'vellum',
|
|
323
|
+
destinationConversationId: sharedDeliveryConvId,
|
|
324
|
+
});
|
|
325
|
+
updateDeliveryStatus(delivery.id, 'sent');
|
|
326
|
+
return { request, delivery };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
test('multiple pending deliveries in same conversation are returned by getPendingDeliveriesByConversation', () => {
|
|
330
|
+
const sharedConv = 'shared-reused-conv-pending';
|
|
331
|
+
ensureConversation(sharedConv);
|
|
332
|
+
|
|
333
|
+
const { request: req1 } = createPendingInSharedConv('src-p1', sharedConv);
|
|
334
|
+
const { request: req2 } = createPendingInSharedConv('src-p2', sharedConv);
|
|
335
|
+
|
|
336
|
+
const deliveries = getPendingDeliveriesByConversation(sharedConv);
|
|
337
|
+
expect(deliveries).toHaveLength(2);
|
|
338
|
+
|
|
339
|
+
const requestIds = deliveries.map((d) => d.requestId);
|
|
340
|
+
expect(requestIds).toContain(req1.id);
|
|
341
|
+
expect(requestIds).toContain(req2.id);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('request codes are unique across multiple requests in same conversation', () => {
|
|
345
|
+
const sharedConv = 'shared-reused-conv-codes';
|
|
346
|
+
ensureConversation(sharedConv);
|
|
347
|
+
|
|
348
|
+
const { request: req1 } = createPendingInSharedConv('src-code1', sharedConv);
|
|
349
|
+
const { request: req2 } = createPendingInSharedConv('src-code2', sharedConv);
|
|
350
|
+
|
|
351
|
+
expect(req1.requestCode).not.toBe(req2.requestCode);
|
|
352
|
+
expect(req1.requestCode).toHaveLength(6);
|
|
353
|
+
expect(req2.requestCode).toHaveLength(6);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('multiple expired deliveries in same conversation are returned by getExpiredDeliveriesByConversation', () => {
|
|
357
|
+
const sharedConv = 'shared-reused-conv-expired';
|
|
358
|
+
ensureConversation(sharedConv);
|
|
359
|
+
|
|
360
|
+
const { request: req1 } = createPendingInSharedConv('src-e1', sharedConv);
|
|
361
|
+
const { request: req2 } = createPendingInSharedConv('src-e2', sharedConv);
|
|
362
|
+
|
|
363
|
+
expireGuardianActionRequest(req1.id, 'sweep_timeout');
|
|
364
|
+
expireGuardianActionRequest(req2.id, 'sweep_timeout');
|
|
365
|
+
|
|
366
|
+
const deliveries = getExpiredDeliveriesByConversation(sharedConv);
|
|
367
|
+
expect(deliveries).toHaveLength(2);
|
|
368
|
+
|
|
369
|
+
const requestIds = deliveries.map((d) => d.requestId);
|
|
370
|
+
expect(requestIds).toContain(req1.id);
|
|
371
|
+
expect(requestIds).toContain(req2.id);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('multiple followup deliveries in same conversation are returned by getFollowupDeliveriesByConversation', () => {
|
|
375
|
+
const sharedConv = 'shared-reused-conv-followup';
|
|
376
|
+
ensureConversation(sharedConv);
|
|
377
|
+
|
|
378
|
+
const { request: req1 } = createPendingInSharedConv('src-fu1', sharedConv);
|
|
379
|
+
const { request: req2 } = createPendingInSharedConv('src-fu2', sharedConv);
|
|
380
|
+
|
|
381
|
+
expireGuardianActionRequest(req1.id, 'sweep_timeout');
|
|
382
|
+
expireGuardianActionRequest(req2.id, 'sweep_timeout');
|
|
383
|
+
startFollowupFromExpiredRequest(req1.id, 'late answer 1');
|
|
384
|
+
startFollowupFromExpiredRequest(req2.id, 'late answer 2');
|
|
385
|
+
|
|
386
|
+
const deliveries = getFollowupDeliveriesByConversation(sharedConv);
|
|
387
|
+
expect(deliveries).toHaveLength(2);
|
|
388
|
+
|
|
389
|
+
const requestIds = deliveries.map((d) => d.requestId);
|
|
390
|
+
expect(requestIds).toContain(req1.id);
|
|
391
|
+
expect(requestIds).toContain(req2.id);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('resolving one pending request leaves the other still pending in shared conversation', () => {
|
|
395
|
+
const sharedConv = 'shared-reused-conv-resolve-one';
|
|
396
|
+
ensureConversation(sharedConv);
|
|
397
|
+
|
|
398
|
+
const { request: req1 } = createPendingInSharedConv('src-r1', sharedConv);
|
|
399
|
+
const { request: req2 } = createPendingInSharedConv('src-r2', sharedConv);
|
|
400
|
+
|
|
401
|
+
resolveGuardianActionRequest(req1.id, 'answer to first', 'vellum');
|
|
402
|
+
|
|
403
|
+
const remaining = getPendingDeliveriesByConversation(sharedConv);
|
|
404
|
+
expect(remaining).toHaveLength(1);
|
|
405
|
+
expect(remaining[0].requestId).toBe(req2.id);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('request code prefix matching is case-insensitive', () => {
|
|
409
|
+
const sharedConv = 'shared-reused-conv-case';
|
|
410
|
+
ensureConversation(sharedConv);
|
|
411
|
+
|
|
412
|
+
const { request: req1 } = createPendingInSharedConv('src-case1', sharedConv);
|
|
413
|
+
const code = req1.requestCode; // e.g. "A1B2C3"
|
|
414
|
+
|
|
415
|
+
// Simulate case-insensitive prefix matching as done in session-process.ts
|
|
416
|
+
const userInput = `${code.toLowerCase()} the answer is 42`;
|
|
417
|
+
const matched = userInput.toUpperCase().startsWith(code);
|
|
418
|
+
expect(matched).toBe(true);
|
|
419
|
+
|
|
420
|
+
// After stripping the code prefix, the answer text is extracted
|
|
421
|
+
const answerText = userInput.slice(code.length).trim();
|
|
422
|
+
expect(answerText).toBe('the answer is 42');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard test: ensures no hardcoded user-facing copy creeps back into the
|
|
3
|
+
* guardian action timeout/follow-up flow files.
|
|
4
|
+
*
|
|
5
|
+
* All user-visible text in the guardian timeout/follow-up path must go
|
|
6
|
+
* through the `guardian-action-message-composer.ts` composition system
|
|
7
|
+
* (which is intentionally excluded from scanning since that is where
|
|
8
|
+
* deterministic fallback copy legitimately lives).
|
|
9
|
+
*
|
|
10
|
+
* The banned patterns below correspond to strings that were removed during
|
|
11
|
+
* M3/M4/M7 of the guardian timeout feature in favour of generated messaging.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import { readFileSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
|
|
18
|
+
const SCANNED_FILES = [
|
|
19
|
+
'calls/call-controller.ts',
|
|
20
|
+
'calls/guardian-action-sweep.ts',
|
|
21
|
+
'runtime/routes/inbound-message-handler.ts',
|
|
22
|
+
'runtime/guardian-action-conversation-turn.ts',
|
|
23
|
+
'daemon/session-process.ts',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const BANNED_PATTERNS: { pattern: RegExp; description: string }[] = [
|
|
27
|
+
{
|
|
28
|
+
pattern: /['"`]I'm sorry, I wasn't able to get that information in time/i,
|
|
29
|
+
description: 'removed M3 hardcoded timeout acknowledgment',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
pattern: /['"`].*already been answered from another channel/i,
|
|
33
|
+
description: 'stale-answered notice must go through composer (string literal)',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: /['"`]Failed to deliver your answer to the call/i,
|
|
37
|
+
description: 'answer-delivery failure notice must go through composer (string literal)',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
pattern: /['"`]I couldn't reach them in time/i,
|
|
41
|
+
description: 'removed M3 hardcoded timeout apology (string literal)',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
pattern: /['"`]The call has already ended/i,
|
|
45
|
+
description: 'call-ended notice must go through composer (string literal)',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: /['"`]Would you like to call them back or send/i,
|
|
49
|
+
description: 'follow-up prompt must go through composer (string literal)',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
pattern: /['"`]You have multiple expired guardian questions\./i,
|
|
53
|
+
description: 'expired disambiguation must go through composer (string literal)',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
pattern: /['"`]You have multiple pending follow-up questions\./i,
|
|
57
|
+
description: 'follow-up disambiguation must go through composer (string literal)',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
describe('guardian action no-hardcoded-copy guard', () => {
|
|
62
|
+
for (const file of SCANNED_FILES) {
|
|
63
|
+
test(`${file} does not contain banned guardian action copy literals`, () => {
|
|
64
|
+
const content = readFileSync(join(__dirname, '..', file), 'utf-8');
|
|
65
|
+
for (const { pattern, description: _description } of BANNED_PATTERNS) {
|
|
66
|
+
const match = content.match(pattern);
|
|
67
|
+
expect(match).toBeNull();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
@@ -31,8 +31,16 @@ import {
|
|
|
31
31
|
cancelGuardianActionRequest,
|
|
32
32
|
createGuardianActionDelivery,
|
|
33
33
|
createGuardianActionRequest,
|
|
34
|
+
expireGuardianActionRequest,
|
|
34
35
|
getDeliveriesByRequestId,
|
|
36
|
+
getExpiredDeliveriesByConversation,
|
|
37
|
+
getExpiredDeliveryByConversation,
|
|
38
|
+
getFollowupDeliveriesByConversation,
|
|
39
|
+
getFollowupDeliveryByConversation,
|
|
35
40
|
getGuardianActionRequest,
|
|
41
|
+
getPendingDeliveriesByConversation,
|
|
42
|
+
getPendingDeliveryByConversation,
|
|
43
|
+
startFollowupFromExpiredRequest,
|
|
36
44
|
updateDeliveryStatus,
|
|
37
45
|
} from '../memory/guardian-action-store.js';
|
|
38
46
|
import { conversations } from '../memory/schema.js';
|
|
@@ -74,6 +82,180 @@ describe('guardian-action-store', () => {
|
|
|
74
82
|
}
|
|
75
83
|
});
|
|
76
84
|
|
|
85
|
+
// ── Helper to create a pending request+delivery targeting a conversation ──
|
|
86
|
+
function createPendingRequestWithDelivery(convId: string, deliveryConvId: string) {
|
|
87
|
+
ensureConversation(convId);
|
|
88
|
+
const session = createCallSession({
|
|
89
|
+
conversationId: convId,
|
|
90
|
+
provider: 'twilio',
|
|
91
|
+
fromNumber: '+15550001111',
|
|
92
|
+
toNumber: '+15550002222',
|
|
93
|
+
});
|
|
94
|
+
const pq = createPendingQuestion(session.id, `Question for ${convId}`);
|
|
95
|
+
const request = createGuardianActionRequest({
|
|
96
|
+
kind: 'ask_guardian',
|
|
97
|
+
sourceChannel: 'voice',
|
|
98
|
+
sourceConversationId: convId,
|
|
99
|
+
callSessionId: session.id,
|
|
100
|
+
pendingQuestionId: pq.id,
|
|
101
|
+
questionText: pq.questionText,
|
|
102
|
+
expiresAt: Date.now() + 60_000,
|
|
103
|
+
});
|
|
104
|
+
const delivery = createGuardianActionDelivery({
|
|
105
|
+
requestId: request.id,
|
|
106
|
+
destinationChannel: 'vellum',
|
|
107
|
+
destinationConversationId: deliveryConvId,
|
|
108
|
+
});
|
|
109
|
+
updateDeliveryStatus(delivery.id, 'sent');
|
|
110
|
+
return { request, delivery };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── getPendingDeliveriesByConversation ──────────────────────────────
|
|
114
|
+
|
|
115
|
+
test('getPendingDeliveriesByConversation returns all pending deliveries for a conversation', () => {
|
|
116
|
+
const sharedConvId = 'shared-pending-conv';
|
|
117
|
+
ensureConversation(sharedConvId);
|
|
118
|
+
|
|
119
|
+
const { request: req1 } = createPendingRequestWithDelivery('source-conv-p1', sharedConvId);
|
|
120
|
+
const { request: req2 } = createPendingRequestWithDelivery('source-conv-p2', sharedConvId);
|
|
121
|
+
|
|
122
|
+
const deliveries = getPendingDeliveriesByConversation(sharedConvId);
|
|
123
|
+
expect(deliveries).toHaveLength(2);
|
|
124
|
+
|
|
125
|
+
const requestIds = deliveries.map((d) => d.requestId);
|
|
126
|
+
expect(requestIds).toContain(req1.id);
|
|
127
|
+
expect(requestIds).toContain(req2.id);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('getPendingDeliveriesByConversation returns single delivery (fast path preserved)', () => {
|
|
131
|
+
const convId = 'single-pending-conv';
|
|
132
|
+
ensureConversation(convId);
|
|
133
|
+
|
|
134
|
+
const { request } = createPendingRequestWithDelivery('source-conv-single-p', convId);
|
|
135
|
+
|
|
136
|
+
const deliveries = getPendingDeliveriesByConversation(convId);
|
|
137
|
+
expect(deliveries).toHaveLength(1);
|
|
138
|
+
expect(deliveries[0].requestId).toBe(request.id);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('getPendingDeliveryByConversation returns first from multiple (backward compat)', () => {
|
|
142
|
+
const convId = 'compat-pending-conv';
|
|
143
|
+
ensureConversation(convId);
|
|
144
|
+
|
|
145
|
+
createPendingRequestWithDelivery('source-conv-compat-p1', convId);
|
|
146
|
+
createPendingRequestWithDelivery('source-conv-compat-p2', convId);
|
|
147
|
+
|
|
148
|
+
const single = getPendingDeliveryByConversation(convId);
|
|
149
|
+
expect(single).not.toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('getPendingDeliveriesByConversation returns empty for non-matching conversation', () => {
|
|
153
|
+
ensureConversation('other-conv');
|
|
154
|
+
createPendingRequestWithDelivery('source-conv-no-match', 'other-conv');
|
|
155
|
+
|
|
156
|
+
const deliveries = getPendingDeliveriesByConversation('nonexistent-conv');
|
|
157
|
+
expect(deliveries).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── getExpiredDeliveriesByConversation ──────────────────────────────
|
|
161
|
+
|
|
162
|
+
test('getExpiredDeliveriesByConversation returns all expired deliveries for a conversation', () => {
|
|
163
|
+
const sharedConvId = 'shared-expired-conv';
|
|
164
|
+
ensureConversation(sharedConvId);
|
|
165
|
+
|
|
166
|
+
const { request: req1 } = createPendingRequestWithDelivery('source-conv-e1', sharedConvId);
|
|
167
|
+
const { request: req2 } = createPendingRequestWithDelivery('source-conv-e2', sharedConvId);
|
|
168
|
+
|
|
169
|
+
expireGuardianActionRequest(req1.id, 'sweep_timeout');
|
|
170
|
+
expireGuardianActionRequest(req2.id, 'sweep_timeout');
|
|
171
|
+
|
|
172
|
+
const deliveries = getExpiredDeliveriesByConversation(sharedConvId);
|
|
173
|
+
expect(deliveries).toHaveLength(2);
|
|
174
|
+
|
|
175
|
+
const requestIds = deliveries.map((d) => d.requestId);
|
|
176
|
+
expect(requestIds).toContain(req1.id);
|
|
177
|
+
expect(requestIds).toContain(req2.id);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('getExpiredDeliveryByConversation returns first from multiple (backward compat)', () => {
|
|
181
|
+
const convId = 'compat-expired-conv';
|
|
182
|
+
ensureConversation(convId);
|
|
183
|
+
|
|
184
|
+
const { request: req1 } = createPendingRequestWithDelivery('source-conv-compat-e1', convId);
|
|
185
|
+
const { request: req2 } = createPendingRequestWithDelivery('source-conv-compat-e2', convId);
|
|
186
|
+
|
|
187
|
+
expireGuardianActionRequest(req1.id, 'sweep_timeout');
|
|
188
|
+
expireGuardianActionRequest(req2.id, 'sweep_timeout');
|
|
189
|
+
|
|
190
|
+
const single = getExpiredDeliveryByConversation(convId);
|
|
191
|
+
expect(single).not.toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('getExpiredDeliveriesByConversation excludes deliveries with followup already started', () => {
|
|
195
|
+
const convId = 'expired-with-followup-conv';
|
|
196
|
+
ensureConversation(convId);
|
|
197
|
+
|
|
198
|
+
const { request: req1 } = createPendingRequestWithDelivery('source-conv-ef1', convId);
|
|
199
|
+
const { request: req2 } = createPendingRequestWithDelivery('source-conv-ef2', convId);
|
|
200
|
+
|
|
201
|
+
expireGuardianActionRequest(req1.id, 'sweep_timeout');
|
|
202
|
+
expireGuardianActionRequest(req2.id, 'sweep_timeout');
|
|
203
|
+
|
|
204
|
+
// Start followup on req1 — only req2 should remain in the expired query
|
|
205
|
+
startFollowupFromExpiredRequest(req1.id, 'late answer');
|
|
206
|
+
|
|
207
|
+
const deliveries = getExpiredDeliveriesByConversation(convId);
|
|
208
|
+
expect(deliveries).toHaveLength(1);
|
|
209
|
+
expect(deliveries[0].requestId).toBe(req2.id);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ── getFollowupDeliveriesByConversation ─────────────────────────────
|
|
213
|
+
|
|
214
|
+
test('getFollowupDeliveriesByConversation returns all awaiting_guardian_choice deliveries', () => {
|
|
215
|
+
const convId = 'shared-followup-conv';
|
|
216
|
+
ensureConversation(convId);
|
|
217
|
+
|
|
218
|
+
const { request: req1 } = createPendingRequestWithDelivery('source-conv-f1', convId);
|
|
219
|
+
const { request: req2 } = createPendingRequestWithDelivery('source-conv-f2', convId);
|
|
220
|
+
|
|
221
|
+
expireGuardianActionRequest(req1.id, 'sweep_timeout');
|
|
222
|
+
expireGuardianActionRequest(req2.id, 'sweep_timeout');
|
|
223
|
+
|
|
224
|
+
startFollowupFromExpiredRequest(req1.id, 'late answer 1');
|
|
225
|
+
startFollowupFromExpiredRequest(req2.id, 'late answer 2');
|
|
226
|
+
|
|
227
|
+
const deliveries = getFollowupDeliveriesByConversation(convId);
|
|
228
|
+
expect(deliveries).toHaveLength(2);
|
|
229
|
+
|
|
230
|
+
const requestIds = deliveries.map((d) => d.requestId);
|
|
231
|
+
expect(requestIds).toContain(req1.id);
|
|
232
|
+
expect(requestIds).toContain(req2.id);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('getFollowupDeliveryByConversation returns first from multiple (backward compat)', () => {
|
|
236
|
+
const convId = 'compat-followup-conv';
|
|
237
|
+
ensureConversation(convId);
|
|
238
|
+
|
|
239
|
+
const { request: req1 } = createPendingRequestWithDelivery('source-conv-compat-f1', convId);
|
|
240
|
+
const { request: req2 } = createPendingRequestWithDelivery('source-conv-compat-f2', convId);
|
|
241
|
+
|
|
242
|
+
expireGuardianActionRequest(req1.id, 'sweep_timeout');
|
|
243
|
+
expireGuardianActionRequest(req2.id, 'sweep_timeout');
|
|
244
|
+
|
|
245
|
+
startFollowupFromExpiredRequest(req1.id, 'late 1');
|
|
246
|
+
startFollowupFromExpiredRequest(req2.id, 'late 2');
|
|
247
|
+
|
|
248
|
+
const single = getFollowupDeliveryByConversation(convId);
|
|
249
|
+
expect(single).not.toBeNull();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('getFollowupDeliveriesByConversation returns empty for non-matching conversation', () => {
|
|
253
|
+
const deliveries = getFollowupDeliveriesByConversation('nonexistent-conv');
|
|
254
|
+
expect(deliveries).toHaveLength(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ── cancelGuardianActionRequest ─────────────────────────────────────
|
|
258
|
+
|
|
77
259
|
test('cancelGuardianActionRequest cancels both pending and sent deliveries', () => {
|
|
78
260
|
const conversationId = 'conv-guardian-cancel';
|
|
79
261
|
ensureConversation(conversationId);
|