@vellumai/assistant 0.12.2 → 0.12.3-staging.1

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 (298) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/docs/architecture/integrations.md +7 -0
  4. package/docs/architecture/memory.md +17 -2
  5. package/docs/credential-execution-service.md +1 -1
  6. package/docs/desktop-browser-cli.md +7 -3
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/openapi.yaml +54 -5
  17. package/package.json +1 -1
  18. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  19. package/scripts/sync-llm-catalog.ts +3 -0
  20. package/scripts/voice-ttft-spike.ts +2 -2
  21. package/src/__tests__/agent-loop.test.ts +259 -0
  22. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  23. package/src/__tests__/anthropic-provider.test.ts +55 -0
  24. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  25. package/src/__tests__/attachments-store.test.ts +22 -3
  26. package/src/__tests__/channel-approval.test.ts +9 -14
  27. package/src/__tests__/channel-reply-delivery.test.ts +56 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  29. package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
  30. package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +257 -6
  34. package/src/__tests__/conversation-attachments.test.ts +106 -0
  35. package/src/__tests__/conversation-fork-crud.test.ts +191 -0
  36. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  37. package/src/__tests__/credential-execution-client.test.ts +70 -53
  38. package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
  39. package/src/__tests__/events-tail-route.test.ts +33 -0
  40. package/src/__tests__/history-repair.test.ts +53 -0
  41. package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
  42. package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
  43. package/src/__tests__/llm-catalog-parity.test.ts +27 -4
  44. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  45. package/src/__tests__/oauth-commands-routes.test.ts +114 -101
  46. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  47. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  48. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  49. package/src/__tests__/persist-media-references.test.ts +50 -0
  50. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  51. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  52. package/src/__tests__/schedule-routes.test.ts +20 -0
  53. package/src/__tests__/scheduler-result-notification.test.ts +23 -4
  54. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  55. package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
  56. package/src/__tests__/secret-routes-scrub.test.ts +22 -27
  57. package/src/__tests__/secret-scanner.test.ts +20 -0
  58. package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
  59. package/src/__tests__/secure-keys.test.ts +7 -3
  60. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  61. package/src/__tests__/skills.test.ts +5 -4
  62. package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
  63. package/src/__tests__/terminal-tools.test.ts +8 -0
  64. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  65. package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
  66. package/src/__tests__/unicode.test.ts +36 -0
  67. package/src/agent/loop.ts +37 -12
  68. package/src/agent/tool-result-follow-up.ts +70 -0
  69. package/src/api/attachment-provenance.test.ts +68 -0
  70. package/src/api/computer-use-tool.test.ts +55 -0
  71. package/src/api/computer-use-tool.ts +27 -0
  72. package/src/api/events/assistant-outbound-attachment.ts +3 -0
  73. package/src/api/events/desktop-activity-changed.ts +10 -0
  74. package/src/api/events/question-request.ts +1 -0
  75. package/src/api/index.ts +11 -0
  76. package/src/api/responses/conversation-message.ts +3 -0
  77. package/src/approvals/approval-primitive.ts +5 -2
  78. package/src/approvals/scoped-approval-grants.ts +6 -2
  79. package/src/browser/virtual-desktop-target.ts +1 -2
  80. package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
  81. package/src/cli/commands/__tests__/plugins.test.ts +20 -1
  82. package/src/cli/commands/__tests__/schedules.test.ts +14 -0
  83. package/src/cli/commands/bash.help.ts +4 -3
  84. package/src/cli/commands/browser.help.ts +5 -1
  85. package/src/cli/commands/credentials.help.ts +3 -3
  86. package/src/cli/commands/oauth/index.help.ts +10 -0
  87. package/src/cli/commands/oauth/providers.ts +12 -0
  88. package/src/cli/commands/oauth/request.test.ts +179 -2
  89. package/src/cli/commands/oauth/request.ts +41 -10
  90. package/src/cli/commands/plugins.ts +12 -5
  91. package/src/cli/commands/schedules.ts +2 -0
  92. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  93. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
  94. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
  95. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
  96. package/src/cli/lib/bundled-marketplace.json +855 -0
  97. package/src/cli/lib/bundled-plugin-packages.json +783 -1
  98. package/src/cli/lib/inspect-plugin.ts +11 -4
  99. package/src/cli/lib/upgrade-plugin.ts +19 -11
  100. package/src/cli/output.ts +18 -4
  101. package/src/config/__tests__/default-provider.test.ts +4 -0
  102. package/src/config/bundled-skills/schedule/SKILL.md +6 -6
  103. package/src/config/loader.ts +9 -17
  104. package/src/config/platform-identity.ts +8 -8
  105. package/src/config/profile-text-generation.test.ts +51 -0
  106. package/src/config/profile-text-generation.ts +51 -0
  107. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  108. package/src/config/schemas/llm.ts +7 -2
  109. package/src/config/schemas/mcp.ts +5 -1
  110. package/src/config/schemas/memory-v3.ts +12 -0
  111. package/src/credential-execution/ces-runtime.ts +2 -2
  112. package/src/credential-execution/executable-discovery.ts +36 -134
  113. package/src/credential-execution/process-manager.test.ts +20 -24
  114. package/src/credential-execution/process-manager.ts +24 -19
  115. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  116. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
  117. package/src/daemon/assistant-attachments.ts +20 -12
  118. package/src/daemon/chat-credential-redaction.ts +16 -1
  119. package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
  120. package/src/daemon/conversation-agent-loop.ts +12 -0
  121. package/src/daemon/conversation-attachments.ts +93 -12
  122. package/src/daemon/conversation-tool-setup.ts +71 -3
  123. package/src/daemon/conversation-turn-finalize.ts +26 -13
  124. package/src/daemon/conversation.ts +72 -17
  125. package/src/daemon/daemon-control.ts +3 -7
  126. package/src/daemon/lifecycle.ts +4 -4
  127. package/src/daemon/mcp-reload-service.ts +1 -1
  128. package/src/daemon/message-types/shared.ts +1 -0
  129. package/src/daemon/orphan-reaper.ts +4 -3
  130. package/src/daemon/persist-media-references.ts +20 -3
  131. package/src/daemon/tool-setup-types.ts +6 -0
  132. package/src/daemon/wake-conversation-ops.ts +50 -15
  133. package/src/desktop/desktop-automation-lease.test.ts +230 -0
  134. package/src/desktop/desktop-automation-lease.ts +90 -6
  135. package/src/desktop/desktop-help.ts +15 -0
  136. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  137. package/src/desktop/desktop-stream-bridge.ts +3 -3
  138. package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
  139. package/src/desktop/desktop-wallpaper-worker.ts +17 -0
  140. package/src/desktop/desktop-wallpaper.test.ts +48 -7
  141. package/src/desktop/desktop-wallpaper.ts +34 -102
  142. package/src/desktop/virtual-desktop-feature.ts +1 -1
  143. package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
  144. package/src/mcp/__tests__/startup.test.ts +30 -7
  145. package/src/mcp/__tests__/tool-caps.test.ts +107 -0
  146. package/src/mcp/manager.ts +168 -101
  147. package/src/mcp/startup.ts +33 -13
  148. package/src/mcp/tool-caps.ts +176 -0
  149. package/src/messaging/provider-message-metadata.ts +3 -3
  150. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  151. package/src/notifications/copy-composer.ts +11 -3
  152. package/src/notifications/schedule-result-producer.ts +4 -6
  153. package/src/oauth/AGENTS.md +2 -0
  154. package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
  155. package/src/oauth/identity-verifier.ts +24 -0
  156. package/src/oauth/oauth-store.ts +11 -1
  157. package/src/oauth/provider-serializer.ts +1 -0
  158. package/src/oauth/seed-providers.ts +12 -0
  159. package/src/permissions/confirmation-guardian-request.test.ts +16 -0
  160. package/src/permissions/confirmation-guardian-request.ts +2 -3
  161. package/src/permissions/question-prompter.test.ts +31 -0
  162. package/src/permissions/question-prompter.ts +2 -0
  163. package/src/persistence/attachments-store.ts +84 -44
  164. package/src/persistence/conversation-crud.ts +111 -20
  165. package/src/persistence/conversation-plugin-facade.ts +30 -0
  166. package/src/persistence/conversation-tool-surface.ts +135 -0
  167. package/src/persistence/conversation-types.test.ts +32 -0
  168. package/src/persistence/conversation-types.ts +26 -4
  169. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  170. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  171. package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
  172. package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
  173. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  174. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  175. package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
  176. package/src/persistence/schema/index.ts +1 -0
  177. package/src/persistence/schema/oauth.ts +1 -0
  178. package/src/persistence/steps.ts +24 -3
  179. package/src/plugin-api/conversation-turn.ts +31 -7
  180. package/src/plugin-api/index.ts +11 -1
  181. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  182. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  183. package/src/plugins/defaults/memory/AGENTS.md +39 -5
  184. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
  185. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  186. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  187. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  188. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  189. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
  190. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  191. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  192. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  193. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  194. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  195. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  196. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  197. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  198. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  199. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  200. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  201. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  202. package/src/plugins/defaults/memory/injectors.ts +4 -3
  203. package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
  204. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  205. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
  206. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  207. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  208. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  209. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  210. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  211. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  212. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  213. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  214. package/src/plugins/defaults/memory/tools.ts +1 -1
  215. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  216. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  217. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  218. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  219. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  220. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  221. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  222. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  223. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  224. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  225. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
  226. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  227. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  228. package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
  229. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  230. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  231. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  232. package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
  233. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  234. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
  235. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  236. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  237. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  238. package/src/prompts/delegation-gate.ts +57 -0
  239. package/src/prompts/system-prompt.ts +14 -32
  240. package/src/providers/inference/adapter-factory.ts +6 -0
  241. package/src/providers/jev/client.test.ts +260 -0
  242. package/src/providers/jev/client.ts +518 -0
  243. package/src/providers/model-catalog.ts +58 -3
  244. package/src/providers/server-tool-pairing.ts +16 -7
  245. package/src/runtime/AGENTS.md +2 -2
  246. package/src/runtime/__tests__/agent-wake.test.ts +94 -1
  247. package/src/runtime/agent-wake.ts +31 -4
  248. package/src/runtime/guardian-action-service.ts +2 -17
  249. package/src/runtime/guardian-reply-router.ts +1 -8
  250. package/src/runtime/http-server.ts +2 -2
  251. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
  252. package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
  253. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
  254. package/src/runtime/routes/channel-route-shared.ts +1 -9
  255. package/src/runtime/routes/conversation-routes.ts +29 -4
  256. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  257. package/src/runtime/routes/desktop-setup-routes.ts +8 -5
  258. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  259. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  260. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  261. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  262. package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
  263. package/src/runtime/routes/inference-profiles-routes.ts +9 -0
  264. package/src/runtime/routes/oauth-commands-routes.ts +57 -54
  265. package/src/runtime/routes/oauth-providers.ts +4 -0
  266. package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
  267. package/src/runtime/routes/oauth-request-hints.ts +149 -0
  268. package/src/runtime/routes/schedule-routes.ts +2 -0
  269. package/src/runtime/routes/secret-routes.ts +99 -119
  270. package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
  271. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
  272. package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
  273. package/src/schedule/run-script.ts +2 -2
  274. package/src/schedule/scheduler.ts +47 -11
  275. package/src/schedule/tool-surface-readiness.ts +65 -0
  276. package/src/schedule/worker-mcp.ts +85 -0
  277. package/src/schedule/worker.ts +9 -31
  278. package/src/security/secure-keys.ts +60 -48
  279. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  280. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  281. package/src/tools/browser/browser-execution.ts +7 -1
  282. package/src/tools/host-terminal/host-shell.ts +12 -6
  283. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  284. package/src/tools/shared/shell-output.test.ts +10 -0
  285. package/src/tools/shared/shell-output.ts +14 -2
  286. package/src/tools/skills/sandbox-runner.ts +13 -2
  287. package/src/tools/skills/scaffold-managed.ts +2 -1
  288. package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
  289. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
  290. package/src/tools/terminal/safe-env.ts +35 -24
  291. package/src/tools/terminal/sanitized-bash.ts +15 -2
  292. package/src/tools/terminal/shell-launch.test.ts +162 -0
  293. package/src/tools/terminal/shell.test.ts +29 -0
  294. package/src/tools/terminal/shell.ts +13 -7
  295. package/src/util/browser-human-verification.ts +14 -0
  296. package/src/util/host-process.test.ts +17 -1
  297. package/src/util/host-process.ts +24 -0
  298. package/src/util/unicode.ts +29 -0
