@openparachute/agent 0.1.2 → 0.2.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 (605) hide show
  1. package/.parachute/module.json +124 -8
  2. package/LICENSE +2 -16
  3. package/README.md +118 -166
  4. package/package.json +32 -43
  5. package/scripts/spawn-agent.ts +371 -0
  6. package/src/_parked/interactive-spawn.test.ts +324 -0
  7. package/src/_parked/interactive-spawn.ts +701 -0
  8. package/src/agent-defs.test.ts +1504 -0
  9. package/src/agent-defs.ts +1702 -0
  10. package/src/agent-mcp-config.test.ts +115 -0
  11. package/src/agent-mcp-config.ts +115 -0
  12. package/src/agents.test.ts +360 -0
  13. package/src/agents.ts +379 -0
  14. package/src/auth.test.ts +46 -0
  15. package/src/auth.ts +140 -0
  16. package/src/backends/attached-queue.test.ts +376 -0
  17. package/src/backends/attached-queue.ts +372 -0
  18. package/src/backends/programmatic.test.ts +1715 -0
  19. package/src/backends/programmatic.ts +927 -0
  20. package/src/backends/registry.test.ts +1494 -0
  21. package/src/backends/registry.ts +1202 -0
  22. package/src/backends/stream-json.test.ts +570 -0
  23. package/src/backends/stream-json.ts +392 -0
  24. package/src/backends/types.ts +223 -0
  25. package/src/bridge.ts +417 -0
  26. package/src/channel-backend-wiring.test.ts +237 -0
  27. package/src/credentials.test.ts +274 -0
  28. package/src/credentials.ts +380 -0
  29. package/src/cron.test.ts +342 -0
  30. package/src/cron.ts +380 -0
  31. package/src/daemon-agent-def-api.test.ts +166 -0
  32. package/src/daemon-agent-defs-api.test.ts +953 -0
  33. package/src/daemon-agent-env-api.test.ts +338 -0
  34. package/src/daemon-attached-queue-store.test.ts +65 -0
  35. package/src/daemon-config-api.test.ts +962 -0
  36. package/src/daemon-jobs-api.test.ts +271 -0
  37. package/src/daemon-vault-chat.test.ts +250 -0
  38. package/src/daemon.test.ts +746 -0
  39. package/src/daemon.ts +3314 -0
  40. package/src/def-vaults.test.ts +136 -0
  41. package/src/def-vaults.ts +165 -0
  42. package/src/delivery-state.test.ts +110 -0
  43. package/src/delivery-state.ts +154 -0
  44. package/src/effective-env.test.ts +114 -0
  45. package/src/effective-env.ts +184 -0
  46. package/src/env-compat.ts +39 -0
  47. package/src/grants.test.ts +638 -0
  48. package/src/grants.ts +675 -0
  49. package/src/hub-jwt.test.ts +161 -0
  50. package/src/hub-jwt.ts +182 -0
  51. package/src/jobs.test.ts +245 -0
  52. package/src/jobs.ts +266 -0
  53. package/src/mcp-http.test.ts +265 -0
  54. package/src/mcp-http.ts +771 -0
  55. package/src/mint-token.test.ts +152 -0
  56. package/src/mint-token.ts +139 -0
  57. package/src/module-manifest.test.ts +158 -0
  58. package/src/oauth-discovery.ts +134 -0
  59. package/src/programmatic-wiring.test.ts +838 -0
  60. package/src/registry.test.ts +227 -0
  61. package/src/registry.ts +228 -0
  62. package/src/resolve-port.test.ts +64 -0
  63. package/src/routing.test.ts +184 -0
  64. package/src/routing.ts +76 -0
  65. package/src/runner.test.ts +506 -0
  66. package/src/runner.ts +255 -0
  67. package/src/sandbox/config.test.ts +150 -0
  68. package/src/sandbox/config.ts +102 -0
  69. package/src/sandbox/egress.test.ts +113 -0
  70. package/src/sandbox/egress.ts +123 -0
  71. package/src/sandbox/index.ts +180 -0
  72. package/src/sandbox/live-seatbelt.test.ts +277 -0
  73. package/src/sandbox/mounts.test.ts +154 -0
  74. package/src/sandbox/mounts.ts +133 -0
  75. package/src/sandbox/sandbox.test.ts +168 -0
  76. package/src/sandbox/types.ts +382 -0
  77. package/src/services-manifest.test.ts +106 -0
  78. package/src/services-manifest.ts +95 -0
  79. package/src/spa-serve.test.ts +116 -0
  80. package/src/spa-serve.ts +116 -0
  81. package/src/spawn-agent-cli.test.ts +172 -0
  82. package/src/spawn-agent.test.ts +1218 -0
  83. package/src/spawn-agent.ts +569 -0
  84. package/src/spawn-deps.test.ts +54 -0
  85. package/src/spawn-deps.ts +166 -0
  86. package/src/telegram/api.ts +153 -0
  87. package/src/terminal-assets.test.ts +50 -0
  88. package/src/terminal-assets.ts +79 -0
  89. package/src/terminal-ui.ts +305 -0
  90. package/src/terminal.test.ts +530 -0
  91. package/src/terminal.ts +458 -0
  92. package/src/transport.ts +270 -0
  93. package/src/transports/http-ui.test.ts +455 -0
  94. package/src/transports/http-ui.ts +201 -0
  95. package/src/transports/telegram.test.ts +174 -0
  96. package/src/transports/telegram.ts +426 -0
  97. package/src/transports/vault.test.ts +2011 -0
  98. package/src/transports/vault.ts +1790 -0
  99. package/src/ui-kit.test.ts +178 -0
  100. package/src/ui-kit.ts +402 -0
  101. package/tsconfig.json +8 -14
  102. package/web/ui/tsconfig.json +2 -1
  103. package/.claude/scheduled_tasks.lock +0 -1
  104. package/.claude/settings.json +0 -5
  105. package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
  106. package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
  107. package/.claude/skills/add-codex/SKILL.md +0 -161
  108. package/.claude/skills/add-dashboard/SKILL.md +0 -138
  109. package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
  110. package/.claude/skills/add-emacs/SKILL.md +0 -296
  111. package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
  112. package/.claude/skills/add-gchat/REMOVE.md +0 -6
  113. package/.claude/skills/add-gchat/SKILL.md +0 -92
  114. package/.claude/skills/add-gchat/VERIFY.md +0 -3
  115. package/.claude/skills/add-github/REMOVE.md +0 -6
  116. package/.claude/skills/add-github/SKILL.md +0 -148
  117. package/.claude/skills/add-github/VERIFY.md +0 -3
  118. package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
  119. package/.claude/skills/add-imessage/REMOVE.md +0 -6
  120. package/.claude/skills/add-imessage/SKILL.md +0 -113
  121. package/.claude/skills/add-imessage/VERIFY.md +0 -3
  122. package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
  123. package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
  124. package/.claude/skills/add-linear/REMOVE.md +0 -6
  125. package/.claude/skills/add-linear/SKILL.md +0 -168
  126. package/.claude/skills/add-linear/VERIFY.md +0 -3
  127. package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
  128. package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
  129. package/.claude/skills/add-matrix/REMOVE.md +0 -6
  130. package/.claude/skills/add-matrix/SKILL.md +0 -148
  131. package/.claude/skills/add-matrix/VERIFY.md +0 -3
  132. package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
  133. package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
  134. package/.claude/skills/add-opencode/SKILL.md +0 -229
  135. package/.claude/skills/add-parallel/SKILL.md +0 -290
  136. package/.claude/skills/add-resend/REMOVE.md +0 -6
  137. package/.claude/skills/add-resend/SKILL.md +0 -93
  138. package/.claude/skills/add-resend/VERIFY.md +0 -3
  139. package/.claude/skills/add-signal/REMOVE.md +0 -13
  140. package/.claude/skills/add-signal/SKILL.md +0 -318
  141. package/.claude/skills/add-signal/VERIFY.md +0 -5
  142. package/.claude/skills/add-slack/REMOVE.md +0 -6
  143. package/.claude/skills/add-slack/SKILL.md +0 -112
  144. package/.claude/skills/add-slack/VERIFY.md +0 -3
  145. package/.claude/skills/add-teams/REMOVE.md +0 -6
  146. package/.claude/skills/add-teams/SKILL.md +0 -207
  147. package/.claude/skills/add-teams/VERIFY.md +0 -3
  148. package/.claude/skills/add-vercel/SKILL.md +0 -147
  149. package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
  150. package/.claude/skills/add-webex/REMOVE.md +0 -6
  151. package/.claude/skills/add-webex/SKILL.md +0 -88
  152. package/.claude/skills/add-webex/VERIFY.md +0 -3
  153. package/.claude/skills/add-wechat/REMOVE.md +0 -49
  154. package/.claude/skills/add-wechat/SKILL.md +0 -170
  155. package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
  156. package/.claude/skills/add-whatsapp/SKILL.md +0 -264
  157. package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
  158. package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
  159. package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
  160. package/.claude/skills/claw/SKILL.md +0 -131
  161. package/.claude/skills/claw/scripts/claw +0 -374
  162. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
  163. package/.claude/skills/customize/SKILL.md +0 -110
  164. package/.claude/skills/debug/SKILL.md +0 -349
  165. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  166. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  167. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  168. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  169. package/.claude/skills/init-first-agent/SKILL.md +0 -120
  170. package/.claude/skills/init-onecli/SKILL.md +0 -270
  171. package/.claude/skills/manage-channels/SKILL.md +0 -87
  172. package/.claude/skills/manage-mounts/SKILL.md +0 -47
  173. package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
  174. package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
  175. package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
  176. package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
  177. package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
  178. package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
  179. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  180. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  181. package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
  182. package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
  183. package/.claude/skills/update-skills/SKILL.md +0 -130
  184. package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
  185. package/.claude/skills/x-integration/SKILL.md +0 -417
  186. package/.claude/skills/x-integration/agent.ts +0 -243
  187. package/.claude/skills/x-integration/host.ts +0 -155
  188. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  189. package/.claude/skills/x-integration/lib/config.ts +0 -62
  190. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  191. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  192. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  193. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  194. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  195. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  196. package/.github/CODEOWNERS +0 -10
  197. package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
  198. package/.github/workflows/bump-version.yml +0 -35
  199. package/.github/workflows/ci.yml +0 -39
  200. package/.github/workflows/label-pr.yml +0 -40
  201. package/.github/workflows/update-tokens.yml +0 -43
  202. package/.husky/pre-commit +0 -1
  203. package/.mcp.json +0 -3
  204. package/.nvmrc +0 -1
  205. package/.prettierrc +0 -4
  206. package/CHANGELOG.md +0 -263
  207. package/CLAUDE.md +0 -307
  208. package/CODE_OF_CONDUCT.md +0 -128
  209. package/CONTRIBUTING.md +0 -159
  210. package/CONTRIBUTORS.md +0 -26
  211. package/LICENSE-NANOCLAW-MIT +0 -21
  212. package/README_ja.md +0 -194
  213. package/README_zh.md +0 -194
  214. package/assets/nanoclaw-favicon.png +0 -0
  215. package/assets/nanoclaw-icon.png +0 -0
  216. package/assets/nanoclaw-logo-dark.png +0 -0
  217. package/assets/nanoclaw-logo.png +0 -0
  218. package/assets/nanoclaw-profile.jpeg +0 -0
  219. package/assets/nanoclaw-sales.png +0 -0
  220. package/assets/social-preview.jpg +0 -0
  221. package/config-examples/mount-allowlist.json +0 -25
  222. package/container/.dockerignore +0 -2
  223. package/container/CLAUDE.md +0 -21
  224. package/container/Dockerfile +0 -121
  225. package/container/agent-runner/bun.lock +0 -243
  226. package/container/agent-runner/package.json +0 -22
  227. package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
  228. package/container/agent-runner/src/config.ts +0 -55
  229. package/container/agent-runner/src/db/connection.ts +0 -267
  230. package/container/agent-runner/src/db/index.ts +0 -20
  231. package/container/agent-runner/src/db/messages-in.ts +0 -138
  232. package/container/agent-runner/src/db/messages-out.ts +0 -143
  233. package/container/agent-runner/src/db/session-routing.ts +0 -30
  234. package/container/agent-runner/src/db/session-state.test.ts +0 -100
  235. package/container/agent-runner/src/db/session-state.ts +0 -79
  236. package/container/agent-runner/src/destinations.ts +0 -135
  237. package/container/agent-runner/src/formatter.test.ts +0 -167
  238. package/container/agent-runner/src/formatter.ts +0 -260
  239. package/container/agent-runner/src/index.ts +0 -110
  240. package/container/agent-runner/src/integration.test.ts +0 -121
  241. package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
  242. package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
  243. package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
  244. package/container/agent-runner/src/mcp-tools/core.ts +0 -262
  245. package/container/agent-runner/src/mcp-tools/index.ts +0 -22
  246. package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
  247. package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
  248. package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
  249. package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
  250. package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
  251. package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
  252. package/container/agent-runner/src/mcp-tools/server.ts +0 -54
  253. package/container/agent-runner/src/mcp-tools/types.ts +0 -6
  254. package/container/agent-runner/src/poll-loop.test.ts +0 -248
  255. package/container/agent-runner/src/poll-loop.ts +0 -437
  256. package/container/agent-runner/src/providers/claude.ts +0 -379
  257. package/container/agent-runner/src/providers/factory.test.ts +0 -19
  258. package/container/agent-runner/src/providers/factory.ts +0 -13
  259. package/container/agent-runner/src/providers/index.ts +0 -6
  260. package/container/agent-runner/src/providers/mock.ts +0 -77
  261. package/container/agent-runner/src/providers/provider-registry.ts +0 -33
  262. package/container/agent-runner/src/providers/types.ts +0 -82
  263. package/container/agent-runner/src/scheduling/task-script.ts +0 -121
  264. package/container/agent-runner/src/timezone.test.ts +0 -93
  265. package/container/agent-runner/src/timezone.ts +0 -107
  266. package/container/agent-runner/tsconfig.json +0 -14
  267. package/container/build.sh +0 -48
  268. package/container/entrypoint.sh +0 -16
  269. package/container/skills/agent-browser/SKILL.md +0 -159
  270. package/container/skills/frontend-engineer/SKILL.md +0 -157
  271. package/container/skills/self-customize/SKILL.md +0 -87
  272. package/container/skills/slack-formatting/SKILL.md +0 -94
  273. package/container/skills/vercel-cli/SKILL.md +0 -111
  274. package/container/skills/welcome/SKILL.md +0 -85
  275. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  276. package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
  277. package/docs/README.md +0 -25
  278. package/docs/SDK_DEEP_DIVE.md +0 -643
  279. package/docs/SECURITY.md +0 -162
  280. package/docs/agent-runner-details.md +0 -749
  281. package/docs/api-details.md +0 -365
  282. package/docs/architecture-diagram.html +0 -422
  283. package/docs/architecture-diagram.md +0 -215
  284. package/docs/architecture.md +0 -751
  285. package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
  286. package/docs/build-and-runtime.md +0 -80
  287. package/docs/cross-mount-stress/README.md +0 -112
  288. package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
  289. package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
  290. package/docs/cross-mount-stress/container-writer.mjs +0 -47
  291. package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
  292. package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
  293. package/docs/cross-mount-stress/host-writer.mjs +0 -47
  294. package/docs/db-central.md +0 -316
  295. package/docs/db-session.md +0 -183
  296. package/docs/db.md +0 -119
  297. package/docs/design/2026-04-29-vault-management-ui.md +0 -231
  298. package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
  299. package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
  300. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
  301. package/docs/docker-sandboxes.md +0 -359
  302. package/docs/isolation-model.md +0 -88
  303. package/docs/ollama.md +0 -79
  304. package/docs/parachute-integration.md +0 -109
  305. package/docs/post-night-rebirth-reflections.md +0 -151
  306. package/eslint.config.js +0 -32
  307. package/pnpm-workspace.yaml +0 -8
  308. package/repo-tokens/README.md +0 -113
  309. package/repo-tokens/action.yml +0 -186
  310. package/repo-tokens/badge.svg +0 -23
  311. package/repo-tokens/examples/green.svg +0 -14
  312. package/repo-tokens/examples/red.svg +0 -14
  313. package/repo-tokens/examples/yellow-green.svg +0 -14
  314. package/repo-tokens/examples/yellow.svg +0 -14
  315. package/scripts/chat.ts +0 -101
  316. package/scripts/cleanup-sessions.sh +0 -150
  317. package/scripts/init-cli-agent.ts +0 -172
  318. package/scripts/init-first-agent.ts +0 -378
  319. package/scripts/parachute.ts +0 -158
  320. package/scripts/run-migrations.ts +0 -105
  321. package/scripts/sanity-live-poll.ts +0 -95
  322. package/scripts/seed-discord.ts +0 -80
  323. package/scripts/test-v2-agent.ts +0 -106
  324. package/scripts/test-v2-channel-e2e.ts +0 -265
  325. package/scripts/test-v2-host.ts +0 -184
  326. package/src/channels/adapter.ts +0 -214
  327. package/src/channels/api-translator.test.ts +0 -306
  328. package/src/channels/api-translator.ts +0 -214
  329. package/src/channels/ask-question.ts +0 -46
  330. package/src/channels/channel-registry.test.ts +0 -421
  331. package/src/channels/channel-registry.ts +0 -313
  332. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  333. package/src/channels/chat-sdk-bridge.ts +0 -652
  334. package/src/channels/cli.ts +0 -276
  335. package/src/channels/discord.ts +0 -90
  336. package/src/channels/index.ts +0 -17
  337. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  338. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  339. package/src/channels/telegram-pairing.test.ts +0 -254
  340. package/src/channels/telegram-pairing.ts +0 -339
  341. package/src/channels/telegram.ts +0 -279
  342. package/src/channels/trust-hint.test.ts +0 -48
  343. package/src/channels/trust-hint.ts +0 -75
  344. package/src/claude-md-compose.migrate.test.ts +0 -64
  345. package/src/claude-md-compose.ts +0 -205
  346. package/src/command-gate.ts +0 -63
  347. package/src/config.test.ts +0 -93
  348. package/src/config.ts +0 -128
  349. package/src/container-config.ts +0 -167
  350. package/src/container-runner.test.ts +0 -32
  351. package/src/container-runner.ts +0 -576
  352. package/src/container-runtime.test.ts +0 -269
  353. package/src/container-runtime.ts +0 -167
  354. package/src/db/_bun-sqlite-shim.ts +0 -88
  355. package/src/db/agent-activity.test.ts +0 -155
  356. package/src/db/agent-activity.ts +0 -121
  357. package/src/db/agent-groups.ts +0 -77
  358. package/src/db/connection.migrate.test.ts +0 -176
  359. package/src/db/connection.ts +0 -259
  360. package/src/db/db-v2.test.ts +0 -440
  361. package/src/db/dropped-messages.ts +0 -44
  362. package/src/db/index.ts +0 -40
  363. package/src/db/messaging-groups.ts +0 -252
  364. package/src/db/migrations/001-initial.ts +0 -112
  365. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  366. package/src/db/migrations/008-dropped-messages.ts +0 -27
  367. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  368. package/src/db/migrations/010-engage-modes.ts +0 -103
  369. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  370. package/src/db/migrations/012-channel-registration.ts +0 -48
  371. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  372. package/src/db/migrations/014-secrets.ts +0 -44
  373. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  374. package/src/db/migrations/016-secret-assignments.ts +0 -30
  375. package/src/db/migrations/017-agent-activity.ts +0 -40
  376. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  377. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  378. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  379. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  380. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  381. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  382. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  383. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  384. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  385. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  386. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  387. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  388. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  389. package/src/db/migrations/027-provider-credentials.ts +0 -41
  390. package/src/db/migrations/_test-helpers.ts +0 -41
  391. package/src/db/migrations/index.ts +0 -127
  392. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  393. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  394. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  395. package/src/db/schema.ts +0 -258
  396. package/src/db/session-db.test.ts +0 -93
  397. package/src/db/session-db.ts +0 -325
  398. package/src/db/sessions.ts +0 -241
  399. package/src/delivery.test.ts +0 -148
  400. package/src/delivery.ts +0 -445
  401. package/src/env.ts +0 -74
  402. package/src/group-folder.test.ts +0 -35
  403. package/src/group-folder.ts +0 -44
  404. package/src/group-init.ts +0 -92
  405. package/src/host-core.test.ts +0 -456
  406. package/src/host-sweep.test.ts +0 -146
  407. package/src/host-sweep.ts +0 -287
  408. package/src/index.ts +0 -232
  409. package/src/install-slug.ts +0 -33
  410. package/src/log.test.ts +0 -81
  411. package/src/log.ts +0 -117
  412. package/src/mcp/http.ts +0 -72
  413. package/src/mcp/server.ts +0 -92
  414. package/src/mcp/stdio.ts +0 -51
  415. package/src/mcp/tools/activity.ts +0 -88
  416. package/src/mcp/tools/agent-groups.ts +0 -183
  417. package/src/mcp/tools/approvals.ts +0 -122
  418. package/src/mcp/tools/channels.test.ts +0 -126
  419. package/src/mcp/tools/channels.ts +0 -134
  420. package/src/mcp/tools/index.ts +0 -27
  421. package/src/mcp/tools/oauth.ts +0 -48
  422. package/src/mcp/tools/secrets.ts +0 -169
  423. package/src/mcp/tools/sessions.ts +0 -135
  424. package/src/mcp/types.ts +0 -51
  425. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  426. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  427. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  428. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  429. package/src/modules/agent-to-agent/index.ts +0 -22
  430. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  431. package/src/modules/approvals/agent.md +0 -45
  432. package/src/modules/approvals/index.ts +0 -21
  433. package/src/modules/approvals/picks.test.ts +0 -291
  434. package/src/modules/approvals/primitive.ts +0 -279
  435. package/src/modules/approvals/project.md +0 -27
  436. package/src/modules/approvals/response-handler.ts +0 -87
  437. package/src/modules/index.ts +0 -24
  438. package/src/modules/interactive/agent.md +0 -21
  439. package/src/modules/interactive/index.ts +0 -69
  440. package/src/modules/interactive/project.md +0 -12
  441. package/src/modules/mount-security/expand-path.test.ts +0 -82
  442. package/src/modules/mount-security/index.ts +0 -459
  443. package/src/modules/mount-security/migrate.test.ts +0 -91
  444. package/src/modules/permissions/access.ts +0 -28
  445. package/src/modules/permissions/channel-approval.test.ts +0 -389
  446. package/src/modules/permissions/channel-approval.ts +0 -188
  447. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  448. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  449. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  450. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  451. package/src/modules/permissions/db/user-dms.ts +0 -58
  452. package/src/modules/permissions/db/user-roles.ts +0 -85
  453. package/src/modules/permissions/db/users.ts +0 -38
  454. package/src/modules/permissions/index.ts +0 -421
  455. package/src/modules/permissions/permissions.test.ts +0 -358
  456. package/src/modules/permissions/sender-approval.test.ts +0 -641
  457. package/src/modules/permissions/sender-approval.ts +0 -165
  458. package/src/modules/permissions/user-dm.ts +0 -200
  459. package/src/modules/provider-credentials/db.ts +0 -121
  460. package/src/modules/provider-credentials/index.ts +0 -12
  461. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  462. package/src/modules/provider-credentials/spawn.ts +0 -114
  463. package/src/modules/scheduling/actions.ts +0 -113
  464. package/src/modules/scheduling/db.test.ts +0 -282
  465. package/src/modules/scheduling/db.ts +0 -148
  466. package/src/modules/scheduling/index.ts +0 -34
  467. package/src/modules/scheduling/recurrence.test.ts +0 -98
  468. package/src/modules/scheduling/recurrence.ts +0 -54
  469. package/src/modules/self-mod/agent.md +0 -30
  470. package/src/modules/self-mod/apply.ts +0 -85
  471. package/src/modules/self-mod/index.ts +0 -30
  472. package/src/modules/self-mod/project.md +0 -39
  473. package/src/modules/self-mod/request.ts +0 -91
  474. package/src/modules/typing/index.ts +0 -165
  475. package/src/oauth/agent-app-connections.ts +0 -103
  476. package/src/oauth/app-configs.test.ts +0 -64
  477. package/src/oauth/app-configs.ts +0 -114
  478. package/src/oauth/app-connections.test.ts +0 -109
  479. package/src/oauth/app-connections.ts +0 -178
  480. package/src/oauth/crypto.ts +0 -56
  481. package/src/oauth/flow.ts +0 -104
  482. package/src/oauth/providers/google.test.ts +0 -38
  483. package/src/oauth/providers/google.ts +0 -46
  484. package/src/oauth/providers/index.ts +0 -48
  485. package/src/oauth/state-store.test.ts +0 -54
  486. package/src/oauth/state-store.ts +0 -93
  487. package/src/parachute/README.md +0 -27
  488. package/src/parachute/create-agent.test.ts +0 -83
  489. package/src/parachute/create-agent.ts +0 -122
  490. package/src/parachute/group-status.test.ts +0 -165
  491. package/src/parachute/group-status.ts +0 -136
  492. package/src/parachute/types.ts +0 -41
  493. package/src/parachute/vault-mcp.test.ts +0 -251
  494. package/src/parachute/vault-mcp.ts +0 -232
  495. package/src/platform-id.test.ts +0 -104
  496. package/src/platform-id.ts +0 -109
  497. package/src/providers/index.ts +0 -6
  498. package/src/providers/provider-container-registry.ts +0 -58
  499. package/src/response-registry.ts +0 -45
  500. package/src/router.ts +0 -530
  501. package/src/secrets/crypto.test.ts +0 -45
  502. package/src/secrets/crypto.ts +0 -55
  503. package/src/secrets/index.ts +0 -461
  504. package/src/secrets/master-key.ts +0 -70
  505. package/src/secrets/secrets.test.ts +0 -651
  506. package/src/session-manager.attachments.test.ts +0 -171
  507. package/src/session-manager.dup-skip.test.ts +0 -173
  508. package/src/session-manager.migrate.test.ts +0 -59
  509. package/src/session-manager.ts +0 -451
  510. package/src/startup-bootstrap.test.ts +0 -226
  511. package/src/startup-bootstrap.ts +0 -207
  512. package/src/state-sqlite.ts +0 -182
  513. package/src/timezone.test.ts +0 -64
  514. package/src/timezone.ts +0 -37
  515. package/src/types.ts +0 -233
  516. package/src/web/auth.test.ts +0 -335
  517. package/src/web/auth.ts +0 -214
  518. package/src/web/discord-validate.test.ts +0 -77
  519. package/src/web/discord-validate.ts +0 -88
  520. package/src/web/hub-discovery.test.ts +0 -98
  521. package/src/web/hub-discovery.ts +0 -69
  522. package/src/web/routes/activity.ts +0 -106
  523. package/src/web/routes/agent-provider.test.ts +0 -282
  524. package/src/web/routes/agent-provider.ts +0 -309
  525. package/src/web/routes/approvals.ts +0 -185
  526. package/src/web/routes/apps.ts +0 -434
  527. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  528. package/src/web/routes/channels-mga-detail.test.ts +0 -472
  529. package/src/web/routes/channels.ts +0 -311
  530. package/src/web/routes/oauth-providers.ts +0 -42
  531. package/src/web/routes/secrets.test.ts +0 -220
  532. package/src/web/routes/secrets.ts +0 -317
  533. package/src/web/routes/sessions.ts +0 -123
  534. package/src/web/routes/settings.test.ts +0 -106
  535. package/src/web/routes/settings.ts +0 -247
  536. package/src/web/routes/setup-status.ts +0 -205
  537. package/src/web/routes/vaults.test.ts +0 -389
  538. package/src/web/routes/vaults.ts +0 -225
  539. package/src/web/server-version.test.ts +0 -16
  540. package/src/web/server.ts +0 -1024
  541. package/src/web/services-manifest.test.ts +0 -148
  542. package/src/web/services-manifest.ts +0 -66
  543. package/src/web/static-serve.test.ts +0 -255
  544. package/src/web/static-serve.ts +0 -104
  545. package/src/web/telegram-validate.test.ts +0 -116
  546. package/src/web/telegram-validate.ts +0 -107
  547. package/src/web/vault-proxy.test.ts +0 -214
  548. package/src/web/vault-proxy.ts +0 -120
  549. package/src/web/wire-channel.ts +0 -181
  550. package/src/webhook-server.ts +0 -134
  551. package/vitest.config.ts +0 -18
  552. package/web/README.md +0 -63
  553. package/web/ui/index.html +0 -13
  554. package/web/ui/package.json +0 -35
  555. package/web/ui/pnpm-lock.yaml +0 -2164
  556. package/web/ui/scripts/verify-base.mjs +0 -31
  557. package/web/ui/src/App.tsx +0 -88
  558. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  559. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  560. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  561. package/web/ui/src/components/CredentialForm.tsx +0 -214
  562. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  563. package/web/ui/src/components/StatusDot.tsx +0 -43
  564. package/web/ui/src/components/VaultPicker.tsx +0 -127
  565. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  566. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  567. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  568. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  569. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  570. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  571. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  572. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  573. package/web/ui/src/components/setup/types.ts +0 -105
  574. package/web/ui/src/lib/api.test.ts +0 -410
  575. package/web/ui/src/lib/api.ts +0 -1248
  576. package/web/ui/src/lib/auth.test.ts +0 -352
  577. package/web/ui/src/lib/auth.ts +0 -405
  578. package/web/ui/src/lib/channel-adapters.ts +0 -136
  579. package/web/ui/src/main.tsx +0 -19
  580. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  581. package/web/ui/src/routes/Apps.tsx +0 -613
  582. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  583. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  584. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  585. package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
  586. package/web/ui/src/routes/GroupDetail.tsx +0 -880
  587. package/web/ui/src/routes/GroupList.tsx +0 -187
  588. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  589. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  590. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  591. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  592. package/web/ui/src/routes/SecretsList.tsx +0 -942
  593. package/web/ui/src/routes/SessionsList.tsx +0 -220
  594. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  595. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  596. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  597. package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
  598. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  599. package/web/ui/src/routes/VaultsList.tsx +0 -295
  600. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  601. package/web/ui/src/styles.css +0 -608
  602. package/web/ui/src/test/setup.ts +0 -23
  603. package/web/ui/src/vite-env.d.ts +0 -10
  604. package/web/ui/vite.config.ts +0 -34
  605. package/web/ui/vitest.config.ts +0 -25
