@vellumai/assistant 0.7.3 → 0.8.0

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 (169) hide show
  1. package/ARCHITECTURE.md +29 -28
  2. package/Dockerfile +1 -0
  3. package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
  4. package/bun.lock +3 -0
  5. package/knip.json +1 -0
  6. package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
  7. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  8. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
  9. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
  11. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  12. package/openapi.yaml +22 -4
  13. package/package.json +3 -1
  14. package/src/__tests__/annotate-risk-options.test.ts +291 -0
  15. package/src/__tests__/approval-cascade.test.ts +8 -16
  16. package/src/__tests__/approval-routes-http.test.ts +6 -0
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +12 -25
  18. package/src/__tests__/call-constants.test.ts +10 -1
  19. package/src/__tests__/call-controller.test.ts +127 -0
  20. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
  21. package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
  22. package/src/__tests__/context-search-memory-source.test.ts +3 -26
  23. package/src/__tests__/context-search-pkb-source.test.ts +12 -6
  24. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -6
  25. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  26. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  27. package/src/__tests__/conversation-agent-loop.test.ts +3 -3
  28. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
  29. package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
  30. package/src/__tests__/conversation-process-callsite.test.ts +1 -6
  31. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -6
  32. package/src/__tests__/conversation-runtime-assembly.test.ts +15 -6
  33. package/src/__tests__/conversation-slash-unknown.test.ts +1 -6
  34. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +73 -1
  36. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +59 -0
  37. package/src/__tests__/conversation-workspace-injection.test.ts +1 -7
  38. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -7
  39. package/src/__tests__/filing-service.test.ts +2 -19
  40. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
  41. package/src/__tests__/injector-chain.test.ts +24 -16
  42. package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
  43. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
  44. package/src/__tests__/notification-decision-fallback.test.ts +91 -0
  45. package/src/__tests__/notification-decision-strategy.test.ts +22 -0
  46. package/src/__tests__/oauth-cli.test.ts +121 -0
  47. package/src/__tests__/relay-server.test.ts +46 -2
  48. package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
  49. package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
  50. package/src/__tests__/secret-response-routing.test.ts +7 -5
  51. package/src/__tests__/server-history-render.test.ts +82 -0
  52. package/src/__tests__/skill-include-graph.test.ts +31 -0
  53. package/src/__tests__/skill-load-tool.test.ts +44 -16
  54. package/src/__tests__/skills.test.ts +39 -0
  55. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
  56. package/src/__tests__/tool-executor.test.ts +155 -0
  57. package/src/__tests__/voice-session-bridge.test.ts +3 -0
  58. package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
  59. package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
  60. package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +15 -27
  61. package/src/agent/loop.ts +11 -0
  62. package/src/approvals/guardian-decision-primitive.ts +0 -13
  63. package/src/approvals/guardian-request-resolvers.ts +4 -32
  64. package/src/calls/call-constants.ts +5 -8
  65. package/src/calls/call-controller.ts +130 -67
  66. package/src/calls/relay-server.ts +7 -1
  67. package/src/calls/voice-session-bridge.ts +1 -1
  68. package/src/cli/commands/memory-v2.ts +7 -7
  69. package/src/cli/commands/oauth/__tests__/connect.test.ts +0 -254
  70. package/src/cli/commands/oauth/connect.ts +10 -52
  71. package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
  72. package/src/config/feature-flag-registry.json +1 -17
  73. package/src/config/loader.ts +72 -19
  74. package/src/config/schemas/memory-v2.ts +1 -1
  75. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
  76. package/src/daemon/conversation-agent-loop-handlers.ts +32 -0
  77. package/src/daemon/conversation-agent-loop.ts +13 -10
  78. package/src/daemon/conversation-lifecycle.ts +22 -8
  79. package/src/daemon/conversation-surfaces.ts +16 -14
  80. package/src/daemon/conversation-tool-setup.ts +9 -5
  81. package/src/daemon/conversation.ts +1 -1
  82. package/src/daemon/handlers/shared.ts +26 -0
  83. package/src/daemon/host-bash-proxy.ts +1 -1
  84. package/src/daemon/host-browser-proxy.ts +1 -1
  85. package/src/daemon/host-cu-proxy.ts +1 -1
  86. package/src/daemon/host-file-proxy.ts +1 -1
  87. package/src/daemon/host-transfer-proxy.ts +2 -2
  88. package/src/daemon/lifecycle.ts +88 -73
  89. package/src/daemon/memory-v2-startup.ts +55 -14
  90. package/src/daemon/message-types/messages.ts +19 -1
  91. package/src/documents/document-store.ts +35 -1
  92. package/src/filing/filing-service.ts +2 -3
  93. package/src/heartbeat/heartbeat-service.ts +1 -1
  94. package/src/ipc/assistant-server.ts +93 -36
  95. package/src/ipc/skill-server.ts +99 -42
  96. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +10 -57
  97. package/src/memory/context-search/sources/memory-v2.ts +1 -17
  98. package/src/memory/context-search/sources/memory.ts +2 -2
  99. package/src/memory/context-search/sources/pkb.ts +2 -3
  100. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +104 -61
  101. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
  102. package/src/memory/graph/conversation-graph-memory.ts +32 -9
  103. package/src/memory/graph/graph-search.test.ts +6 -5
  104. package/src/memory/graph/graph-search.ts +3 -4
  105. package/src/memory/graph/retriever.test.ts +12 -7
  106. package/src/memory/graph/retriever.ts +4 -5
  107. package/src/memory/graph/tool-handlers.ts +3 -4
  108. package/src/memory/graph/tools.ts +4 -4
  109. package/src/memory/indexer.ts +1 -2
  110. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
  111. package/src/memory/jobs/embed-concept-page.ts +223 -87
  112. package/src/memory/jobs-worker.ts +8 -4
  113. package/src/memory/pkb/pkb-search.test.ts +6 -5
  114. package/src/memory/pkb/pkb-search.ts +4 -5
  115. package/src/memory/qdrant-client.ts +3 -0
  116. package/src/memory/search/semantic.ts +4 -5
  117. package/src/memory/v2/__tests__/activation.test.ts +35 -5
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +21 -32
  119. package/src/memory/v2/__tests__/injection.test.ts +140 -23
  120. package/src/memory/v2/__tests__/qdrant.test.ts +310 -9
  121. package/src/memory/v2/__tests__/sim.test.ts +118 -7
  122. package/src/memory/v2/__tests__/static-context.test.ts +1 -13
  123. package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
  124. package/src/memory/v2/consolidation-job.ts +7 -8
  125. package/src/memory/v2/injection.ts +32 -12
  126. package/src/memory/v2/page-store.ts +39 -0
  127. package/src/memory/v2/prompts/consolidation.ts +5 -0
  128. package/src/memory/v2/qdrant.ts +209 -48
  129. package/src/memory/v2/sim.ts +67 -26
  130. package/src/memory/v2/static-context.ts +4 -8
  131. package/src/memory/v2/sweep-job.ts +5 -6
  132. package/src/memory/v2/types.ts +7 -0
  133. package/src/notifications/copy-composer.ts +46 -12
  134. package/src/notifications/decision-engine.ts +46 -0
  135. package/src/permissions/gateway-threshold-reader.ts +116 -8
  136. package/src/permissions/prompter.ts +86 -96
  137. package/src/permissions/secret-prompter.ts +31 -31
  138. package/src/plugins/defaults/injectors.ts +1 -2
  139. package/src/proactive-artifact/job.test.ts +51 -4
  140. package/src/proactive-artifact/job.ts +16 -2
  141. package/src/proactive-artifact/message-copy.ts +18 -1
  142. package/src/prompts/templates/SOUL.md +13 -28
  143. package/src/runtime/auth/route-policy.ts +1 -0
  144. package/src/runtime/channel-approvals.ts +3 -2
  145. package/src/runtime/guardian-reply-router.ts +0 -10
  146. package/src/runtime/pending-interactions.ts +19 -15
  147. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
  148. package/src/runtime/routes/approval-routes.ts +7 -3
  149. package/src/runtime/routes/consolidation-routes.ts +8 -9
  150. package/src/runtime/routes/conversation-query-routes.ts +44 -1
  151. package/src/runtime/routes/debug-bash-routes.ts +2 -0
  152. package/src/runtime/routes/filing-routes.ts +2 -3
  153. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +0 -3
  154. package/src/runtime/routes/memory-item-routes.test.ts +3 -9
  155. package/src/runtime/routes/memory-item-routes.ts +5 -6
  156. package/src/runtime/routes/memory-v2-routes.ts +103 -17
  157. package/src/skills/include-graph.ts +35 -13
  158. package/src/tools/document/document-tool.ts +20 -0
  159. package/src/tools/executor.ts +18 -2
  160. package/src/tools/memory/register.test.ts +7 -5
  161. package/src/tools/permission-checker.ts +15 -0
  162. package/src/tools/skills/load.ts +24 -20
  163. package/src/tools/tool-name-aliases.ts +19 -0
  164. package/src/tools/types.ts +19 -1
  165. package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +4 -62
  166. package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
  167. package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
  168. package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
  169. package/src/workspace/migrations/registry.ts +6 -0
