@openparachute/agent 0.1.1 → 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 (598) 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 -221
  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 -171
  318. package/scripts/init-first-agent.ts +0 -377
  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 -79
  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/ask-question.ts +0 -46
  328. package/src/channels/channel-registry.test.ts +0 -421
  329. package/src/channels/channel-registry.ts +0 -313
  330. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  331. package/src/channels/chat-sdk-bridge.ts +0 -652
  332. package/src/channels/cli.ts +0 -276
  333. package/src/channels/discord.ts +0 -90
  334. package/src/channels/index.ts +0 -17
  335. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  336. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  337. package/src/channels/telegram-pairing.test.ts +0 -254
  338. package/src/channels/telegram-pairing.ts +0 -339
  339. package/src/channels/telegram.ts +0 -279
  340. package/src/channels/trust-hint.test.ts +0 -48
  341. package/src/channels/trust-hint.ts +0 -75
  342. package/src/claude-md-compose.migrate.test.ts +0 -64
  343. package/src/claude-md-compose.ts +0 -205
  344. package/src/command-gate.ts +0 -63
  345. package/src/config.test.ts +0 -93
  346. package/src/config.ts +0 -108
  347. package/src/container-config.ts +0 -167
  348. package/src/container-runner.test.ts +0 -32
  349. package/src/container-runner.ts +0 -576
  350. package/src/container-runtime.test.ts +0 -169
  351. package/src/container-runtime.ts +0 -92
  352. package/src/db/_bun-sqlite-shim.ts +0 -88
  353. package/src/db/agent-activity.test.ts +0 -155
  354. package/src/db/agent-activity.ts +0 -121
  355. package/src/db/agent-groups.ts +0 -77
  356. package/src/db/connection.migrate.test.ts +0 -143
  357. package/src/db/connection.ts +0 -224
  358. package/src/db/db-v2.test.ts +0 -440
  359. package/src/db/dropped-messages.ts +0 -44
  360. package/src/db/index.ts +0 -40
  361. package/src/db/messaging-groups.ts +0 -252
  362. package/src/db/migrations/001-initial.ts +0 -112
  363. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  364. package/src/db/migrations/008-dropped-messages.ts +0 -27
  365. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  366. package/src/db/migrations/010-engage-modes.ts +0 -103
  367. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  368. package/src/db/migrations/012-channel-registration.ts +0 -48
  369. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  370. package/src/db/migrations/014-secrets.ts +0 -44
  371. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  372. package/src/db/migrations/016-secret-assignments.ts +0 -30
  373. package/src/db/migrations/017-agent-activity.ts +0 -40
  374. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  375. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  376. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  377. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  378. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  379. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  380. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  381. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  382. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  383. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  384. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  385. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  386. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  387. package/src/db/migrations/027-provider-credentials.ts +0 -41
  388. package/src/db/migrations/_test-helpers.ts +0 -41
  389. package/src/db/migrations/index.ts +0 -127
  390. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  391. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  392. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  393. package/src/db/schema.ts +0 -258
  394. package/src/db/session-db.test.ts +0 -93
  395. package/src/db/session-db.ts +0 -325
  396. package/src/db/sessions.ts +0 -241
  397. package/src/delivery.test.ts +0 -148
  398. package/src/delivery.ts +0 -445
  399. package/src/env.ts +0 -74
  400. package/src/group-folder.test.ts +0 -35
  401. package/src/group-folder.ts +0 -44
  402. package/src/group-init.ts +0 -92
  403. package/src/host-core.test.ts +0 -456
  404. package/src/host-sweep.test.ts +0 -146
  405. package/src/host-sweep.ts +0 -287
  406. package/src/index.ts +0 -227
  407. package/src/install-slug.ts +0 -33
  408. package/src/log.test.ts +0 -81
  409. package/src/log.ts +0 -117
  410. package/src/mcp/http.ts +0 -72
  411. package/src/mcp/server.ts +0 -92
  412. package/src/mcp/stdio.ts +0 -51
  413. package/src/mcp/tools/activity.ts +0 -88
  414. package/src/mcp/tools/agent-groups.ts +0 -183
  415. package/src/mcp/tools/approvals.ts +0 -122
  416. package/src/mcp/tools/channels.ts +0 -199
  417. package/src/mcp/tools/index.ts +0 -27
  418. package/src/mcp/tools/oauth.ts +0 -48
  419. package/src/mcp/tools/secrets.ts +0 -169
  420. package/src/mcp/tools/sessions.ts +0 -135
  421. package/src/mcp/types.ts +0 -51
  422. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  423. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  424. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  425. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  426. package/src/modules/agent-to-agent/index.ts +0 -22
  427. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  428. package/src/modules/approvals/agent.md +0 -45
  429. package/src/modules/approvals/index.ts +0 -21
  430. package/src/modules/approvals/picks.test.ts +0 -291
  431. package/src/modules/approvals/primitive.ts +0 -279
  432. package/src/modules/approvals/project.md +0 -27
  433. package/src/modules/approvals/response-handler.ts +0 -87
  434. package/src/modules/index.ts +0 -24
  435. package/src/modules/interactive/agent.md +0 -21
  436. package/src/modules/interactive/index.ts +0 -69
  437. package/src/modules/interactive/project.md +0 -12
  438. package/src/modules/mount-security/index.ts +0 -448
  439. package/src/modules/mount-security/migrate.test.ts +0 -91
  440. package/src/modules/permissions/access.ts +0 -28
  441. package/src/modules/permissions/channel-approval.test.ts +0 -389
  442. package/src/modules/permissions/channel-approval.ts +0 -188
  443. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  444. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  445. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  446. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  447. package/src/modules/permissions/db/user-dms.ts +0 -58
  448. package/src/modules/permissions/db/user-roles.ts +0 -85
  449. package/src/modules/permissions/db/users.ts +0 -38
  450. package/src/modules/permissions/index.ts +0 -421
  451. package/src/modules/permissions/permissions.test.ts +0 -358
  452. package/src/modules/permissions/sender-approval.test.ts +0 -470
  453. package/src/modules/permissions/sender-approval.ts +0 -165
  454. package/src/modules/permissions/user-dm.ts +0 -200
  455. package/src/modules/provider-credentials/db.ts +0 -121
  456. package/src/modules/provider-credentials/index.ts +0 -12
  457. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  458. package/src/modules/provider-credentials/spawn.ts +0 -114
  459. package/src/modules/scheduling/actions.ts +0 -113
  460. package/src/modules/scheduling/db.test.ts +0 -282
  461. package/src/modules/scheduling/db.ts +0 -148
  462. package/src/modules/scheduling/index.ts +0 -34
  463. package/src/modules/scheduling/recurrence.test.ts +0 -98
  464. package/src/modules/scheduling/recurrence.ts +0 -54
  465. package/src/modules/self-mod/agent.md +0 -30
  466. package/src/modules/self-mod/apply.ts +0 -85
  467. package/src/modules/self-mod/index.ts +0 -30
  468. package/src/modules/self-mod/project.md +0 -39
  469. package/src/modules/self-mod/request.ts +0 -91
  470. package/src/modules/typing/index.ts +0 -165
  471. package/src/oauth/agent-app-connections.ts +0 -103
  472. package/src/oauth/app-configs.test.ts +0 -64
  473. package/src/oauth/app-configs.ts +0 -114
  474. package/src/oauth/app-connections.test.ts +0 -109
  475. package/src/oauth/app-connections.ts +0 -178
  476. package/src/oauth/crypto.ts +0 -56
  477. package/src/oauth/flow.ts +0 -104
  478. package/src/oauth/providers/google.test.ts +0 -38
  479. package/src/oauth/providers/google.ts +0 -46
  480. package/src/oauth/providers/index.ts +0 -48
  481. package/src/oauth/state-store.test.ts +0 -54
  482. package/src/oauth/state-store.ts +0 -93
  483. package/src/parachute/README.md +0 -27
  484. package/src/parachute/create-agent.test.ts +0 -83
  485. package/src/parachute/create-agent.ts +0 -122
  486. package/src/parachute/group-status.test.ts +0 -165
  487. package/src/parachute/group-status.ts +0 -136
  488. package/src/parachute/types.ts +0 -41
  489. package/src/parachute/vault-mcp.test.ts +0 -251
  490. package/src/parachute/vault-mcp.ts +0 -232
  491. package/src/platform-id.test.ts +0 -104
  492. package/src/platform-id.ts +0 -109
  493. package/src/providers/index.ts +0 -6
  494. package/src/providers/provider-container-registry.ts +0 -58
  495. package/src/response-registry.ts +0 -45
  496. package/src/router.ts +0 -530
  497. package/src/secrets/crypto.test.ts +0 -45
  498. package/src/secrets/crypto.ts +0 -55
  499. package/src/secrets/index.ts +0 -355
  500. package/src/secrets/master-key.ts +0 -70
  501. package/src/secrets/secrets.test.ts +0 -354
  502. package/src/session-manager.migrate.test.ts +0 -59
  503. package/src/session-manager.ts +0 -433
  504. package/src/startup-bootstrap.test.ts +0 -226
  505. package/src/startup-bootstrap.ts +0 -207
  506. package/src/state-sqlite.ts +0 -182
  507. package/src/timezone.test.ts +0 -64
  508. package/src/timezone.ts +0 -37
  509. package/src/types.ts +0 -230
  510. package/src/web/auth.test.ts +0 -335
  511. package/src/web/auth.ts +0 -214
  512. package/src/web/discord-validate.test.ts +0 -77
  513. package/src/web/discord-validate.ts +0 -88
  514. package/src/web/hub-discovery.test.ts +0 -98
  515. package/src/web/hub-discovery.ts +0 -69
  516. package/src/web/routes/activity.ts +0 -106
  517. package/src/web/routes/agent-provider.test.ts +0 -282
  518. package/src/web/routes/agent-provider.ts +0 -309
  519. package/src/web/routes/approvals.ts +0 -185
  520. package/src/web/routes/apps.ts +0 -434
  521. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  522. package/src/web/routes/channels-mga-detail.test.ts +0 -425
  523. package/src/web/routes/channels.ts +0 -489
  524. package/src/web/routes/oauth-providers.ts +0 -42
  525. package/src/web/routes/secrets.test.ts +0 -175
  526. package/src/web/routes/secrets.ts +0 -282
  527. package/src/web/routes/sessions.ts +0 -123
  528. package/src/web/routes/settings.test.ts +0 -106
  529. package/src/web/routes/settings.ts +0 -247
  530. package/src/web/routes/setup-status.ts +0 -205
  531. package/src/web/routes/vaults.test.ts +0 -389
  532. package/src/web/routes/vaults.ts +0 -225
  533. package/src/web/server-version.test.ts +0 -16
  534. package/src/web/server.ts +0 -1003
  535. package/src/web/services-manifest.test.ts +0 -120
  536. package/src/web/services-manifest.ts +0 -61
  537. package/src/web/static-serve.test.ts +0 -255
  538. package/src/web/static-serve.ts +0 -104
  539. package/src/web/telegram-validate.test.ts +0 -116
  540. package/src/web/telegram-validate.ts +0 -107
  541. package/src/web/vault-proxy.test.ts +0 -214
  542. package/src/web/vault-proxy.ts +0 -120
  543. package/src/web/wire-channel.ts +0 -181
  544. package/src/webhook-server.ts +0 -134
  545. package/vitest.config.ts +0 -18
  546. package/web/README.md +0 -63
  547. package/web/ui/index.html +0 -13
  548. package/web/ui/package.json +0 -35
  549. package/web/ui/pnpm-lock.yaml +0 -2164
  550. package/web/ui/scripts/verify-base.mjs +0 -31
  551. package/web/ui/src/App.tsx +0 -88
  552. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  553. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  554. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  555. package/web/ui/src/components/CredentialForm.tsx +0 -214
  556. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  557. package/web/ui/src/components/StatusDot.tsx +0 -43
  558. package/web/ui/src/components/VaultPicker.tsx +0 -127
  559. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  560. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  561. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  562. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  563. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  564. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  565. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  566. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  567. package/web/ui/src/components/setup/types.ts +0 -105
  568. package/web/ui/src/lib/api.test.ts +0 -410
  569. package/web/ui/src/lib/api.ts +0 -1210
  570. package/web/ui/src/lib/auth.test.ts +0 -139
  571. package/web/ui/src/lib/auth.ts +0 -348
  572. package/web/ui/src/lib/channel-adapters.ts +0 -136
  573. package/web/ui/src/main.tsx +0 -19
  574. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  575. package/web/ui/src/routes/Apps.tsx +0 -613
  576. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  577. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  578. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  579. package/web/ui/src/routes/GroupDetail.tsx +0 -755
  580. package/web/ui/src/routes/GroupList.tsx +0 -187
  581. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  582. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  583. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  584. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  585. package/web/ui/src/routes/SecretsList.tsx +0 -921
  586. package/web/ui/src/routes/SessionsList.tsx +0 -220
  587. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  588. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  589. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  590. package/web/ui/src/routes/VaultDetail.test.tsx +0 -361
  591. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  592. package/web/ui/src/routes/VaultsList.tsx +0 -295
  593. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  594. package/web/ui/src/styles.css +0 -608
  595. package/web/ui/src/test/setup.ts +0 -23
  596. package/web/ui/src/vite-env.d.ts +0 -10
  597. package/web/ui/vite.config.ts +0 -34
  598. 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
+ });