failproofai 1.0.0-beta.2 → 1.0.0-beta.4

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 (234) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  39. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  41. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/index.html +1 -1
  44. package/.next/standalone/.next/server/app/index.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  48. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  56. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  57. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  71. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  72. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  89. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  90. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  91. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  116. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  117. package/.next/standalone/.next/server/pages/404.html +1 -1
  118. package/.next/standalone/.next/server/pages/500.html +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  121. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
  124. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
  128. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  129. package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
  130. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  132. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  135. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  137. package/.next/standalone/Cargo.lock +1992 -198
  138. package/.next/standalone/Cargo.toml +1 -1
  139. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  140. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  141. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  142. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  143. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  144. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  145. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  146. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  147. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  148. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  149. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  150. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  151. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  152. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  153. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  154. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  155. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  156. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  157. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  158. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  159. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  160. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  161. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  162. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  163. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  164. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  165. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  166. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  167. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  168. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  169. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  170. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  171. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  172. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  173. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  174. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  175. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  176. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  177. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  178. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  179. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  180. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  181. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  182. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  183. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  184. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  185. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  186. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  187. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  188. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  189. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  190. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  191. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  192. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  193. package/.next/standalone/package.json +15 -9
  194. package/.next/standalone/server.js +1 -1
  195. package/bin/failproofai.mjs +129 -0
  196. package/dist/cli.mjs +2255 -779
  197. package/dist/worker.mjs +365 -52
  198. package/package.json +15 -9
  199. package/scripts/build-daemon-packages.mjs +210 -0
  200. package/scripts/daemon-platforms.mjs +42 -0
  201. package/scripts/prune-standalone.mjs +6 -0
  202. package/scripts/publish-aliases.mjs +62 -41
  203. package/src/hooks/builtin-policies.ts +139 -0
  204. package/src/hooks/cloud-connection.ts +205 -0
  205. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  206. package/src/hooks/cloud-enrollment.ts +173 -0
  207. package/src/hooks/cloud-managed-policies.ts +138 -0
  208. package/src/hooks/collector-config.ts +226 -0
  209. package/src/hooks/configure-wizard.ts +221 -5
  210. package/src/hooks/custom-hooks-loader.ts +69 -25
  211. package/src/hooks/daemon-client.ts +18 -0
  212. package/src/hooks/daemon-download.ts +137 -27
  213. package/src/hooks/daemon-service.ts +23 -8
  214. package/src/hooks/handler.ts +144 -6
  215. package/src/hooks/hook-activity-store.ts +50 -0
  216. package/src/hooks/loader-utils.ts +6 -4
  217. package/src/hooks/policy-types.ts +35 -0
  218. package/src/hooks/session-pause-cli.ts +157 -0
  219. package/src/hooks/session-pause.ts +236 -0
  220. package/src/hooks/tui.ts +115 -0
  221. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  225. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  226. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  227. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  228. package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
  229. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  230. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  231. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  232. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
  233. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
  234. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