package/openapi.yaml CHANGED
@@ -3,7 +3,7 @@
3
3
  openapi: 3.0.0
4
4
  info:
5
5
  title: Vellum Assistant API
6
- version: 0.7.3
6
+ version: 0.8.0
7
7
  description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
8
8
  servers:
9
9
  - url: http://127.0.0.1:7821
@@ -7635,6 +7635,26 @@ paths:
7635
7635
  - k
7636
7636
  - sample
7637
7637
  additionalProperties: false
7638
+ /v1/memory/v2/list-concept-pages:
7639
+ post:
7640
+ operationId: memory_v2_listconceptpages_post
7641
+ summary: List all memory v2 concept pages with metadata
7642
+ description:
7643
+ Returns slugs, body sizes, edge counts, and last-modified timestamps for every concept page on disk.
7644
+ Read-only; used by the desktop About → Memories surface to render a browse-able list.
7645
+ tags:
7646
+ - memory
7647
+ responses:
7648
+ "200":
7649
+ description: Successful response
7650
+ requestBody:
7651
+ required: true
7652
+ content:
7653
+ application/json:
7654
+ schema:
7655
+ type: object
7656
+ properties: {}
7657
+ additionalProperties: false
7638
7658
  /v1/memory/v2/rebuild-corpus-stats:
7639
7659
  post:
7640
7660
  operationId: memory_v2_rebuildcorpusstats_post
@@ -7661,9 +7681,7 @@ paths:
7661
7681
  post:
7662
7682
  operationId: memory_v2_reembedskills_post
7663
7683
  summary: Re-seed v2 skill entries from the current skill catalog
7664
- description:
7665
- Synchronously re-runs seedV2SkillEntries against the current skill catalog. Gated on memory-v2-enabled flag
7666
- and config.memory.v2.enabled.
7684
+ description: Synchronously re-runs seedV2SkillEntries against the current skill catalog. Gated on config.memory.v2.enabled.
7667
7685
  tags:
7668
7686
  - memory
7669
7687
  responses:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,6 +44,7 @@
44
44
  "@vellumai/credential-storage": "file:../packages/credential-storage",
45
45
  "@vellumai/egress-proxy": "file:../packages/egress-proxy",
46
46
  "@vellumai/gateway-client": "file:../packages/gateway-client",
47
+ "@vellumai/ipc-server-utils": "file:../packages/ipc-server-utils",
47
48
  "@vellumai/service-contracts": "file:../packages/service-contracts",
48
49
  "@vellumai/skill-host-contracts": "file:../packages/skill-host-contracts",
49
50
  "@vellumai/slack-text": "file:../packages/slack-text",
@@ -78,6 +79,7 @@
78
79
  "@vellumai/service-contracts",
79
80
  "@vellumai/egress-proxy",
80
81
  "@vellumai/gateway-client",
82
+ "@vellumai/ipc-server-utils",
81
83
  "@vellumai/skill-host-contracts",
82
84
  "@vellumai/slack-text",