@@ -1977,6 +1977,130 @@ describe("AgentLoop", () => {
1977
1977
  expect(calls[0].tools).not.toEqual(dummyTools);
1978
1978
  });
1979
1979
 
1980
+ test("onToolsSent observes the exact tool array of every provider call", async () => {
1981
+ const perCall: ToolDefinition[][] = [
1982
+ [
1983
+ {
1984
+ name: "first",
1985
+ description: "First",
1986
+ input_schema: { type: "object" },
1987
+ },
1988
+ ],
1989
+ [
1990
+ {
1991
+ name: "second",
1992
+ description: "Second",
1993
+ input_schema: { type: "object" },
1994
+ },
1995
+ ],
1996
+ ];
1997
+ let resolveCount = 0;
1998
+ const sent: ToolDefinition[][] = [];
1999
+
2000
+ const { provider, calls } = createMockProvider([
2001
+ toolUseResponse("t1", "first", {}),
2002
+ textResponse("Done"),
2003
+ ]);
2004
+ const loop = new AgentLoop({
2005
+ provider: provider,
2006
+ systemPrompt: "system",
2007
+ conversationId: "test-conversation",
2008
+ toolExecutor: async () => ({ content: "result", isError: false }),
2009
+ resolveTools: () => perCall[resolveCount++]!,
2010
+ onToolsSent: (tools) => {
2011
+ sent.push(tools);
2012
+ },
2013
+ });
2014
+ await loop.run({
2015
+ requestId: "test-request",
2016
+ messages: [userMessage],
2017
+ onEvent: () => {},
2018
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2019
+ });
2020
+
2021
+ // One observation per provider call, each the array that call sent.
2022
+ expect(sent).toHaveLength(2);
2023
+ expect(calls[0].tools).toEqual(sent[0]);
2024
+ expect(calls[1].tools).toEqual(sent[1]);
2025
+ expect(sent.map((tools) => tools.map((t) => t.name))).toEqual([
2026
+ ["first"],
2027
+ ["second"],
2028
+ ]);
2029
+ });
2030
+
2031
+ test("onToolsSent sees the provider-native web_search tool the loop appends", async () => {
2032
+ const dynamicTools: ToolDefinition[] = [
2033
+ {
2034
+ name: "dynamic_tool",
2035
+ description: "Dynamic",
2036
+ input_schema: { type: "object" },
2037
+ },
2038
+ ];
2039
+ const sent: ToolDefinition[][] = [];
2040
+
2041
+ const { provider, calls } = createMockProvider([textResponse("Hi")]);
2042
+ Object.assign(provider, { supportsNativeWebSearch: true });
2043
+ const loop = new AgentLoop({
2044
+ provider: provider,
2045
+ systemPrompt: "system",
2046
+ conversationId: "test-conversation",
2047
+ config: { enableNativeWebSearch: true },
2048
+ resolveTools: () => dynamicTools,
2049
+ onToolsSent: (tools) => {
2050
+ sent.push(tools);
2051
+ },
2052
+ });
2053
+ await loop.run({
2054
+ requestId: "test-request",
2055
+ messages: [userMessage],
2056
+ onEvent: () => {},
2057
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2058
+ });
2059
+
2060
+ // The resolver never saw web_search; the observer sees the wire array.
2061
+ expect(sent).toHaveLength(1);
2062
+ expect(sent[0]!.map((t) => t.name)).toEqual(["dynamic_tool", "web_search"]);
2063
+ expect(calls[0].tools).toEqual(sent[0]);
2064
+ });
2065
+
2066
+ test("onToolsSent does not fire for a call cancelled before the request leaves", async () => {
2067
+ // A pre-model-call hook runs after tool resolution and before the send,
2068
+ // so aborting there is a call whose tools are resolved but never sent.
2069
+ const controller = new AbortController();
2070
+ registerPlugin({
2071
+ manifest: { name: "abort-before-send", version: "0.0.1" },
2072
+ hooks: {
2073
+ "pre-model-call": async (ctx) => {
2074
+ controller.abort();
2075
+ return ctx;
2076
+ },
2077
+ },
2078
+ });
2079
+ const sent: ToolDefinition[][] = [];
2080
+ const { provider } = createMockProvider([textResponse("never")]);
2081
+ const loop = new AgentLoop({
2082
+ provider: provider,
2083
+ systemPrompt: "system",
2084
+ conversationId: "test-conversation",
2085
+ resolveTools: () => dummyTools,
2086
+ onToolsSent: (tools) => {
2087
+ sent.push(tools);
2088
+ },
2089
+ });
2090
+
2091
+ await loop
2092
+ .run({
2093
+ requestId: "test-request",
2094
+ messages: [userMessage],
2095
+ onEvent: () => {},
2096
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2097
+ signal: controller.signal,
2098
+ })
2099
+ .catch(() => {});
2100
+
2101
+ expect(sent).toEqual([]);
2102
+ });
2103
+
1980
2104
  // 28. Tool list can change between turns
