@vellumai/assistant 0.4.48 → 0.4.49

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 (252) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/README.md +2 -23
  3. package/docs/architecture/integrations.md +45 -41
  4. package/docs/architecture/keychain-broker.md +3 -3
  5. package/docs/runbook-trusted-contacts.md +3 -8
  6. package/hook-templates/debug-prompt-logger/hook.json +1 -1
  7. package/hook-templates/debug-prompt-logger/run.sh +1 -3
  8. package/package.json +1 -1
  9. package/src/__tests__/actor-token-service.test.ts +0 -1
  10. package/src/__tests__/anthropic-provider.test.ts +156 -0
  11. package/src/__tests__/approval-cascade.test.ts +810 -0
  12. package/src/__tests__/approval-primitive.test.ts +0 -1
  13. package/src/__tests__/approval-routes-http.test.ts +2 -0
  14. package/src/__tests__/assistant-attachments.test.ts +12 -34
  15. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +76 -0
  16. package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -1
  17. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
  18. package/src/__tests__/channel-guardian.test.ts +0 -2
  19. package/src/__tests__/channel-readiness-routes.test.ts +15 -6
  20. package/src/__tests__/channel-readiness-service.test.ts +10 -9
  21. package/src/__tests__/checker.test.ts +9 -29
  22. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +1 -1
  23. package/src/__tests__/computer-use-tools.test.ts +2 -19
  24. package/src/__tests__/config-watcher.test.ts +0 -1
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +0 -1
  26. package/src/__tests__/context-image-dimensions.test.ts +332 -0
  27. package/src/__tests__/context-token-estimator.test.ts +196 -13
  28. package/src/__tests__/conversation-attention-store.test.ts +0 -1
  29. package/src/__tests__/conversation-attention-telegram.test.ts +0 -1
  30. package/src/__tests__/conversation-routes-guardian-reply.test.ts +144 -0
  31. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  32. package/src/__tests__/credential-metadata-store.test.ts +64 -73
  33. package/src/__tests__/credential-security-invariants.test.ts +13 -7
  34. package/src/__tests__/credential-vault-unit.test.ts +280 -49
  35. package/src/__tests__/credential-vault.test.ts +138 -16
  36. package/src/__tests__/credentials-cli.test.ts +71 -0
  37. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  38. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  39. package/src/__tests__/gateway-only-guard.test.ts +0 -1
  40. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -1
  41. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +0 -1
  42. package/src/__tests__/guardian-routing-invariants.test.ts +0 -1
  43. package/src/__tests__/guardian-verification-voice-binding.test.ts +0 -1
  44. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -39
  45. package/src/__tests__/heartbeat-service.test.ts +0 -1
  46. package/src/__tests__/host-cu-proxy.test.ts +629 -0
  47. package/src/__tests__/host-shell-tool.test.ts +27 -15
  48. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  49. package/src/__tests__/ingress-url-consistency.test.ts +14 -21
  50. package/src/__tests__/integration-status.test.ts +32 -51
  51. package/src/__tests__/intent-routing.test.ts +0 -1
  52. package/src/__tests__/invite-routes-http.test.ts +10 -9
  53. package/src/__tests__/keychain-broker-client.test.ts +11 -43
  54. package/src/__tests__/notification-routing-intent.test.ts +0 -1
  55. package/src/__tests__/oauth-cli.test.ts +373 -14
  56. package/src/__tests__/oauth-provider-profiles.test.ts +9 -9
  57. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  58. package/src/__tests__/oauth-store.test.ts +756 -0
  59. package/src/__tests__/onboarding-starter-tasks.test.ts +0 -1
  60. package/src/__tests__/provider-error-scenarios.test.ts +0 -1
  61. package/src/__tests__/provider-streaming.benchmark.test.ts +0 -1
  62. package/src/__tests__/public-ingress-urls.test.ts +15 -21
  63. package/src/__tests__/recording-handler.test.ts +3 -4
  64. package/src/__tests__/registry.test.ts +2 -2
  65. package/src/__tests__/runtime-events-sse.test.ts +55 -7
  66. package/src/__tests__/schedule-store.test.ts +0 -1
  67. package/src/__tests__/scheduler-recurrence.test.ts +0 -1
  68. package/src/__tests__/scoped-approval-grants.test.ts +0 -1
  69. package/src/__tests__/scoped-grant-security-matrix.test.ts +0 -1
  70. package/src/__tests__/secret-ingress-handler.test.ts +0 -1
  71. package/src/__tests__/send-endpoint-busy.test.ts +21 -6
  72. package/src/__tests__/sequence-store.test.ts +0 -1
  73. package/src/__tests__/session-init.benchmark.test.ts +4 -5
  74. package/src/__tests__/skill-include-graph.test.ts +66 -0
  75. package/src/__tests__/skill-load-feature-flag.test.ts +0 -1
  76. package/src/__tests__/skill-load-tool.test.ts +149 -1
  77. package/src/__tests__/skill-projection-feature-flag.test.ts +0 -1
  78. package/src/__tests__/skills-uninstall.test.ts +1 -1
  79. package/src/__tests__/skills.test.ts +3 -3
  80. package/src/__tests__/slack-channel-config.test.ts +67 -3
  81. package/src/__tests__/slack-share-routes.test.ts +17 -19
  82. package/src/__tests__/system-prompt.test.ts +0 -1
  83. package/src/__tests__/telegram-invite-adapter.test.ts +18 -22
  84. package/src/__tests__/terminal-tools.test.ts +4 -3
  85. package/src/__tests__/test-support/computer-use-skill-harness.ts +3 -2
  86. package/src/__tests__/tool-approval-handler.test.ts +0 -1
  87. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -1
  88. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  89. package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
  90. package/src/__tests__/tool-executor.test.ts +0 -1
  91. package/src/__tests__/tool-grant-request-escalation.test.ts +0 -1
  92. package/src/__tests__/trust-store-pattern-matches.test.ts +29 -0
  93. package/src/__tests__/trust-store.test.ts +1 -22
  94. package/src/__tests__/trusted-contact-approval-notifier.test.ts +0 -1
  95. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
  96. package/src/__tests__/twilio-routes.test.ts +0 -16
  97. package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
  98. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  99. package/src/agent/ax-tree-compaction.test.ts +235 -0
  100. package/src/agent/loop.ts +76 -130
  101. package/src/calls/call-domain.ts +1 -6
  102. package/src/calls/relay-server.ts +9 -13
  103. package/src/calls/twilio-config.ts +2 -7
  104. package/src/calls/twilio-routes.ts +1 -2
  105. package/src/calls/voice-ingress-preflight.ts +1 -1
  106. package/src/cli/commands/browser-relay.ts +18 -12
  107. package/src/cli/commands/completions.ts +0 -3
  108. package/src/cli/commands/credentials.ts +101 -15
  109. package/src/cli/commands/oauth/apps.ts +255 -0
  110. package/src/cli/commands/oauth/connections.ts +299 -0
  111. package/src/cli/commands/oauth/index.ts +52 -0
  112. package/src/cli/commands/oauth/providers.ts +242 -0
  113. package/src/cli/commands/skills.ts +4 -338
  114. package/src/cli/program.ts +1 -5
  115. package/src/cli/reference.ts +1 -3
  116. package/src/config/assistant-feature-flags.ts +0 -3
  117. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  118. package/src/config/bundled-skills/computer-use/SKILL.md +3 -6
  119. package/src/config/bundled-skills/computer-use/TOOLS.json +22 -4
  120. package/src/config/bundled-skills/google-calendar/calendar-client.ts +21 -16
  121. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -4
  122. package/src/config/bundled-skills/settings/SKILL.md +1 -1
  123. package/src/config/bundled-skills/settings/TOOLS.json +2 -8
  124. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +5 -33
  125. package/src/config/env-registry.ts +14 -83
  126. package/src/config/env.ts +11 -50
  127. package/src/config/feature-flag-registry.json +16 -16
  128. package/src/config/loader.ts +0 -6
  129. package/src/config/schema.ts +3 -1
  130. package/src/config/skills.ts +21 -2
  131. package/src/context/image-dimensions.ts +229 -0
  132. package/src/context/token-estimator.ts +75 -12
  133. package/src/context/window-manager.ts +49 -10
  134. package/src/daemon/assistant-attachments.ts +1 -13
  135. package/src/daemon/handlers/config-ingress.ts +8 -33
  136. package/src/daemon/handlers/config-slack-channel.ts +49 -46
  137. package/src/daemon/handlers/config-telegram.ts +32 -16
  138. package/src/daemon/handlers/sessions.ts +10 -24
  139. package/src/daemon/handlers/shared.ts +0 -130
  140. package/src/daemon/host-cu-proxy.ts +401 -0
  141. package/src/daemon/lifecycle.ts +36 -68
  142. package/src/daemon/message-protocol.ts +3 -0
  143. package/src/daemon/message-types/computer-use.ts +2 -119
  144. package/src/daemon/message-types/host-cu.ts +19 -0
  145. package/src/daemon/message-types/messages.ts +3 -0
  146. package/src/daemon/server.ts +14 -21
  147. package/src/daemon/session-agent-loop-handlers.ts +2 -0
  148. package/src/daemon/session-attachments.ts +1 -2
  149. package/src/daemon/session-slash.ts +1 -1
  150. package/src/daemon/session-surfaces.ts +40 -28
  151. package/src/daemon/session-tool-setup.ts +2 -9
  152. package/src/daemon/session.ts +138 -15
  153. package/src/daemon/tool-side-effects.ts +2 -8
  154. package/src/daemon/watch-handler.ts +2 -2
  155. package/src/events/tool-metrics-listener.ts +2 -2
  156. package/src/hooks/manager.ts +1 -4
  157. package/src/inbound/public-ingress-urls.ts +7 -7
  158. package/src/logfire.ts +16 -5
  159. package/src/memory/conversation-key-store.ts +21 -0
  160. package/src/memory/db-init.ts +4 -0
  161. package/src/memory/migrations/149-oauth-tables.ts +60 -0
  162. package/src/memory/migrations/index.ts +1 -0
  163. package/src/memory/schema/index.ts +1 -0
  164. package/src/memory/schema/oauth.ts +65 -0
  165. package/src/messaging/provider.ts +4 -4
  166. package/src/messaging/providers/gmail/client.ts +82 -2
  167. package/src/messaging/providers/gmail/people-client.ts +10 -10
  168. package/src/messaging/providers/telegram-bot/adapter.ts +17 -17
  169. package/src/messaging/providers/whatsapp/adapter.ts +11 -8
  170. package/src/messaging/registry.ts +2 -32
  171. package/src/notifications/copy-composer.ts +0 -5
  172. package/src/notifications/signal.ts +4 -5
  173. package/src/oauth/byo-connection.test.ts +126 -25
  174. package/src/oauth/byo-connection.ts +22 -6
  175. package/src/oauth/connect-orchestrator.ts +113 -57
  176. package/src/oauth/connect-types.ts +17 -23
  177. package/src/oauth/connection-resolver.ts +35 -11
  178. package/src/oauth/connection.ts +1 -1
  179. package/src/oauth/manual-token-connection.ts +104 -0
  180. package/src/oauth/oauth-store.ts +496 -0
  181. package/src/oauth/platform-connection.test.ts +29 -0
  182. package/src/oauth/platform-connection.ts +6 -5
  183. package/src/oauth/provider-behaviors.ts +124 -0
  184. package/src/oauth/scope-policy.ts +9 -2
  185. package/src/oauth/seed-providers.ts +161 -0
  186. package/src/oauth/token-persistence.ts +74 -78
  187. package/src/permissions/checker.ts +3 -3
  188. package/src/permissions/defaults.ts +0 -1
  189. package/src/permissions/prompter.ts +10 -1
  190. package/src/permissions/trust-store.ts +13 -0
  191. package/src/prompts/__tests__/build-cli-reference-section.test.ts +3 -1
  192. package/src/prompts/system-prompt.ts +28 -40
  193. package/src/providers/anthropic/client.ts +133 -24
  194. package/src/providers/retry.ts +1 -27
  195. package/src/runtime/auth/route-policy.ts +0 -3
  196. package/src/runtime/channel-reply-delivery.ts +0 -40
  197. package/src/runtime/gateway-client.ts +0 -7
  198. package/src/runtime/http-server.ts +8 -6
  199. package/src/runtime/http-types.ts +2 -2
  200. package/src/runtime/middleware/twilio-validation.ts +1 -11
  201. package/src/runtime/pending-interactions.ts +14 -12
  202. package/src/runtime/routes/channel-delivery-routes.ts +0 -1
  203. package/src/runtime/routes/conversation-routes.ts +73 -19
  204. package/src/runtime/routes/events-routes.ts +21 -11
  205. package/src/runtime/routes/host-cu-routes.ts +97 -0
  206. package/src/runtime/routes/inbound-stages/background-dispatch.ts +12 -111
  207. package/src/runtime/routes/integrations/slack/share.ts +6 -7
  208. package/src/runtime/routes/log-export-routes.ts +126 -8
  209. package/src/runtime/routes/settings-routes.ts +55 -48
  210. package/src/runtime/routes/surface-action-routes.ts +1 -1
  211. package/src/runtime/routes/watch-routes.ts +128 -0
  212. package/src/schedule/integration-status.ts +10 -9
  213. package/src/security/credential-key.ts +0 -156
  214. package/src/security/keychain-broker-client.ts +5 -6
  215. package/src/security/oauth2.ts +1 -1
  216. package/src/security/token-manager.ts +119 -46
  217. package/src/skills/catalog-install.ts +358 -0
  218. package/src/skills/include-graph.ts +32 -0
  219. package/src/telegram/bot-username.ts +2 -3
  220. package/src/tools/browser/network-recorder.ts +1 -1
  221. package/src/tools/browser/network-recording-types.ts +1 -1
  222. package/src/tools/computer-use/definitions.ts +46 -11
  223. package/src/tools/computer-use/registry.ts +4 -5
  224. package/src/tools/credentials/broker.ts +1 -2
  225. package/src/tools/credentials/metadata-store.ts +17 -121
  226. package/src/tools/credentials/vault.ts +94 -167
  227. package/src/tools/registry.ts +2 -7
  228. package/src/tools/skills/load.ts +62 -3
  229. package/src/tools/watch/watch-state.ts +0 -12
  230. package/src/util/logger.ts +7 -41
  231. package/src/util/platform.ts +9 -28
  232. package/src/watcher/providers/google-calendar.ts +2 -1
  233. package/src/__tests__/computer-use-session-compaction.test.ts +0 -143
  234. package/src/__tests__/computer-use-session-lifecycle.test.ts +0 -322
  235. package/src/__tests__/computer-use-session-working-dir.test.ts +0 -166
  236. package/src/__tests__/computer-use-skill-baseline.test.ts +0 -78
  237. package/src/__tests__/computer-use-skill-endstate.test.ts +0 -105
  238. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +0 -249
  239. package/src/__tests__/ride-shotgun-handler.test.ts +0 -452
  240. package/src/cli/commands/dev.ts +0 -129
  241. package/src/cli/commands/map.ts +0 -391
  242. package/src/cli/commands/oauth.ts +0 -77
  243. package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +0 -16
  244. package/src/daemon/computer-use-session.ts +0 -1026
  245. package/src/daemon/ride-shotgun-handler.ts +0 -569
  246. package/src/oauth/provider-base-urls.ts +0 -21
  247. package/src/oauth/provider-profiles.ts +0 -192
  248. package/src/prompts/computer-use-prompt.ts +0 -98
  249. package/src/runtime/routes/computer-use-routes.ts +0 -641
  250. package/src/runtime/telegram-streaming-delivery.test.ts +0 -729
  251. package/src/runtime/telegram-streaming-delivery.ts +0 -393
  252. package/src/tools/computer-use/request-computer-control.ts +0 -56