83
85
  "@vellumai/twilio-client"
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Tests for `annotatePersistedAssistantMessage` persisting the 3 risk-option
3
+ * arrays alongside the existing `_risk*` scalars.
4
+ *
5
+ * Phase B of the conflation track. Without these annotations, the Rule Editor
6
+ * Modal's chip ladder loses its scope/allowlist/directory options on chat-
7
+ * history reload and falls back to the synthesized `*` allowlist.
8
+ *
9
+ * The test exercises the full populate → annotate → persist round-trip:
10
+ * handleToolResult(event with 3 arrays)
11
+ * → state.toolRiskOutcomes captures them
12
+ * → annotatePersistedAssistantMessage writes _risk*Options onto the row
13
+ * → updateMessageContent receives the JSON-serialized output
14
+ *
15
+ * Read-side coverage (renderHistoryContent in handlers/shared.ts) lives in
16
+ * server-history-render.test.ts.
17
+ */
18
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
19
+
20
+ // ── Mock platform (must precede imports that read it) ─────────────────────────
21
+ mock.module("../util/logger.js", () => ({
22
+ getLogger: () =>
23
+ new Proxy({} as Record<string, unknown>, {
24
+ get: () => () => {},
25
+ }),
26
+ }));
27
+
28
+ mock.module("../config/loader.js", () => ({
29
+ getConfig: () => ({
30
+ skills: {
31
+ entries: {},
32
+ load: { extraDirs: [], watch: false, watchDebounceMs: 0 },
33
+ install: { nodeManager: "npm" },
34
+ allowBundled: null,
35
+ remoteProviders: {
36
+ skillssh: { enabled: true },
37
+ clawhub: { enabled: true },
38
+ },
39
+ remotePolicy: {
40
+ blockSuspicious: true,
41
+ blockMalware: true,
42
+ maxSkillsShRisk: "medium",
43
+ },
44
+ },
45
+ }),
46
+ loadConfig: () => ({}),
47
+ }));
48
+
49
+ let mockedRowContent = "";
50
+ const updates: Array<{ id: string; content: string }> = [];
51
+
52
+ mock.module("../memory/conversation-crud.js", () => ({
53
+ addMessage: () => ({ id: "mock-msg-id" }),
54
+ getMessageById: (id: string) =>
55
+ mockedRowContent ? { id, content: mockedRowContent } : null,
56
+ updateMessageContent: (id: string, content: string) => {
57
+ updates.push({ id, content });
58
+ },
59
+ provenanceFromTrustContext: () => ({}),
60
+ }));
61
+
62
+ mock.module("../memory/llm-request-log-store.js", () => ({
63
+ recordRequestLog: () => {},
64
+ backfillMessageIdOnLogs: () => {},
65
+ }));
66
+
67
+ // ── Imports (after mocks) ─────────────────────────────────────────────────────
68
+ import type {
69
+ EventHandlerDeps,
70
+ EventHandlerState,
71
+ } from "../daemon/conversation-agent-loop-handlers.js";
72
+ import {
73
+ createEventHandlerState,
74
+ handleToolResult,
75
+ } from "../daemon/conversation-agent-loop-handlers.js";
76
+
77
+ // ── Helpers ───────────────────────────────────────────────────────────────────
78
+
79
+ function makeDeps(): EventHandlerDeps {
80
+ return {
81
+ ctx: {
82
+ conversationId: "test-conv",
83
+ provider: { name: "anthropic" },
84
+ traceEmitter: { emit: () => {} },
85
+ streamThinking: false,
86
+ emitActivityState: () => {},
87
+ markWorkspaceTopLevelDirty: () => {},
88
+ currentTurnSurfaces: [],
89
+ } as unknown as EventHandlerDeps["ctx"],
90
+ onEvent: () => {},
91
+ reqId: "test-req",
92
+ isFirstMessage: false,
93
+ shouldGenerateTitle: false,
94
+ rlog: new Proxy({} as Record<string, unknown>, {
95
+ get: () => () => {},
96
+ }) as unknown as EventHandlerDeps["rlog"],
97
+ turnChannelContext: {
98
+ userMessageChannel: "vellum",
99
+ assistantMessageChannel: "vellum",
100
+ } as unknown as EventHandlerDeps["turnChannelContext"],
101
+ turnInterfaceContext: {
102
+ userMessageInterface: "web",
103
+ assistantMessageInterface: "web",
104
+ } as unknown as EventHandlerDeps["turnInterfaceContext"],
105
+ };
106
+ }
107
+
108
+ function setupState(toolUseId: string): EventHandlerState {
109
+ const state = createEventHandlerState();
110
+ state.lastAssistantMessageId = "msg-1";
111
+ state.toolUseIdToName.set(toolUseId, "bash");
112
+ state.toolCallTimestamps.set(toolUseId, { startedAt: Date.now() });
113
+ state.currentTurnToolUseIds.push(toolUseId);
114
+ return state;
115
+ }
116
+
117
+ function findPersistedToolUse(
118
+ rawContent: string,
119
+ toolUseId: string,
120
+ ): Record<string, unknown> {
121
+ const parsed = JSON.parse(rawContent) as Array<Record<string, unknown>>;
122
+ const block = parsed.find(
123
+ (b) => b.type === "tool_use" && b.id === toolUseId,
124
+ );
125
+ if (!block) throw new Error(`tool_use block ${toolUseId} not found`);
126
+ return block;
127
+ }
128
+
129
+ // ── Tests ─────────────────────────────────────────────────────────────────────
130
+
131
+ describe("annotatePersistedAssistantMessage — risk-option arrays (Phase B)", () => {
132
+ beforeEach(() => {
133
+ updates.length = 0;
134
+ mockedRowContent = "";
135
+ });
136
+
137
+ test("persists all 3 risk-option arrays from the live tool_result event", () => {
138
+ const toolUseId = "tu_persist_full";
139
+ const state = setupState(toolUseId);
140
+
141
+ mockedRowContent = JSON.stringify([
142
+ {
143
+ type: "tool_use",
144
+ id: toolUseId,
145
+ name: "bash",
146
+ input: { command: "rm -rf /tmp" },
147
+ },
148
+ ]);
149
+
150
+ const scopeOptions = [
151
+ { pattern: "exact", label: "exact: rm -rf /tmp" },
152
+ { pattern: "by-program", label: "All rm" },
153
+ ];
154
+ const allowlistOptions = [
155
+ { label: "exact", description: "exact match", pattern: "rm -rf /tmp" },
156
+ { label: "All rm", description: "All rm commands", pattern: "rm *" },
157
+ ];
158
+ const directoryScopeOptions = [
159
+ { scope: "/Users/me/code", label: "in code/" },
160
+ { scope: "everywhere", label: "Everywhere" },
161
+ ];
162
+
163
+ handleToolResult(state, makeDeps(), {
164
+ type: "tool_result",
165
+ toolUseId,
166
+ content: "ok",
167
+ isError: false,
168
+ riskLevel: "high",
169
+ riskReason: "Modifies state",
170
+ matchedTrustRuleId: "rule_42",
171
+ riskScopeOptions: scopeOptions,
172
+ riskAllowlistOptions: allowlistOptions,
173
+ riskDirectoryScopeOptions: directoryScopeOptions,
174
+ approvalMode: "prompted",
175
+ approvalReason: "user_approved",
176
+ riskThreshold: "relaxed",
177
+ });
178
+
179
+ expect(updates).toHaveLength(1);
180
+ const block = findPersistedToolUse(updates[0].content, toolUseId);
181
+ // Existing scalars still flow through.
182
+ expect(block._riskLevel).toBe("high");
183
+ expect(block._riskReason).toBe("Modifies state");
184
+ expect(block._matchedTrustRuleId).toBe("rule_42");
185
+ expect(block._approvalMode).toBe("prompted");
186
+ expect(block._approvalReason).toBe("user_approved");
187
+ expect(block._riskThreshold).toBe("relaxed");
188
+ // New: 3 risk-option arrays persisted verbatim.
189
+ expect(block._riskScopeOptions).toEqual(scopeOptions);
190
+ expect(block._riskAllowlistOptions).toEqual(allowlistOptions);
191
+ expect(block._riskDirectoryScopeOptions).toEqual(directoryScopeOptions);
192
+ });
193
+
194
+ test("omits empty arrays from the persisted block (saves DB space)", () => {
195
+ const toolUseId = "tu_persist_empty";
196
+ const state = setupState(toolUseId);
197
+
198
+ mockedRowContent = JSON.stringify([
199
+ {
200
+ type: "tool_use",
201
+ id: toolUseId,
202
+ name: "bash",
203
+ input: { command: "ls" },
204
+ },
205
+ ]);
206
+
207
+ handleToolResult(state, makeDeps(), {
208
+ type: "tool_result",
209
+ toolUseId,
210
+ content: "ok",
211
+ isError: false,
212
+ riskLevel: "low",
213
+ riskScopeOptions: [],
214
+ riskAllowlistOptions: [],
215
+ riskDirectoryScopeOptions: [],
216
+ });
217
+
218
+ expect(updates).toHaveLength(1);
219
+ const block = findPersistedToolUse(updates[0].content, toolUseId);
220
+ expect(block._riskLevel).toBe("low");
221
+ expect(block._riskScopeOptions).toBeUndefined();
222
+ expect(block._riskAllowlistOptions).toBeUndefined();
223
+ expect(block._riskDirectoryScopeOptions).toBeUndefined();
224
+ });
225
+
226
+ test("omits absent (undefined) arrays from the persisted block", () => {
227
+ // Mirrors classic bash/file tools that don't always emit all 3 arrays —
228
+ // e.g. recall, file_read with riskLevel=low and no allowlist coverage.
229
+ const toolUseId = "tu_persist_absent";
230
+ const state = setupState(toolUseId);
231
+
232
+ mockedRowContent = JSON.stringify([
233
+ {
234
+ type: "tool_use",
235
+ id: toolUseId,
236
+ name: "recall",
237
+ input: { query: "anything" },
238
+ },
239
+ ]);
240
+
241
+ handleToolResult(state, makeDeps(), {
242
+ type: "tool_result",
243
+ toolUseId,
244
+ content: "ok",
245
+ isError: false,
246
+ riskLevel: "low",
247
+ // No risk-option arrays passed at all.
248
+ });
249
+
250
+ expect(updates).toHaveLength(1);
251
+ const block = findPersistedToolUse(updates[0].content, toolUseId);
252
+ expect(block._riskLevel).toBe("low");
253
+ expect(block._riskScopeOptions).toBeUndefined();
254
+ expect(block._riskAllowlistOptions).toBeUndefined();
255
+ expect(block._riskDirectoryScopeOptions).toBeUndefined();
256
+ });
257
+
258
+ test("partial coverage — only allowlist options present (e.g. tools with classifier but no scope ladder)", () => {
259
+ const toolUseId = "tu_partial";
260
+ const state = setupState(toolUseId);
261
+
262
+ mockedRowContent = JSON.stringify([
263
+ {
264
+ type: "tool_use",
265
+ id: toolUseId,
266
+ name: "file_write",
267
+ input: { path: "/tmp/foo.txt" },
268
+ },
269
+ ]);
270
+
271
+ const allowlistOptions = [
272
+ { label: "exact", description: "exact match", pattern: "/tmp/foo.txt" },
273
+ ];
274
+
275
+ handleToolResult(state, makeDeps(), {
276
+ type: "tool_result",
277
+ toolUseId,
278
+ content: "ok",
279
+ isError: false,
280
+ riskLevel: "medium",
281
+ riskAllowlistOptions: allowlistOptions,
282
+ });
283
+
284
+ expect(updates).toHaveLength(1);
285
+ const block = findPersistedToolUse(updates[0].content, toolUseId);
286
+ expect(block._riskLevel).toBe("medium");
287
+ expect(block._riskAllowlistOptions).toEqual(allowlistOptions);
288
+ expect(block._riskScopeOptions).toBeUndefined();
289
+ expect(block._riskDirectoryScopeOptions).toBeUndefined();
290
+ });
291
+ });
@@ -291,21 +291,13 @@ function seedPendingConfirmation(
291
291
  conversation: Conversation,
292
292
  requestId: string,
293
293
  ): void {
294
+ // Access private ownedIds so denyAllPending/dispose can find this request.
295
+ // promptResolve/promptReject callbacks are stored in pendingInteractions via
296
+ // registerPendingInteraction, which is called separately in each test.
294
297
  const prompter = conversation["prompter"] as unknown as {
295
- pending: Map<
296
- string,
297
- {
298
- resolve: (...args: unknown[]) => void;
299
- reject: (...args: unknown[]) => void;
300
- timer: ReturnType<typeof setTimeout>;
301
- }
302
- >;
298
+ ownedIds: Set<string>;
303
299
  };
304
- prompter.pending.set(requestId, {
305
- resolve: () => {},
306
- reject: () => {},
307
- timer: setTimeout(() => {}, 60_000),
308
- });
300
+ prompter.ownedIds.add(requestId);
309
301
  }