1981
2105
  test("resolveTools can return different tools on each turn", async () => {
1982
2106
  const toolsPerTurn: ToolDefinition[][] = [
@@ -2426,6 +2550,141 @@ describe("AgentLoop", () => {
2426
2550
  expect(noRetryNudge).toBeUndefined();
2427
2551
  });
2428
2552
 
2553
+ // A native web search left deferred by the same assistant turn (a
2554
+ // server_tool_use with no result, alongside the client tool call) runs on
2555
+ // the next request only if that request's tool-result message holds
2556
+ // tool_result blocks alone. The coaching then rides inside the errored
2557
+ // tool_result instead of as a trailing text block.
2558
+ test("folds retry coaching into the tool_result when the assistant turn left a server tool deferred", async () => {
2559
+ const mixedTurn: ProviderResponse = {
2560
+ content: [
2561
+ {
2562
+ type: "tool_use",
2563
+ id: "t1",
2564
+ name: "read_file",
2565
+ input: { path: "/missing.txt" },
2566
+ },
2567
+ {
2568
+ type: "server_tool_use",
2569
+ id: "srvtoolu_1",
2570
+ name: "web_search",
2571
+ input: { query: "news" },
2572
+ },
2573
+ ],
2574
+ model: "mock-model",
2575
+ usage: { inputTokens: 10, outputTokens: 5 },
2576
+ stopReason: "tool_use",
2577
+ };
2578
+ const { provider, calls } = createMockProvider([
2579
+ mixedTurn,
2580
+ textResponse("Reported the missing file."),
2581
+ ]);
2582
+ const toolExecutor = async () => ({
2583
+ content: "Error: HTTP 404",
2584
+ isError: true,
2585
+ });
2586
+
2587
+ const loop = new AgentLoop({
2588
+ provider,
2589
+ systemPrompt: "system",
2590
+ conversationId: "test-conversation",
2591
+ tools: dummyTools,
2592
+ toolExecutor,
2593
+ });
2594
+ await loop.run({
2595
+ requestId: "test-request",
2596
+ messages: [userMessage],
2597
+ onEvent: () => {},
2598
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2599
+ });
2600
+
2601
+ expect(calls).toHaveLength(2);
2602
+ const followUpMessages = calls[1].messages;
2603
+ const followUp = followUpMessages[followUpMessages.length - 1];
2604
+ expect(followUp.role).toBe("user");
2605
+ expect(followUp.content.map((b) => b.type)).toEqual(["tool_result"]);
2606
+ const erroredToolResult = followUp.content[0] as Extract<
2607
+ ContentBlock,
2608
+ { type: "tool_result" }
2609
+ >;
2610
+ expect(erroredToolResult.is_error).toBe(true);
2611
+ expect(erroredToolResult.content).toContain("Error: HTTP 404");
2612
+ expect(erroredToolResult.content).toContain("looks recoverable");
2613
+ });
2614
+
2615
+ // A deferred search answered by a message with text after the results
2616
+ // (history assembled elsewhere) is rejected by the provider as unpaired.
2617
+ // The ordering-repair retry stamps the synthetic result rather than
2618
+ // re-sending the same history.
2619
+ test("repairs a deferred search rejected as unpaired instead of re-sending the same history", async () => {
2620
+ const mixedTurn: Message = {
2621
+ role: "assistant",
2622
+ content: [
2623
+ {
2624
+ type: "tool_use",
2625
+ id: "t1",
2626
+ name: "read_file",
2627
+ input: { path: "/a" },
2628
+ },
2629
+ {
2630
+ type: "server_tool_use",
2631
+ id: "srvtoolu_1",
2632
+ name: "web_search",
2633
+ input: { query: "news" },
2634
+ },
2635
+ ],
2636
+ };
2637
+ const answeredWithText: Message = {
2638
+ role: "user",
2639
+ content: [
2640
+ {
2641
+ type: "tool_result",
2642
+ tool_use_id: "t1",
2643
+ content: "Error: HTTP 404",
2644
+ is_error: true,
2645
+ },
2646
+ { type: "text", text: "<system_notice>retry</system_notice>" },
2647
+ ],
2648
+ };
2649
+ const { provider, calls } = createMockProvider([
2650
+ new Error(
2651
+ "Anthropic API error (400): messages.1: `web_search` tool use with id `srvtoolu_1` was found without a corresponding `web_search_tool_result` block",
2652
+ ),
2653
+ textResponse("recovered"),
2654
+ ]);
2655
+ const loop = new AgentLoop({
2656
+ provider,
2657
+ systemPrompt: "system",
2658
+ conversationId: "test-conversation",
2659
+ });
2660
+ const events: AgentEvent[] = [];
2661
+
2662
+ const { history } = await loop.run({
2663
+ requestId: "test-request",
2664
+ messages: [userMessage, mixedTurn, answeredWithText],
2665
+ onEvent: collectEvents(events),
2666
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2667
+ });
2668
+
2669
+ expect(calls).toHaveLength(2);
2670
+ expect(events.filter((e) => e.type === "error")).toHaveLength(0);
2671
+ // Deep repair stamped the synthetic result on the orphaned search, and the
2672
+ // outbound sanitizer then summarized the answered pair as text, so the
2673
+ // retry carries no unanswered server_tool_use.
2674
+ expect(calls[1].messages[1].content.map((b) => b.type)).toEqual([
2675
+ "tool_use",
2676
+ "text",
2677
+ ]);
2678
+ expect(calls[1].messages[1].content[1]).toMatchObject({
2679
+ type: "text",
2680
+ text: expect.stringContaining("results unavailable"),
2681
+ });
2682
+ expect(history[history.length - 1]).toEqual({
2683
+ role: "assistant",
2684
+ content: [{ type: "text", text: "recovered" }],
2685
+ });
2686
+ });
2687
+
2429
2688
  // Retry coaching stops after a tool fails 3 times in a row — past that the
2430
2689
  // error is likely unrecoverable and further coaching only burns tokens.
2431
2690
  test("stops appending retry coaching after 3 consecutive failures of a tool", async () => {
@@ -74,6 +74,7 @@ function makeTarget(onRun: (conv: Conversation) => void): {
74
74
  subagentAllowedTools: undefined as ReadonlySet<string> | undefined,
75
75
  subagentToolGateMode: undefined as string | undefined,
76
76
  toolContextPin: undefined,
77
+ delegateIndependentTasksReplay: undefined as boolean | undefined,
77
78
  preactivatedSkillIds: undefined as readonly string[] | undefined,
78
79
  setSubagentAllowedTools: (tools?: ReadonlySet<string>) => {
79
80
  target.subagentAllowedTools = tools;
@@ -82,9 +83,12 @@ function makeTarget(onRun: (conv: Conversation) => void): {
82
83
  target.preactivatedSkillIds = ids;
83
84
  },
84
85
  // The section's gate, rendered as a marker so the assertions turn on the
85
- // real predicate rather than on a stubbed answer.
86
+ // real predicate rather than on a stubbed answer. Mirrors
87
+ // `Conversation.buildCurrentSystemPrompt`: a replayed rendered state wins
88
+ // over the derivation.
86
89
  buildCurrentSystemPrompt: () =>
87
- canSpawnSubagentsForTurn(target as unknown as Conversation)
90
+ (target.delegateIndependentTasksReplay ??
91
+ canSpawnSubagentsForTurn(target as unknown as Conversation))
88
92
  ? `base ${DELEGATION_SECTION}`
89
93
  : "base",
90
94
  // Mirrors `Conversation.syncLoopSystemPrompt`.
@@ -156,6 +160,64 @@ describe("the delegation section on a direct wake", () => {
156
160
  expect(promptDuringRun).toContain(DELEGATION_SECTION);
157
161
  });
158
162
 
163
+ test("a replaying wake renders the section its source rendered", async () => {
164
+ let promptDuringRun = "";
165
+ const { target, loopPrompt } = makeTarget(() => {
166
+ promptDuringRun = loopPrompt();
167
+ });
168
+
169
+ await wakeAgentForOpportunity(
170
+ {
171
+ conversationId: target.conversationId,
172
+ hint: "test hint",
173
+ source: "scheduler",
174
+ // The memory retrospective's shape: an allowlist with no spawn path,
175
+ // gated at execution...
176
+ allowedTools: ["remember", "skill_load"],
177
+ toolGateMode: "execution",
178
+ // ...replaying an interactive source whose live turn rendered the
179
+ // section.
180
+ wireToolDefinitions: [
181
+ { name: "remember", description: "Save", input_schema: {} },
182
+ ],
183
+ delegateIndependentTasks: true,
184
+ },
185
+ { resolveTarget: async () => target },
186
+ );
187
+
188
+ expect(promptDuringRun).toContain(DELEGATION_SECTION);
189
+ // The replay comes off with the rest of the wake's scope.
190
+ expect(target.delegateIndependentTasksReplay).toBeUndefined();
191
+ });
192
+
193
+ test("a replaying wake renders the section off when its source did", async () => {
194
+ // A channel-delivered source records the section off. Replaying that
195
+ // keeps the fork's prompt in parity even though this wake's own scope
196
+ // could spawn.
197
+ let promptDuringRun = "";
198
+ const { target, loopPrompt } = makeTarget(() => {
199
+ promptDuringRun = loopPrompt();
200
+ });
201
+
202
+ await wakeAgentForOpportunity(
203
+ {
204
+ conversationId: target.conversationId,
205
+ hint: "test hint",
206
+ source: "scheduler",
207
+ allowedTools: ["skill_load", "skill_execute", "subagent_spawn"],
208
+ toolGateMode: "execution",
209
+ wireToolDefinitions: [
210
+ { name: "skill_load", description: "Load", input_schema: {} },
211
+ ],
212
+ delegateIndependentTasks: false,
213
+ },
214
+ { resolveTarget: async () => target },
215
+ );
216
+
217
+ expect(promptDuringRun).not.toContain(DELEGATION_SECTION);
218
+ expect(promptDuringRun.length).toBeGreaterThan(0);
219
+ });
220
+
159
221
  test("the restored prompt is built without the wake's persona", async () => {
160
222
  // `wakePersonaOverride` feeds `buildCurrentSystemPrompt`, so a rebuild
161
223
  // that ran before the clear would leave the loop holding the wake's
@@ -1650,6 +1650,61 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1650
1650
  ).toHaveLength(0);
1651
1651
  });
1652
1652
 
1653
+ test("mixed tail answered by a tool_result plus trailing text gets a synthetic web_search_tool_result", async () => {
1654
+ // Text after the client result closes the assistant turn on the provider
1655
+ // side, which then rejects the unanswered search as unpaired. The search
1656
+ // is an orphan here, so the synthetic error result keeps the request
1657
+ // valid at the cost of that one search.
1658
+ const messages: Message[] = [
1659
+ userMsg("Do things"),
1660
+ {
1661
+ role: "assistant",
1662
+ content: [
1663
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
1664
+ {
1665
+ type: "server_tool_use",
1666
+ id: "srvtoolu_b",
1667
+ name: "web_search",
1668
+ input: { query: "test" },
1669
+ },
1670
+ ],
1671
+ },
1672
+ {
1673
+ role: "user",
1674
+ content: [
1675
+ {
1676
+ type: "tool_result",
1677
+ tool_use_id: "tu_a",
1678
+ content: "Error: HTTP 404",
1679
+ is_error: true,
1680
+ },
1681
+ { type: "text", text: "<system_notice>retry</system_notice>" },
1682
+ ],
1683
+ },
1684
+ ];
1685
+ await provider.sendMessage(messages);
1686
+
1687
+ const sent = lastStreamParams!.messages as Array<{
1688
+ role: string;
1689
+ content: Array<{ type: string; tool_use_id?: string }>;
1690
+ }>;
1691
+
1692
+ // The repaired pair leaves the mixed message: ensureToolPairing keeps the
1693
+ // client tool_result adjacent to its tool_use and moves the now-answered
1694
+ // server pair into its own assistant turn, ahead of the trailing text.
1695
+ expect(sent.map((m) => m.content.map((b) => b.type))).toEqual([
1696
+ ["text"],
1697
+ ["tool_use"],
1698
+ ["tool_result"],
1699
+ ["server_tool_use", "web_search_tool_result"],
1700
+ ["text"],
1701
+ ]);
1702
+ expect(sent[3].content[1]).toMatchObject({
1703
+ type: "web_search_tool_result",
1704
+ tool_use_id: "srvtoolu_b",
1705
+ });
1706
+ });
1707
+
1653
1708
  test("deferred mixed heartbeat shape with text and multiple searches goes out verbatim", async () => {
1654
1709
  const messages: Message[] = [
1655
1710
  userMsg("Heartbeat: check the file and the news"),
@@ -149,6 +149,46 @@ describe("approval interception trust-class gates", () => {
149
149
  expect(sessionMock).toHaveBeenCalled();
150
150
  });
151
151
 
152
+ test("guardian apr: callback naming no approval action is consumed as a stale button, never routed as text", async () => {
153
+ _anchorPrincipalId = "guardian-principal-1";
154
+ const sessionMock = registerPendingInteraction(
155
+ "req-guardian-retired-1",
156
+ CONVERSATION_ID,
157
+ TOOL_NAME,
158
+ TOOL_INPUT,
159
+ );
160
+
161
+ // The channel normalizers copy callback data into the message content, so
162
+ // a button press arrives with both set to the same string.
163
+ const callbackData = "apr:req-guardian-retired-1:approve_always";
164
+ const result = await handleApprovalInterception({
165
+ conversationId: CONVERSATION_ID,
166
+ callbackData,
167
+ content: callbackData,
168
+ conversationExternalId: REQUESTER_CHAT,
169
+ sourceChannel: "telegram",
170
+ actorExternalId: "guardian-user-1",
171
+ replyCallbackUrl: "https://gateway.test/deliver",
172
+ trustCtx: {
173
+ sourceChannel: "telegram",
174
+ trustClass: "guardian",
175
+ requesterExternalUserId: "guardian-user-1",
176
+ guardianExternalUserId: "guardian-user-1",
177
+ guardianPrincipalId: "guardian-principal-1",
178
+ },
179
+ assistantId: ASSISTANT_ID,
180
+ });
181
+
182
+ expect(result.handled).toBe(true);
183
+ expect(result.type).toBe("stale_ignored");
184
+ // No decision, and no reply: the text parsers never saw the callback.
185
+ expect(sessionMock).not.toHaveBeenCalled();
186
+ expect(deliverSpy).not.toHaveBeenCalled();
187
+ expect(pendingInteractions.getByConversation(CONVERSATION_ID)).toHaveLength(
188
+ 1,
189
+ );
190
+ });
191
+
152
192
  test("guardian apr: callback with a principal NOT matching the anchor is rejected before any decision", async () => {
153
193
  _anchorPrincipalId = "the-real-guardian-principal";
154
194
  const sessionMock = registerPendingInteraction(
@@ -478,9 +478,9 @@ describe("createInlineAttachment (workspace_ref persistence)", () => {
478
478
  expect(filePath).toBeTruthy();
479
479
  expect(result.filename).toBeDefined();
480
480
  expect(filePath!.endsWith(result.filename!)).toBe(true);
481
- expect(result.filename!.startsWith(`${OVERSIZED_CONTENT_FILENAME_PREFIX}-`)).toBe(
482
- true,
483
- );
481
+ expect(
482
+ result.filename!.startsWith(`${OVERSIZED_CONTENT_FILENAME_PREFIX}-`),
483
+ ).toBe(true);
484
484
  expect(readFileSync(filePath!).toString("utf8")).toBe(original);
485
485
  });
486
486
  });
@@ -598,6 +598,25 @@ describe("linkAttachmentToMessage + getAttachmentsForMessage", () => {
598
598
  expect(linked[1].originalFilename).toBe("second.txt");
599
599
  });
600
600
 
601
+ test("reuses a repeated message attachment link with its original position", async () => {
602
+ const conv = createConversation();
603
+ const msg = await addMessage(conv.id, "assistant", "One file");
604
+ const stored = await uploadAttachment("frame.png", "image/png", "AAAA");
605
+
606
+ expect(linkAttachmentToMessage(msg.id, stored.id, 4)).toBe(stored.id);
607
+ expect(linkAttachmentToMessage(msg.id, stored.id, 0)).toBe(stored.id);
608
+
609
+ const links = rawGet<{ count: number; position: number }>(
610
+ "test:repeatedAttachmentLink",
611
+ `SELECT COUNT(*) AS count, MIN(position) AS position
612
+ FROM message_attachments
613
+ WHERE message_id = ? AND attachment_id = ?`,
614
+ msg.id,
615
+ stored.id,
616
+ );
617
+ expect(links).toEqual({ count: 1, position: 4 });
618
+ });
619
+
601
620
  test("returns empty for message with no attachments", async () => {
602
621
  const conv = createConversation();
603
622
  const msg = await addMessage(conv.id, "assistant", "No attachments");
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import type { ApprovalAction } from "../runtime/channel-approval-types.js";
4
3
  import { parseCallbackData } from "../runtime/routes/channel-route-shared.js";
5
4
 
6
5
  // ═══════════════════════════════════════════════════════════════════════════
@@ -19,19 +18,15 @@ describe("parseCallbackData", () => {
19
18
  expect(result!.source).toBe("button");
20
19
  });
21
20
 
22
- test.each<[string, string]>([
23
- ["apr:req-123:approve_10m", "approve_once"],
24
- ["apr:req-123:approve_conversation", "approve_once"],
25
- ["apr:req-123:approve_always", "approve_once"],
26
- ])(
27
- 'maps legacy action "%s" to %s (backward compat)',
28
- (data, expectedAction) => {
29
- const result = parseCallbackData(data);
30
- expect(result).not.toBeNull();
31
- expect(result!.action).toBe(expectedAction as ApprovalAction);
32
- expect(result!.requestId).toBe("req-123");
33
- },
34
- );
21
+ // The parser never maps one action id onto another: an id outside the
22
+ // approval vocabulary is no action, whatever it resembles.
23
+ test.each([
24
+ "apr:req-123:approve_10m",
25
+ "apr:req-123:approve_conversation",
26
+ "apr:req-123:approve_always",
27
+ ])('returns null for the unrecognized action id in "%s"', (data) => {
28
+ expect(parseCallbackData(data)).toBeNull();
29
+ });
35
30
 
36
31
  test("every channel's button press attributes as the button modality", () => {
37
32
  for (const channel of ["slack", "telegram", "whatsapp", "discord"]) {
@@ -588,6 +588,62 @@ describe("channel-reply-delivery", () => {
588
588
  expect(deliveryCalls[0].payload.text).toBe("Fallback text");
589
589
  });
590
590
 
591
+ it("delivers a screenshot-only reply attachment", async () => {
592
+ const screenshot: RuntimeAttachmentMetadata = {
593
+ id: "final-screenshot",
594
+ filename: "computer-use-click.png",
595
+ mimeType: "image/png",
596
+ sizeBytes: 10,
597
+ kind: "image",
598
+ };
599
+
600
+ await deliverRenderedReplyViaCallback({
601
+ callbackUrl: "http://gateway/deliver/telegram",
602
+ chatId: "chat-screenshot",
603
+ textSegments: [],
604
+ fallbackText: "",
605
+ attachments: [screenshot],
606
+ interSegmentDelayMs: 0,
607
+ });
608
+
609
+ expect(deliveryCalls).toHaveLength(1);
610
+ expect(deliveryCalls[0].payload).toMatchObject({
611
+ chatId: "chat-screenshot",
612
+ attachments: [screenshot],
613
+ });
614
+ });
615
+
616
+ it("delivers the final screenshot with an unrelated PDF", async () => {
617
+ const attachments: RuntimeAttachmentMetadata[] = [
618
+ {
619
+ id: "final-screenshot",
620
+ filename: "computer-use-click.png",
621
+ mimeType: "image/png",
622
+ sizeBytes: 10,
623
+ kind: "image",
624
+ },
625
+ {
626
+ id: "explicit-report",
627
+ filename: "report.pdf",
628
+ mimeType: "application/pdf",
629
+ sizeBytes: 20,
630
+ kind: "document",
631
+ },
632
+ ];
633
+
634
+ await deliverRenderedReplyViaCallback({
635
+ callbackUrl: "http://gateway/deliver/slack",
636
+ chatId: "chat-screenshot-pdf",
637
+ textSegments: ["Done."],
638
+ fallbackText: "Done.",
639
+ attachments,
640
+ interSegmentDelayMs: 0,
641
+ });
642
+
643
+ expect(deliveryCalls).toHaveLength(1);
644
+ expect(deliveryCalls[0].payload.attachments).toEqual(attachments);
645
+ });
646
+
591
647
  it("uses rendered textSegments (tool boundaries) when delivering from conversation history", async () => {
592
648
  conversationMessages.push(
593
649
  { id: "msg-user", role: "user", content: "hi" },
@@ -204,6 +204,22 @@ describe("collectRevealRefsFromCommand", () => {
204
204
  );
205
205
  });
206
206
 
207
+ test("parses a legacy service/field path", () => {
208
+ expect(
209
+ collectRevealRefsFromCommand(
210
+ "assistant credentials reveal resend/api_key",
211
+ ),
212
+ ).toEqual([{ service: "resend", field: "api_key" }]);
213
+ });
214
+
215
+ test("legacy path survives malformed extra arguments", () => {
216
+ expect(
217
+ collectRevealRefsFromCommand(
218
+ "assistant credentials reveal resend/api_key --json extra",
219
+ ),
220
+ ).toEqual([{ service: "resend", field: "api_key" }]);
221
+ });
222
+
207
223
  test("unparseable invocation yields no ref (fails safe)", () => {
208
224
  expect(
209
225
  collectRevealRefsFromCommand("assistant credentials reveal --service x"),
@@ -437,6 +453,13 @@ describe("redactSecretsForChat", () => {
437
453
  expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
438
454
  });
439
455
 
456
+ test("Resend key in persisted assistant text is redacted without a candidate", () => {
457
+ const key = `re_${"a".repeat(8)}_${"b".repeat(24)}`;
458
+ const out = redactSecretsForChat(`the key is ${key} and it failed`, []);
459
+ expect(out).not.toContain(key);
460
+ expect(out).toContain("\u3014redacted:Resend API Key\u3015");
461
+ });
462
+
440
463
  test("no candidate match produces the plain sentinel — never a guess", () => {
441
464
  const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
442
465
  { service: "openai", field: "api_key", value: "different-value" },