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,738 @@
1
+ //! Claude source: transform correctness and engine behaviour.
2
+ //!
3
+ //! Record shapes are verbatim from real transcripts under `~/.claude/projects/`
4
+ //! (Claude Code 2.1.x).
5
+
6
+ use std::fs;
7
+ use std::path::{Path, PathBuf};
8
+ use std::sync::Arc;
9
+ use std::sync::atomic::AtomicBool;
10
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
+
12
+ use fpai_collect::cursor::TailState;
13
+ use fpai_collect::filetail::{self, Ctx, Params, RereadPolicy, Spec};
14
+ use fpai_collect::sources::claude::{self, transform};
15
+ use fpai_collect::supervisor::Shutdown;
16
+ use serde_json::{Value, json};
17
+
18
+ fn tmpdir(name: &str) -> PathBuf {
19
+ let d = std::env::temp_dir().join(format!(
20
+ "fpai-cl-{}-{}-{}",
21
+ name,
22
+ std::process::id(),
23
+ SystemTime::now()
24
+ .duration_since(UNIX_EPOCH)
25
+ .unwrap()
26
+ .as_nanos()
27
+ ));
28
+ fs::create_dir_all(&d).unwrap();
29
+ d
30
+ }
31
+
32
+ fn ctx() -> Ctx {
33
+ Ctx {
34
+ session_id: "fb9f0d4f-f739-4069-ac16-9add45fd2506".into(),
35
+ agent_id: "claude-repo".into(),
36
+ environment: "local".into(),
37
+ }
38
+ }
39
+
40
+ const UUID: &str = "fb9f0d4f-f739-4069-ac16-9add45fd2506";
41
+
42
+ fn user_prompt(ts: &str, text: &str) -> String {
43
+ json!({"type":"user","uuid":"u1","timestamp":ts,"cwd":"/home/u/repo",
44
+ "message":{"role":"user","content":text}})
45
+ .to_string()
46
+ }
47
+
48
+ fn assistant_tool_use(ts: &str, call_id: &str, name: &str) -> String {
49
+ json!({"type":"assistant","timestamp":ts,
50
+ "message":{"model":"claude-opus-4-8","id":"msg_01","type":"message","role":"assistant",
51
+ "content":[{"type":"tool_use","id":call_id,"name":name,"input":{"command":"ls -la"}}],
52
+ "stop_reason":"tool_use",
53
+ "usage":{"input_tokens":2,"output_tokens":91,"cache_read_input_tokens":100}}})
54
+ .to_string()
55
+ }
56
+
57
+ fn tool_result(ts: &str, call_id: &str, out: &str, is_error: bool) -> String {
58
+ json!({"type":"user","timestamp":ts,
59
+ "message":{"role":"user","content":[
60
+ {"type":"tool_result","tool_use_id":call_id,"content":out,"is_error":is_error}]}})
61
+ .to_string()
62
+ }
63
+
64
+ // ── transform ────────────────────────────────────────────────────────────
65
+
66
+ #[test]
67
+ fn a_millisecond_timestamp_is_padded_to_microseconds() {
68
+ // Claude writes milliseconds; the hook source emits microseconds. Both
69
+ // streams share a session timeline, so they must sort against each other.
70
+ assert_eq!(
71
+ transform::with_index("2026-07-18T09:42:04.058Z", 0).unwrap(),
72
+ "2026-07-18T09:42:04.058000Z"
73
+ );
74
+ // The index offset orders several events derived from ONE line.
75
+ assert_eq!(
76
+ transform::with_index("2026-07-18T09:42:04.058Z", 2).unwrap(),
77
+ "2026-07-18T09:42:04.058002Z"
78
+ );
79
+ }
80
+
81
+ #[test]
82
+ fn the_index_offset_saturates_inside_its_second() {
83
+ // Carrying into the next second could reorder an event past a genuinely
84
+ // later line, which is worse than the tie it is fixing.
85
+ let s = transform::with_index("2026-07-18T09:42:04.999999Z", 999).unwrap();
86
+ assert_eq!(s, "2026-07-18T09:42:04.999999Z");
87
+ assert!(s.starts_with("2026-07-18T09:42:04."));
88
+ }
89
+
90
+ #[test]
91
+ fn a_user_prompt_becomes_a_model_request_carrying_the_inherited_model() {
92
+ let mut st = TailState {
93
+ last_model: Some("claude-opus-4-8".into()),
94
+ ..Default::default()
95
+ };
96
+ let (ts, ev) = transform::transform_line(
97
+ &user_prompt("2026-07-18T09:42:04.058Z", "hello"),
98
+ &ctx(),
99
+ 0,
100
+ &mut st,
101
+ );
102
+ assert_eq!(ts.as_deref(), Some("2026-07-18T09:42:04.058Z"));
103
+ assert_eq!(ev.len(), 1);
104
+ assert_eq!(ev[0]["type"], "model_request");
105
+ // A user line names no model; without inheritance this row renders blank.
106
+ assert_eq!(ev[0]["model"], "claude-opus-4-8");
107
+ assert_eq!(ev[0]["messages"][0]["content"], "hello");
108
+ }
109
+
110
+ #[test]
111
+ fn a_tool_call_is_remembered_so_its_result_is_not_a_blank_row() {
112
+ // The tool's name appears on NO result line. This is the whole reason
113
+ // TailState carries pending tools.
114
+ let mut st = TailState::default();
115
+ let c = ctx();
116
+ let (_, calls) = transform::transform_line(
117
+ &assistant_tool_use("2026-07-18T09:42:05.000Z", "toolu_01", "Bash"),
118
+ &c,
119
+ 100,
120
+ &mut st,
121
+ );
122
+ assert_eq!(calls[0]["type"], "tool_use");
123
+ assert_eq!(calls[0]["tool_name"], "Bash");
124
+ assert_eq!(calls[0]["tool_call_id"], "toolu_01");
125
+
126
+ let (_, results) = transform::transform_line(
127
+ &tool_result("2026-07-18T09:42:06.000Z", "toolu_01", "total 4", false),
128
+ &c,
129
+ 200,
130
+ &mut st,
131
+ );
132
+ assert_eq!(results[0]["type"], "tool_result");
133
+ assert_eq!(
134
+ results[0]["tool_name"], "Bash",
135
+ "the name must survive from the call"
136
+ );
137
+ assert_eq!(results[0]["output"], "total 4");
138
+ }
139
+
140
+ #[test]
141
+ fn an_errored_tool_result_carries_a_non_empty_error() {
142
+ let mut st = TailState::default();
143
+ let c = ctx();
144
+ transform::transform_line(
145
+ &assistant_tool_use("2026-07-18T09:42:05.000Z", "t1", "Bash"),
146
+ &c,
147
+ 0,
148
+ &mut st,
149
+ );
150
+ let (_, ev) = transform::transform_line(
151
+ &tool_result("2026-07-18T09:42:06.000Z", "t1", "Exit code 255", true),
152
+ &c,
153
+ 10,
154
+ &mut st,
155
+ );
156
+ assert_eq!(ev[0]["error_type"], "claude_tool_error");
157
+ assert!(!ev[0]["error"].as_str().unwrap().is_empty());
158
+ }
159
+
160
+ #[test]
161
+ fn token_usage_is_counted_once_per_message_id() {
162
+ // One API response spans several lines that each repeat the SAME usage
163
+ // object. Counting per line inflates token totals several-fold.
164
+ let mut st = TailState::default();
165
+ let c = ctx();
166
+ let line = assistant_tool_use("2026-07-18T09:42:05.000Z", "t1", "Bash");
167
+
168
+ let (_, first) = transform::transform_line(&line, &c, 0, &mut st);
169
+ assert!(
170
+ first[0].get("input_tokens").is_some(),
171
+ "first line bills usage"
172
+ );
173
+
174
+ // Same message id again, at a different offset.
175
+ let (_, second) = transform::transform_line(&line, &c, 500, &mut st);
176
+ assert!(
177
+ second[0].get("input_tokens").is_none(),
178
+ "a repeated message id must not be billed twice"
179
+ );
180
+ }
181
+
182
+ #[test]
183
+ fn a_metadata_line_is_skipped_without_needing_a_type_allowlist() {
184
+ // Real transcripts carry mode / file-history-snapshot / ai-title records.
185
+ // They have no timestamp, which is what excludes them — so a new record
186
+ // type in a future Claude release costs nothing.
187
+ let mut st = TailState::default();
188
+ let (ts, ev) = transform::transform_line(
189
+ r#"{"type":"file-history-snapshot","messageId":"x","snapshot":{}}"#,
190
+ &ctx(),
191
+ 0,
192
+ &mut st,
193
+ );
194
+ assert!(ts.is_none());
195
+ assert!(ev.is_empty());
196
+ }
197
+
198
+ // ── thinking blocks ──────────────────────────────────────────────────────
199
+
200
+ /// The shape every thinking block on this machine has: empty text, opaque
201
+ /// signature. 7,687 of 7,687.
202
+ fn thinking_line(ts: &str, msg_id: &str, thinking: &str) -> String {
203
+ json!({"type":"assistant","timestamp":ts,
204
+ "message":{"model":"claude-opus-4-8","id":msg_id,"role":"assistant",
205
+ "content":[{"type":"thinking","thinking":thinking,"signature":"CAIS0pen4queblob"}],
206
+ "usage":{"input_tokens":2,"output_tokens":3223}}})
207
+ .to_string()
208
+ }
209
+
210
+ #[test]
211
+ fn an_empty_thinking_block_produces_no_event_at_all() {
212
+ // Measured: 7,687 of 7,687 blocks on this machine carry `"thinking": ""`
213
+ // and nothing but a signature. One event each would be 7,687 blank rows.
214
+ let mut st = TailState::default();
215
+ let (ts, ev) = transform::transform_line(
216
+ &thinking_line("2026-07-18T09:42:05.000Z", "msg_T", ""),
217
+ &ctx(),
218
+ 0,
219
+ &mut st,
220
+ );
221
+ assert_eq!(ts.as_deref(), Some("2026-07-18T09:42:05.000Z"));
222
+ assert!(ev.is_empty(), "an empty thinking block must ship nothing");
223
+ }
224
+
225
+ #[test]
226
+ fn a_thinking_block_that_does_carry_text_is_shipped() {
227
+ // The arm exists so a future Claude that populates the field arrives as
228
+ // data rather than as a silent gap. Costs nothing while they are all empty.
229
+ let mut st = TailState::default();
230
+ let (_, ev) = transform::transform_line(
231
+ &thinking_line("2026-07-18T09:42:05.000Z", "msg_T", "let me reconsider"),
232
+ &ctx(),
233
+ 0,
234
+ &mut st,
235
+ );
236
+ assert_eq!(ev.len(), 1);
237
+ assert_eq!(ev[0]["type"], "model_response");
238
+ assert_eq!(ev[0]["claude_kind"], "thinking");
239
+ assert_eq!(ev[0]["content"], "let me reconsider");
240
+ }
241
+
242
+ #[test]
243
+ fn a_thinking_only_line_does_not_swallow_its_message_groups_tokens() {
244
+ // Claude writes the thinking block as its own line at the HEAD of a
245
+ // message-id group. Claiming the group on sight of the id would bill a line
246
+ // that emits nothing, and the group's tokens would vanish — 7,699 of 11,213
247
+ // groups on this machine begin with such a line, worth 8.4M of 10.3M output
248
+ // tokens. The claim belongs to the first line that actually emits.
249
+ let mut st = TailState::default();
250
+ let c = ctx();
251
+
252
+ // `msg_01` is also the id `assistant_tool_use` writes — the same group.
253
+ let (_, none) = transform::transform_line(
254
+ &thinking_line("2026-07-18T09:42:05.000Z", "msg_01", ""),
255
+ &c,
256
+ 0,
257
+ &mut st,
258
+ );
259
+ assert!(none.is_empty());
260
+
261
+ // Same message id, the line that carries the visible turn.
262
+ let (_, ev) = transform::transform_line(
263
+ &assistant_tool_use("2026-07-18T09:42:05.100Z", "toolu_9", "Bash"),
264
+ &c,
265
+ 200,
266
+ &mut st,
267
+ );
268
+ assert_eq!(
269
+ ev[0]["output_tokens"], 91,
270
+ "the group's tokens must land on the line that emits"
271
+ );
272
+ }
273
+
274
+ // ── compact boundaries ───────────────────────────────────────────────────
275
+
276
+ fn compact_boundary(ts: &str) -> String {
277
+ json!({"type":"system","subtype":"compact_boundary","content":"Conversation compacted",
278
+ "level":"info","timestamp":ts,"cwd":"/home/u/repo",
279
+ "compactMetadata":{"trigger":"auto","preTokens":1000616,"postTokens":15316,
280
+ "cumulativeDroppedTokens":985300,"durationMs":128718,
281
+ "preservedMessages":{"uuids":["a","b","c"]}}})
282
+ .to_string()
283
+ }
284
+
285
+ #[test]
286
+ fn a_compact_boundary_records_the_context_reset_with_its_metadata() {
287
+ // The only on-disk record that the context was thrown away. Without it a
288
+ // session shows a prompt answered from a summary that is nowhere visible.
289
+ let mut st = TailState {
290
+ last_model: Some("claude-opus-4-8".into()),
291
+ ..Default::default()
292
+ };
293
+ let (ts, ev) = transform::transform_line(&compact_boundary(COMPACT_TS), &ctx(), 42, &mut st);
294
+ assert_eq!(ts.as_deref(), Some(COMPACT_TS));
295
+ assert_eq!(ev.len(), 1);
296
+ assert_eq!(ev[0]["type"], "model_request");
297
+ assert_eq!(ev[0]["claude_kind"], "compact_boundary");
298
+ assert_eq!(ev[0]["claude_compact_trigger"], "auto");
299
+ assert_eq!(ev[0]["claude_compact_pre_tokens"], 1_000_616u64);
300
+ assert_eq!(ev[0]["claude_compact_post_tokens"], 15_316u64);
301
+ assert_eq!(ev[0]["claude_compact_dropped_tokens"], 985_300u64);
302
+ assert_eq!(ev[0]["duration_ms"], 128_718u64);
303
+ // The server builds a model_request's summary from the model alone, so this
304
+ // row would otherwise render blank.
305
+ assert_eq!(ev[0]["model"], "claude-opus-4-8");
306
+ assert_eq!(ev[0]["claude_line_offset"], 42);
307
+ // A uuid index that grows with the preserved window: volume, no signal.
308
+ assert!(ev[0].get("preservedMessages").is_none());
309
+ }
310
+
311
+ const COMPACT_TS: &str = "2026-07-18T09:50:00.000Z";
312
+
313
+ #[test]
314
+ fn other_system_subtypes_ship_nothing() {
315
+ // Six other subtypes occur on this machine. Modelling them all would turn
316
+ // turn_duration alone (659 lines) into pure volume.
317
+ let mut st = TailState::default();
318
+ let line = json!({"type":"system","subtype":"turn_duration","durationMs":135759,
319
+ "messageCount":71,"timestamp":COMPACT_TS})
320
+ .to_string();
321
+ let (ts, ev) = transform::transform_line(&line, &ctx(), 0, &mut st);
322
+ assert_eq!(
323
+ ts.as_deref(),
324
+ Some(COMPACT_TS),
325
+ "the line is still consumed"
326
+ );
327
+ assert!(ev.is_empty());
328
+ }
329
+
330
+ // ── synthetic / failed assistant turns ───────────────────────────────────
331
+
332
+ #[test]
333
+ fn an_aborted_turn_becomes_an_error_with_a_non_empty_message() {
334
+ // The server's is_error is a truthiness check on the message, so an empty
335
+ // one would render a failed turn as a success.
336
+ let mut st = TailState::default();
337
+ let line = json!({"type":"assistant","timestamp":"2026-07-18T09:42:05.000Z",
338
+ "isAbortedMidStream":true,
339
+ "message":{"model":"claude-opus-4-8","id":"msg_A","role":"assistant",
340
+ "content":[{"type":"text","text":"Let me read the exact redaction sec"}],
341
+ "usage":{"input_tokens":2,"output_tokens":5}}})
342
+ .to_string();
343
+ let (_, ev) = transform::transform_line(&line, &ctx(), 0, &mut st);
344
+ assert_eq!(ev.len(), 1, "the failure replaces the turn, got {ev:?}");
345
+ assert_eq!(ev[0]["type"], "error");
346
+ assert_eq!(ev[0]["error_type"], "claude_aborted");
347
+ assert!(!ev[0]["message"].as_str().unwrap().is_empty());
348
+ // Deliberately unbilled: a failed turn's usage is not a served response.
349
+ assert!(ev[0].get("input_tokens").is_none());
350
+ assert!(ev[0].get("output_tokens").is_none());
351
+ }
352
+
353
+ #[test]
354
+ fn an_api_error_turn_with_no_content_still_says_something() {
355
+ // A failed turn is precisely the one with no content, so a
356
+ // "only if non-empty" guard would drop exactly the rows that matter.
357
+ let mut st = TailState::default();
358
+ let line = json!({"type":"assistant","timestamp":"2026-07-18T09:42:05.000Z",
359
+ "isApiErrorMessage":true,
360
+ "message":{"model":"claude-opus-4-8","id":"msg_E","role":"assistant","content":[]}})
361
+ .to_string();
362
+ let (_, ev) = transform::transform_line(&line, &ctx(), 0, &mut st);
363
+ assert_eq!(ev[0]["type"], "error");
364
+ assert_eq!(ev[0]["error_type"], "claude_api_error");
365
+ assert!(!ev[0]["message"].as_str().unwrap().is_empty());
366
+ }
367
+
368
+ #[test]
369
+ fn claude_codes_own_error_label_wins_over_the_derived_one() {
370
+ // `"server_error"` is the string an operator can group on; the marker names
371
+ // are only what is left when Claude Code wrote no label.
372
+ let mut st = TailState::default();
373
+ let line = json!({"type":"assistant","timestamp":"2026-07-18T09:42:05.000Z",
374
+ "isApiErrorMessage":true,"error":"server_error",
375
+ "message":{"model":"<synthetic>","id":"m","role":"assistant",
376
+ "content":[{"type":"text","text":"API Error: Connection closed."}]}})
377
+ .to_string();
378
+ let (_, ev) = transform::transform_line(&line, &ctx(), 0, &mut st);
379
+ assert_eq!(ev[0]["error_type"], "server_error");
380
+ assert_eq!(ev[0]["message"], "API Error: Connection closed.");
381
+ }
382
+
383
+ #[test]
384
+ fn a_synthetic_model_never_leaks_into_the_next_user_turn() {
385
+ // `<synthetic>` is a placeholder, not a model anyone served. Letting it into
386
+ // carried state would stamp it as the model on every later prompt and send
387
+ // the context-window lookup after an id that can never resolve.
388
+ let mut st = TailState::default();
389
+ let c = ctx();
390
+ transform::transform_line(
391
+ &assistant_tool_use("2026-07-18T09:42:05.000Z", "t1", "Bash"),
392
+ &c,
393
+ 0,
394
+ &mut st,
395
+ );
396
+ let synthetic = json!({"type":"assistant","timestamp":"2026-07-18T09:42:06.000Z",
397
+ "message":{"model":"<synthetic>","id":"m2","role":"assistant","stop_reason":"stop_sequence",
398
+ "content":[{"type":"text","text":"No response requested."}],
399
+ "usage":{"input_tokens":0,"output_tokens":0}}})
400
+ .to_string();
401
+ let (_, ev) = transform::transform_line(&synthetic, &c, 100, &mut st);
402
+ assert_eq!(ev[0]["type"], "error");
403
+ assert_eq!(ev[0]["error_type"], "claude_synthetic");
404
+
405
+ let (_, prompt) = transform::transform_line(
406
+ &user_prompt("2026-07-18T09:42:07.000Z", "next"),
407
+ &c,
408
+ 200,
409
+ &mut st,
410
+ );
411
+ assert_eq!(
412
+ prompt[0]["model"], "claude-opus-4-8",
413
+ "the real model must survive the synthetic turn"
414
+ );
415
+ }
416
+
417
+ #[test]
418
+ fn a_synthetic_turn_does_not_claim_the_real_responses_token_group() {
419
+ // A synthetic line is interleaved INSIDE a real message-id group and its
420
+ // usage object is all zeros. Letting it claim the group would zero out the
421
+ // real response's tokens.
422
+ let mut st = TailState::default();
423
+ let c = ctx();
424
+ let synthetic = json!({"type":"assistant","timestamp":"2026-07-18T09:42:06.000Z",
425
+ "message":{"model":"<synthetic>","id":"msg_01","role":"assistant",
426
+ "content":[{"type":"text","text":"No response requested."}],
427
+ "usage":{"input_tokens":0,"output_tokens":0}}})
428
+ .to_string();
429
+ transform::transform_line(&synthetic, &c, 0, &mut st);
430
+ let (_, ev) = transform::transform_line(
431
+ &assistant_tool_use("2026-07-18T09:42:07.000Z", "t1", "Bash"),
432
+ &c,
433
+ 100,
434
+ &mut st,
435
+ );
436
+ assert_eq!(ev[0]["output_tokens"], 91, "the real turn must still bill");
437
+ }
438
+
439
+ #[test]
440
+ fn two_identical_lines_at_different_offsets_produce_different_events() {
441
+ // The offset is the dedup discriminator. Without it the server would
442
+ // collapse a genuinely repeated turn into one row.
443
+ let mut st = TailState::default();
444
+ let c = ctx();
445
+ let line = user_prompt("2026-07-18T09:42:04.058Z", "same");
446
+ let (_, a) = transform::transform_line(&line, &c, 10, &mut st);
447
+ let (_, b) = transform::transform_line(&line, &c, 999, &mut st);
448
+ assert_ne!(a[0]["claude_line_offset"], b[0]["claude_line_offset"]);
449
+ }
450
+
451
+ // ── format table ─────────────────────────────────────────────────────────
452
+
453
+ #[test]
454
+ fn discovery_claims_transcripts_and_nothing_else() {
455
+ let is = claude::FORMAT.is_source_file;
456
+ assert!(is(Path::new(&format!("/p/proj/{UUID}.jsonl"))));
457
+
458
+ // Rewritten in place — a byte cursor would re-ship it forever.
459
+ assert!(!is(Path::new(&format!(
460
+ "/p/proj/{UUID}.jsonl.tool-calls.json"
461
+ ))));
462
+ // A different two-field schema.
463
+ assert!(!is(Path::new("/p/proj/journal.jsonl")));
464
+ // Owned by a future subagent format; claiming it here would ship every
465
+ // subagent line twice under two session ids.
466
+ assert!(!is(Path::new(&format!(
467
+ "/p/proj/{UUID}/subagents/agent-x.jsonl"
468
+ ))));
469
+ assert!(!is(Path::new("/p/proj/notes.txt")));
470
+ }
471
+
472
+ #[test]
473
+ fn the_agent_id_comes_from_the_real_cwd_not_the_lossy_folder_name() {
474
+ // Claude encodes cwd by replacing every `/` with `-`, and folder names
475
+ // contain `-` too, so the encoding is not invertible. On this machine 3 of
476
+ // 16 project dirs decode wrongly by splitting on the last `-`.
477
+ let header = vec![
478
+ r#"{"type":"user","timestamp":"2026-07-18T09:42:04.058Z","cwd":"/home/u/src/openclaw-local"}"#.to_string(),
479
+ ];
480
+ let derived = (claude::FORMAT.agent_id_from_path)(
481
+ Path::new("/p/-home-u-src-openclaw-local/x.jsonl"),
482
+ &header,
483
+ );
484
+ assert_eq!(derived.as_deref(), Some("claude-openclaw-local"));
485
+ }
486
+
487
+ #[test]
488
+ fn the_session_id_is_the_filename_stem() {
489
+ assert_eq!(
490
+ (claude::FORMAT.session_id_from_path)(Path::new(&format!("/p/proj/{UUID}.jsonl")))
491
+ .as_deref(),
492
+ Some(UUID)
493
+ );
494
+ }
495
+
496
+ // ── engine ───────────────────────────────────────────────────────────────
497
+
498
+ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
499
+ Spec {
500
+ format: claude::FORMAT,
501
+ roots: vec![root],
502
+ spool_dir: spool,
503
+ state_dir: state,
504
+ poll_interval: Duration::from_millis(200),
505
+ params: Params {
506
+ agent_id: claude::DEFAULT_AGENT_ID.into(),
507
+ environment: "local".into(),
508
+ end_idle_mins: 0, // end immediately, so the test need not wait
509
+ max_read_bytes: 8 * 1024 * 1024,
510
+ max_batch_bytes: 8 * 1024 * 1024,
511
+ since_days: None,
512
+ },
513
+ }
514
+ }
515
+
516
+ fn spooled(dir: &Path) -> Vec<Value> {
517
+ let mut out = Vec::new();
518
+ let Ok(rd) = fs::read_dir(dir) else {
519
+ return out;
520
+ };
521
+ for e in rd.filter_map(|e| e.ok()) {
522
+ let p = e.path();
523
+ if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
524
+ continue;
525
+ }
526
+ for l in fs::read_to_string(&p).unwrap().lines() {
527
+ if !l.trim().is_empty() {
528
+ out.push(serde_json::from_str(l).unwrap());
529
+ }
530
+ }
531
+ }
532
+ out
533
+ }
534
+
535
+ async fn run_briefly(s: Spec, ms: u64) {
536
+ let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
537
+ let _ = tokio::time::timeout(Duration::from_millis(ms), filetail::run(s, sd)).await;
538
+ }
539
+
540
+ fn write_session(root: &Path, lines: &[String]) -> PathBuf {
541
+ let proj = root.join("-home-u-repo");
542
+ fs::create_dir_all(&proj).unwrap();
543
+ let p = proj.join(format!("{UUID}.jsonl"));
544
+ fs::write(&p, lines.join("\n") + "\n").unwrap();
545
+ p
546
+ }
547
+
548
+ #[tokio::test(flavor = "multi_thread")]
549
+ async fn a_session_produces_a_start_its_turns_and_an_end() {
550
+ let root = tmpdir("full-root");
551
+ let spool = tmpdir("full-spool");
552
+ let state = tmpdir("full-state");
553
+
554
+ write_session(
555
+ &root,
556
+ &[
557
+ user_prompt("2026-07-18T09:42:04.058Z", "list the files"),
558
+ assistant_tool_use("2026-07-18T09:42:05.000Z", "toolu_01", "Bash"),
559
+ tool_result("2026-07-18T09:42:06.000Z", "toolu_01", "total 4", false),
560
+ ],
561
+ );
562
+
563
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
564
+
565
+ let ev = spooled(&spool);
566
+ let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
567
+ assert!(types.contains(&"agent_start"), "got {types:?}");
568
+ assert!(types.contains(&"model_request"), "got {types:?}");
569
+ assert!(types.contains(&"tool_use"), "got {types:?}");
570
+ assert!(types.contains(&"tool_result"), "got {types:?}");
571
+ assert!(types.contains(&"agent_end"), "got {types:?}");
572
+
573
+ // Every event carries the session id from the filename, so hook events
574
+ // for the same run land on this timeline.
575
+ for e in &ev {
576
+ assert_eq!(e["session_id"], UUID);
577
+ assert_eq!(e["agent_id"], "claude-repo", "agent id must come from cwd");
578
+ }
579
+
580
+ fs::remove_dir_all(&root).ok();
581
+ fs::remove_dir_all(&spool).ok();
582
+ fs::remove_dir_all(&state).ok();
583
+ }
584
+
585
+ #[tokio::test(flavor = "multi_thread")]
586
+ async fn a_second_pass_ships_nothing_new() {
587
+ let root = tmpdir("resume-root");
588
+ let spool = tmpdir("resume-spool");
589
+ let state = tmpdir("resume-state");
590
+ write_session(&root, &[user_prompt("2026-07-18T09:42:04.058Z", "hi")]);
591
+
592
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
593
+ assert!(!spooled(&spool).is_empty());
594
+ for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
595
+ fs::remove_file(e.path()).ok();
596
+ }
597
+
598
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
599
+ assert!(
600
+ spooled(&spool).is_empty(),
601
+ "a resumed pass must ship nothing"
602
+ );
603
+
604
+ fs::remove_dir_all(&root).ok();
605
+ fs::remove_dir_all(&spool).ok();
606
+ fs::remove_dir_all(&state).ok();
607
+ }
608
+
609
+ #[tokio::test(flavor = "multi_thread")]
610
+ async fn appended_turns_are_picked_up_without_re_shipping_earlier_ones() {
611
+ let root = tmpdir("append-root");
612
+ let spool = tmpdir("append-spool");
613
+ let state = tmpdir("append-state");
614
+ let path = write_session(&root, &[user_prompt("2026-07-18T09:42:04.058Z", "first")]);
615
+
616
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
617
+ let first = spooled(&spool).len();
618
+ for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
619
+ fs::remove_file(e.path()).ok();
620
+ }
621
+
622
+ let mut body = fs::read_to_string(&path).unwrap();
623
+ body.push_str(&(user_prompt("2026-07-18T09:43:00.000Z", "second") + "\n"));
624
+ fs::write(&path, body).unwrap();
625
+
626
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
627
+ let ev = spooled(&spool);
628
+ assert!(!ev.is_empty(), "the appended turn must ship");
629
+ assert!(
630
+ ev.len() < first + 5,
631
+ "earlier turns must not be re-shipped: {}",
632
+ ev.len()
633
+ );
634
+ assert!(
635
+ ev.iter().any(|e| e["messages"][0]["content"] == "second"),
636
+ "the new prompt must be present"
637
+ );
638
+
639
+ fs::remove_dir_all(&root).ok();
640
+ fs::remove_dir_all(&spool).ok();
641
+ fs::remove_dir_all(&state).ok();
642
+ }
643
+
644
+ #[tokio::test(flavor = "multi_thread")]
645
+ async fn a_compacted_transcript_is_re_read_rather_than_seeking_past_its_end() {
646
+ // `/compact` rewrites the file and can shrink it. Left alone the cursor
647
+ // would sit past EOF and the session would silently stop updating.
648
+ let root = tmpdir("compact-root");
649
+ let spool = tmpdir("compact-spool");
650
+ let state = tmpdir("compact-state");
651
+ let path = write_session(
652
+ &root,
653
+ &[
654
+ user_prompt(
655
+ "2026-07-18T09:42:04.058Z",
656
+ "a long first prompt to make the file big",
657
+ ),
658
+ user_prompt(
659
+ "2026-07-18T09:42:10.000Z",
660
+ "another long prompt so the file shrinks later",
661
+ ),
662
+ ],
663
+ );
664
+
665
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
666
+ for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
667
+ fs::remove_file(e.path()).ok();
668
+ }
669
+
670
+ // Compaction: a much smaller file.
671
+ fs::write(
672
+ &path,
673
+ user_prompt("2026-07-18T09:44:00.000Z", "post") + "\n",
674
+ )
675
+ .unwrap();
676
+
677
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
678
+ let ev = spooled(&spool);
679
+ assert!(
680
+ ev.iter().any(|e| e["messages"][0]["content"] == "post"),
681
+ "a shrunk transcript must be re-read, got {} events",
682
+ ev.len()
683
+ );
684
+
685
+ fs::remove_dir_all(&root).ok();
686
+ fs::remove_dir_all(&spool).ok();
687
+ fs::remove_dir_all(&state).ok();
688
+ }
689
+
690
+ #[tokio::test(flavor = "multi_thread")]
691
+ async fn a_partially_written_final_line_is_held_back_then_picked_up() {
692
+ let root = tmpdir("partial-root");
693
+ let spool = tmpdir("partial-spool");
694
+ let state = tmpdir("partial-state");
695
+ let proj = root.join("-home-u-repo");
696
+ fs::create_dir_all(&proj).unwrap();
697
+ let path = proj.join(format!("{UUID}.jsonl"));
698
+
699
+ let complete = user_prompt("2026-07-18T09:42:04.058Z", "complete");
700
+ fs::write(&path, format!("{complete}\n{{\"type\":\"user\",\"timest")).unwrap();
701
+
702
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
703
+ let n1 = spooled(&spool)
704
+ .iter()
705
+ .filter(|e| e["type"] == "model_request")
706
+ .count();
707
+ assert_eq!(n1, 1, "only the complete line should ship");
708
+ for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
709
+ fs::remove_file(e.path()).ok();
710
+ }
711
+
712
+ fs::write(
713
+ &path,
714
+ format!(
715
+ "{complete}\n{}\n",
716
+ user_prompt("2026-07-18T09:43:00.000Z", "finished")
717
+ ),
718
+ )
719
+ .unwrap();
720
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
721
+ assert!(
722
+ spooled(&spool)
723
+ .iter()
724
+ .any(|e| e["messages"][0]["content"] == "finished"),
725
+ "the completed line must ship on a later pass"
726
+ );
727
+
728
+ fs::remove_dir_all(&root).ok();
729
+ fs::remove_dir_all(&spool).ok();
730
+ fs::remove_dir_all(&state).ok();
731
+ }
732
+
733
+ #[test]
734
+ fn claude_declares_itself_byte_tailable() {
735
+ // If Claude ever starts rewriting mid-file the way droid does, this is the
736
+ // switch — and the engine already supports the other policy.
737
+ assert_eq!(claude::FORMAT.reread, RereadPolicy::ByteCursor);
738
+ }