310
302
 
311
303
  /**
@@ -439,12 +431,12 @@ describe("approval cascading", () => {
439
431
  makeConfirmationDetails(["bash:echo stale"]),
440
432
  );
441
433
 
442
- // Remove req-stale from the prompter's pending map (simulating it was
434
+ // Remove req-stale from the prompter's ownedIds (simulating it was
443
435
  // already resolved by another path before cascade reaches it)
444
436
  const prompter = conversationObj["prompter"] as unknown as {
445
- pending: Map<string, unknown>;
437
+ ownedIds: Set<string>;
446
438
  };
447
- prompter.pending.delete("req-stale");
439
+ prompter.ownedIds.delete("req-stale");
448
440
 
449
441
  // This should not throw — cascade should skip req-stale gracefully
450
442
  expect(() => {
@@ -192,6 +192,8 @@ function makeIdleSession(opts?: {
192
192
  processing = false;
193
193
  },
194
194
  handleConfirmationResponse: (requestId: string, decision: string) => {
195
+ // Simulate PermissionPrompter.resolveConfirmation(): prompter owns deregistration.
196
+ pendingInteractions.resolve(requestId);
195
197
  opts?.onConfirmation?.(requestId, decision);
196
198
  },
197
199
  handleSecretResponse: (
@@ -199,6 +201,8 @@ function makeIdleSession(opts?: {
199
201
  value?: string,
200
202
  delivery?: string,
201
203
  ) => {
204
+ // Simulate SecretPrompter.resolveSecret(): prompter owns deregistration.
205
+ pendingInteractions.resolve(requestId);
202
206
  opts?.onSecret?.(requestId, value, delivery);
203
207
  },
204
208
  } as unknown as Conversation;
@@ -285,6 +289,8 @@ function makeConfirmationEmittingSession(opts?: {
285
289
  await new Promise<void>(() => {});
286
290
  },
287
291
  handleConfirmationResponse: (requestId: string, decision: string) => {
292
+ // Simulate PermissionPrompter.resolveConfirmation(): prompter owns deregistration.
293
+ pendingInteractions.resolve(requestId);
288
294
  opts?.onConfirmation?.(requestId, decision);
289
295
  },
290
296
  handleSecretResponse: () => {},
@@ -389,18 +389,19 @@ describe("auto-analysis batch trigger uses analysis.batchSize cadence", () => {
389
389
  const originalExtractionBatch = TEST_CONFIG.memory.extraction.batchSize;
390
390
  const originalAnalysisBatch = TEST_CONFIG.analysis.batchSize;
391
391
 
392
+ const originalV2Enabled = TEST_CONFIG.memory.v2.enabled;
393
+
392
394
  beforeEach(() => {
393
- // memory-v2-enabled gates v1 graph_extract enqueue; force off so
395
+ _setOverridesForTesting({ "auto-analyze": true });
396
+ // memory.v2.enabled gates v1 graph_extract enqueue; force off so
394
397
  // these cadence tests can observe the v1 path.
395
- _setOverridesForTesting({
396
- "auto-analyze": true,
397
- "memory-v2-enabled": false,
398
- });
398
+ TEST_CONFIG.memory.v2.enabled = false;
399
399
  TEST_CONFIG.memory.extraction.batchSize = 2;
400
400
  TEST_CONFIG.analysis.batchSize = 5;
401
401
  });
402
402
 
403
403
  afterEach(() => {
404
+ TEST_CONFIG.memory.v2.enabled = originalV2Enabled;
404
405
  TEST_CONFIG.memory.extraction.batchSize = originalExtractionBatch;
405
406
  TEST_CONFIG.analysis.batchSize = originalAnalysisBatch;
406
407
  });
@@ -544,10 +545,10 @@ describe("auto-analysis batch trigger uses analysis.batchSize cadence", () => {
544
545
  });
545
546
 
546
547
  // ─────────────────────────────────────────────────────────────────
547
- // Indexer v1/v2 mutual exclusion: when memory-v2-enabled is on AND
548
- // memory.v2.enabled is on, the v1 graph_extract enqueue is suppressed
549
- // (v2 reads from buffer.md, so v1 graph data is unread). When either
550
- // gate is off, v1 graph_extract fires.
548
+ // Indexer v1/v2 mutual exclusion: when memory.v2.enabled is on, the
549
+ // v1 graph_extract enqueue is suppressed (v2 reads from buffer.md,
550
+ // so v1 graph data is unread). When v2 is disabled, v1 graph_extract
551
+ // fires.
551
552
  // ─────────────────────────────────────────────────────────────────
552
553
 
553
554
  describe("indexer v1/v2 mutual exclusion for graph_extract", () => {
@@ -564,8 +565,7 @@ describe("indexer v1/v2 mutual exclusion for graph_extract", () => {
564
565
  TEST_CONFIG.memory.v2.enabled = originalV2Enabled;
565
566
  });
566
567
 
567
- test("v2 active (flag on + config on) → graph_extract not enqueued", async () => {
568
- _setOverridesForTesting({ "memory-v2-enabled": true });
568
+ test("v2 active (config on) → graph_extract not enqueued", async () => {
569
569
  TEST_CONFIG.memory.v2.enabled = true;
570
570
 
571
571
  const source = createConversation("v2-active");
@@ -574,20 +574,7 @@ describe("indexer v1/v2 mutual exclusion for graph_extract", () => {
574
574
  expect(countJobsOfType("graph_extract", source.id)).toBe(0);
575
575
  });
576
576
 
577
- test("flag off → graph_extract enqueued", async () => {
578
- _setOverridesForTesting({ "memory-v2-enabled": false });
579
- TEST_CONFIG.memory.v2.enabled = true;
580
-
581
- const source = createConversation("v2-flag-off");
582
- await indexMessages(source.id, 2);
583
-
584
- expect(countJobsOfType("graph_extract", source.id)).toBeGreaterThanOrEqual(
585
- 1,
586
- );
587
- });
588
-
589
- test("config gate off (flag on) → graph_extract enqueued", async () => {
590
- _setOverridesForTesting({ "memory-v2-enabled": true });
577
+ test("config gate off → graph_extract enqueued", async () => {
591
578
  TEST_CONFIG.memory.v2.enabled = false;
592
579
 
593
580
  const source = createConversation("v2-config-off");
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { isDeniedNumber } from "../calls/call-constants.js";
3
+ import {
4
+ getEndCallListenWindowMs,
5
+ isDeniedNumber,
6
+ } from "../calls/call-constants.js";
4
7
 
5
8
  describe("isDeniedNumber", () => {
6
9
  // Numbers that MUST be blocked
@@ -39,3 +42,9 @@ describe("isDeniedNumber", () => {
39
42
  });
40
43
  }
41
44
  });
45
+
46
+ describe("getEndCallListenWindowMs", () => {
47
+ test("leaves a brief response window before task-complete hangup", () => {
48
+ expect(getEndCallListenWindowMs()).toBe(15_000);
49
+ });
50
+ });
@@ -105,11 +105,13 @@ mock.module("../security/credential-key.js", () => ({
105
105
 
106
106
  let mockConsultationTimeoutMs = 90_000;
107
107
  let mockSilenceTimeoutMs = 30_000;
108
+ let mockEndCallListenWindowMs = 0;
108
109
 
109
110
  mock.module("../calls/call-constants.js", () => ({
110
111
  getMaxCallDurationMs: () => 12 * 60 * 1000,
111
112
  getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs,
112
113
  getSilenceTimeoutMs: () => mockSilenceTimeoutMs,
114
+ getEndCallListenWindowMs: () => mockEndCallListenWindowMs,
113
115
  }));
114
116
 
115
117
  // ── Voice session bridge mock ────────────────────────────────────────
@@ -467,6 +469,7 @@ describe("call-controller", () => {
467
469
  // Reset consultation timeout to the default (long) value
468
470
  mockConsultationTimeoutMs = 90_000;
469
471
  mockSilenceTimeoutMs = 30_000;
472
+ mockEndCallListenWindowMs = 0;
470
473
  // Reset TTS config to defaults so per-test mutations don't leak.
471
474
  const cfg = loadConfig();
472
475
  cfg.services.tts.provider = "elevenlabs";
@@ -755,6 +758,130 @@ describe("call-controller", () => {
755
758
  controller.destroy();
756
759
  });
757
760
 
761
+ test("END_CALL waits through the listen window before completing", async () => {
762
+ mockEndCallListenWindowMs = 25;
763
+ mockStartVoiceTurn.mockImplementation(
764
+ createMockVoiceTurn(["Thank you for calling, goodbye! ", "[END_CALL]"]),
765
+ );
766
+ const { session, relay, controller } = setupController();
767
+
768
+ await controller.handleCallerUtterance("That is all, thanks");
769
+
770
+ expect(relay.endCalled).toBe(false);
771
+ expect(getCallSession(session.id)!.status).toBe("in_progress");
772
+
773
+ await new Promise((r) => setTimeout(r, 35));
774
+
775
+ expect(relay.endCalled).toBe(true);
776
+ const updatedSession = getCallSession(session.id);
777
+ expect(updatedSession!.status).toBe("completed");
778
+ expect(updatedSession!.endedAt).not.toBeNull();
779
+
780
+ controller.destroy();
781
+ });
782
+
783
+ test("delayed END_CALL completion skips side effects when session is already terminal", async () => {
784
+ mockEndCallListenWindowMs = 25;
785
+ mockStartVoiceTurn.mockImplementation(
786
+ createMockVoiceTurn(["Thank you for calling, goodbye! ", "[END_CALL]"]),
787
+ );
788
+ const { session, relay, controller } = setupController();
789
+
790
+ await controller.handleCallerUtterance("That is all, thanks");
791
+
792
+ const externalEndedAt = Date.now();
793
+ updateCallSession(session.id, {
794
+ status: "completed",
795
+ endedAt: externalEndedAt,
796
+ });
797
+
798
+ await new Promise((r) => setTimeout(r, 35));
799
+
800
+ expect(relay.endCalled).toBe(false);
801
+ const updatedSession = getCallSession(session.id);
802
+ expect(updatedSession!.status).toBe("completed");
803
+ expect(updatedSession!.endedAt).toBe(externalEndedAt);
804
+
805
+ controller.destroy();
806
+ });
807
+
808
+ test("callee speech during END_CALL listen window cancels pending completion", async () => {
809
+ mockEndCallListenWindowMs = 30;
810
+ const turnContents: string[] = [];
811
+ mockStartVoiceTurn.mockImplementation(
812
+ async (opts: {
813
+ content: string;
814
+ onTextDelta: (t: string) => void;
815
+ onComplete: () => void;
816
+ }) => {
817
+ turnContents.push(opts.content);
818
+ if (turnContents.length === 1) {
819
+ opts.onTextDelta("Goodbye! [END_CALL]");
820
+ } else {
821
+ opts.onTextDelta("Of course. I'm still here.");
822
+ }
823
+ opts.onComplete();
824
+ return { turnId: `run-${turnContents.length}`, abort: () => {} };
825
+ },
826
+ );
827
+ const { session, relay, controller } = setupController();
828
+
829
+ await controller.handleCallerUtterance("That is all, thanks");
830
+ expect(relay.endCalled).toBe(false);
831
+
832
+ await controller.handleCallerUtterance("Wait, one more thing");
833
+ await new Promise((r) => setTimeout(r, 40));
834
+
835
+ expect(relay.endCalled).toBe(false);
836
+ expect(getCallSession(session.id)!.status).toBe("in_progress");
837
+ expect(turnContents).toContain("Wait, one more thing");
838
+ const allText = relay.sentTokens.map((t) => t.token).join("");
839
+ expect(allText).toContain("I'm still here.");
840
+
841
+ controller.destroy();
842
+ });
843
+
844
+ test("END_CALL listen window restores in_progress after clearing pending guardian input", async () => {
845
+ mockEndCallListenWindowMs = 30;
846
+ const turnContents: string[] = [];
847
+ mockStartVoiceTurn.mockImplementation(
848
+ async (opts: {
849
+ content: string;
850
+ onTextDelta: (t: string) => void;
851
+ onComplete: () => void;
852
+ }) => {
853
+ turnContents.push(opts.content);
854
+ if (turnContents.length === 1) {
855
+ opts.onTextDelta("Let me check. [ASK_GUARDIAN: Is this okay?]");
856
+ } else if (turnContents.length === 2) {
857
+ opts.onTextDelta("Never mind, goodbye. [END_CALL]");
858
+ } else {
859
+ opts.onTextDelta("I'm still here.");
860
+ }
861
+ opts.onComplete();
862
+ return { turnId: `run-${turnContents.length}`, abort: () => {} };
863
+ },
864
+ );
865
+ const { session, relay, controller } = setupController();
866
+
867
+ await controller.handleCallerUtterance("Can you ask?");
868
+ expect(controller.getPendingConsultationQuestionId()).not.toBeNull();
869
+ expect(getCallSession(session.id)!.status).toBe("waiting_on_user");
870
+
871
+ await controller.handleCallerUtterance("Actually never mind");
872
+ expect(controller.getPendingConsultationQuestionId()).toBeNull();
873
+ expect(getCallSession(session.id)!.status).toBe("in_progress");
874
+ expect(relay.endCalled).toBe(false);
875
+
876
+ await controller.handleCallerUtterance("Wait, one more thing");
877
+ await new Promise((r) => setTimeout(r, 40));
878
+
879
+ expect(relay.endCalled).toBe(false);
880
+ expect(getCallSession(session.id)!.status).toBe("in_progress");
881
+
882
+ controller.destroy();
883
+ });
884
+
758
885
  // ── handleUserAnswer ──────────────────────────────────────────────
759
886
 
760
887
  test("handleUserAnswer: returns true immediately and fires LLM asynchronously", async () => {