@@ -0,0 +1,810 @@
1
+ /**
2
+ * Tests for cascading approval decisions to matching pending confirmations.
3
+ *
4
+ * When a user resolves one confirmation with a broad decision (allow_10m,
5
+ * allow_thread, always_allow, always_deny), other pending confirmations in
6
+ * the same conversation that match are auto-resolved.
7
+ */
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
12
+
13
+ import { Minimatch } from "minimatch";
14
+
15
+ import type {
16
+ AgentEvent,
17
+ CheckpointDecision,
18
+ CheckpointInfo,
19
+ } from "../agent/loop.js";
20
+ import type { ServerMessage } from "../daemon/message-protocol.js";
21
+ import type { ConfirmationStateChanged } from "../daemon/message-types/messages.js";
22
+ import type { Message, ProviderResponse } from "../providers/types.js";
23
+ import type { ConfirmationDetails } from "../runtime/pending-interactions.js";
24
+
25
+ const testDir = mkdtempSync(join(tmpdir(), "approval-cascade-test-"));
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Mocks — must precede Session import
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function makeLoggerStub(): Record<string, unknown> {
32
+ const stub: Record<string, unknown> = {};
33
+ for (const m of [
34
+ "info",
35
+ "warn",
36
+ "error",
37
+ "debug",
38
+ "trace",
39
+ "fatal",
40
+ "silent",
41
+ "child",
42
+ ]) {
43
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
44
+ }
45
+ return stub;
46
+ }
47
+
48
+ mock.module("../util/logger.js", () => ({
49
+ getLogger: () => makeLoggerStub(),
50
+ }));
51
+
52
+ mock.module("../util/platform.js", () => ({
53
+ getDataDir: () => testDir,
54
+ }));
55
+
56
+ mock.module("../memory/guardian-action-store.js", () => ({
57
+ getPendingDeliveryByConversation: () => null,
58
+ getGuardianActionRequest: () => null,
59
+ resolveGuardianActionRequest: () => {},
60
+ }));
61
+
62
+ mock.module("../providers/registry.js", () => ({
63
+ getProvider: () => ({ name: "mock-provider" }),
64
+ initializeProviders: () => {},
65
+ }));
66
+
67
+ mock.module("../config/loader.js", () => ({
68
+ getConfig: () => ({
69
+ ui: {},
70
+ provider: "mock-provider",
71
+ maxTokens: 4096,
72
+ thinking: false,
73
+ contextWindow: {
74
+ maxInputTokens: 100000,
75
+ thresholdTokens: 80000,
76
+ preserveRecentMessages: 6,
77
+ summaryModel: "mock-model",
78
+ maxSummaryTokens: 512,
79
+ },
80
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
81
+ timeouts: { permissionTimeoutSec: 300 },
82
+ apiKeys: {},
83
+ skills: { entries: {}, allowBundled: true },
84
+ memory: { retrieval: { injectionStrategy: "inline" } },
85
+ permissions: { mode: "workspace" },
86
+ }),
87
+ loadRawConfig: () => ({}),
88
+ saveRawConfig: () => {},
89
+ invalidateConfigCache: () => {},
90
+ }));
91
+
92
+ mock.module("../prompts/system-prompt.js", () => ({
93
+ buildSystemPrompt: () => "system prompt",
94
+ }));
95
+
96
+ mock.module("../config/skills.js", () => ({
97
+ loadSkillCatalog: () => [],
98
+ loadSkillBySelector: () => ({ skill: null }),
99
+ ensureSkillIcon: async () => null,
100
+ }));
101
+
102
+ mock.module("../config/skill-state.js", () => ({
103
+ resolveSkillStates: () => [],
104
+ }));
105
+
106
+ mock.module("../skills/slash-commands.js", () => ({
107
+ buildInvocableSlashCatalog: () => new Map(),
108
+ resolveSlashSkillCommand: () => ({ kind: "not_slash" }),
109
+ rewriteKnownSlashCommandPrompt: () => "",
110
+ parseSlashCandidate: () => ({ kind: "not_slash" }),
111
+ }));
112
+
113
+ // Trust store mock — uses real minimatch for patternMatchesCandidate so the
114
+ // mock doesn't break trust-store-pattern-matches.test.ts when both files run
115
+ // in the same Bun process (mock.module leaks across test files).
116
+ mock.module("../permissions/trust-store.js", () => ({
117
+ addRule: () => {},
118
+ findHighestPriorityRule: () => null,
119
+ clearCache: () => {},
120
+ patternMatchesCandidate: (pattern: string, candidate: string): boolean => {
121
+ try {
122
+ return new Minimatch(pattern).match(candidate);
123
+ } catch {
124
+ return false;
125
+ }
126
+ },
127
+ }));
128
+
129
+ mock.module("../security/secret-allowlist.js", () => ({
130
+ resetAllowlist: () => {},
131
+ }));
132
+
133
+ mock.module("../memory/admin.js", () => ({
134
+ getMemoryConflictAndCleanupStats: () => ({
135
+ conflicts: { pending: 0, resolved: 0, oldestPendingAgeMs: null },
136
+ cleanup: {
137
+ resolvedBacklog: 0,
138
+ supersededBacklog: 0,
139
+ resolvedCompleted24h: 0,
140
+ supersededCompleted24h: 0,
141
+ },
142
+ }),
143
+ }));
144
+
145
+ mock.module("../memory/conversation-crud.js", () => ({
146
+ getConversationThreadType: () => "default",
147
+ setConversationOriginChannelIfUnset: () => {},
148
+ updateConversationContextWindow: () => {},
149
+ deleteMessageById: () => {},
150
+ provenanceFromTrustContext: () => ({
151
+ source: "user",
152
+ trustContext: undefined,
153
+ }),
154
+ getConversationOriginInterface: () => null,
155
+ getConversationOriginChannel: () => null,
156
+ getMessages: () => [],
157
+ getConversation: () => ({
158
+ id: "conv-1",
159
+ contextSummary: null,
160
+ contextCompactedMessageCount: 0,
161
+ totalInputTokens: 0,
162
+ totalOutputTokens: 0,
163
+ totalEstimatedCost: 0,
164
+ }),
165
+ createConversation: () => ({ id: "conv-1" }),
166
+ addMessage: () => ({ id: `msg-${Date.now()}` }),
167
+ updateConversationUsage: () => {},
168
+ updateConversationTitle: () => {},
169
+ }));
170
+
171
+ mock.module("../memory/conversation-queries.js", () => ({
172
+ listConversations: () => [],
173
+ }));
174
+
175
+ mock.module("../memory/attachments-store.js", () => ({
176
+ uploadAttachment: () => ({ id: `att-${Date.now()}` }),
177
+ linkAttachmentToMessage: () => {},
178
+ }));
179
+
180
+ mock.module("../memory/retriever.js", () => ({
181
+ buildMemoryRecall: async () => ({
182
+ enabled: false,
183
+ degraded: false,
184
+ injectedText: "",
185
+ lexicalHits: 0,
186
+ semanticHits: 0,
187
+ recencyHits: 0,
188
+ injectedTokens: 0,
189
+ latencyMs: 0,
190
+ }),
191
+ injectMemoryRecallIntoUserMessage: (msg: Message) => msg,
192
+ stripMemoryRecallMessages: (msgs: Message[]) => msgs,
193
+ }));
194
+
195
+ mock.module("../context/window-manager.js", () => ({
196
+ ContextWindowManager: class {
197
+ constructor() {}
198
+ shouldCompact() {
199
+ return { needed: false, estimatedTokens: 0 };
200
+ }
201
+ async maybeCompact() {
202
+ return { compacted: false };
203
+ }
204
+ },
205
+ createContextSummaryMessage: () => ({
206
+ role: "user",
207
+ content: [{ type: "text", text: "summary" }],
208
+ }),
209
+ getSummaryFromContextMessage: () => null,
210
+ }));
211
+
212
+ mock.module("../memory/llm-usage-store.js", () => ({
213
+ recordUsageEvent: () => ({ id: "mock-id", createdAt: Date.now() }),
214
+ listUsageEvents: () => [],
215
+ }));
216
+
217
+ mock.module("../agent/loop.js", () => ({
218
+ AgentLoop: class {
219
+ constructor() {}
220
+ async run(
221
+ _messages: Message[],
222
+ _onEvent: (event: AgentEvent) => void,
223
+ _signal?: AbortSignal,
224
+ _requestId?: string,
225
+ _onCheckpoint?: (checkpoint: CheckpointInfo) => CheckpointDecision,
226
+ ): Promise<Message[]> {
227
+ return [];
228
+ }
229
+ },
230
+ }));
231
+
232
+ mock.module("../memory/canonical-guardian-store.js", () => ({
233
+ listPendingCanonicalGuardianRequestsByDestinationConversation: () => [],
234
+ listCanonicalGuardianRequests: () => [],
235
+ listPendingRequestsByConversationScope: () => [],
236
+ createCanonicalGuardianRequest: () => ({
237
+ id: "mock-cg-id",
238
+ code: "MOCK",
239
+ status: "pending",
240
+ }),
241
+ getCanonicalGuardianRequest: () => null,
242
+ getCanonicalGuardianRequestByCode: () => null,
243
+ updateCanonicalGuardianRequest: () => {},
244
+ resolveCanonicalGuardianRequest: () => {},
245
+ createCanonicalGuardianDelivery: () => ({ id: "mock-cgd-id" }),
246
+ listCanonicalGuardianDeliveries: () => [],
247
+ listPendingCanonicalGuardianRequestsByDestinationChat: () => [],
248
+ updateCanonicalGuardianDelivery: () => {},
249
+ generateCanonicalRequestCode: () => "MOCK-CODE",
250
+ }));
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Import Session and pendingInteractions AFTER mocks
254
+ // ---------------------------------------------------------------------------
255
+
256
+ import { Session } from "../daemon/session.js";
257
+ import * as pendingInteractions from "../runtime/pending-interactions.js";
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Helpers
261
+ // ---------------------------------------------------------------------------
262
+
263
+ const CONV_ID = "conv-cascade-test";
264
+
265
+ function makeProvider() {
266
+ return {
267
+ name: "mock",
268
+ async sendMessage(): Promise<ProviderResponse> {
269
+ return {
270
+ content: [],
271
+ model: "mock",
272
+ usage: { inputTokens: 0, outputTokens: 0 },
273
+ stopReason: "end_turn",
274
+ };
275
+ },
276
+ };
277
+ }
278
+
279
+ function makeSession(
280
+ sendToClient?: (msg: ServerMessage) => void,
281
+ conversationId = CONV_ID,
282
+ ): Session {
283
+ return new Session(
284
+ conversationId,
285
+ makeProvider(),
286
+ "system prompt",
287
+ 4096,
288
+ sendToClient ?? (() => {}),
289
+ testDir,
290
+ );
291
+ }
292
+
293
+ /**
294
+ * Seed a pending confirmation directly in the prompter's internal map.
295
+ */
296
+ function seedPendingConfirmation(session: Session, requestId: string): void {
297
+ const prompter = session["prompter"] as unknown as {
298
+ pending: Map<
299
+ string,
300
+ {
301
+ resolve: (...args: unknown[]) => void;
302
+ reject: (...args: unknown[]) => void;
303
+ timer: ReturnType<typeof setTimeout>;
304
+ }
305
+ >;
306
+ };
307
+ prompter.pending.set(requestId, {
308
+ resolve: () => {},
309
+ reject: () => {},
310
+ timer: setTimeout(() => {}, 60_000),
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Register a pending interaction in the pending-interactions tracker with
316
+ * confirmation details.
317
+ */
318
+ function registerPendingInteraction(
319
+ session: Session,
320
+ requestId: string,
321
+ conversationId: string,
322
+ confirmationDetails?: ConfirmationDetails,
323
+ ): void {
324
+ pendingInteractions.register(requestId, {
325
+ session,
326
+ conversationId,
327
+ kind: "confirmation",
328
+ confirmationDetails,
329
+ });
330
+ }
331
+
332
+ function makeConfirmationDetails(patterns: string[]): ConfirmationDetails {
333
+ return {
334
+ toolName: "bash",
335
+ input: { command: "echo hello" },
336
+ riskLevel: "medium",
337
+ allowlistOptions: patterns.map((p) => ({
338
+ label: p,
339
+ description: `Allow ${p}`,
340
+ pattern: p,
341
+ })),
342
+ scopeOptions: [{ label: "Everywhere", scope: "everywhere" }],
343
+ };
344
+ }
345
+
346
+ afterAll(() => {
347
+ try {
348
+ rmSync(testDir, { recursive: true, force: true });
349
+ } catch {
350
+ /* best effort */
351
+ }
352
+ });
353
+
354
+ beforeEach(() => {
355
+ pendingInteractions.clear();
356
+ });
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Tests
360
+ // ---------------------------------------------------------------------------
361
+
362
+ describe("approval cascading", () => {
363
+ test("allow_10m cascades to all pending in same conversation", () => {
364
+ const emitted: ServerMessage[] = [];
365
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
366
+
367
+ // Seed 3 pending confirmations
368
+ seedPendingConfirmation(session, "req-1");
369
+ seedPendingConfirmation(session, "req-2");
370
+ seedPendingConfirmation(session, "req-3");
371
+
372
+ // Register in pending-interactions tracker
373
+ registerPendingInteraction(
374
+ session,
375
+ "req-1",
376
+ CONV_ID,
377
+ makeConfirmationDetails(["bash:echo hello"]),
378
+ );
379
+ registerPendingInteraction(
380
+ session,
381
+ "req-2",
382
+ CONV_ID,
383
+ makeConfirmationDetails(["bash:ls -la"]),
384
+ );
385
+ registerPendingInteraction(
386
+ session,
387
+ "req-3",
388
+ CONV_ID,
389
+ makeConfirmationDetails(["bash:cat file"]),
390
+ );
391
+
392
+ // Resolve the first with allow_10m
393
+ session.handleConfirmationResponse("req-1", "allow_10m");
394
+
395
+ // All 3 should be resolved (approved)
396
+ const confirmMsgs = emitted.filter(
397
+ (m) =>
398
+ m.type === "confirmation_state_changed" &&
399
+ (m as unknown as ConfirmationStateChanged).state === "approved",
400
+ ) as unknown as ConfirmationStateChanged[];
401
+
402
+ expect(confirmMsgs).toHaveLength(3);
403
+
404
+ const resolvedIds = confirmMsgs.map((m) => m.requestId).sort();
405
+ expect(resolvedIds).toEqual(["req-1", "req-2", "req-3"]);
406
+ });
407
+
408
+ test("allow_thread cascades to all pending in same conversation", () => {
409
+ const emitted: ServerMessage[] = [];
410
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
411
+
412
+ seedPendingConfirmation(session, "req-a");
413
+ seedPendingConfirmation(session, "req-b");
414
+ seedPendingConfirmation(session, "req-c");
415
+
416
+ registerPendingInteraction(
417
+ session,
418
+ "req-a",
419
+ CONV_ID,
420
+ makeConfirmationDetails(["bash:echo a"]),
421
+ );
422
+ registerPendingInteraction(
423
+ session,
424
+ "req-b",
425
+ CONV_ID,
426
+ makeConfirmationDetails(["bash:echo b"]),
427
+ );
428
+ registerPendingInteraction(
429
+ session,
430
+ "req-c",
431
+ CONV_ID,
432
+ makeConfirmationDetails(["bash:echo c"]),
433
+ );
434
+
435
+ session.handleConfirmationResponse("req-a", "allow_thread");
436
+
437
+ const confirmMsgs = emitted.filter(
438
+ (m) =>
439
+ m.type === "confirmation_state_changed" &&
440
+ (m as unknown as ConfirmationStateChanged).state === "approved",
441
+ ) as unknown as ConfirmationStateChanged[];
442
+
443
+ expect(confirmMsgs).toHaveLength(3);
444
+
445
+ const resolvedIds = confirmMsgs.map((m) => m.requestId).sort();
446
+ expect(resolvedIds).toEqual(["req-a", "req-b", "req-c"]);
447
+ });
448
+
449
+ test("temporary override does NOT cascade to different conversation", () => {
450
+ const emitted: ServerMessage[] = [];
451
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
452
+
453
+ seedPendingConfirmation(session, "req-same");
454
+ seedPendingConfirmation(session, "req-diff");
455
+
456
+ // Same conversation
457
+ registerPendingInteraction(
458
+ session,
459
+ "req-same",
460
+ CONV_ID,
461
+ makeConfirmationDetails(["bash:echo same"]),
462
+ );
463
+ // Different conversation
464
+ registerPendingInteraction(
465
+ session,
466
+ "req-diff",
467
+ "different-conv",
468
+ makeConfirmationDetails(["bash:echo diff"]),
469
+ );
470
+
471
+ // Seed a primary request
472
+ seedPendingConfirmation(session, "req-primary");
473
+ registerPendingInteraction(
474
+ session,
475
+ "req-primary",
476
+ CONV_ID,
477
+ makeConfirmationDetails(["bash:echo primary"]),
478
+ );
479
+
480
+ session.handleConfirmationResponse("req-primary", "allow_10m");
481
+
482
+ const confirmMsgs = emitted.filter(
483
+ (m) =>
484
+ m.type === "confirmation_state_changed" &&
485
+ (m as unknown as ConfirmationStateChanged).state === "approved",
486
+ ) as unknown as ConfirmationStateChanged[];
487
+
488
+ // primary + req-same should be approved, req-diff should NOT
489
+ const resolvedIds = confirmMsgs.map((m) => m.requestId).sort();
490
+ expect(resolvedIds).toContain("req-primary");
491
+ expect(resolvedIds).toContain("req-same");
492
+ expect(resolvedIds).not.toContain("req-diff");
493
+ });
494
+
495
+ test("always_allow cascades to pattern-matching pending", () => {
496
+ const emitted: ServerMessage[] = [];
497
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
498
+
499
+ // Two with matching patterns (asset_materialize:doc.pdf)
500
+ seedPendingConfirmation(session, "req-match-1");
501
+ seedPendingConfirmation(session, "req-match-2");
502
+ // One with non-overlapping pattern
503
+ seedPendingConfirmation(session, "req-nomatch");
504
+
505
+ registerPendingInteraction(
506
+ session,
507
+ "req-match-1",
508
+ CONV_ID,
509
+ makeConfirmationDetails(["asset_materialize:doc.pdf"]),
510
+ );
511
+ registerPendingInteraction(
512
+ session,
513
+ "req-match-2",
514
+ CONV_ID,
515
+ makeConfirmationDetails(["asset_materialize:report.pdf"]),
516
+ );
517
+ registerPendingInteraction(
518
+ session,
519
+ "req-nomatch",
520
+ CONV_ID,
521
+ makeConfirmationDetails(["bash:rm -rf"]),
522
+ );
523
+
524
+ // Primary request
525
+ seedPendingConfirmation(session, "req-primary");
526
+ registerPendingInteraction(
527
+ session,
528
+ "req-primary",
529
+ CONV_ID,
530
+ makeConfirmationDetails(["asset_materialize:image.png"]),
531
+ );
532
+
533
+ session.handleConfirmationResponse(
534
+ "req-primary",
535
+ "always_allow",
536
+ "asset_materialize:**",
537
+ );
538
+
539
+ const approvedMsgs = emitted.filter(
540
+ (m) =>
541
+ m.type === "confirmation_state_changed" &&
542
+ (m as unknown as ConfirmationStateChanged).state === "approved",
543
+ ) as unknown as ConfirmationStateChanged[];
544
+
545
+ const approvedIds = approvedMsgs.map((m) => m.requestId).sort();
546
+ expect(approvedIds).toContain("req-primary");
547
+ expect(approvedIds).toContain("req-match-1");
548
+ expect(approvedIds).toContain("req-match-2");
549
+ expect(approvedIds).not.toContain("req-nomatch");
550
+ });
551
+
552
+ test("always_allow does NOT cascade to high-risk pending confirmations", () => {
553
+ const emitted: ServerMessage[] = [];
554
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
555
+
556
+ // Medium-risk pending — should cascade
557
+ seedPendingConfirmation(session, "req-medium");
558
+ registerPendingInteraction(
559
+ session,
560
+ "req-medium",
561
+ CONV_ID,
562
+ makeConfirmationDetails(["asset_materialize:report.pdf"]),
563
+ );
564
+
565
+ // High-risk pending — should NOT cascade via always_allow
566
+ seedPendingConfirmation(session, "req-high");
567
+ registerPendingInteraction(session, "req-high", CONV_ID, {
568
+ toolName: "bash",
569
+ input: { command: "rm -rf /" },
570
+ riskLevel: "high",
571
+ allowlistOptions: [
572
+ {
573
+ label: "asset_materialize:dangerous.bin",
574
+ description: "Allow asset_materialize:dangerous.bin",
575
+ pattern: "asset_materialize:dangerous.bin",
576
+ },
577
+ ],
578
+ scopeOptions: [{ label: "Everywhere", scope: "everywhere" }],
579
+ });
580
+
581
+ // Primary request
582
+ seedPendingConfirmation(session, "req-primary");
583
+ registerPendingInteraction(
584
+ session,
585
+ "req-primary",
586
+ CONV_ID,
587
+ makeConfirmationDetails(["asset_materialize:image.png"]),
588
+ );
589
+
590
+ session.handleConfirmationResponse(
591
+ "req-primary",
592
+ "always_allow",
593
+ "asset_materialize:**",
594
+ );
595
+
596
+ const approvedMsgs = emitted.filter(
597
+ (m) =>
598
+ m.type === "confirmation_state_changed" &&
599
+ (m as unknown as ConfirmationStateChanged).state === "approved",
600
+ ) as unknown as ConfirmationStateChanged[];
601
+
602
+ const approvedIds = approvedMsgs.map((m) => m.requestId).sort();
603
+ expect(approvedIds).toContain("req-primary");
604
+ expect(approvedIds).toContain("req-medium");
605
+ expect(approvedIds).not.toContain("req-high");
606
+
607
+ // High-risk should still be pending (not emitted at all)
608
+ const allResolvedIds = emitted
609
+ .filter((m) => m.type === "confirmation_state_changed")
610
+ .map((m) => (m as unknown as ConfirmationStateChanged).requestId);
611
+ expect(allResolvedIds).not.toContain("req-high");
612
+ });
613
+
614
+ test("always_deny cascades deny to pattern-matching pending", () => {
615
+ const emitted: ServerMessage[] = [];
616
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
617
+
618
+ seedPendingConfirmation(session, "req-match-1");
619
+ seedPendingConfirmation(session, "req-nomatch");
620
+
621
+ registerPendingInteraction(
622
+ session,
623
+ "req-match-1",
624
+ CONV_ID,
625
+ makeConfirmationDetails(["asset_materialize:doc.pdf"]),
626
+ );
627
+ registerPendingInteraction(
628
+ session,
629
+ "req-nomatch",
630
+ CONV_ID,
631
+ makeConfirmationDetails(["bash:rm -rf"]),
632
+ );
633
+
634
+ seedPendingConfirmation(session, "req-primary");
635
+ registerPendingInteraction(
636
+ session,
637
+ "req-primary",
638
+ CONV_ID,
639
+ makeConfirmationDetails(["asset_materialize:image.png"]),
640
+ );
641
+
642
+ session.handleConfirmationResponse(
643
+ "req-primary",
644
+ "always_deny",
645
+ "asset_materialize:**",
646
+ );
647
+
648
+ const deniedMsgs = emitted.filter(
649
+ (m) =>
650
+ m.type === "confirmation_state_changed" &&
651
+ (m as unknown as ConfirmationStateChanged).state === "denied",
652
+ ) as unknown as ConfirmationStateChanged[];
653
+
654
+ const deniedIds = deniedMsgs.map((m) => m.requestId).sort();
655
+ expect(deniedIds).toContain("req-primary");
656
+ expect(deniedIds).toContain("req-match-1");
657
+ expect(deniedIds).not.toContain("req-nomatch");
658
+
659
+ // req-nomatch should still be pending (not emitted at all as approved or denied by cascade)
660
+ const allResolvedIds = emitted
661
+ .filter((m) => m.type === "confirmation_state_changed")
662
+ .map((m) => (m as unknown as ConfirmationStateChanged).requestId);
663
+ expect(allResolvedIds).not.toContain("req-nomatch");
664
+ });
665
+
666
+ test("allow (one-time) does NOT cascade", () => {
667
+ const emitted: ServerMessage[] = [];
668
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
669
+
670
+ seedPendingConfirmation(session, "req-1");
671
+ seedPendingConfirmation(session, "req-2");
672
+
673
+ registerPendingInteraction(
674
+ session,
675
+ "req-1",
676
+ CONV_ID,
677
+ makeConfirmationDetails(["bash:echo hello"]),
678
+ );
679
+ registerPendingInteraction(
680
+ session,
681
+ "req-2",
682
+ CONV_ID,
683
+ makeConfirmationDetails(["bash:echo world"]),
684
+ );
685
+
686
+ session.handleConfirmationResponse("req-1", "allow");
687
+
688
+ const confirmMsgs = emitted.filter(
689
+ (m) =>
690
+ m.type === "confirmation_state_changed" &&
691
+ (m as unknown as ConfirmationStateChanged).state === "approved",
692
+ ) as unknown as ConfirmationStateChanged[];
693
+
694
+ // Only the primary should be resolved
695
+ expect(confirmMsgs).toHaveLength(1);
696
+ expect(confirmMsgs[0].requestId).toBe("req-1");
697
+ });
698
+
699
+ test("deny (one-time) does NOT cascade", () => {
700
+ const emitted: ServerMessage[] = [];
701
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
702
+
703
+ seedPendingConfirmation(session, "req-1");
704
+ seedPendingConfirmation(session, "req-2");
705
+
706
+ registerPendingInteraction(
707
+ session,
708
+ "req-1",
709
+ CONV_ID,
710
+ makeConfirmationDetails(["bash:echo hello"]),
711
+ );
712
+ registerPendingInteraction(
713
+ session,
714
+ "req-2",
715
+ CONV_ID,
716
+ makeConfirmationDetails(["bash:echo world"]),
717
+ );
718
+
719
+ session.handleConfirmationResponse("req-1", "deny");
720
+
721
+ const confirmMsgs = emitted.filter(
722
+ (m) =>
723
+ m.type === "confirmation_state_changed" &&
724
+ (m as unknown as ConfirmationStateChanged).state === "denied",
725
+ ) as unknown as ConfirmationStateChanged[];
726
+
727
+ // Only the primary should be denied
728
+ expect(confirmMsgs).toHaveLength(1);
729
+ expect(confirmMsgs[0].requestId).toBe("req-1");
730
+ });
731
+
732
+ test("cascaded events have source 'system' and causedByRequestId", () => {
733
+ const emitted: ServerMessage[] = [];
734
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
735
+
736
+ seedPendingConfirmation(session, "req-primary");
737
+ seedPendingConfirmation(session, "req-cascaded");
738
+
739
+ registerPendingInteraction(
740
+ session,
741
+ "req-primary",
742
+ CONV_ID,
743
+ makeConfirmationDetails(["bash:echo primary"]),
744
+ );
745
+ registerPendingInteraction(
746
+ session,
747
+ "req-cascaded",
748
+ CONV_ID,
749
+ makeConfirmationDetails(["bash:echo cascaded"]),
750
+ );
751
+
752
+ session.handleConfirmationResponse("req-primary", "allow_10m");
753
+
754
+ const cascadedMsg = emitted.find(
755
+ (m) =>
756
+ m.type === "confirmation_state_changed" &&
757
+ (m as unknown as ConfirmationStateChanged).requestId === "req-cascaded",
758
+ ) as unknown as ConfirmationStateChanged;
759
+
760
+ expect(cascadedMsg).toBeDefined();
761
+ expect(cascadedMsg.source).toBe("system");
762
+ expect(cascadedMsg.causedByRequestId).toBe("req-primary");
763
+ });
764
+
765
+ test("already-resolved request handled gracefully", () => {
766
+ const emitted: ServerMessage[] = [];
767
+ const session = makeSession((msg) => emitted.push(msg), CONV_ID);
768
+
769
+ seedPendingConfirmation(session, "req-primary");
770
+ seedPendingConfirmation(session, "req-stale");
771
+
772
+ registerPendingInteraction(
773
+ session,
774
+ "req-primary",
775
+ CONV_ID,
776
+ makeConfirmationDetails(["bash:echo primary"]),
777
+ );
778
+ // Register in pending-interactions but with a request ID that exists
779
+ // in the prompter. We'll remove it from the prompter before cascading
780
+ // reaches it to simulate a stale/already-resolved request.
781
+ registerPendingInteraction(
782
+ session,
783
+ "req-stale",
784
+ CONV_ID,
785
+ makeConfirmationDetails(["bash:echo stale"]),
786
+ );
787
+
788
+ // Remove req-stale from the prompter's pending map (simulating it was
789
+ // already resolved by another path before cascade reaches it)
790
+ const prompter = session["prompter"] as unknown as {
791
+ pending: Map<string, unknown>;
792
+ };
793
+ prompter.pending.delete("req-stale");
794
+
795
+ // This should not throw — cascade should skip req-stale gracefully
796
+ expect(() => {
797
+ session.handleConfirmationResponse("req-primary", "allow_10m");
798
+ }).not.toThrow();
799
+
800
+ // Only the primary should be resolved
801
+ const confirmMsgs = emitted.filter(
802
+ (m) =>
803
+ m.type === "confirmation_state_changed" &&
804
+ (m as unknown as ConfirmationStateChanged).state === "approved",
805
+ ) as unknown as ConfirmationStateChanged[];
806
+
807
+ expect(confirmMsgs).toHaveLength(1);
808
+ expect(confirmMsgs[0].requestId).toBe("req-primary");
809
+ });
810
+ });