@vellumai/assistant 0.3.15 → 0.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +142 -0
- package/Dockerfile +1 -1
- package/README.md +5 -5
- package/docs/architecture/http-token-refresh.md +252 -0
- package/docs/architecture/memory.md +5 -4
- package/docs/architecture/scheduling.md +4 -88
- package/docs/runbook-trusted-contacts.md +283 -0
- package/docs/trusted-contact-access.md +247 -0
- package/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
- package/src/__tests__/access-request-decision.test.ts +331 -0
- package/src/__tests__/asset-materialize-tool.test.ts +7 -7
- package/src/__tests__/asset-search-tool.test.ts +15 -15
- package/src/__tests__/attachments-store.test.ts +13 -13
- package/src/__tests__/call-controller.test.ts +150 -4
- package/src/__tests__/call-conversation-messages.test.ts +2 -2
- package/src/__tests__/call-pointer-messages.test.ts +28 -0
- package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
- package/src/__tests__/channel-approval-routes.test.ts +108 -12
- package/src/__tests__/channel-guardian.test.ts +16 -14
- package/src/__tests__/checker.test.ts +24 -0
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
- package/src/__tests__/config-watcher.test.ts +358 -0
- package/src/__tests__/conversation-pairing.test.ts +24 -24
- package/src/__tests__/conversation-store.test.ts +36 -36
- package/src/__tests__/date-context.test.ts +179 -1
- package/src/__tests__/db-migration-rollback.test.ts +4 -7
- package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
- package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
- package/src/__tests__/gateway-only-guard.test.ts +188 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
- package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
- package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
- package/src/__tests__/guardian-action-sweep.test.ts +9 -9
- package/src/__tests__/guardian-outbound-http.test.ts +194 -2
- package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
- package/src/__tests__/handlers-telegram-config.test.ts +6 -6
- package/src/__tests__/hooks-runner.test.ts +13 -4
- package/src/__tests__/ingress-routes-http.test.ts +443 -0
- package/src/__tests__/intent-routing.test.ts +14 -0
- package/src/__tests__/ipc-snapshot.test.ts +2 -5
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-regressions.test.ts +16 -12
- package/src/__tests__/non-member-access-request.test.ts +282 -0
- package/src/__tests__/notification-decision-strategy.test.ts +136 -0
- package/src/__tests__/notification-routing-intent.test.ts +11 -1
- package/src/__tests__/notification-thread-candidates.test.ts +166 -0
- package/src/__tests__/recording-intent.test.ts +1 -0
- package/src/__tests__/recording-state-machine.test.ts +328 -17
- package/src/__tests__/registry.test.ts +17 -8
- package/src/__tests__/relay-server.test.ts +105 -0
- package/src/__tests__/reminder.test.ts +13 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
- package/src/__tests__/scheduler-recurrence.test.ts +50 -0
- package/src/__tests__/server-history-render.test.ts +8 -8
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-runtime-assembly.test.ts +49 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
- package/src/__tests__/slack-channel-config.test.ts +230 -0
- package/src/__tests__/subagent-manager-notify.test.ts +4 -4
- package/src/__tests__/swarm-session-integration.test.ts +2 -2
- package/src/__tests__/system-prompt.test.ts +43 -0
- package/src/__tests__/task-management-tools.test.ts +3 -3
- package/src/__tests__/task-tools.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +17 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
- package/src/__tests__/trusted-contact-verification.test.ts +360 -0
- package/src/__tests__/update-bulletin-format.test.ts +119 -0
- package/src/__tests__/update-bulletin-state.test.ts +129 -0
- package/src/__tests__/update-bulletin.test.ts +260 -0
- package/src/__tests__/update-template-contract.test.ts +29 -0
- package/src/agent/loop.ts +2 -2
- package/src/amazon/client.ts +2 -3
- package/src/calls/call-controller.ts +115 -34
- package/src/calls/call-conversation-messages.ts +2 -2
- package/src/calls/call-domain.ts +10 -3
- package/src/calls/call-pointer-messages.ts +17 -5
- package/src/calls/guardian-action-sweep.ts +77 -36
- package/src/calls/relay-server.ts +51 -12
- package/src/calls/twilio-routes.ts +3 -1
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -4
- package/src/cli/core-commands.ts +3 -3
- package/src/cli/map.ts +8 -5
- package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
- package/src/config/bundled-skills/tasks/SKILL.md +1 -1
- package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
- package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
- package/src/config/computer-use-prompt.ts +1 -0
- package/src/config/core-schema.ts +16 -0
- package/src/config/env-registry.ts +1 -0
- package/src/config/env.ts +16 -1
- package/src/config/memory-schema.ts +5 -0
- package/src/config/schema.ts +4 -0
- package/src/config/system-prompt.ts +69 -2
- package/src/config/templates/BOOTSTRAP.md +1 -1
- package/src/config/templates/IDENTITY.md +8 -4
- package/src/config/templates/SOUL.md +14 -0
- package/src/config/templates/UPDATES.md +16 -0
- package/src/config/templates/USER.md +5 -1
- package/src/config/types.ts +1 -0
- package/src/config/update-bulletin-format.ts +52 -0
- package/src/config/update-bulletin-state.ts +49 -0
- package/src/config/update-bulletin.ts +82 -0
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
- package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
- package/src/context/window-manager.ts +43 -3
- package/src/daemon/config-watcher.ts +1 -0
- package/src/daemon/connection-policy.ts +21 -1
- package/src/daemon/daemon-control.ts +164 -7
- package/src/daemon/date-context.ts +174 -1
- package/src/daemon/guardian-action-generators.ts +175 -0
- package/src/daemon/guardian-verification-intent.ts +120 -0
- package/src/daemon/handlers/apps.ts +1 -3
- package/src/daemon/handlers/config-channels.ts +2 -2
- package/src/daemon/handlers/config-heartbeat.ts +1 -1
- package/src/daemon/handlers/config-inbox.ts +55 -159
- package/src/daemon/handlers/config-ingress.ts +1 -1
- package/src/daemon/handlers/config-integrations.ts +1 -1
- package/src/daemon/handlers/config-platform.ts +1 -1
- package/src/daemon/handlers/config-scheduling.ts +2 -2
- package/src/daemon/handlers/config-slack-channel.ts +190 -0
- package/src/daemon/handlers/config-telegram.ts +1 -1
- package/src/daemon/handlers/config-twilio.ts +1 -1
- package/src/daemon/handlers/config-voice.ts +100 -0
- package/src/daemon/handlers/config.ts +3 -0
- package/src/daemon/handlers/misc.ts +83 -5
- package/src/daemon/handlers/navigate-settings.ts +27 -0
- package/src/daemon/handlers/recording.ts +270 -144
- package/src/daemon/handlers/sessions.ts +100 -17
- package/src/daemon/handlers/subagents.ts +3 -3
- package/src/daemon/handlers/work-items.ts +10 -7
- package/src/daemon/ipc-contract/integrations.ts +9 -1
- package/src/daemon/ipc-contract/messages.ts +4 -0
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/settings.ts +26 -0
- package/src/daemon/ipc-contract/shared.ts +2 -0
- package/src/daemon/ipc-contract/work-items.ts +1 -7
- package/src/daemon/ipc-contract-inventory.json +5 -1
- package/src/daemon/ipc-contract.ts +5 -1
- package/src/daemon/lifecycle.ts +306 -266
- package/src/daemon/recording-intent.ts +0 -41
- package/src/daemon/response-tier.ts +2 -2
- package/src/daemon/server.ts +6 -6
- package/src/daemon/session-agent-loop-handlers.ts +34 -9
- package/src/daemon/session-agent-loop.ts +15 -8
- package/src/daemon/session-history.ts +3 -2
- package/src/daemon/session-media-retry.ts +3 -0
- package/src/daemon/session-messaging.ts +38 -4
- package/src/daemon/session-notifiers.ts +2 -2
- package/src/daemon/session-process.ts +256 -23
- package/src/daemon/session-queue-manager.ts +2 -0
- package/src/daemon/session-runtime-assembly.ts +39 -0
- package/src/daemon/session-skill-tools.ts +13 -4
- package/src/daemon/session-tool-setup.ts +5 -6
- package/src/daemon/session.ts +19 -8
- package/src/daemon/tls-certs.ts +55 -13
- package/src/daemon/tool-side-effects.ts +13 -5
- package/src/gallery/default-gallery.ts +32 -9
- package/src/influencer/client.ts +2 -1
- package/src/memory/channel-delivery-store.ts +37 -567
- package/src/memory/channel-guardian-store.ts +66 -1317
- package/src/memory/conflict-store.ts +4 -4
- package/src/memory/conversation-attention-store.ts +0 -3
- package/src/memory/conversation-crud.ts +668 -0
- package/src/memory/conversation-queries.ts +361 -0
- package/src/memory/conversation-store.ts +45 -983
- package/src/memory/db-connection.ts +3 -0
- package/src/memory/db-init.ts +25 -0
- package/src/memory/delivery-channels.ts +175 -0
- package/src/memory/delivery-crud.ts +211 -0
- package/src/memory/delivery-status.ts +199 -0
- package/src/memory/embedding-backend.ts +70 -4
- package/src/memory/embedding-local.ts +12 -2
- package/src/memory/entity-extractor.ts +3 -8
- package/src/memory/fts-reconciler.ts +121 -0
- package/src/memory/guardian-action-store.ts +366 -3
- package/src/memory/guardian-approvals.ts +569 -0
- package/src/memory/guardian-bindings.ts +130 -0
- package/src/memory/guardian-rate-limits.ts +196 -0
- package/src/memory/guardian-verification.ts +520 -0
- package/src/memory/job-handlers/index-maintenance.ts +2 -1
- package/src/memory/job-utils.ts +8 -5
- package/src/memory/jobs-store.ts +66 -6
- package/src/memory/jobs-worker.ts +23 -1
- package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
- package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
- package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
- package/src/memory/migrations/100-core-tables.ts +1 -1
- package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
- package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
- package/src/memory/migrations/112-assistant-inbox.ts +1 -1
- package/src/memory/migrations/113-late-migrations.ts +1 -1
- package/src/memory/migrations/116-messages-fts.ts +13 -0
- package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
- package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
- package/src/memory/migrations/index.ts +8 -3
- package/src/memory/migrations/validate-migration-state.ts +114 -15
- package/src/memory/qdrant-circuit-breaker.ts +105 -0
- package/src/memory/retriever.ts +46 -13
- package/src/memory/schema-migration.ts +3 -0
- package/src/memory/schema.ts +25 -7
- package/src/memory/search/semantic.ts +8 -90
- package/src/notifications/README.md +1 -1
- package/src/notifications/broadcaster.ts +20 -2
- package/src/notifications/conversation-pairing.ts +3 -3
- package/src/notifications/decision-engine.ts +173 -8
- package/src/notifications/deliveries-store.ts +27 -8
- package/src/notifications/preferences-store.ts +7 -7
- package/src/notifications/thread-candidates.ts +234 -0
- package/src/notifications/types.ts +18 -0
- package/src/permissions/defaults.ts +11 -1
- package/src/permissions/prompter.ts +17 -0
- package/src/permissions/trust-store.ts +2 -0
- package/src/providers/failover.ts +19 -0
- package/src/providers/registry.ts +46 -1
- package/src/runtime/approval-message-composer.ts +1 -1
- package/src/runtime/channel-guardian-service.ts +15 -3
- package/src/runtime/channel-retry-sweep.ts +7 -2
- package/src/runtime/guardian-action-conversation-turn.ts +85 -0
- package/src/runtime/guardian-action-followup-executor.ts +301 -0
- package/src/runtime/guardian-action-message-composer.ts +245 -0
- package/src/runtime/guardian-outbound-actions.ts +26 -6
- package/src/runtime/guardian-verification-templates.ts +15 -9
- package/src/runtime/http-errors.ts +93 -0
- package/src/runtime/http-server.ts +133 -44
- package/src/runtime/http-types.ts +53 -0
- package/src/runtime/ingress-service.ts +237 -0
- package/src/runtime/middleware/error-handler.ts +4 -3
- package/src/runtime/middleware/rate-limiter.ts +160 -0
- package/src/runtime/middleware/request-logger.ts +71 -0
- package/src/runtime/middleware/twilio-validation.ts +7 -6
- package/src/runtime/pending-interactions.ts +12 -0
- package/src/runtime/routes/access-request-decision.ts +215 -0
- package/src/runtime/routes/app-routes.ts +25 -18
- package/src/runtime/routes/approval-routes.ts +18 -47
- package/src/runtime/routes/attachment-routes.ts +15 -41
- package/src/runtime/routes/call-routes.ts +20 -20
- package/src/runtime/routes/channel-delivery-routes.ts +6 -5
- package/src/runtime/routes/contact-routes.ts +4 -9
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +26 -57
- package/src/runtime/routes/debug-routes.ts +71 -0
- package/src/runtime/routes/events-routes.ts +3 -2
- package/src/runtime/routes/guardian-approval-interception.ts +221 -0
- package/src/runtime/routes/identity-routes.ts +14 -10
- package/src/runtime/routes/inbound-conversation.ts +3 -2
- package/src/runtime/routes/inbound-message-handler.ts +527 -62
- package/src/runtime/routes/ingress-routes.ts +174 -0
- package/src/runtime/routes/integration-routes.ts +78 -16
- package/src/runtime/routes/pairing-routes.ts +11 -10
- package/src/runtime/routes/secret-routes.ts +10 -18
- package/src/runtime/verification-rate-limiter.ts +83 -0
- package/src/schedule/schedule-store.ts +13 -1
- package/src/schedule/scheduler.ts +1 -1
- package/src/security/secret-ingress.ts +5 -2
- package/src/security/secret-scanner.ts +72 -6
- package/src/subagent/manager.ts +6 -4
- package/src/swarm/plan-validator.ts +4 -1
- package/src/tasks/task-runner.ts +3 -1
- package/src/tools/browser/api-map.ts +9 -6
- package/src/tools/calls/call-start.ts +20 -0
- package/src/tools/executor.ts +50 -568
- package/src/tools/permission-checker.ts +272 -0
- package/src/tools/registry.ts +14 -6
- package/src/tools/reminder/reminder-store.ts +7 -7
- package/src/tools/reminder/reminder.ts +6 -3
- package/src/tools/secret-detection-handler.ts +301 -0
- package/src/tools/subagent/message.ts +1 -1
- package/src/tools/system/voice-config.ts +62 -0
- package/src/tools/tasks/index.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +3 -3
- package/src/tools/tasks/work-item-update.ts +4 -5
- package/src/tools/tool-approval-handler.ts +192 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/watcher/watcher-store.ts +9 -9
- package/src/work-items/work-item-runner.ts +9 -6
- /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
- /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
|
@@ -0,0 +1,376 @@
|
|
|
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-followup-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
|
+
import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
|
|
29
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
30
|
+
import {
|
|
31
|
+
createGuardianActionRequest,
|
|
32
|
+
expireGuardianActionRequest,
|
|
33
|
+
finalizeFollowup,
|
|
34
|
+
getGuardianActionRequest,
|
|
35
|
+
markTimedOutWithReason,
|
|
36
|
+
progressFollowupState,
|
|
37
|
+
resolveGuardianActionRequest,
|
|
38
|
+
startFollowupFromExpiredRequest,
|
|
39
|
+
} from '../memory/guardian-action-store.js';
|
|
40
|
+
import { conversations } from '../memory/schema.js';
|
|
41
|
+
|
|
42
|
+
initializeDb();
|
|
43
|
+
|
|
44
|
+
function ensureConversation(id: string): void {
|
|
45
|
+
const db = getDb();
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
db.insert(conversations).values({
|
|
48
|
+
id,
|
|
49
|
+
title: `Conversation ${id}`,
|
|
50
|
+
createdAt: now,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
}).run();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resetTables(): void {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
db.run('DELETE FROM guardian_action_deliveries');
|
|
58
|
+
db.run('DELETE FROM guardian_action_requests');
|
|
59
|
+
db.run('DELETE FROM call_pending_questions');
|
|
60
|
+
db.run('DELETE FROM call_events');
|
|
61
|
+
db.run('DELETE FROM call_sessions');
|
|
62
|
+
db.run('DELETE FROM messages');
|
|
63
|
+
db.run('DELETE FROM conversations');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createTestRequest(convId: string) {
|
|
67
|
+
ensureConversation(convId);
|
|
68
|
+
const session = createCallSession({
|
|
69
|
+
conversationId: convId,
|
|
70
|
+
provider: 'twilio',
|
|
71
|
+
fromNumber: '+15550001111',
|
|
72
|
+
toNumber: '+15550002222',
|
|
73
|
+
});
|
|
74
|
+
const pq = createPendingQuestion(session.id, 'What is the gate code?');
|
|
75
|
+
return createGuardianActionRequest({
|
|
76
|
+
kind: 'ask_guardian',
|
|
77
|
+
sourceChannel: 'voice',
|
|
78
|
+
sourceConversationId: convId,
|
|
79
|
+
callSessionId: session.id,
|
|
80
|
+
pendingQuestionId: pq.id,
|
|
81
|
+
questionText: pq.questionText,
|
|
82
|
+
expiresAt: Date.now() + 60_000,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('guardian-action-followup-store', () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
resetTables();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(() => {
|
|
92
|
+
resetDb();
|
|
93
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── markTimedOutWithReason ──────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
test('markTimedOutWithReason sets expired_reason correctly for call_timeout', () => {
|
|
99
|
+
const request = createTestRequest('conv-followup-1');
|
|
100
|
+
const result = markTimedOutWithReason(request.id, 'call_timeout');
|
|
101
|
+
|
|
102
|
+
expect(result).not.toBeNull();
|
|
103
|
+
expect(result!.status).toBe('expired');
|
|
104
|
+
expect(result!.expiredReason).toBe('call_timeout');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('markTimedOutWithReason sets expired_reason correctly for sweep_timeout', () => {
|
|
108
|
+
const request = createTestRequest('conv-followup-2');
|
|
109
|
+
const result = markTimedOutWithReason(request.id, 'sweep_timeout');
|
|
110
|
+
|
|
111
|
+
expect(result).not.toBeNull();
|
|
112
|
+
expect(result!.status).toBe('expired');
|
|
113
|
+
expect(result!.expiredReason).toBe('sweep_timeout');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('markTimedOutWithReason returns null for already-expired request', () => {
|
|
117
|
+
const request = createTestRequest('conv-followup-3');
|
|
118
|
+
|
|
119
|
+
// First call succeeds
|
|
120
|
+
const first = markTimedOutWithReason(request.id, 'call_timeout');
|
|
121
|
+
expect(first).not.toBeNull();
|
|
122
|
+
|
|
123
|
+
// Second call returns null (already expired)
|
|
124
|
+
const second = markTimedOutWithReason(request.id, 'sweep_timeout');
|
|
125
|
+
expect(second).toBeNull();
|
|
126
|
+
|
|
127
|
+
// Verify the original reason is preserved
|
|
128
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
129
|
+
expect(reloaded!.expiredReason).toBe('call_timeout');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('markTimedOutWithReason returns null for answered request', () => {
|
|
133
|
+
const request = createTestRequest('conv-followup-4');
|
|
134
|
+
resolveGuardianActionRequest(request.id, 'The code is 1234', 'telegram');
|
|
135
|
+
|
|
136
|
+
const result = markTimedOutWithReason(request.id, 'call_timeout');
|
|
137
|
+
expect(result).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── startFollowupFromExpiredRequest ─────────────────────────────────
|
|
141
|
+
|
|
142
|
+
test('startFollowupFromExpiredRequest transitions correctly', () => {
|
|
143
|
+
const request = createTestRequest('conv-followup-5');
|
|
144
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
145
|
+
|
|
146
|
+
const result = startFollowupFromExpiredRequest(request.id, 'The code is 5678');
|
|
147
|
+
expect(result).not.toBeNull();
|
|
148
|
+
expect(result!.followupState).toBe('awaiting_guardian_choice');
|
|
149
|
+
expect(result!.lateAnswerText).toBe('The code is 5678');
|
|
150
|
+
expect(result!.lateAnsweredAt).toBeGreaterThan(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('startFollowupFromExpiredRequest rejects pending request', () => {
|
|
154
|
+
const request = createTestRequest('conv-followup-6');
|
|
155
|
+
|
|
156
|
+
const result = startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
157
|
+
expect(result).toBeNull();
|
|
158
|
+
|
|
159
|
+
// Verify followup_state unchanged
|
|
160
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
161
|
+
expect(reloaded!.followupState).toBe('none');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('startFollowupFromExpiredRequest rejects answered request', () => {
|
|
165
|
+
const request = createTestRequest('conv-followup-7');
|
|
166
|
+
resolveGuardianActionRequest(request.id, 'Original answer', 'telegram');
|
|
167
|
+
|
|
168
|
+
const result = startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
169
|
+
expect(result).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('startFollowupFromExpiredRequest rejects already-in-followup request', () => {
|
|
173
|
+
const request = createTestRequest('conv-followup-8');
|
|
174
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
175
|
+
startFollowupFromExpiredRequest(request.id, 'First late answer');
|
|
176
|
+
|
|
177
|
+
// Second attempt should fail
|
|
178
|
+
const result = startFollowupFromExpiredRequest(request.id, 'Another late answer');
|
|
179
|
+
expect(result).toBeNull();
|
|
180
|
+
|
|
181
|
+
// Verify original late answer preserved
|
|
182
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
183
|
+
expect(reloaded!.lateAnswerText).toBe('First late answer');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── progressFollowupState ───────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
test('progressFollowupState valid transition: awaiting_guardian_choice -> dispatching', () => {
|
|
189
|
+
const request = createTestRequest('conv-followup-9');
|
|
190
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
191
|
+
startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
192
|
+
|
|
193
|
+
const result = progressFollowupState(request.id, 'dispatching', 'call_back');
|
|
194
|
+
expect(result).not.toBeNull();
|
|
195
|
+
expect(result!.followupState).toBe('dispatching');
|
|
196
|
+
expect(result!.followupAction).toBe('call_back');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('progressFollowupState rejects terminal transition: awaiting_guardian_choice -> declined', () => {
|
|
200
|
+
const request = createTestRequest('conv-followup-10');
|
|
201
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
202
|
+
startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
203
|
+
|
|
204
|
+
// Terminal transitions must go through finalizeFollowup, not progressFollowupState
|
|
205
|
+
const result = progressFollowupState(request.id, 'declined', 'decline');
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
|
|
208
|
+
// Verify state unchanged
|
|
209
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
210
|
+
expect(reloaded!.followupState).toBe('awaiting_guardian_choice');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('progressFollowupState rejects invalid transition: none -> dispatching', () => {
|
|
214
|
+
const request = createTestRequest('conv-followup-11');
|
|
215
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
216
|
+
|
|
217
|
+
// followup_state is 'none', cannot jump to 'dispatching'
|
|
218
|
+
const result = progressFollowupState(request.id, 'dispatching');
|
|
219
|
+
expect(result).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('progressFollowupState rejects invalid transition: dispatching -> awaiting_guardian_choice', () => {
|
|
223
|
+
const request = createTestRequest('conv-followup-12');
|
|
224
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
225
|
+
startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
226
|
+
progressFollowupState(request.id, 'dispatching', 'call_back');
|
|
227
|
+
|
|
228
|
+
// Cannot go back to awaiting_guardian_choice
|
|
229
|
+
const result = progressFollowupState(request.id, 'awaiting_guardian_choice');
|
|
230
|
+
expect(result).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('progressFollowupState rejects transition from terminal state', () => {
|
|
234
|
+
const request = createTestRequest('conv-followup-13');
|
|
235
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
236
|
+
startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
237
|
+
progressFollowupState(request.id, 'dispatching', 'call_back');
|
|
238
|
+
finalizeFollowup(request.id, 'completed');
|
|
239
|
+
|
|
240
|
+
// completed is terminal — progressFollowupState cannot leave it
|
|
241
|
+
const result = progressFollowupState(request.id, 'dispatching');
|
|
242
|
+
expect(result).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('progressFollowupState rejects none -> awaiting_guardian_choice even on expired request', () => {
|
|
246
|
+
const request = createTestRequest('conv-followup-13b');
|
|
247
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
248
|
+
|
|
249
|
+
// none -> awaiting_guardian_choice must only go through startFollowupFromExpiredRequest
|
|
250
|
+
// (which atomically sets lateAnswerText and lateAnsweredAt)
|
|
251
|
+
const result = progressFollowupState(request.id, 'awaiting_guardian_choice');
|
|
252
|
+
expect(result).toBeNull();
|
|
253
|
+
|
|
254
|
+
// Verify followup_state unchanged
|
|
255
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
256
|
+
expect(reloaded!.followupState).toBe('none');
|
|
257
|
+
expect(reloaded!.status).toBe('expired');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('progressFollowupState rejects non-expired request', () => {
|
|
261
|
+
const request = createTestRequest('conv-followup-13c');
|
|
262
|
+
|
|
263
|
+
// Request is still 'pending', not 'expired' — follow-up transitions must not apply
|
|
264
|
+
const result = progressFollowupState(request.id, 'awaiting_guardian_choice');
|
|
265
|
+
expect(result).toBeNull();
|
|
266
|
+
|
|
267
|
+
// Verify followup_state unchanged
|
|
268
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
269
|
+
expect(reloaded!.followupState).toBe('none');
|
|
270
|
+
expect(reloaded!.status).toBe('pending');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ── finalizeFollowup ────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
test('finalizeFollowup sets followup_completed_at for completed', () => {
|
|
276
|
+
const request = createTestRequest('conv-followup-14');
|
|
277
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
278
|
+
startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
279
|
+
progressFollowupState(request.id, 'dispatching', 'call_back');
|
|
280
|
+
|
|
281
|
+
const result = finalizeFollowup(request.id, 'completed');
|
|
282
|
+
expect(result).not.toBeNull();
|
|
283
|
+
expect(result!.followupState).toBe('completed');
|
|
284
|
+
expect(result!.followupCompletedAt).toBeGreaterThan(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('finalizeFollowup sets followup_completed_at for failed', () => {
|
|
288
|
+
const request = createTestRequest('conv-followup-15');
|
|
289
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
290
|
+
startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
291
|
+
progressFollowupState(request.id, 'dispatching', 'message_back');
|
|
292
|
+
|
|
293
|
+
const result = finalizeFollowup(request.id, 'failed');
|
|
294
|
+
expect(result).not.toBeNull();
|
|
295
|
+
expect(result!.followupState).toBe('failed');
|
|
296
|
+
expect(result!.followupCompletedAt).toBeGreaterThan(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('finalizeFollowup with declined from awaiting_guardian_choice', () => {
|
|
300
|
+
const request = createTestRequest('conv-followup-16');
|
|
301
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
302
|
+
startFollowupFromExpiredRequest(request.id, 'Late answer');
|
|
303
|
+
|
|
304
|
+
const result = finalizeFollowup(request.id, 'declined');
|
|
305
|
+
expect(result).not.toBeNull();
|
|
306
|
+
expect(result!.followupState).toBe('declined');
|
|
307
|
+
expect(result!.followupCompletedAt).toBeGreaterThan(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('finalizeFollowup rejects invalid transition from none', () => {
|
|
311
|
+
const request = createTestRequest('conv-followup-17');
|
|
312
|
+
markTimedOutWithReason(request.id, 'call_timeout');
|
|
313
|
+
|
|
314
|
+
// followup_state is 'none', cannot finalize
|
|
315
|
+
const result = finalizeFollowup(request.id, 'completed');
|
|
316
|
+
expect(result).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('finalizeFollowup rejects non-expired request', () => {
|
|
320
|
+
const request = createTestRequest('conv-followup-17b');
|
|
321
|
+
|
|
322
|
+
// Request is still 'pending', not 'expired' — finalize must not apply
|
|
323
|
+
const result = finalizeFollowup(request.id, 'completed');
|
|
324
|
+
expect(result).toBeNull();
|
|
325
|
+
|
|
326
|
+
// Verify followup_state unchanged
|
|
327
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
328
|
+
expect(reloaded!.followupState).toBe('none');
|
|
329
|
+
expect(reloaded!.status).toBe('pending');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ── Existing behavior preserved ─────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
test('resolve/expire behavior unchanged: resolveGuardianActionRequest still works', () => {
|
|
335
|
+
const request = createTestRequest('conv-followup-18');
|
|
336
|
+
|
|
337
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Answer here', 'telegram', 'user-1');
|
|
338
|
+
expect(resolved).not.toBeNull();
|
|
339
|
+
expect(resolved!.status).toBe('answered');
|
|
340
|
+
expect(resolved!.answerText).toBe('Answer here');
|
|
341
|
+
// Follow-up fields remain at defaults
|
|
342
|
+
expect(resolved!.followupState).toBe('none');
|
|
343
|
+
expect(resolved!.expiredReason).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('expireGuardianActionRequest defaults to sweep_timeout reason', () => {
|
|
347
|
+
const request = createTestRequest('conv-followup-19');
|
|
348
|
+
|
|
349
|
+
expireGuardianActionRequest(request.id);
|
|
350
|
+
|
|
351
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
352
|
+
expect(reloaded!.status).toBe('expired');
|
|
353
|
+
expect(reloaded!.expiredReason).toBe('sweep_timeout');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('expireGuardianActionRequest accepts explicit reason', () => {
|
|
357
|
+
const request = createTestRequest('conv-followup-20');
|
|
358
|
+
|
|
359
|
+
expireGuardianActionRequest(request.id, 'call_timeout');
|
|
360
|
+
|
|
361
|
+
const reloaded = getGuardianActionRequest(request.id);
|
|
362
|
+
expect(reloaded!.status).toBe('expired');
|
|
363
|
+
expect(reloaded!.expiredReason).toBe('call_timeout');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('new fields default correctly on freshly created request', () => {
|
|
367
|
+
const request = createTestRequest('conv-followup-21');
|
|
368
|
+
|
|
369
|
+
expect(request.expiredReason).toBeNull();
|
|
370
|
+
expect(request.followupState).toBe('none');
|
|
371
|
+
expect(request.lateAnswerText).toBeNull();
|
|
372
|
+
expect(request.lateAnsweredAt).toBeNull();
|
|
373
|
+
expect(request.followupAction).toBeNull();
|
|
374
|
+
expect(request.followupCompletedAt).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
getExpiredDeliveriesByDestination,
|
|
39
|
+
getExpiredDeliveryByConversation,
|
|
40
|
+
getGuardianActionRequest,
|
|
41
|
+
resolveGuardianActionRequest,
|
|
42
|
+
startFollowupFromExpiredRequest,
|
|
43
|
+
updateDeliveryStatus,
|
|
44
|
+
} from '../memory/guardian-action-store.js';
|
|
45
|
+
import { conversations } from '../memory/schema.js';
|
|
46
|
+
|
|
47
|
+
initializeDb();
|
|
48
|
+
|
|
49
|
+
function ensureConversation(id: string): void {
|
|
50
|
+
const db = getDb();
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
db.insert(conversations).values({
|
|
53
|
+
id,
|
|
54
|
+
title: `Conversation ${id}`,
|
|
55
|
+
createdAt: now,
|
|
56
|
+
updatedAt: now,
|
|
57
|
+
}).run();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resetTables(): void {
|
|
61
|
+
const db = getDb();
|
|
62
|
+
db.run('DELETE FROM guardian_action_deliveries');
|
|
63
|
+
db.run('DELETE FROM guardian_action_requests');
|
|
64
|
+
db.run('DELETE FROM call_pending_questions');
|
|
65
|
+
db.run('DELETE FROM call_events');
|
|
66
|
+
db.run('DELETE FROM call_sessions');
|
|
67
|
+
db.run('DELETE FROM messages');
|
|
68
|
+
db.run('DELETE FROM conversations');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createExpiredRequest(convId: string, opts?: { chatId?: string; externalUserId?: string; conversationId?: string }) {
|
|
72
|
+
ensureConversation(convId);
|
|
73
|
+
const session = createCallSession({
|
|
74
|
+
conversationId: convId,
|
|
75
|
+
provider: 'twilio',
|
|
76
|
+
fromNumber: '+15550001111',
|
|
77
|
+
toNumber: '+15550002222',
|
|
78
|
+
});
|
|
79
|
+
const pq = createPendingQuestion(session.id, 'What is the gate code?');
|
|
80
|
+
const request = createGuardianActionRequest({
|
|
81
|
+
kind: 'ask_guardian',
|
|
82
|
+
sourceChannel: 'voice',
|
|
83
|
+
sourceConversationId: convId,
|
|
84
|
+
callSessionId: session.id,
|
|
85
|
+
pendingQuestionId: pq.id,
|
|
86
|
+
questionText: pq.questionText,
|
|
87
|
+
expiresAt: Date.now() - 10_000, // already expired
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Create delivery
|
|
91
|
+
const deliveryConvId = opts?.conversationId ?? `delivery-conv-${request.id}`;
|
|
92
|
+
if (opts?.conversationId) {
|
|
93
|
+
ensureConversation(opts.conversationId);
|
|
94
|
+
} else {
|
|
95
|
+
ensureConversation(deliveryConvId);
|
|
96
|
+
}
|
|
97
|
+
const delivery = createGuardianActionDelivery({
|
|
98
|
+
requestId: request.id,
|
|
99
|
+
destinationChannel: 'telegram',
|
|
100
|
+
destinationChatId: opts?.chatId ?? 'chat-123',
|
|
101
|
+
destinationExternalUserId: opts?.externalUserId ?? 'user-456',
|
|
102
|
+
destinationConversationId: deliveryConvId,
|
|
103
|
+
});
|
|
104
|
+
updateDeliveryStatus(delivery.id, 'sent');
|
|
105
|
+
|
|
106
|
+
// Expire the request and delivery
|
|
107
|
+
expireGuardianActionRequest(request.id, 'sweep_timeout');
|
|
108
|
+
|
|
109
|
+
return { request: getGuardianActionRequest(request.id)!, delivery, deliveryConvId };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe('guardian-action-late-reply', () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
resetTables();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterAll(() => {
|
|
118
|
+
resetDb();
|
|
119
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── getExpiredDeliveriesByDestination ──────────────────────────────
|
|
123
|
+
|
|
124
|
+
test('getExpiredDeliveriesByDestination returns expired deliveries for follow-up eligible requests', () => {
|
|
125
|
+
const { request } = createExpiredRequest('conv-late-1', { chatId: 'chat-abc', externalUserId: 'user-xyz' });
|
|
126
|
+
|
|
127
|
+
const deliveries = getExpiredDeliveriesByDestination('self', 'telegram', 'chat-abc');
|
|
128
|
+
expect(deliveries).toHaveLength(1);
|
|
129
|
+
expect(deliveries[0].requestId).toBe(request.id);
|
|
130
|
+
expect(deliveries[0].status).toBe('expired');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('getExpiredDeliveriesByDestination returns empty for non-matching channel', () => {
|
|
134
|
+
createExpiredRequest('conv-late-2', { chatId: 'chat-abc' });
|
|
135
|
+
|
|
136
|
+
const deliveries = getExpiredDeliveriesByDestination('self', 'sms', 'chat-abc');
|
|
137
|
+
expect(deliveries).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('getExpiredDeliveriesByDestination returns empty when followup already started', () => {
|
|
141
|
+
const { request } = createExpiredRequest('conv-late-3', { chatId: 'chat-started' });
|
|
142
|
+
|
|
143
|
+
// Start a follow-up, transitioning followup_state from 'none' to 'awaiting_guardian_choice'
|
|
144
|
+
startFollowupFromExpiredRequest(request.id, 'late answer text');
|
|
145
|
+
|
|
146
|
+
const deliveries = getExpiredDeliveriesByDestination('self', 'telegram', 'chat-started');
|
|
147
|
+
expect(deliveries).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── getExpiredDeliveryByConversation ───────────────────────────────
|
|
151
|
+
|
|
152
|
+
test('getExpiredDeliveryByConversation returns expired delivery for mac channel', () => {
|
|
153
|
+
const { delivery, deliveryConvId } = createExpiredRequest('conv-late-4', { conversationId: 'mac-conv-1' });
|
|
154
|
+
|
|
155
|
+
const found = getExpiredDeliveryByConversation(deliveryConvId);
|
|
156
|
+
expect(found).not.toBeNull();
|
|
157
|
+
expect(found!.id).toBe(delivery.id);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('getExpiredDeliveryByConversation returns null for non-matching conversation', () => {
|
|
161
|
+
createExpiredRequest('conv-late-5', { conversationId: 'mac-conv-2' });
|
|
162
|
+
|
|
163
|
+
const found = getExpiredDeliveryByConversation('nonexistent-conv');
|
|
164
|
+
expect(found).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('getExpiredDeliveryByConversation returns null when followup already started', () => {
|
|
168
|
+
const { request, deliveryConvId } = createExpiredRequest('conv-late-6', { conversationId: 'mac-conv-3' });
|
|
169
|
+
|
|
170
|
+
startFollowupFromExpiredRequest(request.id, 'already answered');
|
|
171
|
+
|
|
172
|
+
const found = getExpiredDeliveryByConversation(deliveryConvId);
|
|
173
|
+
expect(found).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── startFollowupFromExpiredRequest ───────────────────────────────
|
|
177
|
+
|
|
178
|
+
test('startFollowupFromExpiredRequest transitions to awaiting_guardian_choice and records late answer', () => {
|
|
179
|
+
const { request } = createExpiredRequest('conv-late-7');
|
|
180
|
+
|
|
181
|
+
const updated = startFollowupFromExpiredRequest(request.id, 'The gate code is 1234');
|
|
182
|
+
expect(updated).not.toBeNull();
|
|
183
|
+
expect(updated!.followupState).toBe('awaiting_guardian_choice');
|
|
184
|
+
expect(updated!.lateAnswerText).toBe('The gate code is 1234');
|
|
185
|
+
expect(updated!.lateAnsweredAt).toBeGreaterThan(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('startFollowupFromExpiredRequest returns null if followup already started', () => {
|
|
189
|
+
const { request } = createExpiredRequest('conv-late-8');
|
|
190
|
+
|
|
191
|
+
// First call succeeds
|
|
192
|
+
const first = startFollowupFromExpiredRequest(request.id, 'answer 1');
|
|
193
|
+
expect(first).not.toBeNull();
|
|
194
|
+
|
|
195
|
+
// Second call fails — already in awaiting_guardian_choice
|
|
196
|
+
const second = startFollowupFromExpiredRequest(request.id, 'answer 2');
|
|
197
|
+
expect(second).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('startFollowupFromExpiredRequest returns null for pending requests (not expired)', () => {
|
|
201
|
+
const convId = 'conv-late-9';
|
|
202
|
+
ensureConversation(convId);
|
|
203
|
+
const session = createCallSession({
|
|
204
|
+
conversationId: convId,
|
|
205
|
+
provider: 'twilio',
|
|
206
|
+
fromNumber: '+15550001111',
|
|
207
|
+
toNumber: '+15550002222',
|
|
208
|
+
});
|
|
209
|
+
const pq = createPendingQuestion(session.id, 'Still pending question');
|
|
210
|
+
const request = createGuardianActionRequest({
|
|
211
|
+
kind: 'ask_guardian',
|
|
212
|
+
sourceChannel: 'voice',
|
|
213
|
+
sourceConversationId: convId,
|
|
214
|
+
callSessionId: session.id,
|
|
215
|
+
pendingQuestionId: pq.id,
|
|
216
|
+
questionText: pq.questionText,
|
|
217
|
+
expiresAt: Date.now() + 60_000, // not expired
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const result = startFollowupFromExpiredRequest(request.id, 'late answer');
|
|
221
|
+
expect(result).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── Follow-up flow for already-answered requests ──────────────────
|
|
225
|
+
|
|
226
|
+
test('already-answered requests do not appear in expired delivery queries', () => {
|
|
227
|
+
const convId = 'conv-late-10';
|
|
228
|
+
ensureConversation(convId);
|
|
229
|
+
const session = createCallSession({
|
|
230
|
+
conversationId: convId,
|
|
231
|
+
provider: 'twilio',
|
|
232
|
+
fromNumber: '+15550001111',
|
|
233
|
+
toNumber: '+15550002222',
|
|
234
|
+
});
|
|
235
|
+
const pq = createPendingQuestion(session.id, 'Already answered question');
|
|
236
|
+
const request = createGuardianActionRequest({
|
|
237
|
+
kind: 'ask_guardian',
|
|
238
|
+
sourceChannel: 'voice',
|
|
239
|
+
sourceConversationId: convId,
|
|
240
|
+
callSessionId: session.id,
|
|
241
|
+
pendingQuestionId: pq.id,
|
|
242
|
+
questionText: pq.questionText,
|
|
243
|
+
expiresAt: Date.now() + 60_000,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const answeredConvId = 'answered-conv-1';
|
|
247
|
+
ensureConversation(answeredConvId);
|
|
248
|
+
const delivery = createGuardianActionDelivery({
|
|
249
|
+
requestId: request.id,
|
|
250
|
+
destinationChannel: 'telegram',
|
|
251
|
+
destinationChatId: 'chat-answered',
|
|
252
|
+
destinationExternalUserId: 'user-answered',
|
|
253
|
+
destinationConversationId: answeredConvId,
|
|
254
|
+
});
|
|
255
|
+
updateDeliveryStatus(delivery.id, 'sent');
|
|
256
|
+
|
|
257
|
+
// Answer the request (transitions to 'answered', not 'expired')
|
|
258
|
+
resolveGuardianActionRequest(request.id, 'the code is 5678', 'telegram', 'user-answered');
|
|
259
|
+
|
|
260
|
+
// Should not appear in expired queries
|
|
261
|
+
const expiredByDest = getExpiredDeliveriesByDestination('self', 'telegram', 'chat-answered');
|
|
262
|
+
expect(expiredByDest).toHaveLength(0);
|
|
263
|
+
|
|
264
|
+
const expiredByConv = getExpiredDeliveryByConversation(answeredConvId);
|
|
265
|
+
expect(expiredByConv).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── Composed follow-up text verification ──────────────────────────
|
|
269
|
+
|
|
270
|
+
test('composeGuardianActionMessageGenerative produces follow-up text for late answer scenario', async () => {
|
|
271
|
+
// The composer is tested directly rather than through the handler
|
|
272
|
+
const { composeGuardianActionMessageGenerative } = await import('../runtime/guardian-action-message-composer.js');
|
|
273
|
+
|
|
274
|
+
const text = await composeGuardianActionMessageGenerative({
|
|
275
|
+
scenario: 'guardian_late_answer_followup',
|
|
276
|
+
questionText: 'What is the gate code?',
|
|
277
|
+
lateAnswerText: 'The gate code is 1234',
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// In test mode, the deterministic fallback is used
|
|
281
|
+
expect(text).toContain('called earlier');
|
|
282
|
+
expect(text).toContain('call them back');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('composeGuardianActionMessageGenerative produces stale text for expired scenario', async () => {
|
|
286
|
+
const { composeGuardianActionMessageGenerative } = await import('../runtime/guardian-action-message-composer.js');
|
|
287
|
+
|
|
288
|
+
const text = await composeGuardianActionMessageGenerative({
|
|
289
|
+
scenario: 'guardian_stale_expired',
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(text).toContain('expired');
|
|
293
|
+
});
|
|
294
|
+
});
|