@@ -0,0 +1,2011 @@
1
+ /**
2
+ * Tier 1 unit tests for the vault transport.
3
+ *
4
+ * These exercise the transport WITHOUT a live vault — `fetch` is stubbed to
5
+ * capture the outbound note write, and `ctx.emit` is recorded to assert inbound
6
+ * delivery. They cover:
7
+ * - reply(): writes the right POST .../api/notes tagged BOTH the queryable parent
8
+ * `#agent/message` AND the directional child `#agent/message/outbound` (no
9
+ * `outbound` metadata key), with direction, `metadata.agent`, Bearer token; returns the id;
10
+ * - reply(): threads in_reply_to when the bridge passes it;
11
+ * - loadTranscript(): queries the single `#agent/message` parent tag, filters by
12
+ * `noteAgentKey(meta)` (the routing key) client-side;
13
+ * - ingestInbound(): emits the inbound content + meta onto its channel;
14
+ * - ingestInbound(): IGNORES a `#agent/message/outbound`-tagged note (loop avoidance);
15
+ * - schema: `AGENT_VAULT_TAG_SCHEMA` declares the `#agent/*` namespace rollup;
16
+ * - registry: a vault channel instantiates from config.
17
+ *
18
+ * TAG NAMESPACE — `#agent/*` (design 2026-06-17-vault-native-agents). WRITE + READ
19
+ * are the `#agent/message*` tags only — the channel→agent data-model rename CONTRACT
20
+ * dropped the legacy `#channel-message*` / interim `#agent-message*` dual-read. The
21
+ * routing key is written under `metadata.agent` ONLY (the `channel` dual-write is
22
+ * dropped); `noteAgentKey` keeps an `agent ?? channel` read fallback for stragglers.
23
+ * The channel-name slugs, `?channel=`, the `Channel*` types, and the `channel/<name>/`
24
+ * note path prefix are DOMAIN — unchanged.
25
+ */
26
+
27
+ import { describe, test, expect, afterEach } from "bun:test";
28
+ import { VaultTransport, AGENT_VAULT_TAG_SCHEMA, AGENT_THREAD_TAG, AGENT_JOB_TAG, InboundClaimConflictError, noteAgentKey } from "./vault.ts";
29
+ import type { TransportContext, InboundMessage } from "../transport.ts";
30
+ import { instantiateTransport } from "../registry.ts";
31
+
32
+ const realFetch = globalThis.fetch;
33
+ afterEach(() => {
34
+ globalThis.fetch = realFetch;
35
+ });
36
+
37
+ /** A test context that records emitted inbound messages. */
38
+ function fakeCtx(channel: string): TransportContext & { emitted: InboundMessage[] } {
39
+ const emitted: InboundMessage[] = [];
40
+ return {
41
+ channel,
42
+ emitted,
43
+ emit(msg) {
44
+ emitted.push(msg);
45
+ },
46
+ emitPermissionVerdict() {},
47
+ };
48
+ }
49
+
50
+ function baseConfig() {
51
+ return {
52
+ vault: "default",
53
+ vaultUrl: "http://127.0.0.1:1940",
54
+ token: "write-token-xyz",
55
+ webhookSecret: "s3cret",
56
+ };
57
+ }
58
+
59
+ describe("noteAgentKey — the expand-phase dual-read routing key", () => {
60
+ test("returns `agent` when present", () => {
61
+ expect(noteAgentKey({ agent: "eng" })).toBe("eng");
62
+ });
63
+ test("falls back to legacy `channel` when `agent` is absent", () => {
64
+ expect(noteAgentKey({ channel: "ops" })).toBe("ops");
65
+ });
66
+ test("prefers `agent` over `channel` when BOTH are present", () => {
67
+ expect(noteAgentKey({ agent: "eng", channel: "legacy" })).toBe("eng");
68
+ });
69
+ test("returns undefined when neither is present", () => {
70
+ expect(noteAgentKey({})).toBeUndefined();
71
+ expect(noteAgentKey(undefined)).toBeUndefined();
72
+ expect(noteAgentKey(null)).toBeUndefined();
73
+ });
74
+ test("ignores empty-string / non-string values (falls through)", () => {
75
+ // An empty `agent` is not a usable routing key → fall back to channel.
76
+ expect(noteAgentKey({ agent: "", channel: "ops" })).toBe("ops");
77
+ // Non-string values are ignored entirely.
78
+ expect(noteAgentKey({ agent: 123 as unknown as string, channel: "ops" })).toBe("ops");
79
+ expect(noteAgentKey({ agent: "", channel: "" })).toBeUndefined();
80
+ });
81
+ });
82
+
83
+ describe("VaultTransport — reply (outbound note write)", () => {
84
+ test("reply() POSTs .../api/notes tagged #agent/message + #agent/message/outbound + direction + channel + Bearer", async () => {
85
+ const calls: { url: string; init: RequestInit }[] = [];
86
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
87
+ calls.push({ url: String(url), init: init ?? {} });
88
+ return new Response(JSON.stringify({ id: "note-created-1" }), {
89
+ status: 201,
90
+ headers: { "content-type": "application/json" },
91
+ });
92
+ }) as typeof fetch;
93
+
94
+ const t = new VaultTransport(baseConfig());
95
+ await t.start(fakeCtx("eng"));
96
+ const result = await t.reply({ channel: "eng", text: "the reply text" });
97
+
98
+ expect(result.sent).toEqual(["note-created-1"]);
99
+ // start() also fires ensureSchema() (PUT .../api/tags/*); isolate the note POST.
100
+ const noteCalls = calls.filter((c) => c.url.endsWith("/api/notes"));
101
+ expect(noteCalls).toHaveLength(1);
102
+ const call = noteCalls[0]!;
103
+ expect(call.url).toBe("http://127.0.0.1:1940/vault/default/api/notes");
104
+ expect(call.init.method).toBe("POST");
105
+ const headers = call.init.headers as Record<string, string>;
106
+ expect(headers.authorization).toBe("Bearer write-token-xyz");
107
+
108
+ const sent = JSON.parse(String(call.init.body)) as {
109
+ content: string;
110
+ path: string;
111
+ tags: string[];
112
+ metadata: Record<string, string>;
113
+ };
114
+ expect(sent.content).toBe("the reply text");
115
+ // Two orthogonal tags: the parent `#agent/message` is carried LITERALLY so
116
+ // the note is queryable under it (a slash is namespace, NOT query inheritance —
117
+ // a child-only-tagged note is invisible to a `tag:#agent/message` query), and
118
+ // the directional child `#agent/message/outbound` is the trigger discriminator.
119
+ // We WRITE only the `#agent/message*` tags.
120
+ expect(sent.tags).toEqual(["#agent/message", "#agent/message/outbound"]);
121
+ // Regression guard: the queryable parent tag MUST be present literally.
122
+ expect(sent.tags).toContain("#agent/message");
123
+ expect(sent.tags).toContain("#agent/message/outbound");
124
+ // Write-discipline: the interim/legacy tags are gone (CONTRACT dropped them).
125
+ expect(sent.tags).not.toContain("#agent-message");
126
+ expect(sent.tags).not.toContain("#agent-message/outbound");
127
+ expect(sent.tags).not.toContain("#channel-message");
128
+ expect(sent.tags).not.toContain("#channel-message/outbound");
129
+ // The note PATH prefix is DOMAIN (`channel/<name>/`) — unchanged by the rename.
130
+ expect(sent.path.startsWith("channel/eng/")).toBe(true);
131
+ // CONTRACT: the routing key is written under `metadata.agent` ONLY — no `channel`.
132
+ expect(sent.metadata.agent).toBe("eng");
133
+ expect(sent.metadata.channel).toBeUndefined();
134
+ expect(sent.metadata.direction).toBe("outbound");
135
+ expect(sent.metadata.sender).toBe("session");
136
+ // The old `outbound:"1"` presence marker is gone — no such metadata key.
137
+ expect("outbound" in sent.metadata).toBe(false);
138
+ expect(typeof sent.metadata.ts).toBe("string");
139
+ });
140
+
141
+ test("reply() threads in_reply_to from args.meta", async () => {
142
+ let captured: Record<string, string> | undefined;
143
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
144
+ // Ignore the ensureSchema PUTs fired by start(); only the note POST carries metadata.
145
+ if (String(url).endsWith("/api/notes")) {
146
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string> };
147
+ captured = body.metadata;
148
+ }
149
+ return new Response(JSON.stringify({ id: "n2" }), { status: 201 });
150
+ }) as typeof fetch;
151
+
152
+ const t = new VaultTransport(baseConfig());
153
+ await t.start(fakeCtx("eng"));
154
+ await t.reply({ channel: "eng", text: "re", meta: { in_reply_to: "inbound-99" } });
155
+ expect(captured!.in_reply_to).toBe("inbound-99");
156
+ });
157
+
158
+ test("reply() stamps metadata.thread from args.meta.thread (the definition→thread→message link)", async () => {
159
+ let captured: Record<string, string> | undefined;
160
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
161
+ if (String(url).endsWith("/api/notes")) {
162
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string> };
163
+ captured = body.metadata;
164
+ }
165
+ return new Response(JSON.stringify({ id: "n3" }), { status: 201 });
166
+ }) as typeof fetch;
167
+
168
+ const t = new VaultTransport(baseConfig());
169
+ await t.start(fakeCtx("eng"));
170
+ await t.reply({ channel: "eng", text: "re", meta: { in_reply_to: "inbound-99", thread: "fire-7" } });
171
+ expect(captured!.thread).toBe("fire-7");
172
+ expect(captured!.in_reply_to).toBe("inbound-99");
173
+ });
174
+
175
+ test("reply() falls back to the proposed id when the response has no id", async () => {
176
+ globalThis.fetch = (async () =>
177
+ new Response("", { status: 201 })) as unknown as typeof fetch;
178
+ const t = new VaultTransport(baseConfig());
179
+ await t.start(fakeCtx("eng"));
180
+ const result = await t.reply({ channel: "eng", text: "x" });
181
+ expect(result.sent).toHaveLength(1);
182
+ expect(typeof result.sent[0]).toBe("string");
183
+ });
184
+
185
+ test("reply() throws on a non-ok vault response", async () => {
186
+ globalThis.fetch = (async () =>
187
+ new Response("boom", { status: 500 })) as unknown as typeof fetch;
188
+ const t = new VaultTransport(baseConfig());
189
+ await t.start(fakeCtx("eng"));
190
+ await expect(t.reply({ channel: "eng", text: "x" })).rejects.toThrow(/write reply failed/);
191
+ });
192
+ });
193
+
194
+ describe("VaultTransport — writeThread (#agent/thread note, the unified model)", () => {
195
+ test("MULTI-THREADED: writeThread() PATCH-upserts (if_missing:create) a fresh-per-fire #agent/thread note with indexed status/definition/mode + timing + Bearer (NO read-back)", async () => {
196
+ const calls: { url: string; init: RequestInit }[] = [];
197
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
198
+ calls.push({ url: String(url), init: init ?? {} });
199
+ return new Response(JSON.stringify({ id: "thread-note-1" }), {
200
+ status: 201,
201
+ headers: { "content-type": "application/json" },
202
+ });
203
+ }) as typeof fetch;
204
+
205
+ const t = new VaultTransport(baseConfig());
206
+ await t.start(fakeCtx("eng"));
207
+ const result = await t.writeThread({
208
+ channel: "eng",
209
+ name: "digest",
210
+ definition: "Agents/digest",
211
+ mode: "multi-threaded",
212
+ status: "ok",
213
+ input: "run the daily digest",
214
+ output: "digest complete: 3 items",
215
+ started_at: "2026-06-18T07:00:00.000Z",
216
+ ended_at: "2026-06-18T07:00:12.000Z",
217
+ usage: { inputTokens: 100, outputTokens: 40, totalCostUsd: 0.002 },
218
+ });
219
+
220
+ expect(result.sent).toEqual(["thread-note-1"]);
221
+ // start() also fires ensureSchema() (PUT .../api/tags/*); isolate the thread-note
222
+ // write. The write is a PATCH-by-path upsert (NOT POST — POST 409s on an existing
223
+ // path), so it targets /api/notes/<encoded-path>, discriminated by method.
224
+ const noteCalls = calls.filter((c) => c.url.includes("/api/notes/") && c.init.method === "PATCH");
225
+ expect(noteCalls).toHaveLength(1);
226
+ // Multi-threaded does NO read-back (no GET to /api/notes/<path>) — fresh per fire.
227
+ const getCalls = calls.filter((c) => c.url.includes("/api/notes/") && (c.init.method ?? "GET") === "GET");
228
+ expect(getCalls).toHaveLength(0);
229
+ const call = noteCalls[0]!;
230
+ expect(decodeURIComponent(call.url)).toContain("/vault/default/api/notes/Threads/eng/");
231
+ expect(call.init.method).toBe("PATCH");
232
+ expect((call.init.headers as Record<string, string>).authorization).toBe("Bearer write-token-xyz");
233
+
234
+ const sent = JSON.parse(String(call.init.body)) as {
235
+ content: string;
236
+ path: string;
237
+ tags: string[];
238
+ metadata: Record<string, string>;
239
+ if_missing: string;
240
+ force: boolean;
241
+ };
242
+ // The upsert verb: PATCH + `if_missing: "create"` (creates when missing — every
243
+ // multi-threaded fire — updates when present) + `force: true` (the 428 precondition).
244
+ expect(sent.if_missing).toBe("create");
245
+ expect(sent.force).toBe(true);
246
+ // LOOP SAFETY (HARD CONSTRAINT 4): the thread note carries the thread tag EXACTLY —
247
+ // NOT a message tag + NOT the inbound child — so it can never wake a session.
248
+ expect(sent.tags).toEqual([AGENT_THREAD_TAG]);
249
+ expect(sent.tags).not.toContain("#agent/message");
250
+ expect(sent.tags).not.toContain("#agent/message/inbound");
251
+ // Indexed/queryable fields.
252
+ expect(sent.metadata.status).toBe("ok");
253
+ expect(sent.metadata.definition).toBe("Agents/digest");
254
+ expect(sent.metadata.mode).toBe("multi-threaded");
255
+ // Thread-state + routing key + usage (stringified for the vault).
256
+ // CONTRACT: routing key under `metadata.agent` ONLY — no `channel`.
257
+ expect(sent.metadata.agent).toBe("eng");
258
+ expect(sent.metadata.channel).toBeUndefined();
259
+ expect(sent.metadata.started_at).toBe("2026-06-18T07:00:00.000Z");
260
+ expect(sent.metadata.last_turn_at).toBe("2026-06-18T07:00:12.000Z");
261
+ expect(sent.metadata.turn_count).toBe("1");
262
+ expect(sent.metadata.input_tokens).toBe("100");
263
+ expect(sent.metadata.output_tokens).toBe("40");
264
+ expect(sent.metadata.total_cost_usd).toBe("0.002");
265
+ // The body is a rolling SUMMARY with the two documented sections.
266
+ expect(sent.content).toContain("## Summary");
267
+ expect(sent.content).toContain("## Latest turn");
268
+ expect(sent.content).toContain("run the daily digest");
269
+ expect(sent.content).toContain("digest complete: 3 items");
270
+ // Multi-threaded path leaf is a fresh uuid under Threads/<channel>/.
271
+ expect(sent.path.startsWith("Threads/eng/")).toBe(true);
272
+ });
273
+
274
+ test("SINGLE-THREADED: writeThread() upserts ONE deterministic-path note named after the def (reads existing first)", async () => {
275
+ const posts: { url: string; init: RequestInit }[] = [];
276
+ const gets: string[] = [];
277
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
278
+ const u = String(url);
279
+ const method = init?.method ?? "GET";
280
+ if (u.includes("/api/notes/") && method === "GET") {
281
+ gets.push(u);
282
+ // First turn: the note doesn't exist yet (404 → turn_count starts at 0).
283
+ return new Response("not found", { status: 404 });
284
+ }
285
+ // The write is a PATCH-by-path upsert (if_missing:create), NOT POST.
286
+ if (u.includes("/api/notes/") && method === "PATCH") {
287
+ posts.push({ url: u, init: init ?? {} });
288
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
289
+ }
290
+ return new Response("{}", { status: 200 }); // ensureSchema PUTs
291
+ }) as typeof fetch;
292
+
293
+ const t = new VaultTransport(baseConfig());
294
+ await t.start(fakeCtx("eng"));
295
+ await t.writeThread({
296
+ channel: "eng",
297
+ name: "eng",
298
+ mode: "single-threaded",
299
+ status: "ok",
300
+ input: "hello",
301
+ output: "hi there",
302
+ started_at: "2026-06-18T07:00:00.000Z",
303
+ ended_at: "2026-06-18T07:00:05.000Z",
304
+ usage: { inputTokens: 10, outputTokens: 5 },
305
+ });
306
+
307
+ // It READ the existing note first (the upsert read-back), by the DETERMINISTIC path.
308
+ expect(gets).toHaveLength(1);
309
+ expect(decodeURIComponent(gets[0]!)).toContain("/api/notes/Threads/eng/eng");
310
+ // Then UPSERTED via PATCH (if_missing:create) to the same deterministic path.
311
+ expect(posts).toHaveLength(1);
312
+ expect(posts[0]!.init.method).toBe("PATCH");
313
+ expect(decodeURIComponent(posts[0]!.url)).toContain("/api/notes/Threads/eng/eng");
314
+ const sent = JSON.parse(String(posts[0]!.init.body)) as {
315
+ path: string;
316
+ tags: string[];
317
+ metadata: Record<string, string>;
318
+ content: string;
319
+ if_missing: string;
320
+ force: boolean;
321
+ };
322
+ expect(sent.if_missing).toBe("create"); // upsert verb (not POST — POST 409s).
323
+ expect(sent.force).toBe(true);
324
+ expect(sent.tags).toEqual([AGENT_THREAD_TAG]); // loop safety.
325
+ expect(sent.path).toBe("Threads/eng/eng"); // deterministic, named after the def.
326
+ expect(sent.metadata.mode).toBe("single-threaded");
327
+ expect(sent.metadata.turn_count).toBe("1"); // first turn (no prior).
328
+ expect(sent.metadata.started_at).toBe("2026-06-18T07:00:00.000Z");
329
+ expect(sent.metadata.last_turn_at).toBe("2026-06-18T07:00:05.000Z");
330
+ expect(sent.content).toContain("## Summary");
331
+ expect(sent.content).toContain("single-threaded thread for eng");
332
+ });
333
+
334
+ test("SINGLE-THREADED over TWO turns: same deterministic path, turn_count==2, summed usage, preserved started_at", async () => {
335
+ // Simulate a vault: the second turn reads back the note the FIRST turn wrote.
336
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
337
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
338
+ const u = String(url);
339
+ const method = init?.method ?? "GET";
340
+ if (u.includes("/api/notes/") && method === "GET") {
341
+ if (!stored) return new Response("not found", { status: 404 });
342
+ return new Response(JSON.stringify(stored), { status: 200 });
343
+ }
344
+ // PATCH-by-path with if_missing:create is the upsert (turn 1 creates, turn 2 updates).
345
+ if (u.includes("/api/notes/") && method === "PATCH") {
346
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
347
+ stored = { metadata: body.metadata, content: body.content }; // the vault upserts it.
348
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
349
+ }
350
+ return new Response("{}", { status: 200 });
351
+ }) as typeof fetch;
352
+
353
+ const t = new VaultTransport(baseConfig());
354
+ await t.start(fakeCtx("eng"));
355
+
356
+ await t.writeThread({
357
+ channel: "eng",
358
+ name: "eng",
359
+ mode: "single-threaded",
360
+ status: "ok",
361
+ input: "turn one",
362
+ output: "reply one",
363
+ started_at: "2026-06-18T07:00:00.000Z",
364
+ ended_at: "2026-06-18T07:00:05.000Z",
365
+ usage: { inputTokens: 10, outputTokens: 5, totalCostUsd: 0.001 },
366
+ });
367
+ expect(stored!.metadata.turn_count).toBe("1");
368
+
369
+ await t.writeThread({
370
+ channel: "eng",
371
+ name: "eng",
372
+ mode: "single-threaded",
373
+ status: "ok",
374
+ input: "turn two",
375
+ output: "reply two",
376
+ started_at: "2026-06-18T08:00:00.000Z", // a LATER start — must NOT overwrite the first.
377
+ ended_at: "2026-06-18T08:00:09.000Z",
378
+ usage: { inputTokens: 20, outputTokens: 8, totalCostUsd: 0.002 },
379
+ });
380
+
381
+ // ONE note, upserted: turn_count incremented, usage SUMMED, started_at PRESERVED,
382
+ // last_turn_at advanced.
383
+ expect(stored!.metadata.turn_count).toBe("2");
384
+ expect(stored!.metadata.input_tokens).toBe("30"); // 10 + 20
385
+ expect(stored!.metadata.output_tokens).toBe("13"); // 5 + 8
386
+ expect(stored!.metadata.total_cost_usd).toBe("0.003"); // 0.001 + 0.002
387
+ expect(stored!.metadata.started_at).toBe("2026-06-18T07:00:00.000Z"); // first turn's, preserved.
388
+ expect(stored!.metadata.last_turn_at).toBe("2026-06-18T08:00:09.000Z"); // latest turn.
389
+ // The body's summary reflects 2 turns + the latest turn's content.
390
+ expect(stored!.content).toContain("2 turns");
391
+ expect(stored!.content).toContain("turn two");
392
+ expect(stored!.content).toContain("reply two");
393
+ });
394
+
395
+ test("SINGLE-THREADED re-record of the SAME turn (sameTurn) flips status WITHOUT double-counting turn_count (PR #3 FIX 1)", async () => {
396
+ // The outbound-failure path: the turn was recorded `ok`, then the additive transcript
397
+ // write failed, so the same turn is re-recorded `error`. `sameTurn` must keep the count
398
+ // (the turn was already counted) — the reviewer caught the original re-record bumping it.
399
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
400
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
401
+ const u = String(url);
402
+ const method = init?.method ?? "GET";
403
+ if (u.includes("/api/notes/") && method === "GET") {
404
+ if (!stored) return new Response("not found", { status: 404 });
405
+ return new Response(JSON.stringify(stored), { status: 200 });
406
+ }
407
+ if (u.includes("/api/notes/") && method === "PATCH") {
408
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
409
+ stored = { metadata: body.metadata, content: body.content };
410
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
411
+ }
412
+ return new Response("{}", { status: 200 });
413
+ }) as typeof fetch;
414
+ const t = new VaultTransport(baseConfig());
415
+ await t.start(fakeCtx("eng"));
416
+ await t.writeThread({
417
+ channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
418
+ input: "q", output: "a", started_at: "2026-06-18T07:00:00.000Z",
419
+ ended_at: "2026-06-18T07:00:05.000Z", threadId: "t1",
420
+ });
421
+ expect(stored!.metadata.turn_count).toBe("1");
422
+ // Re-record the SAME turn as error (outbound delivery failed). sameTurn → no increment.
423
+ await t.writeThread({
424
+ channel: "eng", name: "eng", mode: "single-threaded", status: "error",
425
+ input: "q", output: "reply produced but NOT delivered", started_at: "2026-06-18T07:00:00.000Z",
426
+ ended_at: "2026-06-18T07:00:06.000Z", threadId: "t1", sameTurn: true,
427
+ });
428
+ expect(stored!.metadata.turn_count).toBe("1"); // NOT 2 — the same turn, not a new one.
429
+ expect(stored!.metadata.status).toBe("error");
430
+ expect(stored!.content).toContain("NOT delivered");
431
+ });
432
+
433
+ test("SINGLE-THREADED FULL lifecycle start→end(ok)→end(error,sameTurn): count goes 0→1→1, never double-counts (thread-as-container + FIX 1)", async () => {
434
+ // The real drain path now writes a `working` start-ensure BEFORE the turn, then an
435
+ // `end` record, then (on outbound failure) an `end` re-record with sameTurn. This is
436
+ // the one combination the prior FIX-1 test didn't exercise: a start-ensure preceding
437
+ // the re-record. The start must NOT count; the first end counts once; the sameTurn
438
+ // re-record must keep it.
439
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
440
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
441
+ const u = String(url);
442
+ const method = init?.method ?? "GET";
443
+ if (u.includes("/api/notes/") && method === "GET") {
444
+ if (!stored) return new Response("not found", { status: 404 });
445
+ return new Response(JSON.stringify(stored), { status: 200 });
446
+ }
447
+ if (u.includes("/api/notes/") && method === "PATCH") {
448
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
449
+ stored = { metadata: body.metadata, content: body.content };
450
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
451
+ }
452
+ return new Response("{}", { status: 200 });
453
+ }) as typeof fetch;
454
+ const t = new VaultTransport(baseConfig());
455
+ await t.start(fakeCtx("eng"));
456
+ const base = {
457
+ channel: "eng", name: "eng", mode: "single-threaded" as const, input: "q",
458
+ started_at: "2026-06-18T07:00:00.000Z", ended_at: "2026-06-18T07:00:05.000Z", threadId: "t1",
459
+ };
460
+ // 1) start-ensure (working) — the container, BEFORE the turn. Must NOT count.
461
+ await t.writeThread({ ...base, status: "working", output: "", phase: "start" });
462
+ expect(stored!.metadata.turn_count).toBe("0");
463
+ expect(stored!.metadata.status).toBe("working");
464
+ // 2) end(ok) — the turn completed: count once.
465
+ await t.writeThread({ ...base, status: "ok", output: "a", phase: "end" });
466
+ expect(stored!.metadata.turn_count).toBe("1");
467
+ expect(stored!.metadata.status).toBe("ok");
468
+ // 3) end(error, sameTurn) — outbound write failed, re-record the SAME turn. No increment.
469
+ await t.writeThread({ ...base, status: "error", output: "reply produced but NOT delivered", phase: "end", sameTurn: true });
470
+ expect(stored!.metadata.turn_count).toBe("1"); // STILL 1 — start didn't count, sameTurn didn't re-count.
471
+ expect(stored!.metadata.status).toBe("error");
472
+ });
473
+
474
+ test("MULTI-THREADED re-record reuses the passed threadId leaf — ONE note, not a duplicate (PR #3 FIX 1)", async () => {
475
+ const patchPaths: string[] = [];
476
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
477
+ const u = String(url);
478
+ if (u.includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
479
+ patchPaths.push(decodeURIComponent(u));
480
+ return new Response(JSON.stringify({ id: "x" }), { status: 200 });
481
+ }
482
+ return new Response("{}", { status: 200 });
483
+ }) as typeof fetch;
484
+ const t = new VaultTransport(baseConfig());
485
+ await t.start(fakeCtx("eng"));
486
+ const base = {
487
+ channel: "eng", name: "d", mode: "multi-threaded" as const,
488
+ input: "q", started_at: "2026-06-18T07:00:00.000Z", ended_at: "2026-06-18T07:00:05.000Z",
489
+ threadId: "fixed-uuid",
490
+ };
491
+ await t.writeThread({ ...base, status: "ok", output: "a" });
492
+ await t.writeThread({ ...base, status: "error", output: "undelivered", sameTurn: true });
493
+ // Both writes hit the SAME per-fire path (the reused threadId) — without the fix the
494
+ // second would mint a fresh uuid → a DIFFERENT path → a duplicate note for one turn.
495
+ const threadPatches = patchPaths.filter((p) => p.includes("/Threads/eng/"));
496
+ expect(threadPatches).toHaveLength(2);
497
+ expect(threadPatches[0]).toContain("/Threads/eng/fixed-uuid");
498
+ expect(threadPatches[1]).toContain("/Threads/eng/fixed-uuid");
499
+ });
500
+
501
+ test("SINGLE-THREADED error on turn 2: turn_count==2, status:error, started_at preserved, last_turn_at advanced", async () => {
502
+ // Same stored-note simulation as the two-turn test: turn 2 reads back turn 1's note.
503
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
504
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
505
+ const u = String(url);
506
+ const method = init?.method ?? "GET";
507
+ if (u.includes("/api/notes/") && method === "GET") {
508
+ if (!stored) return new Response("not found", { status: 404 });
509
+ return new Response(JSON.stringify(stored), { status: 200 });
510
+ }
511
+ if (u.includes("/api/notes/") && method === "PATCH") {
512
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
513
+ stored = { metadata: body.metadata, content: body.content };
514
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
515
+ }
516
+ return new Response("{}", { status: 200 });
517
+ }) as typeof fetch;
518
+
519
+ const t = new VaultTransport(baseConfig());
520
+ await t.start(fakeCtx("eng"));
521
+
522
+ // Turn 1 — ok.
523
+ await t.writeThread({
524
+ channel: "eng",
525
+ name: "eng",
526
+ mode: "single-threaded",
527
+ status: "ok",
528
+ input: "turn one",
529
+ output: "reply one",
530
+ started_at: "2026-06-18T07:00:00.000Z",
531
+ ended_at: "2026-06-18T07:00:05.000Z",
532
+ });
533
+ expect(stored!.metadata.status).toBe("ok");
534
+
535
+ // Turn 2 — ERROR. The single-threaded thread keeps upserting (the failure is part of
536
+ // the rolling thread record); the status reflects this latest turn.
537
+ await t.writeThread({
538
+ channel: "eng",
539
+ name: "eng",
540
+ mode: "single-threaded",
541
+ status: "error",
542
+ input: "turn two",
543
+ output: "claude -p exited 1: boom",
544
+ started_at: "2026-06-18T08:00:00.000Z", // later — must NOT overwrite the first.
545
+ ended_at: "2026-06-18T08:00:09.000Z",
546
+ });
547
+
548
+ expect(stored!.metadata.turn_count).toBe("2"); // incremented despite the error.
549
+ expect(stored!.metadata.status).toBe("error"); // the latest turn's outcome.
550
+ expect(stored!.metadata.started_at).toBe("2026-06-18T07:00:00.000Z"); // preserved.
551
+ expect(stored!.metadata.last_turn_at).toBe("2026-06-18T08:00:09.000Z"); // advanced.
552
+ // The body's latest-turn section is the Error block.
553
+ expect(stored!.content).toContain("**Error:**");
554
+ expect(stored!.content).toContain("claude -p exited 1: boom");
555
+ });
556
+
557
+ test("SINGLE-THREADED: a 500 on the read-back GET rejects (not a silent aggregate reset)", async () => {
558
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
559
+ const u = String(url);
560
+ const method = init?.method ?? "GET";
561
+ // The single-threaded read-back GET returns a 500 (an UNEXPECTED non-404 error) →
562
+ // readThreadNote throws → writeThread rejects, surfacing the misconfig rather than
563
+ // silently resetting the thread's aggregates.
564
+ if (u.includes("/api/notes/") && method === "GET") {
565
+ return new Response("boom", { status: 500 });
566
+ }
567
+ return new Response("{}", { status: 200 });
568
+ }) as unknown as typeof fetch;
569
+
570
+ const t = new VaultTransport(baseConfig());
571
+ await t.start(fakeCtx("eng"));
572
+ await expect(
573
+ t.writeThread({
574
+ channel: "eng",
575
+ name: "eng",
576
+ mode: "single-threaded",
577
+ status: "ok",
578
+ input: "x",
579
+ output: "y",
580
+ started_at: "2026-06-18T07:00:00.000Z",
581
+ ended_at: "2026-06-18T07:00:01.000Z",
582
+ }),
583
+ ).rejects.toThrow(/read thread note failed/);
584
+ });
585
+
586
+ test("SINGLE-THREADED cost rounding: 0.1 + 0.2 serializes as \"0.3\" (no IEEE-754 drift)", async () => {
587
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
588
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
589
+ const u = String(url);
590
+ const method = init?.method ?? "GET";
591
+ if (u.includes("/api/notes/") && method === "GET") {
592
+ if (!stored) return new Response("not found", { status: 404 });
593
+ return new Response(JSON.stringify(stored), { status: 200 });
594
+ }
595
+ if (u.includes("/api/notes/") && method === "PATCH") {
596
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
597
+ stored = { metadata: body.metadata, content: body.content };
598
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
599
+ }
600
+ return new Response("{}", { status: 200 });
601
+ }) as typeof fetch;
602
+
603
+ const t = new VaultTransport(baseConfig());
604
+ await t.start(fakeCtx("eng"));
605
+
606
+ await t.writeThread({
607
+ channel: "eng",
608
+ name: "eng",
609
+ mode: "single-threaded",
610
+ status: "ok",
611
+ input: "one",
612
+ output: "r1",
613
+ started_at: "2026-06-18T07:00:00.000Z",
614
+ ended_at: "2026-06-18T07:00:05.000Z",
615
+ usage: { totalCostUsd: 0.1 },
616
+ });
617
+ await t.writeThread({
618
+ channel: "eng",
619
+ name: "eng",
620
+ mode: "single-threaded",
621
+ status: "ok",
622
+ input: "two",
623
+ output: "r2",
624
+ started_at: "2026-06-18T08:00:00.000Z",
625
+ ended_at: "2026-06-18T08:00:09.000Z",
626
+ usage: { totalCostUsd: 0.2 },
627
+ });
628
+
629
+ // The naive sum 0.1 + 0.2 === 0.30000000000000004; the round-to-9-decimals guard
630
+ // serializes it cleanly as "0.3".
631
+ expect(stored!.metadata.total_cost_usd).toBe("0.3");
632
+ });
633
+
634
+ test("writeThread() on a MULTI-THREADED error turn records status:error + the failure reason in the body (NO read-back)", async () => {
635
+ const calls: { url: string; init: RequestInit }[] = [];
636
+ let captured: { tags: string[]; metadata: Record<string, string>; content: string } | undefined;
637
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
638
+ calls.push({ url: String(url), init: init ?? {} });
639
+ if (String(url).includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
640
+ captured = JSON.parse(String(init?.body));
641
+ }
642
+ return new Response(JSON.stringify({ id: "thread-err-1" }), { status: 200 });
643
+ }) as typeof fetch;
644
+
645
+ const t = new VaultTransport(baseConfig());
646
+ await t.start(fakeCtx("eng"));
647
+ await t.writeThread({
648
+ channel: "eng",
649
+ mode: "multi-threaded",
650
+ status: "error",
651
+ input: "do the thing",
652
+ output: "claude -p exited 1: boom",
653
+ started_at: "2026-06-18T07:00:00.000Z",
654
+ ended_at: "2026-06-18T07:00:01.000Z",
655
+ });
656
+
657
+ expect(captured!.metadata.status).toBe("error");
658
+ // No definition → the field is absent (not an empty string).
659
+ expect("definition" in captured!.metadata).toBe(false);
660
+ // The body's latest-turn section is the Error block on a failure.
661
+ expect(captured!.content).toContain("**Error:**");
662
+ expect(captured!.content).toContain("claude -p exited 1: boom");
663
+ // Multi-threaded does NO read-back even on the error path (fresh per fire).
664
+ expect(
665
+ calls.filter((c) => c.url.includes("/api/notes/") && (c.init.method ?? "GET") === "GET"),
666
+ ).toHaveLength(0);
667
+ });
668
+
669
+ test("writeThread() throws on a non-ok vault response (PATCH)", async () => {
670
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
671
+ // multi-threaded → no GET; the PATCH upsert fails.
672
+ if (String(url).includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
673
+ return new Response("boom", { status: 500 });
674
+ }
675
+ return new Response("{}", { status: 200 });
676
+ }) as unknown as typeof fetch;
677
+ const t = new VaultTransport(baseConfig());
678
+ await t.start(fakeCtx("eng"));
679
+ await expect(
680
+ t.writeThread({
681
+ channel: "eng",
682
+ mode: "multi-threaded",
683
+ status: "ok",
684
+ input: "x",
685
+ output: "y",
686
+ started_at: "2026-06-18T07:00:00.000Z",
687
+ ended_at: "2026-06-18T07:00:01.000Z",
688
+ }),
689
+ ).rejects.toThrow(/write thread note failed/);
690
+ });
691
+
692
+ // ── Thread-as-container: the phase:"start" working-ensure (Part B) ────────────────────
693
+ // A turn now writes TWO thread notes: a `phase:"start"` working-ensure BEFORE the turn
694
+ // (status:working, NO reply, turn_count UNCHANGED) and a `phase:"end"` final record after
695
+ // (status:ok/error, turn counted). turn_count must be counted EXACTLY ONCE — on `end` —
696
+ // never double-counted across the start+end pair. These assert that at the transport.
697
+
698
+ test("SINGLE-THREADED start→end does NOT double-count: turn 1 start writes turn_count 0 (working), end writes 1", async () => {
699
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
700
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
701
+ const u = String(url);
702
+ const method = init?.method ?? "GET";
703
+ if (u.includes("/api/notes/") && method === "GET") {
704
+ if (!stored) return new Response("not found", { status: 404 });
705
+ return new Response(JSON.stringify(stored), { status: 200 });
706
+ }
707
+ if (u.includes("/api/notes/") && method === "PATCH") {
708
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
709
+ stored = { metadata: body.metadata, content: body.content };
710
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
711
+ }
712
+ return new Response("{}", { status: 200 });
713
+ }) as typeof fetch;
714
+ const t = new VaultTransport(baseConfig());
715
+ await t.start(fakeCtx("eng"));
716
+
717
+ // START-ENSURE (before the turn): status working, turn_count UNCHANGED (prior 0 → 0).
718
+ await t.writeThread({
719
+ channel: "eng", name: "eng", mode: "single-threaded", status: "working",
720
+ input: "turn one", output: "", started_at: "2026-06-18T07:00:00.000Z",
721
+ ended_at: "2026-06-18T07:00:00.000Z", threadId: "t1", phase: "start",
722
+ });
723
+ expect(stored!.metadata.status).toBe("working");
724
+ expect(stored!.metadata.turn_count).toBe("0"); // NOT counted yet.
725
+ // The working body shows the input + an awaiting-reply state — NO fake reply.
726
+ expect(stored!.content).toContain("turn one");
727
+ expect(stored!.content).toContain("working");
728
+ expect(stored!.content).not.toContain("**Reply:**");
729
+ // last_turn_at is not stamped on a brand-new working-ensure (no turn completed yet).
730
+ expect(stored!.metadata.last_turn_at).toBeUndefined();
731
+
732
+ // END (after the turn): status ok, turn_count now 1 (counted exactly once).
733
+ await t.writeThread({
734
+ channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
735
+ input: "turn one", output: "reply one", started_at: "2026-06-18T07:00:00.000Z",
736
+ ended_at: "2026-06-18T07:00:05.000Z", threadId: "t1", phase: "end",
737
+ });
738
+ expect(stored!.metadata.status).toBe("ok");
739
+ expect(stored!.metadata.turn_count).toBe("1"); // counted ONCE across start+end.
740
+ expect(stored!.metadata.last_turn_at).toBe("2026-06-18T07:00:05.000Z");
741
+ expect(stored!.content).toContain("reply one");
742
+ });
743
+
744
+ test("SINGLE-THREADED turn 2 start preserves prior count (1), end increments to 2 — start never double-counts", async () => {
745
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
746
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
747
+ const u = String(url);
748
+ const method = init?.method ?? "GET";
749
+ if (u.includes("/api/notes/") && method === "GET") {
750
+ if (!stored) return new Response("not found", { status: 404 });
751
+ return new Response(JSON.stringify(stored), { status: 200 });
752
+ }
753
+ if (u.includes("/api/notes/") && method === "PATCH") {
754
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
755
+ stored = { metadata: body.metadata, content: body.content };
756
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
757
+ }
758
+ return new Response("{}", { status: 200 });
759
+ }) as typeof fetch;
760
+ const t = new VaultTransport(baseConfig());
761
+ await t.start(fakeCtx("eng"));
762
+ const tn = (status: "working" | "ok", input: string, output: string, ended: string, phase: "start" | "end") => ({
763
+ channel: "eng", name: "eng", mode: "single-threaded" as const, status,
764
+ input, output, started_at: "2026-06-18T07:00:00.000Z", ended_at: ended, phase,
765
+ });
766
+
767
+ // Turn 1 — start (0) then end (1).
768
+ await t.writeThread(tn("working", "one", "", "2026-06-18T07:00:00.000Z", "start"));
769
+ expect(stored!.metadata.turn_count).toBe("0");
770
+ await t.writeThread(tn("ok", "one", "reply one", "2026-06-18T07:00:05.000Z", "end"));
771
+ expect(stored!.metadata.turn_count).toBe("1");
772
+
773
+ // Turn 2 — start reads prior=1 → writes 1 (UNCHANGED, the no-double-count invariant),
774
+ // then end increments to 2. The start working-ensure must NOT bump the count.
775
+ await t.writeThread(tn("working", "two", "", "2026-06-18T08:00:00.000Z", "start"));
776
+ expect(stored!.metadata.turn_count).toBe("1"); // start preserves the count.
777
+ expect(stored!.metadata.status).toBe("working");
778
+ expect(stored!.metadata.started_at).toBe("2026-06-18T07:00:00.000Z"); // first turn's, preserved.
779
+ await t.writeThread(tn("ok", "two", "reply two", "2026-06-18T08:00:09.000Z", "end"));
780
+ expect(stored!.metadata.turn_count).toBe("2"); // counted twice total — once per turn.
781
+ expect(stored!.metadata.last_turn_at).toBe("2026-06-18T08:00:09.000Z");
782
+ });
783
+
784
+ test("MULTI-THREADED start writes turn_count 0 (working) at the per-fire path; end writes 1 at the SAME path", async () => {
785
+ const patches: { path: string; metadata: Record<string, string>; content: string }[] = [];
786
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
787
+ const u = String(url);
788
+ if (u.includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
789
+ const body = JSON.parse(String(init?.body)) as {
790
+ path: string; metadata: Record<string, string>; content: string;
791
+ };
792
+ patches.push({ path: decodeURIComponent(u), metadata: body.metadata, content: body.content });
793
+ return new Response(JSON.stringify({ id: "x" }), { status: 200 });
794
+ }
795
+ return new Response("{}", { status: 200 });
796
+ }) as typeof fetch;
797
+ const t = new VaultTransport(baseConfig());
798
+ await t.start(fakeCtx("eng"));
799
+ const base = {
800
+ channel: "eng", name: "d", mode: "multi-threaded" as const, input: "q",
801
+ started_at: "2026-06-18T07:00:00.000Z", threadId: "fire-1",
802
+ };
803
+ // START — working, turn_count 0, the per-fire note created.
804
+ await t.writeThread({ ...base, status: "working", output: "", ended_at: "2026-06-18T07:00:00.000Z", phase: "start" });
805
+ // END — ok, turn_count 1, the SAME per-fire path (same threadId).
806
+ await t.writeThread({ ...base, status: "ok", output: "a", ended_at: "2026-06-18T07:00:05.000Z", phase: "end" });
807
+
808
+ expect(patches).toHaveLength(2);
809
+ expect(patches[0]!.metadata.status).toBe("working");
810
+ expect(patches[0]!.metadata.turn_count).toBe("0");
811
+ expect(patches[1]!.metadata.status).toBe("ok");
812
+ expect(patches[1]!.metadata.turn_count).toBe("1");
813
+ // Both writes hit the SAME per-fire path (the reused threadId) — start updates, not dupes.
814
+ expect(patches[0]!.path).toContain("/Threads/eng/fire-1");
815
+ expect(patches[1]!.path).toContain("/Threads/eng/fire-1");
816
+ // The working body shows no fake reply; the end body carries the real reply.
817
+ expect(patches[0]!.content).not.toContain("**Reply:**");
818
+ expect(patches[1]!.content).toContain("a");
819
+ });
820
+
821
+ // ── thread ≡ session (metadata.session — the unified record) ──────────────────────────
822
+
823
+ test("writeThread() persists metadata.session when thread.session is set", async () => {
824
+ const posts: { metadata: Record<string, string> }[] = [];
825
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
826
+ const u = String(url);
827
+ if (u.includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
828
+ posts.push({ metadata: (JSON.parse(String(init?.body)) as { metadata: Record<string, string> }).metadata });
829
+ return new Response(JSON.stringify({ id: "x" }), { status: 200 });
830
+ }
831
+ // multi-threaded → no GET read-back; serve ensureSchema PUTs + anything else 200.
832
+ return new Response("{}", { status: 200 });
833
+ }) as typeof fetch;
834
+ const t = new VaultTransport(baseConfig());
835
+ await t.start(fakeCtx("eng"));
836
+ await t.writeThread({
837
+ channel: "eng",
838
+ mode: "multi-threaded",
839
+ status: "ok",
840
+ input: "q",
841
+ output: "a",
842
+ started_at: "2026-06-18T07:00:00.000Z",
843
+ ended_at: "2026-06-18T07:00:05.000Z",
844
+ session: "11111111-1111-4111-8111-111111111111",
845
+ });
846
+ expect(posts).toHaveLength(1);
847
+ expect(posts[0]!.metadata.session).toBe("11111111-1111-4111-8111-111111111111");
848
+ });
849
+
850
+ test("SINGLE-THREADED upsert PRESERVES a prior metadata.session when the new write carries none", async () => {
851
+ // The start-phase working-ensure carries NO session; it must not drop the prior one.
852
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
853
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
854
+ const u = String(url);
855
+ const method = init?.method ?? "GET";
856
+ if (u.includes("/api/notes/") && method === "GET") {
857
+ if (!stored) return new Response("not found", { status: 404 });
858
+ return new Response(JSON.stringify(stored), { status: 200 });
859
+ }
860
+ if (u.includes("/api/notes/") && method === "PATCH") {
861
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
862
+ stored = { metadata: body.metadata, content: body.content };
863
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
864
+ }
865
+ return new Response("{}", { status: 200 });
866
+ }) as typeof fetch;
867
+ const t = new VaultTransport(baseConfig());
868
+ await t.start(fakeCtx("eng"));
869
+
870
+ // Turn 1 END establishes the session on the note.
871
+ await t.writeThread({
872
+ channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
873
+ input: "one", output: "reply one", started_at: "2026-06-18T07:00:00.000Z",
874
+ ended_at: "2026-06-18T07:00:05.000Z", phase: "end",
875
+ session: "sess-ESTABLISHED",
876
+ });
877
+ expect(stored!.metadata.session).toBe("sess-ESTABLISHED");
878
+
879
+ // Turn 2 START-ENSURE carries NO session — the upsert must PRESERVE the prior one.
880
+ await t.writeThread({
881
+ channel: "eng", name: "eng", mode: "single-threaded", status: "working",
882
+ input: "two", output: "", started_at: "2026-06-18T08:00:00.000Z",
883
+ ended_at: "2026-06-18T08:00:00.000Z", phase: "start",
884
+ });
885
+ expect(stored!.metadata.session).toBe("sess-ESTABLISHED"); // preserved, not dropped.
886
+ });
887
+
888
+ test("readThreadSession() round-trips the stored session (the pre-turn resume read)", async () => {
889
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
890
+ const gets: string[] = [];
891
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
892
+ const u = String(url);
893
+ const method = init?.method ?? "GET";
894
+ if (u.includes("/api/notes/") && method === "GET") {
895
+ gets.push(decodeURIComponent(u));
896
+ if (!stored) return new Response("not found", { status: 404 });
897
+ return new Response(JSON.stringify(stored), { status: 200 });
898
+ }
899
+ if (u.includes("/api/notes/") && method === "PATCH") {
900
+ const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
901
+ stored = { metadata: body.metadata, content: body.content };
902
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
903
+ }
904
+ return new Response("{}", { status: 200 });
905
+ }) as typeof fetch;
906
+ const t = new VaultTransport(baseConfig());
907
+ await t.start(fakeCtx("eng"));
908
+
909
+ // Before any turn: no note → undefined (the first-turn create path).
910
+ expect(await t.readThreadSession("eng", "eng")).toBeUndefined();
911
+
912
+ // Write a thread note carrying a session…
913
+ await t.writeThread({
914
+ channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
915
+ input: "x", output: "y", started_at: "2026-06-18T07:00:00.000Z",
916
+ ended_at: "2026-06-18T07:00:05.000Z", phase: "end",
917
+ session: "sess-ROUNDTRIP",
918
+ });
919
+
920
+ // …readThreadSession reads it back off the DETERMINISTIC single-threaded path.
921
+ expect(await t.readThreadSession("eng", "eng")).toBe("sess-ROUNDTRIP");
922
+ expect(gets.some((g) => g.includes("/api/notes/Threads/eng/eng"))).toBe(true);
923
+ });
924
+
925
+ test("clearThreadSession() wipes the session (PATCH session:\"\", force) → readThreadSession undefined (the per-agent reset)", async () => {
926
+ // The vault: a stateful note whose metadata is replaced by each PATCH (mirrors the real
927
+ // PATCH-merge for the fields we send). readThreadSession's truthy guard treats "" as none.
928
+ let stored: { metadata: Record<string, string>; content: string } | undefined;
929
+ const patches: { metadata: Record<string, unknown>; force?: boolean }[] = [];
930
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
931
+ const u = String(url);
932
+ const method = init?.method ?? "GET";
933
+ if (u.includes("/api/notes/") && method === "GET") {
934
+ if (!stored) return new Response("not found", { status: 404 });
935
+ return new Response(JSON.stringify(stored), { status: 200 });
936
+ }
937
+ if (u.includes("/api/notes/") && method === "PATCH") {
938
+ const body = JSON.parse(String(init?.body)) as {
939
+ metadata: Record<string, string>;
940
+ content?: string;
941
+ force?: boolean;
942
+ };
943
+ patches.push({ metadata: body.metadata, force: body.force });
944
+ // Merge the PATCHed metadata over the prior (the vault upserts field-by-field).
945
+ stored = { metadata: { ...(stored?.metadata ?? {}), ...body.metadata }, content: body.content ?? stored?.content ?? "" };
946
+ return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
947
+ }
948
+ return new Response("{}", { status: 200 });
949
+ }) as typeof fetch;
950
+ const t = new VaultTransport(baseConfig());
951
+ await t.start(fakeCtx("eng"));
952
+
953
+ // Establish a session, then RESET it.
954
+ await t.writeThread({
955
+ channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
956
+ input: "x", output: "y", started_at: "2026-06-18T07:00:00.000Z",
957
+ ended_at: "2026-06-18T07:00:05.000Z", phase: "end", session: "sess-TO-CLEAR",
958
+ });
959
+ expect(await t.readThreadSession("eng", "eng")).toBe("sess-TO-CLEAR");
960
+
961
+ await t.clearThreadSession("eng", "eng");
962
+ // The clear PATCH wrote session:"" with force (the vault mutation precondition).
963
+ const clearPatch = patches[patches.length - 1]!;
964
+ expect(clearPatch.metadata.session).toBe("");
965
+ expect(clearPatch.force).toBe(true);
966
+ // …and readThreadSession now reports NO session (the "" guard) → next turn starts fresh.
967
+ expect(await t.readThreadSession("eng", "eng")).toBeUndefined();
968
+ });
969
+
970
+ test("clearThreadSession() is a no-op when no thread note exists yet (404)", async () => {
971
+ let patched = false;
972
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
973
+ if (String(url).includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
974
+ patched = true;
975
+ return new Response("not found", { status: 404 });
976
+ }
977
+ return new Response("{}", { status: 200 });
978
+ }) as typeof fetch;
979
+ const t = new VaultTransport(baseConfig());
980
+ await t.start(fakeCtx("eng"));
981
+ // Must NOT throw on a 404 (no thread yet = already fresh).
982
+ await t.clearThreadSession("eng", "eng");
983
+ expect(patched).toBe(true); // it tried (and tolerated the 404).
984
+ });
985
+
986
+ test("clearThreadSession() throws on a non-ok, non-404 vault response", async () => {
987
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
988
+ if (String(url).includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
989
+ return new Response("boom", { status: 500 });
990
+ }
991
+ return new Response("{}", { status: 200 });
992
+ }) as typeof fetch;
993
+ const t = new VaultTransport(baseConfig());
994
+ await t.start(fakeCtx("eng"));
995
+ await expect(t.clearThreadSession("eng", "eng")).rejects.toThrow(/clear thread session failed/);
996
+ });
997
+ });
998
+
999
+ describe("VaultTransport — loadTranscript (read the durable store)", () => {
1000
+ test("queries by tag only (NO operator metadata filter), filters this channel client-side, sorts ascending by ts", async () => {
1001
+ const getUrls: string[] = [];
1002
+ let capturedAuth = "";
1003
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1004
+ const u = String(url);
1005
+ // Ignore the ensureSchema PUTs fired by start(); only the GET /api/notes is the transcript read.
1006
+ if (u.includes("/api/notes") && (init?.method ?? "GET") === "GET") {
1007
+ getUrls.push(u);
1008
+ capturedAuth = (init?.headers as Record<string, string> | undefined)?.authorization ?? "";
1009
+ // CONTRACT: a SINGLE `#agent/message` query (no interim/legacy union).
1010
+ if (u.includes("tag=%23agent%2Fmessage")) {
1011
+ // Return notes OUT of ts order (prove the ascending sort) + a note from a
1012
+ // DIFFERENT channel (prove the client-side channel filter excludes it).
1013
+ return new Response(
1014
+ JSON.stringify([
1015
+ {
1016
+ id: "n-out",
1017
+ content: "session reply",
1018
+ tags: ["#agent/message", "#agent/message/outbound"],
1019
+ metadata: { agent: "eng", direction: "outbound", sender: "session", ts: "2026-06-08T00:00:02Z", in_reply_to: "n-in" },
1020
+ },
1021
+ {
1022
+ id: "n-other",
1023
+ content: "different channel — must be excluded",
1024
+ tags: ["#agent/message", "#agent/message/inbound"],
1025
+ metadata: { agent: "other", direction: "inbound", sender: "x", ts: "2026-06-08T00:00:03Z" },
1026
+ },
1027
+ {
1028
+ id: "n-in",
1029
+ content: "hi session",
1030
+ tags: ["#agent/message", "#agent/message/inbound"],
1031
+ metadata: { agent: "eng", direction: "inbound", sender: "aaron", ts: "2026-06-08T00:00:01Z" },
1032
+ },
1033
+ ]),
1034
+ { status: 200, headers: { "content-type": "application/json" } },
1035
+ );
1036
+ }
1037
+ return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
1038
+ }
1039
+ // ensureSchema PUTs
1040
+ return new Response("{}", { status: 200 });
1041
+ }) as typeof fetch;
1042
+
1043
+ const t = new VaultTransport(baseConfig());
1044
+ await t.start(fakeCtx("eng"));
1045
+ const msgs = await t.loadTranscript();
1046
+
1047
+ // CONTRACT: exactly ONE `#agent/message` query — the interim/legacy union is gone.
1048
+ // It carries the encoded parent tag + include_content, and DELIBERATELY no
1049
+ // `metadata=` operator filter (the routing-key field isn't indexed on a bare
1050
+ // vault; we filter client-side). Overfetches the tag so other channels don't
1051
+ // crowd us out.
1052
+ const agentGets = getUrls.filter((u) => u.includes("tag=%23agent%2Fmessage"));
1053
+ expect(agentGets).toHaveLength(1);
1054
+ // No interim/legacy queries are issued.
1055
+ expect(getUrls.some((u) => u.includes("tag=%23agent-message"))).toBe(false);
1056
+ expect(getUrls.some((u) => u.includes("tag=%23channel-message"))).toBe(false);
1057
+ const agentGet = agentGets[0]!;
1058
+ expect(agentGet.startsWith("http://127.0.0.1:1940/vault/default/api/notes?")).toBe(true);
1059
+ expect(agentGet).toContain("include_content=true");
1060
+ expect(agentGet).not.toContain("metadata=");
1061
+ expect(capturedAuth).toBe("Bearer write-token-xyz");
1062
+
1063
+ // The "other" channel note is filtered OUT; the two "eng" notes remain, sorted
1064
+ // ascending by ts (n-in before n-out).
1065
+ expect(msgs).toHaveLength(2);
1066
+ expect(msgs[0]!.id).toBe("n-in");
1067
+ expect(msgs[0]!.direction).toBe("inbound");
1068
+ expect(msgs[0]!.text).toBe("hi session");
1069
+ expect(msgs[0]!.sender).toBe("aaron");
1070
+ expect(msgs[1]!.id).toBe("n-out");
1071
+ expect(msgs[1]!.direction).toBe("outbound");
1072
+ expect(msgs[1]!.inReplyTo).toBe("n-in");
1073
+ });
1074
+
1075
+ test("caps the returned transcript to the requested limit (most-recent by ts)", async () => {
1076
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1077
+ const u = String(url);
1078
+ if (u.includes("/api/notes") && (init?.method ?? "GET") === "GET") {
1079
+ if (u.includes("tag=%23agent%2Fmessage")) {
1080
+ return new Response(
1081
+ JSON.stringify([1, 2, 3, 4].map((i) => ({
1082
+ id: "n" + i,
1083
+ content: "m" + i,
1084
+ tags: ["#agent/message", "#agent/message/inbound"],
1085
+ metadata: { agent: "eng", direction: "inbound", sender: "aaron", ts: "2026-06-08T00:00:0" + i + "Z" },
1086
+ }))),
1087
+ { status: 200 },
1088
+ );
1089
+ }
1090
+ return new Response("[]", { status: 200 });
1091
+ }
1092
+ return new Response("{}", { status: 200 });
1093
+ }) as typeof fetch;
1094
+ const t = new VaultTransport(baseConfig());
1095
+ await t.start(fakeCtx("eng"));
1096
+ const msgs = await t.loadTranscript({ limit: 2 });
1097
+ // 4 notes fetched → the 2 most recent (by ts) returned, ascending.
1098
+ expect(msgs.map((m) => m.id)).toEqual(["n3", "n4"]);
1099
+ });
1100
+
1101
+ test("falls back to the outbound child tag for direction when metadata.direction is absent", async () => {
1102
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1103
+ const u = String(url);
1104
+ if (u.includes("/api/notes") && (init?.method ?? "GET") === "GET") {
1105
+ if (u.includes("tag=%23agent%2Fmessage")) {
1106
+ return new Response(
1107
+ JSON.stringify([
1108
+ // Outbound child → direction inferred "outbound".
1109
+ { id: "a", content: "x", tags: ["#agent/message", "#agent/message/outbound"], metadata: { agent: "eng", ts: "2026-06-08T00:00:01Z" } },
1110
+ // No direction signal at all → defaults to "inbound".
1111
+ { id: "b", content: "y", tags: ["#agent/message", "#agent/message/inbound"], metadata: { agent: "eng", ts: "2026-06-08T00:00:02Z" } },
1112
+ ]),
1113
+ { status: 200 },
1114
+ );
1115
+ }
1116
+ return new Response("[]", { status: 200 });
1117
+ }
1118
+ return new Response("{}", { status: 200 });
1119
+ }) as typeof fetch;
1120
+ const t = new VaultTransport(baseConfig());
1121
+ await t.start(fakeCtx("eng"));
1122
+ const msgs = await t.loadTranscript();
1123
+ expect(msgs.find((m) => m.id === "a")!.direction).toBe("outbound");
1124
+ expect(msgs.find((m) => m.id === "b")!.direction).toBe("inbound");
1125
+ });
1126
+
1127
+ test("throws a clear error on a non-ok vault response", async () => {
1128
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1129
+ const u = String(url);
1130
+ if (u.includes("/api/notes") && (init?.method ?? "GET") === "GET") {
1131
+ return new Response("nope", { status: 502 });
1132
+ }
1133
+ return new Response("{}", { status: 200 });
1134
+ }) as typeof fetch;
1135
+ const t = new VaultTransport(baseConfig());
1136
+ await t.start(fakeCtx("eng"));
1137
+ await expect(t.loadTranscript()).rejects.toThrow(/load transcript failed/);
1138
+ });
1139
+ });
1140
+
1141
+ describe("VaultTransport — writeInbound (the chat's send → wakes the session)", () => {
1142
+ test("POSTs an INBOUND note tagged [#agent/message, #agent/message/inbound] with direction + channel + sender + Bearer", async () => {
1143
+ const calls: { url: string; init: RequestInit }[] = [];
1144
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1145
+ calls.push({ url: String(url), init: init ?? {} });
1146
+ return new Response(JSON.stringify({ id: "inbound-note-1" }), {
1147
+ status: 201,
1148
+ headers: { "content-type": "application/json" },
1149
+ });
1150
+ }) as typeof fetch;
1151
+
1152
+ const t = new VaultTransport(baseConfig());
1153
+ await t.start(fakeCtx("eng"));
1154
+ const result = await t.writeInbound("wake up", "operator");
1155
+
1156
+ expect(result.id).toBe("inbound-note-1");
1157
+ const noteCalls = calls.filter((c) => c.url.endsWith("/api/notes") && c.init.method === "POST");
1158
+ expect(noteCalls).toHaveLength(1);
1159
+ const call = noteCalls[0]!;
1160
+ expect(call.url).toBe("http://127.0.0.1:1940/vault/default/api/notes");
1161
+ expect((call.init.headers as Record<string, string>).authorization).toBe("Bearer write-token-xyz");
1162
+
1163
+ const sent = JSON.parse(String(call.init.body)) as {
1164
+ content: string;
1165
+ path: string;
1166
+ tags: string[];
1167
+ metadata: Record<string, string>;
1168
+ };
1169
+ expect(sent.content).toBe("wake up");
1170
+ // The INBOUND tag pair — the child is the trigger discriminator that wakes the session.
1171
+ expect(sent.tags).toEqual(["#agent/message", "#agent/message/inbound"]);
1172
+ expect(sent.tags).toContain("#agent/message");
1173
+ expect(sent.tags).toContain("#agent/message/inbound");
1174
+ // It must NOT carry the outbound tag (that would be a reply, never wake).
1175
+ expect(sent.tags).not.toContain("#agent/message/outbound");
1176
+ // Write-discipline: the legacy tag family is gone (CONTRACT dropped it).
1177
+ expect(sent.tags).not.toContain("#channel-message");
1178
+ // CONTRACT: the routing key under `metadata.agent` ONLY — no `channel`. The vault
1179
+ // trigger keys on `has_metadata:["agent"]` to fire on this inbound note.
1180
+ expect(sent.metadata.agent).toBe("eng");
1181
+ expect(sent.metadata.channel).toBeUndefined();
1182
+ expect(sent.metadata.direction).toBe("inbound");
1183
+ expect(sent.metadata.sender).toBe("operator");
1184
+ expect(typeof sent.metadata.ts).toBe("string");
1185
+ // Note PATH prefix is DOMAIN (`channel/<name>/`) — unchanged.
1186
+ expect(sent.path.startsWith("channel/eng/")).toBe(true);
1187
+ });
1188
+
1189
+ test("defaults sender to 'operator' when omitted", async () => {
1190
+ let captured: Record<string, string> | undefined;
1191
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1192
+ if (String(url).endsWith("/api/notes") && init?.method === "POST") {
1193
+ captured = (JSON.parse(String(init?.body)) as { metadata: Record<string, string> }).metadata;
1194
+ }
1195
+ return new Response(JSON.stringify({ id: "n" }), { status: 201 });
1196
+ }) as typeof fetch;
1197
+ const t = new VaultTransport(baseConfig());
1198
+ await t.start(fakeCtx("eng"));
1199
+ await t.writeInbound("hi");
1200
+ expect(captured!.sender).toBe("operator");
1201
+ });
1202
+
1203
+ test("does NOT emit (no double-wake) — the trigger is the single wake path", async () => {
1204
+ globalThis.fetch = (async () =>
1205
+ new Response(JSON.stringify({ id: "n" }), { status: 201 })) as unknown as typeof fetch;
1206
+ const t = new VaultTransport(baseConfig());
1207
+ const ctx = fakeCtx("eng");
1208
+ await t.start(ctx);
1209
+ await t.writeInbound("hi");
1210
+ // writeInbound must never ctx.emit — the vault trigger wakes the session.
1211
+ expect(ctx.emitted).toHaveLength(0);
1212
+ });
1213
+
1214
+ test("throws a clear error on a non-ok vault response", async () => {
1215
+ globalThis.fetch = (async () =>
1216
+ new Response("boom", { status: 500 })) as unknown as typeof fetch;
1217
+ const t = new VaultTransport(baseConfig());
1218
+ await t.start(fakeCtx("eng"));
1219
+ await expect(t.writeInbound("x")).rejects.toThrow(/write inbound failed/);
1220
+ });
1221
+ });
1222
+
1223
+ describe("VaultTransport — writeCallback (agent-to-agent reply_to substrate)", () => {
1224
+ test("writes an INBOUND note carrying the callback metadata contract, NO reply_to, both inbound tags", async () => {
1225
+ let sent: { content: string; tags: string[]; metadata: Record<string, string> } | undefined;
1226
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1227
+ if (String(url).endsWith("/api/notes") && init?.method === "POST") {
1228
+ sent = JSON.parse(String(init?.body));
1229
+ }
1230
+ return new Response(JSON.stringify({ id: "callback-note-1" }), { status: 201 });
1231
+ }) as typeof fetch;
1232
+
1233
+ const t = new VaultTransport(baseConfig());
1234
+ await t.start(fakeCtx("orchestrator")); // the SENDER's channel.
1235
+ const result = await t.writeCallback("[callback] worker finished (ok) — see source_message.", {
1236
+ callback: "true",
1237
+ status: "ok",
1238
+ source_channel: "worker",
1239
+ source_thread: "thread-uuid-1",
1240
+ source_message: "reply-note-7",
1241
+ correlation_id: "corr-abc",
1242
+ delegation_depth: "3",
1243
+ });
1244
+
1245
+ expect(result.sent).toEqual(["callback-note-1"]);
1246
+ // The callback is an INBOUND note (so it wakes the sender via the normal vault trigger).
1247
+ expect(sent!.tags).toEqual(["#agent/message", "#agent/message/inbound"]);
1248
+ expect(sent!.tags).not.toContain("#agent/message/outbound");
1249
+ // The metadata contract — all present fields stamped.
1250
+ expect(sent!.metadata.callback).toBe("true");
1251
+ expect(sent!.metadata.status).toBe("ok");
1252
+ expect(sent!.metadata.source_channel).toBe("worker");
1253
+ expect(sent!.metadata.source_thread).toBe("thread-uuid-1");
1254
+ expect(sent!.metadata.source_message).toBe("reply-note-7");
1255
+ expect(sent!.metadata.correlation_id).toBe("corr-abc");
1256
+ expect(sent!.metadata.delegation_depth).toBe("3");
1257
+ // The channel it's routed to is THIS transport's channel (the sender's), direction inbound.
1258
+ // CONTRACT: routing key under `metadata.agent` ONLY — no `channel`.
1259
+ expect(sent!.metadata.agent).toBe("orchestrator");
1260
+ expect(sent!.metadata.channel).toBeUndefined();
1261
+ expect(sent!.metadata.direction).toBe("inbound");
1262
+ expect(sent!.metadata.sender).toBe("callback:worker");
1263
+ // LOOP GUARD: the callback note must NEVER carry a reply_to (terminal callback).
1264
+ expect(sent!.metadata.reply_to).toBeUndefined();
1265
+ });
1266
+
1267
+ test("omits source_message + correlation_id when absent (error callback, no reply)", async () => {
1268
+ let sent: { metadata: Record<string, string> } | undefined;
1269
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1270
+ if (String(url).endsWith("/api/notes") && init?.method === "POST") sent = JSON.parse(String(init?.body));
1271
+ return new Response(JSON.stringify({ id: "n" }), { status: 201 });
1272
+ }) as typeof fetch;
1273
+ const t = new VaultTransport(baseConfig());
1274
+ await t.start(fakeCtx("orchestrator"));
1275
+ await t.writeCallback("[callback] worker finished with an error.", {
1276
+ callback: "true",
1277
+ status: "error",
1278
+ source_channel: "worker",
1279
+ source_thread: "thread-2",
1280
+ delegation_depth: "1",
1281
+ });
1282
+ expect(sent!.metadata.status).toBe("error");
1283
+ expect(sent!.metadata.source_message).toBeUndefined();
1284
+ expect(sent!.metadata.correlation_id).toBeUndefined();
1285
+ });
1286
+
1287
+ test("a stray reply_to on the meta is STRIPPED (defense-in-depth loop guard)", async () => {
1288
+ let sent: { metadata: Record<string, string> } | undefined;
1289
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1290
+ if (String(url).endsWith("/api/notes") && init?.method === "POST") sent = JSON.parse(String(init?.body));
1291
+ return new Response(JSON.stringify({ id: "n" }), { status: 201 });
1292
+ }) as typeof fetch;
1293
+ const t = new VaultTransport(baseConfig());
1294
+ await t.start(fakeCtx("orchestrator"));
1295
+ // Simulate a (mistaken) caller widening the shape with a reply_to — it must NOT survive.
1296
+ await t.writeCallback("x", {
1297
+ callback: "true",
1298
+ status: "ok",
1299
+ source_channel: "worker",
1300
+ source_thread: "t",
1301
+ delegation_depth: "1",
1302
+ // @ts-expect-error — intentionally passing an extra field the contract forbids.
1303
+ reply_to: "should-be-stripped",
1304
+ });
1305
+ expect(sent!.metadata.reply_to).toBeUndefined();
1306
+ });
1307
+ });
1308
+
1309
+ describe("VaultTransport — ingestInbound", () => {
1310
+ test("emits the inbound content + meta onto its channel", () => {
1311
+ const t = new VaultTransport(baseConfig());
1312
+ const ctx = fakeCtx("eng");
1313
+ // start synchronously enough for the test (start just stores ctx).
1314
+ void t.start(ctx);
1315
+ void t.ingestInbound({
1316
+ id: "note-in-1",
1317
+ content: "hello session",
1318
+ tags: ["#agent/message", "#agent/message/inbound"],
1319
+ metadata: { agent: "eng", direction: "inbound", sender: "aaron", ts: "2026-06-08T00:00:00Z" },
1320
+ });
1321
+ expect(ctx.emitted).toHaveLength(1);
1322
+ const m = ctx.emitted[0]!;
1323
+ expect(m.channel).toBe("eng");
1324
+ expect(m.content).toBe("hello session");
1325
+ expect(m.source).toBe("vault");
1326
+ expect(m.meta.source).toBe("vault");
1327
+ expect(m.meta.note_id).toBe("note-in-1");
1328
+ expect(m.meta.sender).toBe("aaron");
1329
+ expect(m.meta.direction).toBe("inbound");
1330
+ // CONTRACT: the routing key on the in-memory event meta is stamped under `agent`
1331
+ // ONLY (the `channel` dual-write is dropped). The top-level InboundMessage.channel
1332
+ // TS field stays the channel name.
1333
+ expect(m.meta.agent).toBe("eng");
1334
+ expect(m.meta.channel).toBeUndefined();
1335
+ });
1336
+
1337
+ test("IGNORES a #agent/message/outbound-tagged note (loop avoidance)", () => {
1338
+ const t = new VaultTransport(baseConfig());
1339
+ const ctx = fakeCtx("eng");
1340
+ void t.start(ctx);
1341
+ void t.ingestInbound({
1342
+ id: "our-own-reply",
1343
+ content: "I am awake",
1344
+ tags: ["#agent/message", "#agent/message/outbound"],
1345
+ metadata: { channel: "eng", direction: "outbound", sender: "session" },
1346
+ });
1347
+ expect(ctx.emitted).toHaveLength(0);
1348
+ });
1349
+
1350
+ test("IGNORES a note with direction:outbound even if the outbound tag is absent", () => {
1351
+ const t = new VaultTransport(baseConfig());
1352
+ const ctx = fakeCtx("eng");
1353
+ void t.start(ctx);
1354
+ void t.ingestInbound({
1355
+ id: "x",
1356
+ content: "y",
1357
+ metadata: { channel: "eng", direction: "outbound" },
1358
+ });
1359
+ expect(ctx.emitted).toHaveLength(0);
1360
+ });
1361
+
1362
+ test("SURFACES attachments on the emitted InboundMessage when the note carries them (Phase 1)", async () => {
1363
+ // The webhook payload carries `note.attachments` inline (the has-attachments signal);
1364
+ // ingestInbound then fetches the authoritative attachment list (REST) and surfaces the
1365
+ // refs on the emitted message so the programmatic backend can stage them.
1366
+ const calls: string[] = [];
1367
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1368
+ calls.push(String(url));
1369
+ // The attachment-list endpoint → a bare Attachment[] array (vault REST shape).
1370
+ return new Response(
1371
+ JSON.stringify([
1372
+ { id: "a1", noteId: "note-att-1", path: "2026-06-24/pic.png", mimeType: "image/png", createdAt: "x" },
1373
+ { id: "a2", noteId: "note-att-1", path: "2026-06-24/doc.pdf", mimeType: "application/pdf", createdAt: "x" },
1374
+ ]),
1375
+ { status: 200, headers: { "content-type": "application/json" } },
1376
+ );
1377
+ }) as typeof fetch;
1378
+
1379
+ const t = new VaultTransport(baseConfig());
1380
+ const ctx = fakeCtx("eng");
1381
+ void t.start(ctx);
1382
+ await t.ingestInbound({
1383
+ id: "note-att-1",
1384
+ content: "look at these",
1385
+ tags: ["#agent/message", "#agent/message/inbound"],
1386
+ metadata: { agent: "eng", direction: "inbound", sender: "aaron" },
1387
+ // inline list from the trigger payload — the has-attachments SIGNAL.
1388
+ attachments: [{ id: "a1", path: "2026-06-24/pic.png", mimeType: "image/png" }],
1389
+ });
1390
+
1391
+ // It fetched the attachment-list endpoint with the channel's vault token.
1392
+ expect(calls.some((u) => u.endsWith("/vault/default/api/notes/note-att-1/attachments"))).toBe(true);
1393
+
1394
+ expect(ctx.emitted).toHaveLength(1);
1395
+ const m = ctx.emitted[0]!;
1396
+ expect(m.content).toBe("look at these");
1397
+ expect(m.attachments).toBeDefined();
1398
+ expect(m.attachments).toHaveLength(2);
1399
+ expect(m.attachments![0]).toEqual({ path: "2026-06-24/pic.png", mimeType: "image/png", filename: "pic.png" });
1400
+ expect(m.attachments![1]).toEqual({ path: "2026-06-24/doc.pdf", mimeType: "application/pdf", filename: "doc.pdf" });
1401
+ });
1402
+
1403
+ test("attachment-list fetch FAILURE is best-effort — the message is still emitted with text, no attachments", async () => {
1404
+ globalThis.fetch = (async () => new Response("boom", { status: 500 })) as unknown as typeof fetch;
1405
+ const t = new VaultTransport(baseConfig());
1406
+ const ctx = fakeCtx("eng");
1407
+ void t.start(ctx);
1408
+ await t.ingestInbound({
1409
+ id: "note-att-fail",
1410
+ content: "still delivered",
1411
+ tags: ["#agent/message", "#agent/message/inbound"],
1412
+ metadata: { agent: "eng", direction: "inbound" },
1413
+ attachments: [{ id: "a1", path: "2026-06-24/pic.png", mimeType: "image/png" }],
1414
+ });
1415
+ expect(ctx.emitted).toHaveLength(1);
1416
+ expect(ctx.emitted[0]!.content).toBe("still delivered");
1417
+ expect(ctx.emitted[0]!.attachments).toBeUndefined();
1418
+ });
1419
+
1420
+ test("NO inline attachments → NO fetch, emits synchronously (today's behavior)", () => {
1421
+ // Any fetch here would throw — proving the no-attachment path never reaches out.
1422
+ globalThis.fetch = (async () => {
1423
+ throw new Error("must not fetch");
1424
+ }) as unknown as typeof fetch;
1425
+ const t = new VaultTransport(baseConfig());
1426
+ const ctx = fakeCtx("eng");
1427
+ void t.start(ctx);
1428
+ // Not awaited — emit must be synchronous (before any await) when there are no attachments.
1429
+ void t.ingestInbound({
1430
+ id: "note-plain",
1431
+ content: "no files",
1432
+ tags: ["#agent/message", "#agent/message/inbound"],
1433
+ metadata: { agent: "eng", direction: "inbound" },
1434
+ });
1435
+ expect(ctx.emitted).toHaveLength(1);
1436
+ expect(ctx.emitted[0]!.attachments).toBeUndefined();
1437
+ });
1438
+
1439
+ test("FLATTENS the agent-to-agent callback fields (reply_to/correlation_id/delegation_depth) into meta", () => {
1440
+ // The READ side of the callback round-trip: a SENDING agent stamps reply_to et al on the
1441
+ // inbound note's metadata; ingestInbound must surface them in `meta` so contextFor.emit's
1442
+ // callbackFieldsFromMeta can pick them up. (ingestInbound already flattens ALL metadata —
1443
+ // this pins the behavior the callback substrate depends on.)
1444
+ const t = new VaultTransport(baseConfig());
1445
+ const ctx = fakeCtx("worker");
1446
+ void t.start(ctx);
1447
+ void t.ingestInbound({
1448
+ id: "note-deleg-1",
1449
+ content: "do the sub-task",
1450
+ tags: ["#agent/message", "#agent/message/inbound"],
1451
+ metadata: {
1452
+ channel: "worker",
1453
+ direction: "inbound",
1454
+ sender: "orchestrator",
1455
+ reply_to: "orchestrator",
1456
+ correlation_id: "corr-1",
1457
+ delegation_depth: "2",
1458
+ },
1459
+ });
1460
+ expect(ctx.emitted).toHaveLength(1);
1461
+ const m = ctx.emitted[0]!.meta;
1462
+ expect(m.reply_to).toBe("orchestrator");
1463
+ expect(m.correlation_id).toBe("corr-1");
1464
+ expect(m.delegation_depth).toBe("2"); // string-valued, as the vault stores it.
1465
+ });
1466
+ });
1467
+
1468
+ describe("VaultTransport — ensureSchema (tag-schema declaration on connect)", () => {
1469
+ /** Drain microtasks so a fire-and-forget `void this.ensureSchema()` from
1470
+ * start() has issued its fetches before we assert. */
1471
+ const flush = () => new Promise<void>((r) => setTimeout(r, 0));
1472
+
1473
+ test("PUTs each AGENT_VAULT_TAG_SCHEMA entry with the right URL encoding, Bearer, and body", async () => {
1474
+ const calls: { url: string; init: RequestInit }[] = [];
1475
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1476
+ calls.push({ url: String(url), init: init ?? {} });
1477
+ return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
1478
+ }) as typeof fetch;
1479
+
1480
+ const t = new VaultTransport(baseConfig());
1481
+ await t.ensureSchema();
1482
+
1483
+ expect(calls).toHaveLength(AGENT_VAULT_TAG_SCHEMA.length);
1484
+
1485
+ // Namespace ROOT `#agent` — no parent_names, just a description. Plain `#` → `%23`.
1486
+ const root = calls[0]!;
1487
+ expect(root.url).toBe(
1488
+ "http://127.0.0.1:1940/vault/default/api/tags/%23agent",
1489
+ );
1490
+ expect(root.init.method).toBe("PUT");
1491
+ expect((root.init.headers as Record<string, string>).authorization).toBe(
1492
+ "Bearer write-token-xyz",
1493
+ );
1494
+ const rootBody = JSON.parse(String(root.init.body)) as {
1495
+ description?: string;
1496
+ parent_names?: string[];
1497
+ };
1498
+ expect("parent_names" in rootBody).toBe(false);
1499
+
1500
+ // Definition (NEW) — name carries `#` + `/`; rolls up to the namespace root.
1501
+ const def = calls[1]!;
1502
+ expect(def.url).toBe(
1503
+ "http://127.0.0.1:1940/vault/default/api/tags/%23agent%2Fdefinition",
1504
+ );
1505
+ expect(decodeURIComponent(def.url.split("/api/tags/")[1]!)).toBe("#agent/definition");
1506
+ const defBody = JSON.parse(String(def.init.body)) as { parent_names?: string[] };
1507
+ expect(defBody.parent_names).toEqual(["#agent"]);
1508
+
1509
+ // Message parent (NEW) — rolls up to the namespace root.
1510
+ const parent = calls[2]!;
1511
+ expect(parent.url).toBe(
1512
+ "http://127.0.0.1:1940/vault/default/api/tags/%23agent%2Fmessage",
1513
+ );
1514
+ const parentBody = JSON.parse(String(parent.init.body)) as {
1515
+ description?: string;
1516
+ parent_names?: string[];
1517
+ };
1518
+ expect(parentBody.description).toBe(
1519
+ "A message in a Parachute channel (parent of /inbound + /outbound).",
1520
+ );
1521
+ expect(parentBody.parent_names).toEqual(["#agent"]);
1522
+
1523
+ // Inbound child (NEW) — name carries BOTH `#` and `/`. The vault route matches a
1524
+ // single path segment (`[^/]+`) then decodeURIComponent's it, so the `/` MUST
1525
+ // be encoded as `%2F` (a bare slash would fail the single-segment match → 404).
1526
+ const inbound = calls[3]!;
1527
+ expect(inbound.url).toBe(
1528
+ "http://127.0.0.1:1940/vault/default/api/tags/%23agent%2Fmessage%2Finbound",
1529
+ );
1530
+ // Confirm the encoding decodes back to the literal tag name the vault stores.
1531
+ const encodedSegment = inbound.url.split("/api/tags/")[1]!;
1532
+ expect(decodeURIComponent(encodedSegment)).toBe("#agent/message/inbound");
1533
+ const inboundBody = JSON.parse(String(inbound.init.body)) as {
1534
+ description?: string;
1535
+ parent_names?: string[];
1536
+ };
1537
+ expect(inboundBody.parent_names).toEqual(["#agent/message"]);
1538
+ expect(inboundBody.description).toBe(
1539
+ "Human→session message; the vault trigger fires on this.",
1540
+ );
1541
+
1542
+ // Outbound child (NEW) — same encoding, parent declared.
1543
+ const outbound = calls[4]!;
1544
+ expect(outbound.url).toBe(
1545
+ "http://127.0.0.1:1940/vault/default/api/tags/%23agent%2Fmessage%2Foutbound",
1546
+ );
1547
+ expect(decodeURIComponent(outbound.url.split("/api/tags/")[1]!)).toBe(
1548
+ "#agent/message/outbound",
1549
+ );
1550
+ const outboundBody = JSON.parse(String(outbound.init.body)) as { parent_names?: string[] };
1551
+ expect(outboundBody.parent_names).toEqual(["#agent/message"]);
1552
+
1553
+ // Job (NEW) — rolls up to the namespace root.
1554
+ const job = calls[5]!;
1555
+ expect(decodeURIComponent(job.url.split("/api/tags/")[1]!)).toBe("#agent/job");
1556
+ const jobBody = JSON.parse(String(job.init.body)) as { parent_names?: string[] };
1557
+ expect(jobBody.parent_names).toEqual(["#agent"]);
1558
+ });
1559
+
1560
+ test("schema declares ONLY the #agent/* namespace rollup (CONTRACT dropped interim + legacy, 7 entries)", async () => {
1561
+ // The `#agent/*` namespace (design 2026-06-17-vault-native-agents) rolls up
1562
+ // definitions, messages, jobs, AND threads to the `#agent` root. The channel→agent
1563
+ // CONTRACT dropped the interim flat `#agent-message*` AND legacy `#channel-message*`
1564
+ // schema entries — exactly 7 entries, all under `#agent/*`.
1565
+ const names = AGENT_VAULT_TAG_SCHEMA.map((e) => e.name);
1566
+ expect(names).toEqual([
1567
+ "#agent",
1568
+ "#agent/definition",
1569
+ "#agent/message",
1570
+ "#agent/message/inbound",
1571
+ "#agent/message/outbound",
1572
+ "#agent/job",
1573
+ "#agent/thread",
1574
+ ]);
1575
+ // The interim/legacy families are gone entirely.
1576
+ expect(names).not.toContain("#agent-message");
1577
+ expect(names).not.toContain("#channel-message");
1578
+ // The namespace children all roll up to the `#agent` root (the human rollup).
1579
+ const byName = (n: string) => AGENT_VAULT_TAG_SCHEMA.find((e) => e.name === n)!;
1580
+ expect(byName("#agent/definition").parent_names).toEqual(["#agent"]);
1581
+ expect(byName("#agent/message").parent_names).toEqual(["#agent"]);
1582
+ expect(byName("#agent/job").parent_names).toEqual(["#agent"]);
1583
+ expect(byName("#agent/thread").parent_names).toEqual(["#agent"]);
1584
+ expect(byName("#agent/message/inbound").parent_names).toEqual(["#agent/message"]);
1585
+ expect(byName("#agent/message/outbound").parent_names).toEqual(["#agent/message"]);
1586
+ // `#agent/thread` declares INDEXED string fields so threads are operator-queryable —
1587
+ // "all failed threads" (status), "all threads of agent X" (definition), "all
1588
+ // multi-threaded threads" (mode). The three axes carry over from the run record VERBATIM.
1589
+ expect(byName("#agent/thread").fields).toEqual({
1590
+ // The canonical `agent` routing-key alias is declared indexed.
1591
+ agent: { type: "string", indexed: true },
1592
+ status: { type: "string", indexed: true },
1593
+ definition: { type: "string", indexed: true },
1594
+ mode: { type: "string", indexed: true },
1595
+ });
1596
+ // `#agent/message` declares the indexed `agent` routing key.
1597
+ expect(byName("#agent/message").fields).toEqual({
1598
+ agent: { type: "string", indexed: true },
1599
+ });
1600
+ // CONTRACT: `#agent/job` indexes the routing key under `agent` ONLY — no `channel`.
1601
+ expect(byName("#agent/job").fields).toEqual({
1602
+ agent: { type: "string", indexed: true },
1603
+ enabled: { type: "string", indexed: true },
1604
+ lastStatus: { type: "string", indexed: true },
1605
+ });
1606
+ });
1607
+
1608
+ test("schema is sourced from AGENT_VAULT_TAG_SCHEMA — declares exactly its entries", async () => {
1609
+ const declared: string[] = [];
1610
+ globalThis.fetch = (async (url: string | URL | Request) => {
1611
+ declared.push(decodeURIComponent(String(url).split("/api/tags/")[1]!));
1612
+ return new Response("{}", { status: 200 });
1613
+ }) as typeof fetch;
1614
+
1615
+ const t = new VaultTransport(baseConfig());
1616
+ await t.ensureSchema();
1617
+
1618
+ expect(declared).toEqual(AGENT_VAULT_TAG_SCHEMA.map((e) => e.name));
1619
+ });
1620
+
1621
+ test("ensureSchema sends the indexed `fields` body for #agent/thread", async () => {
1622
+ let threadBody: { fields?: Record<string, unknown> } | undefined;
1623
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1624
+ const name = decodeURIComponent(String(url).split("/api/tags/")[1]!);
1625
+ if (name === AGENT_THREAD_TAG) threadBody = JSON.parse(String(init?.body));
1626
+ return new Response("{}", { status: 200 });
1627
+ }) as typeof fetch;
1628
+
1629
+ const t = new VaultTransport(baseConfig());
1630
+ await t.ensureSchema();
1631
+
1632
+ expect(threadBody?.fields).toEqual({
1633
+ // Expand phase: the new `agent` routing-key alias is declared indexed (additive).
1634
+ agent: { type: "string", indexed: true },
1635
+ status: { type: "string", indexed: true },
1636
+ definition: { type: "string", indexed: true },
1637
+ mode: { type: "string", indexed: true },
1638
+ });
1639
+ });
1640
+
1641
+ test("ensureSchema sends the indexed `fields` body for #agent/job (query by agent/enabled/lastStatus)", async () => {
1642
+ let jobBody: { fields?: Record<string, unknown> } | undefined;
1643
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1644
+ const name = decodeURIComponent(String(url).split("/api/tags/")[1]!);
1645
+ if (name === AGENT_JOB_TAG) jobBody = JSON.parse(String(init?.body));
1646
+ return new Response("{}", { status: 200 });
1647
+ }) as typeof fetch;
1648
+
1649
+ const t = new VaultTransport(baseConfig());
1650
+ await t.ensureSchema();
1651
+
1652
+ expect(jobBody?.fields).toEqual({
1653
+ // CONTRACT: index the routing key under `agent` ONLY — no `channel`.
1654
+ agent: { type: "string", indexed: true },
1655
+ enabled: { type: "string", indexed: true },
1656
+ lastStatus: { type: "string", indexed: true },
1657
+ });
1658
+ });
1659
+
1660
+ test("best-effort: a rejecting fetch does NOT throw out of ensureSchema", async () => {
1661
+ globalThis.fetch = (async () => {
1662
+ throw new Error("ECONNREFUSED");
1663
+ }) as unknown as typeof fetch;
1664
+
1665
+ const t = new VaultTransport(baseConfig());
1666
+ // Must resolve, not reject.
1667
+ await expect(t.ensureSchema()).resolves.toBeUndefined();
1668
+ });
1669
+
1670
+ test("best-effort: a 500 response does NOT throw out of ensureSchema", async () => {
1671
+ globalThis.fetch = (async () =>
1672
+ new Response("boom", { status: 500 })) as unknown as typeof fetch;
1673
+
1674
+ const t = new VaultTransport(baseConfig());
1675
+ await expect(t.ensureSchema()).resolves.toBeUndefined();
1676
+ });
1677
+
1678
+ test("start() stays non-fatal + the transport still works when schema-ensure fails", async () => {
1679
+ // A fetch that fails the PUT (schema) but the test asserts start() resolves
1680
+ // and ingestInbound still emits — the transport is fully functional regardless.
1681
+ globalThis.fetch = (async () => {
1682
+ throw new Error("vault unreachable");
1683
+ }) as unknown as typeof fetch;
1684
+
1685
+ const t = new VaultTransport(baseConfig());
1686
+ const ctx = fakeCtx("eng");
1687
+ await expect(t.start(ctx)).resolves.toBeUndefined();
1688
+ await flush(); // let the fire-and-forget ensureSchema settle (it must not reject globally)
1689
+
1690
+ // Transport still delivers inbound after a failed schema declaration.
1691
+ void t.ingestInbound({
1692
+ id: "n1",
1693
+ content: "still works",
1694
+ tags: ["#agent/message", "#agent/message/inbound"],
1695
+ metadata: { channel: "eng", direction: "inbound", sender: "aaron" },
1696
+ });
1697
+ expect(ctx.emitted).toHaveLength(1);
1698
+ expect(ctx.emitted[0]!.content).toBe("still works");
1699
+ });
1700
+ });
1701
+
1702
+ describe("registry — vault", () => {
1703
+ test("a vault channel instantiates from config", () => {
1704
+ const transport = instantiateTransport({
1705
+ name: "eng",
1706
+ transport: "vault",
1707
+ config: baseConfig(),
1708
+ });
1709
+ expect(transport.kind).toBe("vault");
1710
+ expect(transport).toBeInstanceOf(VaultTransport);
1711
+ });
1712
+
1713
+ test("a vault channel without a token throws", () => {
1714
+ expect(() =>
1715
+ instantiateTransport({
1716
+ name: "eng",
1717
+ transport: "vault",
1718
+ config: { vault: "default", webhookSecret: "s" },
1719
+ }),
1720
+ ).toThrow(/token/);
1721
+ });
1722
+ });
1723
+
1724
+ describe("VaultTransport — injectInbound (runner seam)", () => {
1725
+ test("injectInbound writes an INBOUND note (both tags) with runner provenance", async () => {
1726
+ const calls: { url: string; init: RequestInit }[] = [];
1727
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1728
+ calls.push({ url: String(url), init: init ?? {} });
1729
+ return new Response(JSON.stringify({ id: "inbound-1" }), {
1730
+ status: 201,
1731
+ headers: { "content-type": "application/json" },
1732
+ });
1733
+ }) as typeof fetch;
1734
+
1735
+ const t = new VaultTransport(baseConfig());
1736
+ await t.start(fakeCtx("eng"));
1737
+ const r = await t.injectInbound({ content: "Run the morning weave", sender: "runner:morning" });
1738
+ expect(r.id).toBe("inbound-1");
1739
+
1740
+ const noteCalls = calls.filter((c) => c.url.endsWith("/api/notes"));
1741
+ expect(noteCalls).toHaveLength(1);
1742
+ const body = JSON.parse(String(noteCalls[0]!.init.body));
1743
+ // Inbound: BOTH the parent + the inbound child (the trigger discriminator).
1744
+ expect(body.tags).toEqual(["#agent/message", "#agent/message/inbound"]);
1745
+ expect(body.content).toBe("Run the morning weave");
1746
+ expect(body.metadata.direction).toBe("inbound");
1747
+ expect(body.metadata.sender).toBe("runner:morning");
1748
+ // NEVER stamps channel_inbound_rendered_at (so the trigger fires).
1749
+ expect(body.metadata.channel_inbound_rendered_at).toBeUndefined();
1750
+ });
1751
+
1752
+ test("injectInbound defaults sender to 'runner'", async () => {
1753
+ globalThis.fetch = (async () =>
1754
+ new Response(JSON.stringify({ id: "x" }), {
1755
+ status: 201,
1756
+ headers: { "content-type": "application/json" },
1757
+ })) as unknown as typeof fetch;
1758
+ const t = new VaultTransport(baseConfig());
1759
+ await t.start(fakeCtx("eng"));
1760
+ // No throw + returns the id; the default-sender path is exercised.
1761
+ expect((await t.injectInbound({ content: "hi" })).id).toBe("x");
1762
+ });
1763
+ });
1764
+
1765
+ describe("VaultTransport — scheduled-job notes (vault-native store)", () => {
1766
+ test("listJobNotes queries by #agent/job + maps metadata; skips malformed", async () => {
1767
+ const urls: string[] = [];
1768
+ globalThis.fetch = (async (url: string | URL | Request) => {
1769
+ urls.push(String(url));
1770
+ return new Response(
1771
+ JSON.stringify([
1772
+ {
1773
+ id: "note-uuid-1",
1774
+ content: "the message",
1775
+ metadata: { jobId: "morning", channel: "eng", cron: "0 9 * * *", tz: "UTC", enabled: "true", createdAt: "t0" },
1776
+ },
1777
+ // a note WITHOUT jobId metadata → slug falls back to the note id
1778
+ {
1779
+ id: "Channels/eng/jobs/legacy",
1780
+ content: "legacy",
1781
+ metadata: { channel: "eng", cron: "0 0 * * *", enabled: "false" },
1782
+ },
1783
+ // malformed (no cron) → skipped
1784
+ { id: "job-bad", content: "x", metadata: { channel: "eng" } },
1785
+ ]),
1786
+ { status: 200, headers: { "content-type": "application/json" } },
1787
+ );
1788
+ }) as typeof fetch;
1789
+
1790
+ const t = new VaultTransport(baseConfig());
1791
+ const jobs = await t.listJobNotes();
1792
+ expect(urls[0]).toContain("tag=%23agent%2Fjob");
1793
+ expect(urls[0]).toContain("include_content=true");
1794
+ expect(jobs).toHaveLength(2);
1795
+ // id = the slug from metadata.jobId; noteId = the vault note id.
1796
+ expect(jobs[0]).toMatchObject({ id: "morning", noteId: "note-uuid-1", channel: "eng", cron: "0 9 * * *", tz: "UTC", enabled: true });
1797
+ // legacy note (no jobId) → id falls back to the note id.
1798
+ expect(jobs[1]).toMatchObject({ id: "Channels/eng/jobs/legacy", noteId: "Channels/eng/jobs/legacy", enabled: false });
1799
+ });
1800
+
1801
+ test("listJobNotes throws on a non-ok vault response", async () => {
1802
+ globalThis.fetch = (async () => new Response("nope", { status: 502 })) as unknown as typeof fetch;
1803
+ const t = new VaultTransport(baseConfig());
1804
+ await expect(t.listJobNotes()).rejects.toThrow(/list jobs failed \(502\)/);
1805
+ });
1806
+
1807
+ test("upsertJobNote POSTs a #agent/job note at the deterministic path", async () => {
1808
+ const calls: { url: string; init: RequestInit }[] = [];
1809
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1810
+ calls.push({ url: String(url), init: init ?? {} });
1811
+ return new Response(JSON.stringify({ id: "Channels/eng/jobs/m" }), {
1812
+ status: 201,
1813
+ headers: { "content-type": "application/json" },
1814
+ });
1815
+ }) as typeof fetch;
1816
+ const t = new VaultTransport(baseConfig());
1817
+ const r = await t.upsertJobNote({
1818
+ id: "m",
1819
+ message: "go",
1820
+ channel: "eng",
1821
+ cron: "0 9 * * *",
1822
+ enabled: true,
1823
+ createdAt: "t0",
1824
+ });
1825
+ expect(r.id).toBe("Channels/eng/jobs/m");
1826
+ const body = JSON.parse(String(calls[0]!.init.body));
1827
+ expect(body.path).toBe("Channels/eng/jobs/m");
1828
+ expect(body.tags).toEqual(["#agent/job"]);
1829
+ expect(body.metadata.enabled).toBe("true");
1830
+ expect(body.metadata.jobId).toBe("m"); // slug persisted for stable display
1831
+ // CONTRACT: routing key under `metadata.agent` ONLY — no `channel`.
1832
+ expect(body.metadata.agent).toBe("eng");
1833
+ expect(body.metadata.channel).toBeUndefined();
1834
+ });
1835
+
1836
+ test("patchJobNote sends a PATCH with only the changed metadata", async () => {
1837
+ const calls: { url: string; init: RequestInit }[] = [];
1838
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1839
+ calls.push({ url: String(url), init: init ?? {} });
1840
+ return new Response(null, { status: 200 });
1841
+ }) as typeof fetch;
1842
+ const t = new VaultTransport(baseConfig());
1843
+ await t.patchJobNote("job-1", { lastStatus: "ok", lastRunAt: "t1" });
1844
+ expect(calls[0]!.init.method).toBe("PATCH");
1845
+ expect(calls[0]!.url).toContain("/api/notes/job-1");
1846
+ const patchBody = JSON.parse(String(calls[0]!.init.body));
1847
+ expect(patchBody.metadata).toEqual({ lastRunAt: "t1", lastStatus: "ok" });
1848
+ // MUST carry the vault mutation precondition or the PATCH 428s (real-vault bug).
1849
+ expect(patchBody.force).toBe(true);
1850
+ });
1851
+
1852
+ test("deleteJobNote DELETEs by id", async () => {
1853
+ const calls: { url: string; method?: string }[] = [];
1854
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1855
+ calls.push({ url: String(url), method: init?.method });
1856
+ return new Response(null, { status: 204 });
1857
+ }) as typeof fetch;
1858
+ const t = new VaultTransport(baseConfig());
1859
+ await t.deleteJobNote("job-1");
1860
+ expect(calls[0]!.method).toBe("DELETE");
1861
+ expect(calls[0]!.url).toContain("/api/notes/job-1");
1862
+ });
1863
+
1864
+ test("deleteJobNote throws on a non-ok vault response", async () => {
1865
+ globalThis.fetch = (async () => new Response("no", { status: 404 })) as unknown as typeof fetch;
1866
+ const t = new VaultTransport(baseConfig());
1867
+ await expect(t.deleteJobNote("job-1")).rejects.toThrow(/delete job failed \(404\)/);
1868
+ });
1869
+ });
1870
+
1871
+ // ---------------------------------------------------------------------------
1872
+ // Channel-queue inbound notes — FIX 3 (CAS claim) + FIX 6 (handled exclusion).
1873
+ // ---------------------------------------------------------------------------
1874
+
1875
+ describe("VaultTransport — listInboundQueue", () => {
1876
+ test("FIX 6: EXCLUDES handled notes so pending is never crowded out past the cap", async () => {
1877
+ // The vault returns many `handled` notes plus one still-`pending` note. The handled
1878
+ // ones must be dropped client-side so the pending one is always in the returned queue.
1879
+ const handled = Array.from({ length: 50 }, (_, i) => ({
1880
+ id: `h${i}`,
1881
+ content: `handled ${i}`,
1882
+ metadata: { channel: "eng", direction: "inbound", sender: "operator", ts: `2026-01-01T00:${String(i).padStart(2, "0")}:00Z`, status: "handled" },
1883
+ updated_at: `2026-01-01T01:00:00Z`,
1884
+ }));
1885
+ const pending = {
1886
+ id: "p1",
1887
+ content: "still pending",
1888
+ metadata: { channel: "eng", direction: "inbound", sender: "operator", ts: "2026-01-02T00:00:00Z", status: "pending" },
1889
+ updated_at: "2026-01-02T00:00:00Z",
1890
+ };
1891
+ let listUrl = "";
1892
+ globalThis.fetch = (async (url: string | URL | Request) => {
1893
+ const u = String(url);
1894
+ // start() fires ensureSchema PUTs (.../api/tags/*); only capture the list GET.
1895
+ if (u.includes("/api/notes?")) {
1896
+ listUrl = u;
1897
+ return new Response(JSON.stringify([...handled, pending]), {
1898
+ status: 200,
1899
+ headers: { "content-type": "application/json" },
1900
+ });
1901
+ }
1902
+ return new Response(null, { status: 200 });
1903
+ }) as typeof fetch;
1904
+
1905
+ const t = new VaultTransport(baseConfig());
1906
+ await t.start(fakeCtx("eng"));
1907
+ const queue = await t.listInboundQueue();
1908
+ // No handled notes survive; the pending one IS present.
1909
+ expect(queue.every((n) => n.status !== "handled")).toBe(true);
1910
+ expect(queue.map((n) => n.id)).toEqual(["p1"]);
1911
+ expect(queue[0]!.status).toBe("pending");
1912
+ // The list request asks the vault NEWEST-first (so a hard cap drops the oldest
1913
+ // handled notes, never a recent pending).
1914
+ expect(listUrl).toContain("sort=desc");
1915
+ });
1916
+
1917
+ test("FIX 6: in-flight notes are KEPT (only handled is excluded)", async () => {
1918
+ globalThis.fetch = (async () =>
1919
+ new Response(
1920
+ JSON.stringify([
1921
+ { id: "a", content: "p", metadata: { channel: "eng", ts: "t1", status: "pending" }, updated_at: "u1" },
1922
+ { id: "b", content: "f", metadata: { channel: "eng", ts: "t2", status: "in-flight", claimedAt: "c2" }, updated_at: "u2" },
1923
+ { id: "c", content: "h", metadata: { channel: "eng", ts: "t3", status: "handled" }, updated_at: "u3" },
1924
+ ]),
1925
+ { status: 200, headers: { "content-type": "application/json" } },
1926
+ )) as unknown as typeof fetch;
1927
+ const t = new VaultTransport(baseConfig());
1928
+ await t.start(fakeCtx("eng"));
1929
+ const queue = await t.listInboundQueue();
1930
+ expect(queue.map((n) => n.id)).toEqual(["a", "b"]);
1931
+ expect(queue.find((n) => n.id === "b")!.status).toBe("in-flight");
1932
+ });
1933
+
1934
+ test("FIX 3: threads the note's updated_at through as updatedAt (the CAS precondition)", async () => {
1935
+ globalThis.fetch = (async () =>
1936
+ new Response(
1937
+ JSON.stringify([
1938
+ { id: "n1", content: "hi", metadata: { channel: "eng", ts: "t1", status: "pending" }, updated_at: "2026-06-01T00:00:00Z" },
1939
+ ]),
1940
+ { status: 200, headers: { "content-type": "application/json" } },
1941
+ )) as unknown as typeof fetch;
1942
+ const t = new VaultTransport(baseConfig());
1943
+ await t.start(fakeCtx("eng"));
1944
+ const queue = await t.listInboundQueue();
1945
+ expect(queue[0]!.updatedAt).toBe("2026-06-01T00:00:00Z");
1946
+ });
1947
+ });
1948
+
1949
+ describe("VaultTransport — setInboundStatus (FIX 3 compare-and-swap claim)", () => {
1950
+ test("with ifUpdatedAt: sends if_updated_at (NOT force) as the precondition", async () => {
1951
+ let body: any;
1952
+ globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
1953
+ body = JSON.parse(String(init?.body));
1954
+ return new Response(null, { status: 200 });
1955
+ }) as typeof fetch;
1956
+ const t = new VaultTransport(baseConfig());
1957
+ await t.setInboundStatus("n1", "in-flight", "2026-06-01T00:00:01Z", "2026-06-01T00:00:00Z");
1958
+ expect(body.if_updated_at).toBe("2026-06-01T00:00:00Z");
1959
+ expect(body.force).toBeUndefined();
1960
+ expect(body.metadata.status).toBe("in-flight");
1961
+ expect(body.metadata.claimedAt).toBe("2026-06-01T00:00:01Z");
1962
+ });
1963
+
1964
+ test("without ifUpdatedAt: keeps the last-write-wins force:true (release/handled/sweep)", async () => {
1965
+ let body: any;
1966
+ globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
1967
+ body = JSON.parse(String(init?.body));
1968
+ return new Response(null, { status: 200 });
1969
+ }) as typeof fetch;
1970
+ const t = new VaultTransport(baseConfig());
1971
+ await t.setInboundStatus("n1", "handled", null);
1972
+ expect(body.force).toBe(true);
1973
+ expect(body.if_updated_at).toBeUndefined();
1974
+ });
1975
+
1976
+ test("a 409 (stale precondition) on a CAS write throws InboundClaimConflictError", async () => {
1977
+ globalThis.fetch = (async () =>
1978
+ new Response(JSON.stringify({ error_type: "conflict" }), { status: 409 })) as unknown as typeof fetch;
1979
+ const t = new VaultTransport(baseConfig());
1980
+ await expect(
1981
+ t.setInboundStatus("n1", "in-flight", "now", "stale-updated-at"),
1982
+ ).rejects.toBeInstanceOf(InboundClaimConflictError);
1983
+ });
1984
+
1985
+ test("a 428 (precondition required) on a CAS write also throws InboundClaimConflictError", async () => {
1986
+ globalThis.fetch = (async () =>
1987
+ new Response(JSON.stringify({ error: "precondition_required" }), { status: 428 })) as unknown as typeof fetch;
1988
+ const t = new VaultTransport(baseConfig());
1989
+ await expect(
1990
+ t.setInboundStatus("n1", "in-flight", "now", "some-updated-at"),
1991
+ ).rejects.toBeInstanceOf(InboundClaimConflictError);
1992
+ });
1993
+
1994
+ test("a 409 on a NON-CAS write (no ifUpdatedAt) throws a plain Error, not a conflict", async () => {
1995
+ globalThis.fetch = (async () =>
1996
+ new Response("conflict", { status: 409 })) as unknown as typeof fetch;
1997
+ const t = new VaultTransport(baseConfig());
1998
+ const err = await t.setInboundStatus("n1", "handled", null).catch((e) => e);
1999
+ expect(err).toBeInstanceOf(Error);
2000
+ expect(err).not.toBeInstanceOf(InboundClaimConflictError);
2001
+ });
2002
+
2003
+ test("a 500 on a CAS write throws a plain Error (a real failure, not a lost race)", async () => {
2004
+ globalThis.fetch = (async () =>
2005
+ new Response("boom", { status: 500 })) as unknown as typeof fetch;
2006
+ const t = new VaultTransport(baseConfig());
2007
+ const err = await t.setInboundStatus("n1", "in-flight", "now", "u1").catch((e) => e);
2008
+ expect(err).toBeInstanceOf(Error);
2009
+ expect(err).not.toBeInstanceOf(InboundClaimConflictError);
2010
+ });
2011
+ });