@@ -0,0 +1,1478 @@
1
+ //! opencode source: transform correctness and poll behaviour, against REAL
2
+ //! SQLite databases.
3
+ //!
4
+ //! Nothing here is a hand-shaped fixture. [`SCHEMA`] is the verbatim DDL of a
5
+ //! live `~/.local/share/opencode/opencode.db` (opencode 1.18.11), including
6
+ //! `session_message` — the dead legacy table this source must never read — and
7
+ //! the `part.data` / `message.data` payloads, ids and epoch-millisecond
8
+ //! timestamps are copied from real rows of that capture.
9
+ //!
10
+ //! The headline is [`a_part_that_mutates_from_running_to_completed_is_re_read`]:
11
+ //! opencode fills a tool call's result into the SAME row ~12 seconds after it
12
+ //! appears, so that test replays the observed mutation and asserts both that
13
+ //! the result ships and that a rowid watermark would have had nothing to hand
14
+ //! back.
15
+ //!
16
+ //! Databases are created in WAL mode and read through
17
+ //! [`sqlitepoll::open_readonly`], which is exactly the pairing production uses.
18
+
19
+ use std::fs;
20
+ use std::path::{Path, PathBuf};
21
+ use std::time::{SystemTime, UNIX_EPOCH};
22
+
23
+ use fpai_collect::sources::opencode::{self, transform};
24
+ use fpai_collect::sqlitepoll::{self, PollOutcome, PollRequest, Watermark};
25
+ use rusqlite::Connection;
26
+ use serde_json::{Value, json};
27
+
28
+ /// Verbatim DDL from the capture, minus the tables this source never reads
29
+ /// (`account*`, `credential`, `event*`, `permission`, `todo`, `workspace`, …).
30
+ ///
31
+ /// `session_message` is kept deliberately: it is named exactly like the table
32
+ /// this source wants, it is permanently empty, and four shipped migrations
33
+ /// `DELETE FROM` it. A poller that reached for it by name would read nothing
34
+ /// and report success forever.
35
+ const SCHEMA: &str = r#"
36
+ CREATE TABLE `project` (
37
+ `id` text PRIMARY KEY,
38
+ `worktree` text NOT NULL,
39
+ `vcs` text,
40
+ `name` text,
41
+ `icon_url` text,
42
+ `icon_url_override` text,
43
+ `icon_color` text,
44
+ `time_created` integer NOT NULL,
45
+ `time_updated` integer NOT NULL,
46
+ `time_initialized` integer,
47
+ `sandboxes` text NOT NULL,
48
+ `commands` text
49
+ );
50
+ CREATE TABLE `session` (
51
+ `id` text PRIMARY KEY,
52
+ `project_id` text NOT NULL,
53
+ `workspace_id` text,
54
+ `parent_id` text,
55
+ `slug` text NOT NULL,
56
+ `directory` text NOT NULL,
57
+ `path` text,
58
+ `title` text NOT NULL,
59
+ `version` text NOT NULL,
60
+ `share_url` text,
61
+ `summary_additions` integer,
62
+ `summary_deletions` integer,
63
+ `summary_files` integer,
64
+ `summary_diffs` text,
65
+ `metadata` text,
66
+ `cost` real DEFAULT 0 NOT NULL,
67
+ `tokens_input` integer DEFAULT 0 NOT NULL,
68
+ `tokens_output` integer DEFAULT 0 NOT NULL,
69
+ `tokens_reasoning` integer DEFAULT 0 NOT NULL,
70
+ `tokens_cache_read` integer DEFAULT 0 NOT NULL,
71
+ `tokens_cache_write` integer DEFAULT 0 NOT NULL,
72
+ `revert` text,
73
+ `permission` text,
74
+ `agent` text,
75
+ `model` text,
76
+ `time_created` integer NOT NULL,
77
+ `time_updated` integer NOT NULL,
78
+ `time_compacting` integer,
79
+ `time_archived` integer,
80
+ CONSTRAINT `fk_session_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
81
+ );
82
+ CREATE INDEX `session_project_idx` ON `session` (`project_id`);
83
+ CREATE TABLE `message` (
84
+ `id` text PRIMARY KEY,
85
+ `session_id` text NOT NULL,
86
+ `time_created` integer NOT NULL,
87
+ `time_updated` integer NOT NULL,
88
+ `data` text NOT NULL,
89
+ CONSTRAINT `fk_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
90
+ );
91
+ CREATE INDEX `message_session_time_created_id_idx` ON `message` (`session_id`,`time_created`,`id`);
92
+ CREATE TABLE `part` (
93
+ `id` text PRIMARY KEY,
94
+ `message_id` text NOT NULL,
95
+ `session_id` text NOT NULL,
96
+ `time_created` integer NOT NULL,
97
+ `time_updated` integer NOT NULL,
98
+ `data` text NOT NULL,
99
+ CONSTRAINT `fk_part_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
100
+ );
101
+ CREATE INDEX `part_message_id_id_idx` ON `part` (`message_id`,`id`);
102
+ CREATE INDEX `part_session_idx` ON `part` (`session_id`);
103
+ CREATE TABLE `session_message` (
104
+ `id` text PRIMARY KEY,
105
+ `session_id` text NOT NULL,
106
+ `type` text NOT NULL,
107
+ `seq` integer NOT NULL,
108
+ `time_created` integer NOT NULL,
109
+ `time_updated` integer NOT NULL,
110
+ `data` text NOT NULL,
111
+ CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
112
+ );
113
+ "#;
114
+
115
+ // Real ids and directories from the capture.
116
+ const SESSION: &str = "ses_0395f98c4ffe9Axu1kXPL9lnVR";
117
+ const DIRECTORY: &str = "/tmp/probe-opencode";
118
+ const AGENT: &str = "opencode-probe-opencode";
119
+ const USER_MSG: &str = "msg_fc6ab275b001QFpPqoEGCunJ50";
120
+ const ASSISTANT_MSG: &str = "msg_fc6ab29a1001XwwrJYstY20PH0";
121
+ const TOOL_PART: &str = "prt_fc6ab3d28001jvAlnWvL07p0le";
122
+ const CALL_ID: &str = "toolu_bdrk_016VM3PbpoehGTXrHGuRo4jG";
123
+ const MODEL: &str = "claude-sonnet-4-6";
124
+
125
+ // The observed lifetime of the 12-second tool call, to the millisecond.
126
+ const PROMPT_MS: i64 = 1_785_744_533_339;
127
+ const TOOL_CREATED_MS: i64 = 1_785_744_538_920;
128
+ const TOOL_RUNNING_MS: i64 = 1_785_744_539_061;
129
+ const TOOL_DONE_MS: i64 = 1_785_744_551_135;
130
+
131
+ fn tmpdir(name: &str) -> PathBuf {
132
+ let d = std::env::temp_dir().join(format!(
133
+ "fpai-oc-{}-{}-{}",
134
+ name,
135
+ std::process::id(),
136
+ SystemTime::now()
137
+ .duration_since(UNIX_EPOCH)
138
+ .unwrap()
139
+ .as_nanos()
140
+ ));
141
+ fs::create_dir_all(&d).unwrap();
142
+ d
143
+ }
144
+
145
+ /// A writable WAL database carrying the real schema — the shape production
146
+ /// reads, not a simplification of it.
147
+ fn make_db(dir: &Path) -> (PathBuf, Connection) {
148
+ let path = dir.join("opencode.db");
149
+ let conn = Connection::open(&path).unwrap();
150
+ conn.pragma_update(None, "journal_mode", "WAL").unwrap();
151
+ conn.execute_batch(SCHEMA).unwrap();
152
+ conn.execute(
153
+ "INSERT INTO project VALUES ('global','/',NULL,NULL,NULL,NULL,NULL,1,1,NULL,'[]',NULL)",
154
+ [],
155
+ )
156
+ .unwrap();
157
+ (path, conn)
158
+ }
159
+
160
+ /// `path` is passed explicitly on purpose: it is the column that must never be
161
+ /// used, so every test states what misleading value it holds.
162
+ fn insert_session(conn: &Connection, id: &str, directory: &str, path: &str, tc: i64, tu: i64) {
163
+ conn.execute(
164
+ "INSERT INTO session (id, project_id, slug, directory, path, title, version, \
165
+ cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, \
166
+ tokens_cache_write, time_created, time_updated) \
167
+ VALUES (?1,'global','clever-nebula',?2,?3,'Directory files & README review','1.18.11', \
168
+ 0, 13, 431, 0, 75048, 30323, ?4, ?5)",
169
+ rusqlite::params![id, directory, path, tc, tu],
170
+ )
171
+ .unwrap();
172
+ }
173
+
174
+ fn touch_session(conn: &Connection, id: &str, tu: i64) {
175
+ conn.execute(
176
+ "UPDATE session SET time_updated = ?2 WHERE id = ?1",
177
+ rusqlite::params![id, tu],
178
+ )
179
+ .unwrap();
180
+ }
181
+
182
+ fn insert_message(conn: &Connection, id: &str, session: &str, tc: i64, tu: i64, data: &Value) {
183
+ conn.execute(
184
+ "INSERT INTO message (id, session_id, time_created, time_updated, data) \
185
+ VALUES (?1,?2,?3,?4,?5)",
186
+ rusqlite::params![id, session, tc, tu, data.to_string()],
187
+ )
188
+ .unwrap();
189
+ }
190
+
191
+ /// Verbatim user-message metadata: note the NESTED `model` object, which is a
192
+ /// different shape from an assistant message's flat `modelID`.
193
+ fn user_message(conn: &Connection, id: &str, session: &str, tc: i64) {
194
+ insert_message(
195
+ conn,
196
+ id,
197
+ session,
198
+ tc,
199
+ tc,
200
+ &json!({"role":"user","time":{"created":tc},"agent":"build",
201
+ "model":{"providerID":"aikin","modelID":MODEL},"summary":{"diffs":[]}}),
202
+ );
203
+ }
204
+
205
+ fn assistant_message(conn: &Connection, id: &str, session: &str, tc: i64, finish: Option<&str>) {
206
+ let mut data = json!({"parentID":USER_MSG,"role":"assistant","mode":"build","agent":"build",
207
+ "path":{"cwd":DIRECTORY,"root":"/"},"cost":0,
208
+ "tokens":{"total":15334,"input":3,"output":79,"reasoning":0,
209
+ "cache":{"write":45,"read":15207}},
210
+ "modelID":MODEL,"providerID":"aikin","time":{"created":tc}});
211
+ if let Some(f) = finish {
212
+ data["finish"] = json!(f);
213
+ }
214
+ insert_message(conn, id, session, tc, tc, &data);
215
+ }
216
+
217
+ fn insert_part(
218
+ conn: &Connection,
219
+ id: &str,
220
+ message: &str,
221
+ session: &str,
222
+ tc: i64,
223
+ tu: i64,
224
+ data: &Value,
225
+ ) {
226
+ conn.execute(
227
+ "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) \
228
+ VALUES (?1,?2,?3,?4,?5,?6)",
229
+ rusqlite::params![id, message, session, tc, tu, data.to_string()],
230
+ )
231
+ .unwrap();
232
+ }
233
+
234
+ /// The UPDATE opencode issues when a tool finishes: same row, new `data`, new
235
+ /// `time_updated`.
236
+ fn update_part(conn: &Connection, id: &str, tu: i64, data: &Value) {
237
+ let n = conn
238
+ .execute(
239
+ "UPDATE part SET time_updated = ?2, data = ?3 WHERE id = ?1",
240
+ rusqlite::params![id, tu, data.to_string()],
241
+ )
242
+ .unwrap();
243
+ assert_eq!(n, 1, "the mutation must hit exactly the row under test");
244
+ }
245
+
246
+ fn rowid_of(conn: &Connection, id: &str) -> i64 {
247
+ conn.query_row("SELECT rowid FROM part WHERE id = ?1", [id], |r| r.get(0))
248
+ .unwrap()
249
+ }
250
+
251
+ fn text_part(text: &str) -> Value {
252
+ json!({"type":"text","text":text})
253
+ }
254
+
255
+ /// An assistant text part, which unlike a user's carries streaming timings.
256
+ fn streamed_text_part(text: &str, start: i64, end: Option<i64>) -> Value {
257
+ let mut time = json!({ "start": start });
258
+ if let Some(e) = end {
259
+ time["end"] = json!(e);
260
+ }
261
+ json!({"type":"text","text":text,"time":time})
262
+ }
263
+
264
+ fn running_tool_part() -> Value {
265
+ json!({"type":"tool","tool":"bash","callID":CALL_ID,
266
+ "state":{"status":"running",
267
+ "input":{"command":"sleep 12 && echo SECOND_MARKER","timeout":20000},
268
+ "time":{"start":TOOL_CREATED_MS}}})
269
+ }
270
+
271
+ fn completed_tool_part() -> Value {
272
+ json!({"type":"tool","tool":"bash","callID":CALL_ID,
273
+ "state":{"status":"completed",
274
+ "input":{"command":"sleep 12 && echo SECOND_MARKER","timeout":20000},
275
+ "output":"SECOND_MARKER\n",
276
+ "metadata":{"output":"SECOND_MARKER\n","exit":0,"truncated":false},
277
+ "title":"sleep 12 && echo SECOND_MARKER",
278
+ "time":{"start":TOOL_CREATED_MS,"end":TOOL_DONE_MS - 1}}})
279
+ }
280
+
281
+ fn poll_at(db: &Path, watermark: i64, max_rows: u64) -> PollOutcome {
282
+ let conn = sqlitepoll::open_readonly(db).unwrap();
283
+ let req = PollRequest {
284
+ watermark,
285
+ max_rows,
286
+ environment: "local".into(),
287
+ agent_id: opencode::DEFAULT_AGENT_ID.into(),
288
+ };
289
+ (opencode::FORMAT.poll)(&conn, &req).unwrap()
290
+ }
291
+
292
+ fn poll_all(db: &Path) -> Vec<Value> {
293
+ poll_at(db, 0, 1000).events
294
+ }
295
+
296
+ fn of_type<'a>(events: &'a [Value], kind: &str) -> Vec<&'a Value> {
297
+ events.iter().filter(|e| e["type"] == kind).collect()
298
+ }
299
+
300
+ /// A session with one finished turn: prompt, assistant answer, nothing pending.
301
+ fn seed_turn(conn: &Connection) {
302
+ insert_session(
303
+ conn,
304
+ SESSION,
305
+ DIRECTORY,
306
+ "tmp/probe-opencode",
307
+ PROMPT_MS - 1,
308
+ PROMPT_MS + 10,
309
+ );
310
+ user_message(conn, USER_MSG, SESSION, PROMPT_MS);
311
+ insert_part(
312
+ conn,
313
+ "prt_user_text",
314
+ USER_MSG,
315
+ SESSION,
316
+ PROMPT_MS,
317
+ PROMPT_MS,
318
+ &text_part("List the files in this directory"),
319
+ );
320
+ assistant_message(conn, ASSISTANT_MSG, SESSION, PROMPT_MS + 5, Some("stop"));
321
+ insert_part(
322
+ conn,
323
+ "prt_assistant_text",
324
+ ASSISTANT_MSG,
325
+ SESSION,
326
+ PROMPT_MS + 5,
327
+ PROMPT_MS + 10,
328
+ &streamed_text_part(
329
+ "calc.py, opencode.json, README.md",
330
+ PROMPT_MS + 5,
331
+ Some(PROMPT_MS + 9),
332
+ ),
333
+ );
334
+ }
335
+
336
+ // ── the mutation this whole source exists for ────────────────────────────
337
+
338
+ #[test]
339
+ fn a_part_that_mutates_from_running_to_completed_is_re_read() {
340
+ // Replays the observed lifetime of rowid 20: NEW at status=running with an
341
+ // empty output, then UPDATEd in place 12.0s later with the real result.
342
+ let dir = tmpdir("mutate");
343
+ let (db, conn) = make_db(&dir);
344
+ seed_turn(&conn);
345
+ assistant_message(
346
+ &conn,
347
+ "msg_tool_turn",
348
+ SESSION,
349
+ TOOL_CREATED_MS,
350
+ Some("tool-calls"),
351
+ );
352
+ insert_part(
353
+ &conn,
354
+ TOOL_PART,
355
+ "msg_tool_turn",
356
+ SESSION,
357
+ TOOL_CREATED_MS,
358
+ TOOL_RUNNING_MS,
359
+ &running_tool_part(),
360
+ );
361
+ touch_session(&conn, SESSION, TOOL_RUNNING_MS);
362
+
363
+ let first = poll_at(&db, 0, 1000);
364
+ let first_call = of_type(&first.events, "tool_use");
365
+ assert_eq!(
366
+ first_call.len(),
367
+ 1,
368
+ "the call is known as soon as it starts"
369
+ );
370
+ assert!(
371
+ of_type(&first.events, "tool_result").is_empty(),
372
+ "the result does not exist yet — state.output is empty while running"
373
+ );
374
+ let call_before = first_call[0].clone();
375
+ let watermark = first.watermark;
376
+
377
+ // What a rowid watermark would have to work with.
378
+ let rowid_before = rowid_of(&conn, TOOL_PART);
379
+ let max_rowid: i64 = conn
380
+ .query_row("SELECT max(rowid) FROM part", [], |r| r.get(0))
381
+ .unwrap();
382
+
383
+ update_part(&conn, TOOL_PART, TOOL_DONE_MS, &completed_tool_part());
384
+
385
+ assert_eq!(
386
+ rowid_of(&conn, TOOL_PART),
387
+ rowid_before,
388
+ "opencode UPDATEs the row in place; its rowid never moves"
389
+ );
390
+ let appended: i64 = conn
391
+ .query_row(
392
+ "SELECT count(*) FROM part WHERE rowid > ?1",
393
+ [max_rowid],
394
+ |r| r.get(0),
395
+ )
396
+ .unwrap();
397
+ assert_eq!(
398
+ appended, 0,
399
+ "a rowid watermark has nothing new to hand back — every tool call in \
400
+ this product would ship with an empty result, forever, while looking \
401
+ like it works"
402
+ );
403
+
404
+ let second = poll_at(&db, watermark, 1000);
405
+ let results = of_type(&second.events, "tool_result");
406
+ assert_eq!(
407
+ results.len(),
408
+ 1,
409
+ "the updated-at watermark re-reads the row"
410
+ );
411
+ assert_eq!(results[0]["output"], "SECOND_MARKER\n");
412
+ assert_eq!(results[0]["tool_call_id"], CALL_ID);
413
+ assert_eq!(results[0]["tool_name"], "bash");
414
+
415
+ // The re-read also re-emits the call. It must be byte-identical, or the
416
+ // server's content dedup cannot collapse it and every completed tool
417
+ // leaves two calls on the timeline.
418
+ let call_after = of_type(&second.events, "tool_use");
419
+ assert_eq!(call_after.len(), 1);
420
+ assert_eq!(
421
+ serde_json::to_string(call_after[0]).unwrap(),
422
+ serde_json::to_string(&call_before).unwrap(),
423
+ "a re-read call must hash the same as the first read"
424
+ );
425
+
426
+ fs::remove_dir_all(&dir).ok();
427
+ }
428
+
429
+ #[test]
430
+ fn a_running_tool_part_yields_only_the_call() {
431
+ // The result is not merely missing from our output — it does not exist.
432
+ let dir = tmpdir("running");
433
+ let (db, conn) = make_db(&dir);
434
+ seed_turn(&conn);
435
+ assistant_message(
436
+ &conn,
437
+ "msg_tool_turn",
438
+ SESSION,
439
+ TOOL_CREATED_MS,
440
+ Some("tool-calls"),
441
+ );
442
+ insert_part(
443
+ &conn,
444
+ TOOL_PART,
445
+ "msg_tool_turn",
446
+ SESSION,
447
+ TOOL_CREATED_MS,
448
+ TOOL_RUNNING_MS,
449
+ &running_tool_part(),
450
+ );
451
+
452
+ let ev = poll_all(&db);
453
+ assert_eq!(of_type(&ev, "tool_use").len(), 1);
454
+ assert!(of_type(&ev, "tool_result").is_empty());
455
+ // The call is complete even so: a running tool's input is already parsed.
456
+ assert_eq!(
457
+ of_type(&ev, "tool_use")[0]["input"]["command"],
458
+ "sleep 12 && echo SECOND_MARKER"
459
+ );
460
+
461
+ fs::remove_dir_all(&dir).ok();
462
+ }
463
+
464
+ #[test]
465
+ fn one_completed_tool_row_yields_the_call_and_its_result_in_order() {
466
+ // Call and result share ONE row — opencode never writes a second one — so
467
+ // both events come from the same read, and the index offset keeps them in
468
+ // order even when a tool finishes inside the millisecond it started.
469
+ let dir = tmpdir("bothlegs");
470
+ let (db, conn) = make_db(&dir);
471
+ seed_turn(&conn);
472
+ assistant_message(
473
+ &conn,
474
+ "msg_tool_turn",
475
+ SESSION,
476
+ TOOL_CREATED_MS,
477
+ Some("tool-calls"),
478
+ );
479
+ let instant = json!({"type":"tool","tool":"bash","callID":CALL_ID,
480
+ "state":{"status":"completed","input":{"command":"echo hi"},"output":"hi\n",
481
+ "time":{"start":TOOL_CREATED_MS,"end":TOOL_CREATED_MS}}});
482
+ insert_part(
483
+ &conn,
484
+ TOOL_PART,
485
+ "msg_tool_turn",
486
+ SESSION,
487
+ TOOL_CREATED_MS,
488
+ TOOL_CREATED_MS,
489
+ &instant,
490
+ );
491
+
492
+ let ev = poll_all(&db);
493
+ let call = of_type(&ev, "tool_use")[0];
494
+ let result = of_type(&ev, "tool_result")[0];
495
+ assert!(
496
+ call["timestamp"].as_str().unwrap() < result["timestamp"].as_str().unwrap(),
497
+ "a result that sorted before its own call is visibly wrong on a timeline"
498
+ );
499
+ assert_eq!(call["timestamp"], "2026-08-03T08:08:58.920000Z");
500
+ assert_eq!(result["timestamp"], "2026-08-03T08:08:58.920001Z");
501
+ assert_eq!(result["opencode_block_index"], 1);
502
+ assert_eq!(call["opencode_part_id"], TOOL_PART);
503
+ assert_eq!(result["opencode_part_id"], TOOL_PART);
504
+
505
+ fs::remove_dir_all(&dir).ok();
506
+ }
507
+
508
+ #[test]
509
+ fn an_errored_tool_row_carries_a_non_empty_error() {
510
+ let dir = tmpdir("toolerr");
511
+ let (db, conn) = make_db(&dir);
512
+ seed_turn(&conn);
513
+ assistant_message(
514
+ &conn,
515
+ "msg_tool_turn",
516
+ SESSION,
517
+ TOOL_CREATED_MS,
518
+ Some("tool-calls"),
519
+ );
520
+ let failed = json!({"type":"tool","tool":"bash","callID":CALL_ID,
521
+ "state":{"status":"error","input":{"command":"false"},
522
+ "error":"Command failed with exit code 1",
523
+ "time":{"start":TOOL_CREATED_MS,"end":TOOL_DONE_MS}}});
524
+ insert_part(
525
+ &conn,
526
+ TOOL_PART,
527
+ "msg_tool_turn",
528
+ SESSION,
529
+ TOOL_CREATED_MS,
530
+ TOOL_DONE_MS,
531
+ &failed,
532
+ );
533
+
534
+ let ev = poll_all(&db);
535
+ let result = of_type(&ev, "tool_result")[0];
536
+ assert_eq!(result["error"], "Command failed with exit code 1");
537
+ assert_eq!(result["error_type"], "opencode_tool_error");
538
+ assert_eq!(
539
+ result["output"], "Command failed with exit code 1",
540
+ "an errored tool has no output; the message is the whole result"
541
+ );
542
+ assert_eq!(result["opencode_tool_status"], "error");
543
+
544
+ fs::remove_dir_all(&dir).ok();
545
+ }
546
+
547
+ #[test]
548
+ fn a_pending_tool_row_reports_nothing_until_its_input_exists() {
549
+ // `pending` is the call before its arguments have finished streaming.
550
+ // Shipping it would put a blank call in the product, and the row comes
551
+ // back the moment it moves to `running`.
552
+ let dir = tmpdir("pending");
553
+ let (db, conn) = make_db(&dir);
554
+ seed_turn(&conn);
555
+ assistant_message(
556
+ &conn,
557
+ "msg_tool_turn",
558
+ SESSION,
559
+ TOOL_CREATED_MS,
560
+ Some("tool-calls"),
561
+ );
562
+ insert_part(
563
+ &conn,
564
+ TOOL_PART,
565
+ "msg_tool_turn",
566
+ SESSION,
567
+ TOOL_CREATED_MS,
568
+ TOOL_CREATED_MS,
569
+ &json!({"type":"tool","tool":"bash","callID":CALL_ID,"state":{"status":"pending"}}),
570
+ );
571
+
572
+ let ev = poll_all(&db);
573
+ assert!(of_type(&ev, "tool_use").is_empty());
574
+
575
+ update_part(&conn, TOOL_PART, TOOL_RUNNING_MS, &running_tool_part());
576
+ let ev = poll_all(&db);
577
+ assert_eq!(
578
+ of_type(&ev, "tool_use").len(),
579
+ 1,
580
+ "it ships once it is real"
581
+ );
582
+
583
+ fs::remove_dir_all(&dir).ok();
584
+ }
585
+
586
+ // ── text parts ───────────────────────────────────────────────────────────
587
+
588
+ #[test]
589
+ fn a_user_part_becomes_a_request_and_an_assistant_part_a_response() {
590
+ // A part carries no role at all — it comes from the joined message row.
591
+ let dir = tmpdir("roles");
592
+ let (db, conn) = make_db(&dir);
593
+ seed_turn(&conn);
594
+
595
+ let ev = poll_all(&db);
596
+ let req = of_type(&ev, "model_request");
597
+ let resp = of_type(&ev, "model_response");
598
+ assert_eq!(req.len(), 1);
599
+ assert_eq!(resp.len(), 1);
600
+ assert_eq!(
601
+ req[0]["messages"][0]["content"],
602
+ "List the files in this directory"
603
+ );
604
+ assert_eq!(req[0]["messages"][0]["role"], "user");
605
+ assert_eq!(req[0]["opencode_cwd"], DIRECTORY);
606
+ // The model comes from the user message's NESTED `model` object; the
607
+ // assistant's from its flat `modelID`. Both must resolve or half the rows
608
+ // render as an empty summary.
609
+ assert_eq!(req[0]["model"], MODEL);
610
+ assert_eq!(resp[0]["model"], MODEL);
611
+ assert_eq!(resp[0]["content"], "calc.py, opencode.json, README.md");
612
+ assert_eq!(resp[0]["role"], "assistant");
613
+ assert_eq!(resp[0]["opencode_provider"], "aikin");
614
+ assert_eq!(resp[0]["opencode_message_id"], ASSISTANT_MSG);
615
+
616
+ fs::remove_dir_all(&dir).ok();
617
+ }
618
+
619
+ #[test]
620
+ fn a_still_streaming_text_part_is_withheld_until_it_settles() {
621
+ // An assistant text part is written empty and grown token by token. Without
622
+ // the `time.end` gate, one poll per few seconds of a long answer leaves a
623
+ // row per poll in the product, each a longer prefix of the same text.
624
+ let dir = tmpdir("streaming");
625
+ let (db, conn) = make_db(&dir);
626
+ insert_session(&conn, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS);
627
+ user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
628
+ insert_part(
629
+ &conn,
630
+ "prt_u",
631
+ USER_MSG,
632
+ SESSION,
633
+ PROMPT_MS,
634
+ PROMPT_MS,
635
+ &text_part("go"),
636
+ );
637
+ assistant_message(&conn, ASSISTANT_MSG, SESSION, PROMPT_MS + 5, None);
638
+ insert_part(
639
+ &conn,
640
+ "prt_stream",
641
+ ASSISTANT_MSG,
642
+ SESSION,
643
+ PROMPT_MS + 5,
644
+ PROMPT_MS + 6,
645
+ &streamed_text_part("The comm", PROMPT_MS + 5, None),
646
+ );
647
+
648
+ let first = poll_at(&db, 0, 1000);
649
+ assert!(
650
+ of_type(&first.events, "model_response").is_empty(),
651
+ "a half-written answer must not ship"
652
+ );
653
+ // A user prompt has no `time` object at all and is settled on arrival.
654
+ assert_eq!(of_type(&first.events, "model_request").len(), 1);
655
+
656
+ update_part(
657
+ &conn,
658
+ "prt_stream",
659
+ PROMPT_MS + 40,
660
+ &streamed_text_part(
661
+ "The command completed successfully.",
662
+ PROMPT_MS + 5,
663
+ Some(PROMPT_MS + 39),
664
+ ),
665
+ );
666
+ let second = poll_at(&db, first.watermark, 1000);
667
+ assert_eq!(
668
+ of_type(&second.events, "model_response")[0]["content"],
669
+ "The command completed successfully."
670
+ );
671
+
672
+ fs::remove_dir_all(&dir).ok();
673
+ }
674
+
675
+ #[test]
676
+ fn step_and_unknown_part_types_emit_nothing() {
677
+ // `step-start`/`step-finish` bracket every single turn — they outnumber
678
+ // content parts on the real capture 10:24. An unknown future type must be
679
+ // ignored the same way rather than breaking the poll.
680
+ let dir = tmpdir("steps");
681
+ let (db, conn) = make_db(&dir);
682
+ seed_turn(&conn);
683
+ insert_part(
684
+ &conn,
685
+ "prt_ss",
686
+ ASSISTANT_MSG,
687
+ SESSION,
688
+ PROMPT_MS + 11,
689
+ PROMPT_MS + 11,
690
+ &json!({"type":"step-start"}),
691
+ );
692
+ insert_part(
693
+ &conn,
694
+ "prt_sf",
695
+ ASSISTANT_MSG,
696
+ SESSION,
697
+ PROMPT_MS + 12,
698
+ PROMPT_MS + 12,
699
+ &json!({"type":"step-finish","reason":"stop","tokens":{"total":15373},"cost":0}),
700
+ );
701
+ insert_part(
702
+ &conn,
703
+ "prt_future",
704
+ ASSISTANT_MSG,
705
+ SESSION,
706
+ PROMPT_MS + 13,
707
+ PROMPT_MS + 13,
708
+ &json!({"type":"some-future-type","payload":{}}),
709
+ );
710
+
711
+ let out = poll_at(&db, 0, 1000);
712
+ assert_eq!(out.rows_seen, 6, "the rows are read");
713
+ assert_eq!(
714
+ out.events
715
+ .iter()
716
+ .filter(|e| e["type"] == "model_request" || e["type"] == "model_response")
717
+ .count(),
718
+ 2,
719
+ "…and only the two content parts become events"
720
+ );
721
+
722
+ fs::remove_dir_all(&dir).ok();
723
+ }
724
+
725
+ // ── agent id: directory, never path ──────────────────────────────────────
726
+
727
+ #[test]
728
+ fn the_agent_id_comes_from_the_directory_and_never_from_path() {
729
+ // `session.path` is relative to a git worktree and is the EMPTY STRING at
730
+ // the repository root — the case that matters most. Here it holds a value
731
+ // that would produce a plausible-looking wrong answer.
732
+ let dir = tmpdir("agentid");
733
+ let (db, conn) = make_db(&dir);
734
+ insert_session(
735
+ &conn,
736
+ SESSION,
737
+ "/home/u/failproofai",
738
+ "some/other/worktree/leaf",
739
+ PROMPT_MS - 1,
740
+ PROMPT_MS + 10,
741
+ );
742
+ user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
743
+ insert_part(
744
+ &conn,
745
+ "prt_u",
746
+ USER_MSG,
747
+ SESSION,
748
+ PROMPT_MS,
749
+ PROMPT_MS,
750
+ &text_part("hi"),
751
+ );
752
+
753
+ let ev = poll_all(&db);
754
+ assert!(!ev.is_empty());
755
+ for e in &ev {
756
+ assert_eq!(e["agent_id"], "opencode-failproofai");
757
+ }
758
+ let dumped = serde_json::to_string(&ev).unwrap();
759
+ assert!(
760
+ !dumped.contains("worktree"),
761
+ "session.path must not reach the event stream at all"
762
+ );
763
+
764
+ fs::remove_dir_all(&dir).ok();
765
+ }
766
+
767
+ #[test]
768
+ fn an_empty_path_at_a_git_root_still_resolves_the_project() {
769
+ // The exact live observation: a session started at a git worktree root has
770
+ // `path = ""`. Anything deriving the project from it produces nothing.
771
+ let dir = tmpdir("gitroot");
772
+ let (db, conn) = make_db(&dir);
773
+ insert_session(
774
+ &conn,
775
+ SESSION,
776
+ "/tmp/probe-opencode-git",
777
+ "",
778
+ PROMPT_MS - 1,
779
+ PROMPT_MS + 10,
780
+ );
781
+ user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
782
+ insert_part(
783
+ &conn,
784
+ "prt_u",
785
+ USER_MSG,
786
+ SESSION,
787
+ PROMPT_MS,
788
+ PROMPT_MS,
789
+ &text_part("GITPROBE"),
790
+ );
791
+
792
+ assert_eq!(poll_all(&db)[0]["agent_id"], "opencode-probe-opencode-git");
793
+
794
+ fs::remove_dir_all(&dir).ok();
795
+ }
796
+
797
+ #[test]
798
+ fn a_directory_with_no_usable_project_falls_back_to_the_configured_id() {
799
+ let dir = tmpdir("rootdir");
800
+ let (db, conn) = make_db(&dir);
801
+ insert_session(&conn, SESSION, "/", "", PROMPT_MS - 1, PROMPT_MS + 10);
802
+ user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
803
+ insert_part(
804
+ &conn,
805
+ "prt_u",
806
+ USER_MSG,
807
+ SESSION,
808
+ PROMPT_MS,
809
+ PROMPT_MS,
810
+ &text_part("hi"),
811
+ );
812
+
813
+ assert_eq!(poll_all(&db)[0]["agent_id"], opencode::DEFAULT_AGENT_ID);
814
+
815
+ fs::remove_dir_all(&dir).ok();
816
+ }
817
+
818
+ #[test]
819
+ fn the_agent_id_is_sanitized_the_same_way_claudes_is() {
820
+ // The two run over the same project directories on the same machine; a
821
+ // project that sanitized differently under each would appear as two
822
+ // unrelated agents.
823
+ assert_eq!(transform::sanitize_id_part("my project!"), "my-project");
824
+ assert_eq!(transform::sanitize_id_part("--a--b--"), "a-b");
825
+ assert_eq!(
826
+ transform::sanitize_id_part("keep.dots_and_underscores"),
827
+ "keep.dots_and_underscores"
828
+ );
829
+ assert_eq!(transform::sanitize_id_part(&"x".repeat(80)).len(), 48);
830
+ assert_eq!(
831
+ transform::agent_id_from_directory("/home/u/repo/"),
832
+ Some("opencode-repo".to_string())
833
+ );
834
+ assert_eq!(transform::agent_id_from_directory("/"), None);
835
+ assert_eq!(transform::agent_id_from_directory(""), None);
836
+ }
837
+
838
+ // ── session bracketing ───────────────────────────────────────────────────
839
+
840
+ #[test]
841
+ fn a_finished_session_is_bracketed_by_a_start_and_an_end() {
842
+ let dir = tmpdir("bracket");
843
+ let (db, conn) = make_db(&dir);
844
+ seed_turn(&conn);
845
+
846
+ let ev = poll_all(&db);
847
+ let start = of_type(&ev, "agent_start");
848
+ let end = of_type(&ev, "agent_end");
849
+ assert_eq!(start.len(), 1);
850
+ assert_eq!(end.len(), 1);
851
+ assert_eq!(start[0]["goal"], "List the files in this directory");
852
+ assert_eq!(start[0]["opencode_cwd"], DIRECTORY);
853
+ assert_eq!(start[0]["opencode_version"], "1.18.11");
854
+ assert_eq!(start[0]["opencode_slug"], "clever-nebula");
855
+ assert_eq!(start[0]["opencode_part_id"], SESSION);
856
+ // Session totals, namespaced: a consumer that summed generic
857
+ // `input_tokens` across events would count every turn once per end.
858
+ assert_eq!(end[0]["opencode_tokens"]["input"], 13);
859
+ assert_eq!(end[0]["opencode_tokens"]["cache_read"], 75048);
860
+ assert!(end[0].get("input_tokens").is_none());
861
+ assert!(
862
+ start[0]["timestamp"].as_str().unwrap() < end[0]["timestamp"].as_str().unwrap(),
863
+ "the start must precede the end"
864
+ );
865
+ // Index 999 puts the end after content sharing its millisecond.
866
+ assert!(end[0]["timestamp"].as_str().unwrap().ends_with("999Z"));
867
+
868
+ fs::remove_dir_all(&dir).ok();
869
+ }
870
+
871
+ #[test]
872
+ fn a_session_mid_turn_gets_a_start_but_no_end() {
873
+ // `finish:"tool-calls"` means the assistant stopped only to run tools and
874
+ // another message is coming. Ending the session there would show it as
875
+ // over while it is still working.
876
+ let dir = tmpdir("midturn");
877
+ let (db, conn) = make_db(&dir);
878
+ insert_session(
879
+ &conn,
880
+ SESSION,
881
+ DIRECTORY,
882
+ "",
883
+ PROMPT_MS - 1,
884
+ TOOL_RUNNING_MS,
885
+ );
886
+ user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
887
+ insert_part(
888
+ &conn,
889
+ "prt_u",
890
+ USER_MSG,
891
+ SESSION,
892
+ PROMPT_MS,
893
+ PROMPT_MS,
894
+ &text_part("run it"),
895
+ );
896
+ assistant_message(
897
+ &conn,
898
+ ASSISTANT_MSG,
899
+ SESSION,
900
+ TOOL_CREATED_MS,
901
+ Some("tool-calls"),
902
+ );
903
+
904
+ let ev = poll_all(&db);
905
+ assert_eq!(of_type(&ev, "agent_start").len(), 1);
906
+ assert!(of_type(&ev, "agent_end").is_empty());
907
+
908
+ // A user message as the latest one is mid-turn too — the assistant has not
909
+ // even started.
910
+ let dir2 = tmpdir("midturn2");
911
+ let (db2, conn2) = make_db(&dir2);
912
+ insert_session(&conn2, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS);
913
+ user_message(&conn2, USER_MSG, SESSION, PROMPT_MS);
914
+ insert_part(
915
+ &conn2,
916
+ "prt_u",
917
+ USER_MSG,
918
+ SESSION,
919
+ PROMPT_MS,
920
+ PROMPT_MS,
921
+ &text_part("run it"),
922
+ );
923
+ assert!(of_type(&poll_all(&db2), "agent_end").is_empty());
924
+
925
+ fs::remove_dir_all(&dir).ok();
926
+ fs::remove_dir_all(&dir2).ok();
927
+ }
928
+
929
+ #[test]
930
+ fn a_session_that_was_opened_and_never_used_emits_nothing() {
931
+ // opencode inserts the session row the moment a session is selected. An
932
+ // `agent_start` there would put an empty session in the product — and
933
+ // would risk a SECOND one later once the first prompt gave it a goal.
934
+ let dir = tmpdir("unused");
935
+ let (db, conn) = make_db(&dir);
936
+ insert_session(&conn, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS - 1);
937
+
938
+ let out = poll_at(&db, 0, 1000);
939
+ assert_eq!(out.rows_seen, 1, "the row is read");
940
+ assert!(out.events.is_empty(), "…and produces nothing");
941
+ assert_eq!(out.watermark, PROMPT_MS - 1, "the watermark still advances");
942
+
943
+ fs::remove_dir_all(&dir).ok();
944
+ }
945
+
946
+ #[test]
947
+ fn the_start_is_byte_stable_across_the_many_re_reads_of_a_session_row() {
948
+ // A session row moves on every turn, so it is re-read constantly. The
949
+ // start must hash the same each time or one session leaves a trail of
950
+ // starts. `session.title` is the trap: opencode backfills an LLM-generated
951
+ // one seconds in, so it is deliberately not carried.
952
+ let dir = tmpdir("stablestart");
953
+ let (db, conn) = make_db(&dir);
954
+ seed_turn(&conn);
955
+ let first = of_type(&poll_all(&db), "agent_start")[0].clone();
956
+
957
+ conn.execute(
958
+ "UPDATE session SET title = 'A newly generated title', time_updated = ?1, \
959
+ tokens_output = 999 WHERE id = ?2",
960
+ rusqlite::params![PROMPT_MS + 5000, SESSION],
961
+ )
962
+ .unwrap();
963
+ let second = of_type(&poll_all(&db), "agent_start")[0].clone();
964
+
965
+ assert_eq!(
966
+ serde_json::to_string(&first).unwrap(),
967
+ serde_json::to_string(&second).unwrap(),
968
+ "a re-read start must be byte-identical so the server collapses it"
969
+ );
970
+
971
+ fs::remove_dir_all(&dir).ok();
972
+ }
973
+
974
+ // ── ordering, watermark, limits ──────────────────────────────────────────
975
+
976
+ #[test]
977
+ fn rows_come_back_in_time_updated_order_not_insertion_order() {
978
+ // The engine orders on the same column the watermark advances along; a
979
+ // batch ordered any other way could ship a row and then move the watermark
980
+ // past an earlier one.
981
+ let dir = tmpdir("order");
982
+ let (db, conn) = make_db(&dir);
983
+ seed_turn(&conn);
984
+ // Inserted newest-first, and the LATER row also has the LOWER rowid.
985
+ insert_part(
986
+ &conn,
987
+ "prt_z_late",
988
+ ASSISTANT_MSG,
989
+ SESSION,
990
+ PROMPT_MS + 50,
991
+ PROMPT_MS + 90,
992
+ &streamed_text_part("late", PROMPT_MS + 50, Some(PROMPT_MS + 89)),
993
+ );
994
+ insert_part(
995
+ &conn,
996
+ "prt_a_early",
997
+ ASSISTANT_MSG,
998
+ SESSION,
999
+ PROMPT_MS + 20,
1000
+ PROMPT_MS + 30,
1001
+ &streamed_text_part("early", PROMPT_MS + 20, Some(PROMPT_MS + 29)),
1002
+ );
1003
+
1004
+ let ev = poll_all(&db);
1005
+ let texts: Vec<&str> = of_type(&ev, "model_response")
1006
+ .iter()
1007
+ .map(|e| e["content"].as_str().unwrap())
1008
+ .collect();
1009
+ assert_eq!(
1010
+ texts,
1011
+ vec!["calc.py, opencode.json, README.md", "early", "late"]
1012
+ );
1013
+
1014
+ // The batch is ordered by the column the watermark advances along —
1015
+ // `time_updated` — with the session row ahead of parts that share its
1016
+ // millisecond. Deliberately NOT by event timestamp: an `agent_start` is
1017
+ // backdated to `session.time_created` and a `tool_result` post-dated to
1018
+ // `state.time.end`, so the spooled order is not monotonic in time and the
1019
+ // server sorts on the timestamp anyway.
1020
+ let origins: Vec<&str> = ev
1021
+ .iter()
1022
+ .map(|e| e["opencode_part_id"].as_str().unwrap())
1023
+ .collect();
1024
+ assert_eq!(
1025
+ origins,
1026
+ vec![
1027
+ "prt_user_text", // time_updated PROMPT_MS
1028
+ SESSION, // …+10, session before part on a tie
1029
+ SESSION,
1030
+ "prt_assistant_text", // …+10
1031
+ "prt_a_early", // …+30, despite the higher rowid
1032
+ "prt_z_late", // …+90
1033
+ ]
1034
+ );
1035
+
1036
+ fs::remove_dir_all(&dir).ok();
1037
+ }
1038
+
1039
+ #[test]
1040
+ fn the_watermark_advances_and_a_second_poll_returns_nothing() {
1041
+ let dir = tmpdir("watermark");
1042
+ let (db, conn) = make_db(&dir);
1043
+ seed_turn(&conn);
1044
+
1045
+ let first = poll_at(&db, 0, 1000);
1046
+ assert!(!first.events.is_empty());
1047
+ assert_eq!(first.watermark, PROMPT_MS + 10);
1048
+
1049
+ let second = poll_at(&db, first.watermark, 1000);
1050
+ assert!(second.events.is_empty());
1051
+ assert_eq!(second.rows_seen, 0);
1052
+ assert_eq!(second.watermark, first.watermark, "an idle poll holds it");
1053
+ assert!(!second.more);
1054
+
1055
+ fs::remove_dir_all(&dir).ok();
1056
+ }
1057
+
1058
+ #[test]
1059
+ fn hitting_the_row_limit_reports_more_so_a_backlog_drains() {
1060
+ let dir = tmpdir("limit");
1061
+ let (db, conn) = make_db(&dir);
1062
+ seed_turn(&conn);
1063
+ for i in 0..10 {
1064
+ let ts = PROMPT_MS + 100 + i * 10;
1065
+ insert_part(
1066
+ &conn,
1067
+ &format!("prt_bulk_{i:02}"),
1068
+ ASSISTANT_MSG,
1069
+ SESSION,
1070
+ ts,
1071
+ ts,
1072
+ &streamed_text_part(&format!("chunk {i}"), ts, Some(ts)),
1073
+ );
1074
+ }
1075
+
1076
+ let out = poll_at(&db, 0, 3);
1077
+ assert!(out.more, "a backlog must drain rather than trickle");
1078
+ assert!(out.rows_seen > 3);
1079
+ assert!(out.watermark < PROMPT_MS + 190);
1080
+
1081
+ // Drain the rest exactly the way the engine does.
1082
+ let mut wm = out.watermark;
1083
+ let mut seen = out.events.len();
1084
+ for _ in 0..20 {
1085
+ let next = poll_at(&db, wm, 3);
1086
+ wm = next.watermark;
1087
+ seen += next.events.len();
1088
+ if !next.more {
1089
+ break;
1090
+ }
1091
+ }
1092
+ assert_eq!(poll_at(&db, wm, 3).events.len(), 0, "the drain terminates");
1093
+ assert_eq!(
1094
+ seen,
1095
+ poll_at(&db, 0, 1000).events.len(),
1096
+ "draining in pages ships exactly what one big poll would"
1097
+ );
1098
+
1099
+ fs::remove_dir_all(&dir).ok();
1100
+ }
1101
+
1102
+ #[test]
1103
+ fn the_limit_never_cuts_a_millisecond_in_half() {
1104
+ // `time_updated` is milliseconds and rows routinely share one. Cutting
1105
+ // between two of them and then asking for `time_updated > :last` skips the
1106
+ // rest of that millisecond forever — a silent, permanent hole.
1107
+ let dir = tmpdir("msgroup");
1108
+ let (db, conn) = make_db(&dir);
1109
+ insert_session(&conn, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS - 1);
1110
+ user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
1111
+ insert_part(
1112
+ &conn,
1113
+ "prt_u",
1114
+ USER_MSG,
1115
+ SESSION,
1116
+ PROMPT_MS,
1117
+ PROMPT_MS,
1118
+ &text_part("go"),
1119
+ );
1120
+ assistant_message(&conn, ASSISTANT_MSG, SESSION, PROMPT_MS + 5, Some("stop"));
1121
+ // Three parts sharing ONE millisecond, straddling a limit of 2.
1122
+ for i in 0..3 {
1123
+ insert_part(
1124
+ &conn,
1125
+ &format!("prt_same_{i}"),
1126
+ ASSISTANT_MSG,
1127
+ SESSION,
1128
+ PROMPT_MS + 20,
1129
+ PROMPT_MS + 20,
1130
+ &streamed_text_part(&format!("same {i}"), PROMPT_MS + 20, Some(PROMPT_MS + 20)),
1131
+ );
1132
+ }
1133
+
1134
+ let first = poll_at(&db, 0, 2);
1135
+ assert!(first.more);
1136
+ assert!(
1137
+ first.watermark < PROMPT_MS + 20,
1138
+ "the watermark stops BEFORE the straddled millisecond, not inside it"
1139
+ );
1140
+
1141
+ let mut texts: Vec<String> = first
1142
+ .events
1143
+ .iter()
1144
+ .filter(|e| e["type"] == "model_response")
1145
+ .map(|e| e["content"].as_str().unwrap().to_string())
1146
+ .collect();
1147
+ let second = poll_at(&db, first.watermark, 2);
1148
+ texts.extend(
1149
+ second
1150
+ .events
1151
+ .iter()
1152
+ .filter(|e| e["type"] == "model_response")
1153
+ .map(|e| e["content"].as_str().unwrap().to_string()),
1154
+ );
1155
+ texts.sort();
1156
+ assert_eq!(texts, vec!["same 0", "same 1", "same 2"], "no row is lost");
1157
+
1158
+ fs::remove_dir_all(&dir).ok();
1159
+ }
1160
+
1161
+ #[test]
1162
+ fn a_millisecond_bigger_than_the_whole_limit_still_advances() {
1163
+ // The degenerate case of the rule above: trimming to a millisecond
1164
+ // boundary would leave nothing to ship and the watermark could never move
1165
+ // past it. That millisecond is taken whole instead.
1166
+ let dir = tmpdir("degenerate");
1167
+ let (db, conn) = make_db(&dir);
1168
+ insert_session(&conn, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS - 1);
1169
+ user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
1170
+ insert_part(
1171
+ &conn,
1172
+ "prt_u",
1173
+ USER_MSG,
1174
+ SESSION,
1175
+ PROMPT_MS,
1176
+ PROMPT_MS,
1177
+ &text_part("go"),
1178
+ );
1179
+ assistant_message(&conn, ASSISTANT_MSG, SESSION, PROMPT_MS + 5, Some("stop"));
1180
+ for i in 0..4 {
1181
+ insert_part(
1182
+ &conn,
1183
+ &format!("prt_burst_{i}"),
1184
+ ASSISTANT_MSG,
1185
+ SESSION,
1186
+ PROMPT_MS + 20,
1187
+ PROMPT_MS + 20,
1188
+ &streamed_text_part(&format!("burst {i}"), PROMPT_MS + 20, Some(PROMPT_MS + 20)),
1189
+ );
1190
+ }
1191
+
1192
+ // Walk up to the burst with a limit of one, then hit it.
1193
+ let mut wm = 0;
1194
+ let mut responses = 0;
1195
+ for _ in 0..12 {
1196
+ let out = poll_at(&db, wm, 1);
1197
+ responses += out
1198
+ .events
1199
+ .iter()
1200
+ .filter(|e| e["type"] == "model_response")
1201
+ .count();
1202
+ assert!(out.watermark >= wm, "the watermark never goes backwards");
1203
+ if out.watermark == wm && !out.more {
1204
+ break;
1205
+ }
1206
+ wm = out.watermark;
1207
+ }
1208
+ assert_eq!(
1209
+ responses, 4,
1210
+ "a burst wider than the limit still ships whole"
1211
+ );
1212
+ assert_eq!(wm, PROMPT_MS + 20);
1213
+
1214
+ fs::remove_dir_all(&dir).ok();
1215
+ }
1216
+
1217
+ // ── dedup discrimination and determinism ─────────────────────────────────
1218
+
1219
+ #[test]
1220
+ fn identical_parts_are_discriminated_by_their_part_id() {
1221
+ // Two identical calls in one turn are two real events. Without a
1222
+ // discriminator the server's content dedup collapses them into one and the
1223
+ // product under-reports what the agent did.
1224
+ let dir = tmpdir("dedup");
1225
+ let (db, conn) = make_db(&dir);
1226
+ seed_turn(&conn);
1227
+ assistant_message(
1228
+ &conn,
1229
+ "msg_tool_turn",
1230
+ SESSION,
1231
+ TOOL_CREATED_MS,
1232
+ Some("tool-calls"),
1233
+ );
1234
+ let twin = json!({"type":"tool","tool":"bash","callID":CALL_ID,
1235
+ "state":{"status":"completed","input":{"command":"ls"},"output":"a\n",
1236
+ "time":{"start":TOOL_CREATED_MS,"end":TOOL_CREATED_MS}}});
1237
+ insert_part(
1238
+ &conn,
1239
+ "prt_twin_a",
1240
+ "msg_tool_turn",
1241
+ SESSION,
1242
+ TOOL_CREATED_MS,
1243
+ TOOL_CREATED_MS,
1244
+ &twin,
1245
+ );
1246
+ insert_part(
1247
+ &conn,
1248
+ "prt_twin_b",
1249
+ "msg_tool_turn",
1250
+ SESSION,
1251
+ TOOL_CREATED_MS,
1252
+ TOOL_CREATED_MS,
1253
+ &twin,
1254
+ );
1255
+
1256
+ let ev = poll_all(&db);
1257
+ let calls = of_type(&ev, "tool_use");
1258
+ assert_eq!(calls.len(), 2);
1259
+ assert_eq!(calls[0]["opencode_part_id"], "prt_twin_a");
1260
+ assert_eq!(calls[1]["opencode_part_id"], "prt_twin_b");
1261
+ assert_ne!(
1262
+ serde_json::to_string(calls[0]).unwrap(),
1263
+ serde_json::to_string(calls[1]).unwrap(),
1264
+ "otherwise-identical events must not hash the same"
1265
+ );
1266
+ // Even the same callID on both must not merge them.
1267
+ assert_eq!(calls[0]["tool_call_id"], calls[1]["tool_call_id"]);
1268
+
1269
+ fs::remove_dir_all(&dir).ok();
1270
+ }
1271
+
1272
+ #[test]
1273
+ fn a_tool_part_with_no_call_id_falls_back_to_the_part_id() {
1274
+ let dir = tmpdir("nocallid");
1275
+ let (db, conn) = make_db(&dir);
1276
+ seed_turn(&conn);
1277
+ assistant_message(
1278
+ &conn,
1279
+ "msg_tool_turn",
1280
+ SESSION,
1281
+ TOOL_CREATED_MS,
1282
+ Some("tool-calls"),
1283
+ );
1284
+ insert_part(
1285
+ &conn,
1286
+ TOOL_PART,
1287
+ "msg_tool_turn",
1288
+ SESSION,
1289
+ TOOL_CREATED_MS,
1290
+ TOOL_CREATED_MS,
1291
+ &json!({"type":"tool","tool":"bash","state":{"status":"completed","input":{},"output":"x",
1292
+ "time":{"start":TOOL_CREATED_MS,"end":TOOL_CREATED_MS}}}),
1293
+ );
1294
+
1295
+ let ev = poll_all(&db);
1296
+ assert_eq!(of_type(&ev, "tool_use")[0]["tool_call_id"], TOOL_PART);
1297
+ assert_eq!(of_type(&ev, "tool_result")[0]["tool_call_id"], TOOL_PART);
1298
+
1299
+ fs::remove_dir_all(&dir).ok();
1300
+ }
1301
+
1302
+ #[test]
1303
+ fn re_reading_the_same_rows_produces_identical_bytes() {
1304
+ // The whole re-read design rests on this: the server collapses a re-read
1305
+ // only when it hashes the same, and this source re-reads constantly.
1306
+ let dir = tmpdir("determinism");
1307
+ let (db, conn) = make_db(&dir);
1308
+ seed_turn(&conn);
1309
+ assistant_message(
1310
+ &conn,
1311
+ "msg_tool_turn",
1312
+ SESSION,
1313
+ TOOL_CREATED_MS,
1314
+ Some("tool-calls"),
1315
+ );
1316
+ insert_part(
1317
+ &conn,
1318
+ TOOL_PART,
1319
+ "msg_tool_turn",
1320
+ SESSION,
1321
+ TOOL_CREATED_MS,
1322
+ TOOL_DONE_MS,
1323
+ &completed_tool_part(),
1324
+ );
1325
+ insert_part(
1326
+ &conn,
1327
+ "prt_extra",
1328
+ ASSISTANT_MSG,
1329
+ SESSION,
1330
+ PROMPT_MS + 20,
1331
+ PROMPT_MS + 20,
1332
+ &streamed_text_part("more", PROMPT_MS + 20, Some(PROMPT_MS + 20)),
1333
+ );
1334
+
1335
+ let a = serde_json::to_string(&poll_all(&db)).unwrap();
1336
+ for _ in 0..5 {
1337
+ assert_eq!(a, serde_json::to_string(&poll_all(&db)).unwrap());
1338
+ }
1339
+ // …and paging through it produces the same events as one big poll.
1340
+ let mut paged: Vec<Value> = Vec::new();
1341
+ let mut wm = 0;
1342
+ loop {
1343
+ let out = poll_at(&db, wm, 1);
1344
+ paged.extend(out.events);
1345
+ if out.watermark == wm {
1346
+ break;
1347
+ }
1348
+ wm = out.watermark;
1349
+ }
1350
+ assert_eq!(a, serde_json::to_string(&paged).unwrap());
1351
+
1352
+ fs::remove_dir_all(&dir).ok();
1353
+ }
1354
+
1355
+ #[test]
1356
+ fn a_malformed_data_blob_costs_that_row_not_the_whole_poll() {
1357
+ let dir = tmpdir("malformed");
1358
+ let (db, conn) = make_db(&dir);
1359
+ seed_turn(&conn);
1360
+ conn.execute(
1361
+ "INSERT INTO part VALUES ('prt_bad',?1,?2,?3,?3,'{not json')",
1362
+ rusqlite::params![ASSISTANT_MSG, SESSION, PROMPT_MS + 20],
1363
+ )
1364
+ .unwrap();
1365
+
1366
+ let out = poll_at(&db, 0, 1000);
1367
+ assert_eq!(out.rows_seen, 4);
1368
+ assert_eq!(of_type(&out.events, "model_response").len(), 1);
1369
+ assert_eq!(out.watermark, PROMPT_MS + 20, "the poll still advances");
1370
+
1371
+ fs::remove_dir_all(&dir).ok();
1372
+ }
1373
+
1374
+ // ── timestamps, format table, discovery ──────────────────────────────────
1375
+
1376
+ #[test]
1377
+ fn epoch_milliseconds_become_rfc3339_with_six_subsecond_digits() {
1378
+ // opencode stores INTEGER milliseconds. The index offset is what keeps
1379
+ // several events derived from ONE row in order.
1380
+ assert_eq!(
1381
+ transform::to_rfc3339_micros(TOOL_DONE_MS, 0).unwrap(),
1382
+ "2026-08-03T08:09:11.135000Z"
1383
+ );
1384
+ assert_eq!(
1385
+ transform::to_rfc3339_micros(TOOL_DONE_MS, 1).unwrap(),
1386
+ "2026-08-03T08:09:11.135001Z"
1387
+ );
1388
+ assert_eq!(
1389
+ transform::to_rfc3339_micros(TOOL_DONE_MS, 999).unwrap(),
1390
+ "2026-08-03T08:09:11.135999Z"
1391
+ );
1392
+ }
1393
+
1394
+ #[test]
1395
+ fn every_event_carries_the_full_envelope() {
1396
+ let dir = tmpdir("envelope");
1397
+ let (db, conn) = make_db(&dir);
1398
+ seed_turn(&conn);
1399
+ assistant_message(
1400
+ &conn,
1401
+ "msg_tool_turn",
1402
+ SESSION,
1403
+ TOOL_CREATED_MS,
1404
+ Some("tool-calls"),
1405
+ );
1406
+ insert_part(
1407
+ &conn,
1408
+ TOOL_PART,
1409
+ "msg_tool_turn",
1410
+ SESSION,
1411
+ TOOL_CREATED_MS,
1412
+ TOOL_DONE_MS,
1413
+ &completed_tool_part(),
1414
+ );
1415
+
1416
+ let ev = poll_all(&db);
1417
+ let kinds: Vec<&str> = ev.iter().map(|e| e["type"].as_str().unwrap()).collect();
1418
+ for kind in [
1419
+ "agent_start",
1420
+ "model_request",
1421
+ "model_response",
1422
+ "tool_use",
1423
+ "tool_result",
1424
+ ] {
1425
+ assert!(kinds.contains(&kind), "missing {kind}");
1426
+ }
1427
+ for e in &ev {
1428
+ assert_eq!(e["session_id"], SESSION);
1429
+ assert_eq!(e["agent_id"], AGENT);
1430
+ assert_eq!(e["environment"], "local");
1431
+ assert!(e["opencode_part_id"].is_string(), "dedup discriminator");
1432
+ let ts = e["timestamp"].as_str().unwrap();
1433
+ assert!(ts.ends_with('Z') && ts.len() == 27, "RFC3339 micros: {ts}");
1434
+ }
1435
+
1436
+ fs::remove_dir_all(&dir).ok();
1437
+ }
1438
+
1439
+ #[test]
1440
+ fn the_dead_session_message_table_is_never_read() {
1441
+ // It is named exactly like the table this source wants, it is permanently
1442
+ // empty on a live install, and four shipped migrations DELETE FROM it. A
1443
+ // poller that reached for it would report success and ship nothing.
1444
+ let dir = tmpdir("deadtable");
1445
+ let (db, conn) = make_db(&dir);
1446
+ seed_turn(&conn);
1447
+ conn.execute(
1448
+ "INSERT INTO session_message VALUES ('sm_1',?1,'text',0,?2,?2,'{\"text\":\"LEGACY\"}')",
1449
+ rusqlite::params![SESSION, PROMPT_MS + 40],
1450
+ )
1451
+ .unwrap();
1452
+
1453
+ let out = poll_at(&db, 0, 1000);
1454
+ assert_eq!(out.rows_seen, 3, "session + two parts, and nothing else");
1455
+ assert!(
1456
+ !serde_json::to_string(&out.events)
1457
+ .unwrap()
1458
+ .contains("LEGACY"),
1459
+ "the legacy table must not reach the event stream"
1460
+ );
1461
+
1462
+ fs::remove_dir_all(&dir).ok();
1463
+ }
1464
+
1465
+ #[test]
1466
+ fn opencode_declares_an_updated_at_watermark() {
1467
+ // If this ever flips to RowId, every tool call in the product ships with
1468
+ // an empty result and nothing errors. It is the single most
1469
+ // consequential line in this source.
1470
+ assert_eq!(opencode::FORMAT.watermark, Watermark::UpdatedAt);
1471
+ assert_eq!(opencode::FORMAT.kind, "opencode");
1472
+ }
1473
+
1474
+ #[test]
1475
+ fn the_default_database_is_the_xdg_data_one() {
1476
+ let path = opencode::default_db_path();
1477
+ assert!(path.ends_with("opencode/opencode.db"), "{}", path.display());
1478
+ }