@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.
Files changed (290) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +1 -1
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +331 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  40. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  41. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  42. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  43. package/src/__tests__/hooks-runner.test.ts +13 -4
  44. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  45. package/src/__tests__/intent-routing.test.ts +14 -0
  46. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  47. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  48. package/src/__tests__/memory-regressions.test.ts +16 -12
  49. package/src/__tests__/non-member-access-request.test.ts +282 -0
  50. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  51. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  52. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  53. package/src/__tests__/recording-intent.test.ts +1 -0
  54. package/src/__tests__/recording-state-machine.test.ts +328 -17
  55. package/src/__tests__/registry.test.ts +17 -8
  56. package/src/__tests__/relay-server.test.ts +105 -0
  57. package/src/__tests__/reminder.test.ts +13 -0
  58. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  59. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  60. package/src/__tests__/server-history-render.test.ts +8 -8
  61. package/src/__tests__/session-agent-loop.test.ts +1 -0
  62. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  63. package/src/__tests__/session-skill-tools.test.ts +1 -0
  64. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  65. package/src/__tests__/slack-channel-config.test.ts +230 -0
  66. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  67. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  68. package/src/__tests__/system-prompt.test.ts +43 -0
  69. package/src/__tests__/task-management-tools.test.ts +3 -3
  70. package/src/__tests__/task-tools.test.ts +3 -3
  71. package/src/__tests__/trust-store.test.ts +17 -1
  72. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  73. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  74. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  75. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  76. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  77. package/src/__tests__/update-bulletin.test.ts +260 -0
  78. package/src/__tests__/update-template-contract.test.ts +29 -0
  79. package/src/agent/loop.ts +2 -2
  80. package/src/amazon/client.ts +2 -3
  81. package/src/calls/call-controller.ts +115 -34
  82. package/src/calls/call-conversation-messages.ts +2 -2
  83. package/src/calls/call-domain.ts +10 -3
  84. package/src/calls/call-pointer-messages.ts +17 -5
  85. package/src/calls/guardian-action-sweep.ts +77 -36
  86. package/src/calls/relay-server.ts +51 -12
  87. package/src/calls/twilio-routes.ts +3 -1
  88. package/src/calls/types.ts +1 -1
  89. package/src/calls/voice-session-bridge.ts +4 -4
  90. package/src/cli/core-commands.ts +3 -3
  91. package/src/cli/map.ts +8 -5
  92. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  93. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  94. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  95. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  96. package/src/config/computer-use-prompt.ts +1 -0
  97. package/src/config/core-schema.ts +16 -0
  98. package/src/config/env-registry.ts +1 -0
  99. package/src/config/env.ts +16 -1
  100. package/src/config/memory-schema.ts +5 -0
  101. package/src/config/schema.ts +4 -0
  102. package/src/config/system-prompt.ts +69 -2
  103. package/src/config/templates/BOOTSTRAP.md +1 -1
  104. package/src/config/templates/IDENTITY.md +8 -4
  105. package/src/config/templates/SOUL.md +14 -0
  106. package/src/config/templates/UPDATES.md +16 -0
  107. package/src/config/templates/USER.md +5 -1
  108. package/src/config/types.ts +1 -0
  109. package/src/config/update-bulletin-format.ts +52 -0
  110. package/src/config/update-bulletin-state.ts +49 -0
  111. package/src/config/update-bulletin.ts +82 -0
  112. package/src/config/vellum-skills/catalog.json +6 -0
  113. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  114. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  115. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  116. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  117. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  118. package/src/context/window-manager.ts +43 -3
  119. package/src/daemon/config-watcher.ts +1 -0
  120. package/src/daemon/connection-policy.ts +21 -1
  121. package/src/daemon/daemon-control.ts +164 -7
  122. package/src/daemon/date-context.ts +174 -1
  123. package/src/daemon/guardian-action-generators.ts +175 -0
  124. package/src/daemon/guardian-verification-intent.ts +120 -0
  125. package/src/daemon/handlers/apps.ts +1 -3
  126. package/src/daemon/handlers/config-channels.ts +2 -2
  127. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  128. package/src/daemon/handlers/config-inbox.ts +55 -159
  129. package/src/daemon/handlers/config-ingress.ts +1 -1
  130. package/src/daemon/handlers/config-integrations.ts +1 -1
  131. package/src/daemon/handlers/config-platform.ts +1 -1
  132. package/src/daemon/handlers/config-scheduling.ts +2 -2
  133. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  134. package/src/daemon/handlers/config-telegram.ts +1 -1
  135. package/src/daemon/handlers/config-twilio.ts +1 -1
  136. package/src/daemon/handlers/config-voice.ts +100 -0
  137. package/src/daemon/handlers/config.ts +3 -0
  138. package/src/daemon/handlers/misc.ts +83 -5
  139. package/src/daemon/handlers/navigate-settings.ts +27 -0
  140. package/src/daemon/handlers/recording.ts +270 -144
  141. package/src/daemon/handlers/sessions.ts +100 -17
  142. package/src/daemon/handlers/subagents.ts +3 -3
  143. package/src/daemon/handlers/work-items.ts +10 -7
  144. package/src/daemon/ipc-contract/integrations.ts +9 -1
  145. package/src/daemon/ipc-contract/messages.ts +4 -0
  146. package/src/daemon/ipc-contract/sessions.ts +1 -1
  147. package/src/daemon/ipc-contract/settings.ts +26 -0
  148. package/src/daemon/ipc-contract/shared.ts +2 -0
  149. package/src/daemon/ipc-contract/work-items.ts +1 -7
  150. package/src/daemon/ipc-contract-inventory.json +5 -1
  151. package/src/daemon/ipc-contract.ts +5 -1
  152. package/src/daemon/lifecycle.ts +306 -266
  153. package/src/daemon/recording-intent.ts +0 -41
  154. package/src/daemon/response-tier.ts +2 -2
  155. package/src/daemon/server.ts +6 -6
  156. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  157. package/src/daemon/session-agent-loop.ts +15 -8
  158. package/src/daemon/session-history.ts +3 -2
  159. package/src/daemon/session-media-retry.ts +3 -0
  160. package/src/daemon/session-messaging.ts +38 -4
  161. package/src/daemon/session-notifiers.ts +2 -2
  162. package/src/daemon/session-process.ts +256 -23
  163. package/src/daemon/session-queue-manager.ts +2 -0
  164. package/src/daemon/session-runtime-assembly.ts +39 -0
  165. package/src/daemon/session-skill-tools.ts +13 -4
  166. package/src/daemon/session-tool-setup.ts +5 -6
  167. package/src/daemon/session.ts +19 -8
  168. package/src/daemon/tls-certs.ts +55 -13
  169. package/src/daemon/tool-side-effects.ts +13 -5
  170. package/src/gallery/default-gallery.ts +32 -9
  171. package/src/influencer/client.ts +2 -1
  172. package/src/memory/channel-delivery-store.ts +37 -567
  173. package/src/memory/channel-guardian-store.ts +66 -1317
  174. package/src/memory/conflict-store.ts +4 -4
  175. package/src/memory/conversation-attention-store.ts +0 -3
  176. package/src/memory/conversation-crud.ts +668 -0
  177. package/src/memory/conversation-queries.ts +361 -0
  178. package/src/memory/conversation-store.ts +45 -983
  179. package/src/memory/db-connection.ts +3 -0
  180. package/src/memory/db-init.ts +25 -0
  181. package/src/memory/delivery-channels.ts +175 -0
  182. package/src/memory/delivery-crud.ts +211 -0
  183. package/src/memory/delivery-status.ts +199 -0
  184. package/src/memory/embedding-backend.ts +70 -4
  185. package/src/memory/embedding-local.ts +12 -2
  186. package/src/memory/entity-extractor.ts +3 -8
  187. package/src/memory/fts-reconciler.ts +121 -0
  188. package/src/memory/guardian-action-store.ts +366 -3
  189. package/src/memory/guardian-approvals.ts +569 -0
  190. package/src/memory/guardian-bindings.ts +130 -0
  191. package/src/memory/guardian-rate-limits.ts +196 -0
  192. package/src/memory/guardian-verification.ts +520 -0
  193. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  194. package/src/memory/job-utils.ts +8 -5
  195. package/src/memory/jobs-store.ts +66 -6
  196. package/src/memory/jobs-worker.ts +23 -1
  197. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  198. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  199. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  200. package/src/memory/migrations/100-core-tables.ts +1 -1
  201. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  202. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  203. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  204. package/src/memory/migrations/113-late-migrations.ts +1 -1
  205. package/src/memory/migrations/116-messages-fts.ts +13 -0
  206. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  207. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  208. package/src/memory/migrations/index.ts +8 -3
  209. package/src/memory/migrations/validate-migration-state.ts +114 -15
  210. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  211. package/src/memory/retriever.ts +46 -13
  212. package/src/memory/schema-migration.ts +3 -0
  213. package/src/memory/schema.ts +25 -7
  214. package/src/memory/search/semantic.ts +8 -90
  215. package/src/notifications/README.md +1 -1
  216. package/src/notifications/broadcaster.ts +20 -2
  217. package/src/notifications/conversation-pairing.ts +3 -3
  218. package/src/notifications/decision-engine.ts +173 -8
  219. package/src/notifications/deliveries-store.ts +27 -8
  220. package/src/notifications/preferences-store.ts +7 -7
  221. package/src/notifications/thread-candidates.ts +234 -0
  222. package/src/notifications/types.ts +18 -0
  223. package/src/permissions/defaults.ts +11 -1
  224. package/src/permissions/prompter.ts +17 -0
  225. package/src/permissions/trust-store.ts +2 -0
  226. package/src/providers/failover.ts +19 -0
  227. package/src/providers/registry.ts +46 -1
  228. package/src/runtime/approval-message-composer.ts +1 -1
  229. package/src/runtime/channel-guardian-service.ts +15 -3
  230. package/src/runtime/channel-retry-sweep.ts +7 -2
  231. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  232. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  233. package/src/runtime/guardian-action-message-composer.ts +245 -0
  234. package/src/runtime/guardian-outbound-actions.ts +26 -6
  235. package/src/runtime/guardian-verification-templates.ts +15 -9
  236. package/src/runtime/http-errors.ts +93 -0
  237. package/src/runtime/http-server.ts +133 -44
  238. package/src/runtime/http-types.ts +53 -0
  239. package/src/runtime/ingress-service.ts +237 -0
  240. package/src/runtime/middleware/error-handler.ts +4 -3
  241. package/src/runtime/middleware/rate-limiter.ts +160 -0
  242. package/src/runtime/middleware/request-logger.ts +71 -0
  243. package/src/runtime/middleware/twilio-validation.ts +7 -6
  244. package/src/runtime/pending-interactions.ts +12 -0
  245. package/src/runtime/routes/access-request-decision.ts +215 -0
  246. package/src/runtime/routes/app-routes.ts +25 -18
  247. package/src/runtime/routes/approval-routes.ts +18 -47
  248. package/src/runtime/routes/attachment-routes.ts +15 -41
  249. package/src/runtime/routes/call-routes.ts +20 -20
  250. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  251. package/src/runtime/routes/contact-routes.ts +4 -9
  252. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  253. package/src/runtime/routes/conversation-routes.ts +26 -57
  254. package/src/runtime/routes/debug-routes.ts +71 -0
  255. package/src/runtime/routes/events-routes.ts +3 -2
  256. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  257. package/src/runtime/routes/identity-routes.ts +14 -10
  258. package/src/runtime/routes/inbound-conversation.ts +3 -2
  259. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  260. package/src/runtime/routes/ingress-routes.ts +174 -0
  261. package/src/runtime/routes/integration-routes.ts +78 -16
  262. package/src/runtime/routes/pairing-routes.ts +11 -10
  263. package/src/runtime/routes/secret-routes.ts +10 -18
  264. package/src/runtime/verification-rate-limiter.ts +83 -0
  265. package/src/schedule/schedule-store.ts +13 -1
  266. package/src/schedule/scheduler.ts +1 -1
  267. package/src/security/secret-ingress.ts +5 -2
  268. package/src/security/secret-scanner.ts +72 -6
  269. package/src/subagent/manager.ts +6 -4
  270. package/src/swarm/plan-validator.ts +4 -1
  271. package/src/tasks/task-runner.ts +3 -1
  272. package/src/tools/browser/api-map.ts +9 -6
  273. package/src/tools/calls/call-start.ts +20 -0
  274. package/src/tools/executor.ts +50 -568
  275. package/src/tools/permission-checker.ts +272 -0
  276. package/src/tools/registry.ts +14 -6
  277. package/src/tools/reminder/reminder-store.ts +7 -7
  278. package/src/tools/reminder/reminder.ts +6 -3
  279. package/src/tools/secret-detection-handler.ts +301 -0
  280. package/src/tools/subagent/message.ts +1 -1
  281. package/src/tools/system/voice-config.ts +62 -0
  282. package/src/tools/tasks/index.ts +3 -3
  283. package/src/tools/tasks/work-item-list.ts +3 -3
  284. package/src/tools/tasks/work-item-update.ts +4 -5
  285. package/src/tools/tool-approval-handler.ts +192 -0
  286. package/src/tools/tool-manifest.ts +2 -0
  287. package/src/watcher/watcher-store.ts +9 -9
  288. package/src/work-items/work-item-runner.ts +9 -6
  289. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  290. /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
+ });