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,1191 @@
1
+ //! Pi source: transform correctness and engine behaviour.
2
+ //!
3
+ //! Record shapes are verbatim from real transcripts captured under
4
+ //! `~/.pi/agent/sessions/`, from BOTH pi-coding-agent 0.73.1 and 0.83.0. The
5
+ //! two releases write the same grammar with one visible difference — 0.83.0
6
+ //! emits leading prose alongside its tool calls where 0.73.1 emitted calls
7
+ //! alone — so both are exercised here rather than one standing in for the
8
+ //! other.
9
+
10
+ use std::fs;
11
+ use std::path::{Path, PathBuf};
12
+ use std::sync::Arc;
13
+ use std::sync::atomic::AtomicBool;
14
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
+
16
+ use fpai_collect::cursor::TailState;
17
+ use fpai_collect::filetail::{self, Ctx, Params, RereadPolicy, Spec};
18
+ use fpai_collect::sources::pi::{self, transform};
19
+ use fpai_collect::supervisor::Shutdown;
20
+ use serde_json::{Value, json};
21
+
22
+ fn tmpdir(name: &str) -> PathBuf {
23
+ let d = std::env::temp_dir().join(format!(
24
+ "fpai-pi-{}-{}-{}",
25
+ name,
26
+ std::process::id(),
27
+ SystemTime::now()
28
+ .duration_since(UNIX_EPOCH)
29
+ .unwrap()
30
+ .as_nanos()
31
+ ));
32
+ fs::create_dir_all(&d).unwrap();
33
+ d
34
+ }
35
+
36
+ /// The session uuid of the real 0.73.1 tool-using transcript.
37
+ const UUID: &str = "019fc6a6-9eb6-744b-a700-58ea5b3e8ff9";
38
+ /// pi's encoding of `/tmp/probe-pi`. Decoding it naively yields `/tmp/probe/pi`
39
+ /// and therefore the project name `pi` — which is exactly the mistake the
40
+ /// header `cwd` exists to prevent.
41
+ const ENCODED_CWD: &str = "--tmp-probe-pi--";
42
+
43
+ fn ctx() -> Ctx {
44
+ Ctx {
45
+ session_id: UUID.into(),
46
+ agent_id: "pi-probe-pi".into(),
47
+ environment: "local".into(),
48
+ }
49
+ }
50
+
51
+ // ── real record shapes ───────────────────────────────────────────────────
52
+
53
+ fn session_header(ts: &str) -> String {
54
+ json!({"type":"session","version":3,"id":UUID,"timestamp":ts,"cwd":"/tmp/probe-pi"}).to_string()
55
+ }
56
+
57
+ fn model_change(ts: &str) -> String {
58
+ json!({"type":"model_change","id":"3c651a37","parentId":null,"timestamp":ts,
59
+ "provider":"aikin","modelId":"claude-sonnet-4-6"})
60
+ .to_string()
61
+ }
62
+
63
+ fn thinking_level_change(ts: &str) -> String {
64
+ json!({"type":"thinking_level_change","id":"b42de093","parentId":"3c651a37",
65
+ "timestamp":ts,"thinkingLevel":"off"})
66
+ .to_string()
67
+ }
68
+
69
+ fn user_prompt(ts: &str, text: &str) -> String {
70
+ json!({"type":"message","id":"0ecb7564","parentId":"b42de093","timestamp":ts,
71
+ "message":{"role":"user","content":[{"type":"text","text":text}],
72
+ "timestamp":1785744236236u64}})
73
+ .to_string()
74
+ }
75
+
76
+ /// A 0.73.1 assistant turn: tool calls only, no prose.
77
+ fn assistant_tool_calls(ts: &str, calls: &[(&str, &str)]) -> String {
78
+ let content: Vec<Value> = calls
79
+ .iter()
80
+ .map(|(id, name)| {
81
+ json!({"type":"toolCall","id":id,"name":name,
82
+ "arguments":{"command":"ls -la /tmp/probe-pi"}})
83
+ })
84
+ .collect();
85
+ json!({"type":"message","id":"81470a2e","parentId":"0ecb7564","timestamp":ts,
86
+ "message":{"role":"assistant","content":content,"api":"anthropic-messages",
87
+ "provider":"aikin","model":"claude-sonnet-4-6",
88
+ "usage":{"input":3,"output":103,"cacheRead":2547,"cacheWrite":339,
89
+ "totalTokens":2992,"cost":{"input":0,"output":0,"cacheRead":0,
90
+ "cacheWrite":0,"total":0}},
91
+ "stopReason":"toolUse","timestamp":1785744236260u64,
92
+ "responseId":"msg_bdrk_01Y324EoS5bmSs86938K3grV"}})
93
+ .to_string()
94
+ }
95
+
96
+ /// A 0.83.0 assistant turn: prose FIRST, then the calls, plus the fields that
97
+ /// release added (`cacheWrite1h`, `rawStopReason`).
98
+ fn assistant_prose_then_calls(ts: &str, prose: &str, calls: &[(&str, &str)]) -> String {
99
+ let mut content = vec![json!({"type":"text","text":prose})];
100
+ content.extend(calls.iter().map(|(id, name)| {
101
+ json!({"type":"toolCall","id":id,"name":name,
102
+ "arguments":{"path":"/tmp/probe-pi83/README.md"}})
103
+ }));
104
+ json!({"type":"message","id":"d3794c08","parentId":"f0536fe0","timestamp":ts,
105
+ "message":{"role":"assistant","content":content,"api":"anthropic-messages",
106
+ "provider":"aikin","model":"claude-sonnet-4-6",
107
+ "usage":{"input":3,"output":114,"cacheRead":0,"cacheWrite":2930,
108
+ "totalTokens":3047,"cost":{"input":0,"output":0,"cacheRead":0,
109
+ "cacheWrite":0,"total":0},"cacheWrite1h":0},
110
+ "stopReason":"toolUse","timestamp":1785744818713u64,
111
+ "responseId":"msg_bdrk_01MkBvo6F9ViUeCuGGvnsTcj","rawStopReason":"tool_use"}})
112
+ .to_string()
113
+ }
114
+
115
+ fn assistant_text(ts: &str, text: &str) -> String {
116
+ json!({"type":"message","id":"0436eda2","parentId":"f6ce1248","timestamp":ts,
117
+ "message":{"role":"assistant","content":[{"type":"text","text":text}],
118
+ "api":"anthropic-messages","provider":"aikin","model":"claude-sonnet-4-6",
119
+ "usage":{"input":1,"output":14,"cacheRead":3392,"cacheWrite":78,
120
+ "totalTokens":3485,"cost":{"input":0,"output":0,"cacheRead":0,
121
+ "cacheWrite":0,"total":0}},
122
+ "stopReason":"stop","timestamp":1785744268202u64,
123
+ "responseId":"msg_bdrk_01VdNUJ8bNnNiJvvfDVTD8J6"}})
124
+ .to_string()
125
+ }
126
+
127
+ /// The real failed turn: EMPTY content, `stopReason:"error"`, no `responseId`.
128
+ fn assistant_error(ts: &str, message: &str) -> String {
129
+ json!({"type":"message","id":"95487fe5","parentId":"c1e2d2d2","timestamp":ts,
130
+ "message":{"role":"assistant","content":[],"api":"anthropic-messages",
131
+ "provider":"aikin","model":"claude-sonnet-4-6",
132
+ "usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,
133
+ "totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,
134
+ "cacheWrite":0,"total":0}},
135
+ "stopReason":"error","timestamp":1785743864654u64,
136
+ "errorMessage":message}})
137
+ .to_string()
138
+ }
139
+
140
+ fn tool_result(ts: &str, call_id: &str, name: &str, out: &str, is_error: bool) -> String {
141
+ json!({"type":"message","id":"fe29ac29","parentId":"81470a2e","timestamp":ts,
142
+ "message":{"role":"toolResult","toolCallId":call_id,"toolName":name,
143
+ "content":[{"type":"text","text":out}],"isError":is_error,
144
+ "timestamp":1785744238967u64}})
145
+ .to_string()
146
+ }
147
+
148
+ /// Same record with `toolName` stripped — the shape the carried name has to
149
+ /// cover.
150
+ fn tool_result_unnamed(ts: &str, call_id: &str, out: &str) -> String {
151
+ json!({"type":"message","id":"fe29ac29","parentId":"81470a2e","timestamp":ts,
152
+ "message":{"role":"toolResult","toolCallId":call_id,
153
+ "content":[{"type":"text","text":out}],"isError":false,
154
+ "timestamp":1785744238967u64}})
155
+ .to_string()
156
+ }
157
+
158
+ fn of_type<'a>(events: &'a [Value], kind: &str) -> Vec<&'a Value> {
159
+ events.iter().filter(|e| e["type"] == kind).collect()
160
+ }
161
+
162
+ // ── transform ────────────────────────────────────────────────────────────
163
+
164
+ #[test]
165
+ fn a_millisecond_timestamp_is_padded_to_microseconds() {
166
+ // pi writes `new Date().toISOString()`, i.e. milliseconds; the hook source
167
+ // emits microseconds. Both streams share a session timeline, so they must
168
+ // sort against each other.
169
+ assert_eq!(
170
+ transform::with_index("2026-08-03T08:03:56.214Z", 0).unwrap(),
171
+ "2026-08-03T08:03:56.214000Z"
172
+ );
173
+ // The index offset orders several events derived from ONE record.
174
+ assert_eq!(
175
+ transform::with_index("2026-08-03T08:03:56.214Z", 2).unwrap(),
176
+ "2026-08-03T08:03:56.214002Z"
177
+ );
178
+ }
179
+
180
+ #[test]
181
+ fn the_index_offset_saturates_inside_its_second() {
182
+ // Carrying into the next second could reorder an event past a genuinely
183
+ // later record, which is worse than the tie it is fixing.
184
+ let s = transform::with_index("2026-08-03T08:03:56.999999Z", 999).unwrap();
185
+ assert_eq!(s, "2026-08-03T08:03:56.999999Z");
186
+ assert!(s.starts_with("2026-08-03T08:03:56."));
187
+ }
188
+
189
+ #[test]
190
+ fn a_model_change_record_primes_the_model_for_the_first_prompt() {
191
+ // This is why this format needs no `seed_state`. pi writes `model_change`
192
+ // BEFORE the session's first user message, so the opening row already has
193
+ // a model — and the server builds a model_request row's summary from the
194
+ // model alone, so without one the very first row of every session renders
195
+ // blank. Seeding out of band (what the Claude source has to do) is the one
196
+ // part of the format table that is not dedup-safe across a re-read.
197
+ let mut st = TailState::default();
198
+ let c = ctx();
199
+
200
+ let (ts, ev) =
201
+ transform::transform_line(&model_change("2026-08-03T08:03:56.232Z"), &c, 140, &mut st);
202
+ assert_eq!(ts.as_deref(), Some("2026-08-03T08:03:56.232Z"));
203
+ assert!(ev.is_empty(), "a model change is not itself a turn");
204
+ assert_eq!(st.last_model.as_deref(), Some("claude-sonnet-4-6"));
205
+
206
+ let (_, ev) = transform::transform_line(
207
+ &user_prompt("2026-08-03T08:03:56.238Z", "hello"),
208
+ &c,
209
+ 300,
210
+ &mut st,
211
+ );
212
+ assert_eq!(ev.len(), 1);
213
+ assert_eq!(ev[0]["type"], "model_request");
214
+ assert_eq!(ev[0]["model"], "claude-sonnet-4-6");
215
+ assert_eq!(ev[0]["messages"][0]["content"], "hello");
216
+ assert_eq!(ev[0]["messages"][0]["role"], "user");
217
+ }
218
+
219
+ #[test]
220
+ fn a_user_prompt_spread_over_several_text_blocks_is_one_request() {
221
+ // pi's user content is an ARRAY, unlike Claude Code's bare string. Emitting
222
+ // one row per block would split a single prompt into several turns.
223
+ let mut st = TailState::default();
224
+ let line = json!({"type":"message","id":"a","parentId":null,
225
+ "timestamp":"2026-08-03T08:03:56.238Z",
226
+ "message":{"role":"user","content":[
227
+ {"type":"text","text":"first"},{"type":"text","text":"second"}]}})
228
+ .to_string();
229
+ let (_, ev) = transform::transform_line(&line, &ctx(), 0, &mut st);
230
+ assert_eq!(ev.len(), 1);
231
+ assert_eq!(ev[0]["messages"][0]["content"], "first\nsecond");
232
+ }
233
+
234
+ #[test]
235
+ fn a_tool_call_and_its_result_are_paired_by_id_not_by_arrival_order() {
236
+ // One assistant record issues SEVERAL calls, and each result is its own
237
+ // later record. Pairing positionally happens to work on the transcripts we
238
+ // captured and breaks the moment two results are written out of order —
239
+ // silently, by attributing one tool's output to another tool's name.
240
+ let mut st = TailState::default();
241
+ let c = ctx();
242
+
243
+ let (_, calls) = transform::transform_line(
244
+ &assistant_tool_calls(
245
+ "2026-08-03T08:03:58.953Z",
246
+ &[
247
+ ("toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP", "bash"),
248
+ ("toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe", "read"),
249
+ ],
250
+ ),
251
+ &c,
252
+ 420,
253
+ &mut st,
254
+ );
255
+ assert_eq!(calls.len(), 2);
256
+ assert_eq!(calls[0]["tool_name"], "bash");
257
+ assert_eq!(calls[1]["tool_name"], "read");
258
+
259
+ // Results delivered in the REVERSE of the call order.
260
+ let (_, second) = transform::transform_line(
261
+ &tool_result_unnamed(
262
+ "2026-08-03T08:03:58.967Z",
263
+ "toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe",
264
+ "# Probe Pi\n",
265
+ ),
266
+ &c,
267
+ 900,
268
+ &mut st,
269
+ );
270
+ let (_, first) = transform::transform_line(
271
+ &tool_result_unnamed(
272
+ "2026-08-03T08:03:58.967Z",
273
+ "toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP",
274
+ "total 144\n",
275
+ ),
276
+ &c,
277
+ 1200,
278
+ &mut st,
279
+ );
280
+ assert_eq!(second[0]["tool_name"], "read", "paired by id, not position");
281
+ assert_eq!(first[0]["tool_name"], "bash", "paired by id, not position");
282
+ assert_eq!(second[0]["output"], "# Probe Pi\n");
283
+ assert_eq!(first[0]["output"], "total 144\n");
284
+ }
285
+
286
+ #[test]
287
+ fn a_tool_result_missing_its_tool_name_is_still_not_a_blank_row() {
288
+ // pi does name the tool on the result record, so the carried name is pure
289
+ // defence — but the server builds a result row's summary from the tool name
290
+ // alone, so the day a writer omits it every result becomes an unreadable
291
+ // row. This is what `TailState::pending_tools` is for.
292
+ let mut st = TailState::default();
293
+ let c = ctx();
294
+ transform::transform_line(
295
+ &assistant_tool_calls("2026-08-03T08:19:37.583Z", &[("toolu_01", "bash")]),
296
+ &c,
297
+ 0,
298
+ &mut st,
299
+ );
300
+ let (_, ev) = transform::transform_line(
301
+ &tool_result_unnamed("2026-08-03T08:19:37.592Z", "toolu_01", "TIMING_MARKER\n"),
302
+ &c,
303
+ 500,
304
+ &mut st,
305
+ );
306
+ assert_eq!(ev[0]["type"], "tool_result");
307
+ assert_eq!(ev[0]["tool_name"], "bash", "the name must survive the call");
308
+ assert_eq!(ev[0]["tool_call_id"], "toolu_01");
309
+ }
310
+
311
+ #[test]
312
+ fn a_carried_tool_name_survives_the_cursor_round_trip() {
313
+ // The call and its result can land in different polls, and between them the
314
+ // carried state is serialized into the cursor file. If it did not round
315
+ // trip, a live tail and a full re-read would disagree at the same offset
316
+ // and the server could no longer collapse the re-read.
317
+ let mut st = TailState::default();
318
+ transform::transform_line(
319
+ &assistant_tool_calls("2026-08-03T08:19:37.583Z", &[("toolu_01", "bash")]),
320
+ &ctx(),
321
+ 0,
322
+ &mut st,
323
+ );
324
+ let encoded = serde_json::to_string(&st).unwrap();
325
+ let mut restored: TailState = serde_json::from_str(&encoded).unwrap();
326
+ assert_eq!(restored.tool_name("toolu_01"), Some("bash"));
327
+
328
+ let (_, ev) = transform::transform_line(
329
+ &tool_result_unnamed("2026-08-03T08:19:37.592Z", "toolu_01", "ok"),
330
+ &ctx(),
331
+ 500,
332
+ &mut restored,
333
+ );
334
+ assert_eq!(ev[0]["tool_name"], "bash");
335
+ }
336
+
337
+ #[test]
338
+ fn an_assistant_turn_that_mixes_prose_with_tool_calls_ships_all_of_it() {
339
+ // The 0.83.0 case: `["text","toolCall","toolCall"]` where 0.73.1 wrote
340
+ // `["toolCall","toolCall"]` for the same prompt. Anything that inspects
341
+ // content[0] to classify the turn drops the prose on one release and the
342
+ // calls on the other.
343
+ let mut st = TailState::default();
344
+ let (_, ev) = transform::transform_line(
345
+ &assistant_prose_then_calls(
346
+ "2026-08-03T08:13:41.674Z",
347
+ "I'll do both at the same time!",
348
+ &[
349
+ ("toolu_bdrk_01BLAbzzkxkrZS6nPF6nNQQy", "bash"),
350
+ ("toolu_bdrk_01BBuVKvhJ7McsBpy9txBJLC", "read"),
351
+ ],
352
+ ),
353
+ &ctx(),
354
+ 700,
355
+ &mut st,
356
+ );
357
+ assert_eq!(ev.len(), 3, "got {ev:#?}");
358
+ assert_eq!(ev[0]["type"], "model_response");
359
+ assert_eq!(ev[0]["content"], "I'll do both at the same time!");
360
+ assert_eq!(ev[0]["pi_stop_reason"], "toolUse");
361
+ assert_eq!(ev[1]["type"], "tool_use");
362
+ assert_eq!(ev[1]["tool_name"], "bash");
363
+ assert_eq!(ev[2]["type"], "tool_use");
364
+ assert_eq!(ev[2]["tool_name"], "read");
365
+ // pi names the payload `arguments`; ingest reads `input`.
366
+ assert_eq!(ev[2]["input"]["path"], "/tmp/probe-pi83/README.md");
367
+ }
368
+
369
+ #[test]
370
+ fn several_events_from_one_record_stay_in_order_on_the_timeline() {
371
+ // They all share one millisecond, and the server's tie-break is a random
372
+ // id — so without the index offset a turn comes back with its tool calls
373
+ // ahead of the sentence that introduced them.
374
+ let mut st = TailState::default();
375
+ let (_, ev) = transform::transform_line(
376
+ &assistant_prose_then_calls(
377
+ "2026-08-03T08:13:41.674Z",
378
+ "doing both",
379
+ &[("a", "bash"), ("b", "read")],
380
+ ),
381
+ &ctx(),
382
+ 700,
383
+ &mut st,
384
+ );
385
+ let stamps: Vec<&str> = ev
386
+ .iter()
387
+ .map(|e| e["timestamp"].as_str().unwrap())
388
+ .collect();
389
+ assert_eq!(
390
+ stamps,
391
+ vec![
392
+ "2026-08-03T08:13:41.674000Z",
393
+ "2026-08-03T08:13:41.674001Z",
394
+ "2026-08-03T08:13:41.674002Z"
395
+ ]
396
+ );
397
+ assert!(ev[0].get("pi_block_index").is_none(), "index 0 is implicit");
398
+ assert_eq!(ev[1]["pi_block_index"], 1);
399
+ }
400
+
401
+ #[test]
402
+ fn parallel_calls_that_carry_no_id_do_not_collapse_into_one_row() {
403
+ // Two identical calls in one record with no id would hash to the same
404
+ // content and the server would keep only one, hiding half the work.
405
+ let mut st = TailState::default();
406
+ let line = json!({"type":"message","id":"a","parentId":null,
407
+ "timestamp":"2026-08-03T08:13:41.674Z",
408
+ "message":{"role":"assistant","model":"claude-sonnet-4-6","content":[
409
+ {"type":"toolCall","name":"bash","arguments":{"command":"ls"}},
410
+ {"type":"toolCall","name":"bash","arguments":{"command":"ls"}}]}})
411
+ .to_string();
412
+ let (_, ev) = transform::transform_line(&line, &ctx(), 700, &mut st);
413
+ assert_eq!(ev.len(), 2);
414
+ assert_ne!(ev[0]["tool_call_id"], ev[1]["tool_call_id"]);
415
+ assert_eq!(ev[0]["tool_call_id"], "pi-700-0");
416
+ }
417
+
418
+ #[test]
419
+ fn an_errored_tool_result_carries_a_non_empty_error() {
420
+ let mut st = TailState::default();
421
+ let (_, ev) = transform::transform_line(
422
+ &tool_result(
423
+ "2026-08-03T08:03:58.967Z",
424
+ "toolu_01",
425
+ "bash",
426
+ "exit code 127: command not found",
427
+ true,
428
+ ),
429
+ &ctx(),
430
+ 0,
431
+ &mut st,
432
+ );
433
+ assert_eq!(ev[0]["error_type"], "pi_tool_error");
434
+ assert!(!ev[0]["error"].as_str().unwrap().is_empty());
435
+ assert_eq!(ev[0]["tool_name"], "bash");
436
+ }
437
+
438
+ #[test]
439
+ fn a_failed_turn_with_no_content_still_produces_a_visible_row() {
440
+ // Verbatim from a real 404: `content:[]`, `stopReason:"error"`, an
441
+ // `errorMessage`, and no `responseId`. Emitting only from content blocks
442
+ // leaves the prompt that triggered it as the last thing in the session and
443
+ // the failure entirely invisible.
444
+ let mut st = TailState::default();
445
+ let (_, ev) = transform::transform_line(
446
+ &assistant_error("2026-08-03T07:58:00.520Z", "404 {\"detail\":\"Not Found\"}"),
447
+ &ctx(),
448
+ 0,
449
+ &mut st,
450
+ );
451
+ assert_eq!(ev.len(), 1, "got {ev:#?}");
452
+ assert_eq!(ev[0]["type"], "model_response");
453
+ assert_eq!(ev[0]["error_type"], "pi_model_error");
454
+ assert_eq!(ev[0]["content"], "404 {\"detail\":\"Not Found\"}");
455
+ assert_eq!(ev[0]["pi_stop_reason"], "error");
456
+ // Index 0 because there were no blocks to sort behind.
457
+ assert_eq!(ev[0]["timestamp"], "2026-08-03T07:58:00.520000Z");
458
+ }
459
+
460
+ #[test]
461
+ fn token_usage_is_billed_once_per_record_including_on_a_failed_turn() {
462
+ // pi writes exactly ONE record per API response, so usage is billed
463
+ // unconditionally — unlike the Claude source, which must gate on a message
464
+ // id because Claude Code repeats one usage object across several lines.
465
+ // Gating here would silently drop the usage of every errored turn, since
466
+ // those carry no `responseId` to gate on.
467
+ let mut st = TailState::default();
468
+ let c = ctx();
469
+
470
+ let (_, a) = transform::transform_line(
471
+ &assistant_text("2026-08-03T08:04:29.565Z", "six lines"),
472
+ &c,
473
+ 0,
474
+ &mut st,
475
+ );
476
+ assert_eq!(a[0]["input_tokens"], 1);
477
+ assert_eq!(a[0]["output_tokens"], 14);
478
+ assert_eq!(a[0]["pi_usage"]["totalTokens"], 3485);
479
+
480
+ // A different record at a different offset bills again — two responses are
481
+ // two bills.
482
+ let (_, b) = transform::transform_line(
483
+ &assistant_text("2026-08-03T08:04:31.000Z", "again"),
484
+ &c,
485
+ 900,
486
+ &mut st,
487
+ );
488
+ assert_eq!(b[0]["input_tokens"], 1);
489
+
490
+ let (_, err) = transform::transform_line(
491
+ &assistant_error("2026-08-03T07:58:00.520Z", "404"),
492
+ &c,
493
+ 1800,
494
+ &mut st,
495
+ );
496
+ assert!(
497
+ err[0].get("pi_usage").is_some(),
498
+ "an errored turn still reports what it cost"
499
+ );
500
+ }
501
+
502
+ #[test]
503
+ fn usage_lands_on_only_the_first_event_of_a_multi_event_record() {
504
+ // Three events from one response, each carrying the same usage, would
505
+ // triple that response's token total.
506
+ let mut st = TailState::default();
507
+ let (_, ev) = transform::transform_line(
508
+ &assistant_prose_then_calls(
509
+ "2026-08-03T08:13:41.674Z",
510
+ "doing both",
511
+ &[("a", "bash"), ("b", "read")],
512
+ ),
513
+ &ctx(),
514
+ 0,
515
+ &mut st,
516
+ );
517
+ assert_eq!(ev.len(), 3);
518
+ assert!(ev[0].get("pi_usage").is_some());
519
+ assert!(ev[1].get("pi_usage").is_none());
520
+ assert!(ev[2].get("pi_usage").is_none());
521
+ }
522
+
523
+ #[test]
524
+ fn the_session_header_yields_no_events_but_still_starts_the_clock() {
525
+ // `agent_start` is built from the header lines by the engine, not from this
526
+ // dispatch — emitting here too would give every session two start events.
527
+ // The timestamp is still returned so an otherwise-empty file can end.
528
+ let mut st = TailState::default();
529
+ let (ts, ev) = transform::transform_line(
530
+ &session_header("2026-08-03T08:03:56.214Z"),
531
+ &ctx(),
532
+ 0,
533
+ &mut st,
534
+ );
535
+ assert_eq!(ts.as_deref(), Some("2026-08-03T08:03:56.214Z"));
536
+ assert!(ev.is_empty());
537
+ }
538
+
539
+ #[test]
540
+ fn records_this_source_does_not_model_still_advance_the_session_clock() {
541
+ // EVERY pi record carries a timestamp, so the Claude source's "no timestamp
542
+ // means metadata" shortcut does not exist here. Returning None for these
543
+ // would freeze `agent_end` at the last *message*, dating a session that
544
+ // ended with a compaction or a label minutes before it really did.
545
+ let mut st = TailState::default();
546
+ let c = ctx();
547
+ for line in [
548
+ thinking_level_change("2026-08-03T08:03:56.232Z"),
549
+ json!({"type":"label","id":"a","parentId":"b","timestamp":"2026-08-03T08:05:00.000Z",
550
+ "targetId":"c","label":"checkpoint"})
551
+ .to_string(),
552
+ json!({"type":"compaction","id":"d","parentId":"c",
553
+ "timestamp":"2026-08-03T08:06:00.000Z","message":{"role":"user","content":[]}})
554
+ .to_string(),
555
+ json!({"type":"session_info","id":"e","parentId":"d",
556
+ "timestamp":"2026-08-03T08:07:00.000Z","title":"probe"})
557
+ .to_string(),
558
+ ] {
559
+ let (ts, ev) = transform::transform_line(&line, &c, 0, &mut st);
560
+ assert!(ts.is_some(), "{line} must report its timestamp");
561
+ assert!(ev.is_empty(), "{line} must not become a turn");
562
+ }
563
+ }
564
+
565
+ #[test]
566
+ fn a_custom_message_is_not_mistaken_for_a_conversation_turn() {
567
+ // `custom` (and the pre-v3 `hookMessage` the v2→v3 migration renames to it)
568
+ // is extension output. Falling through to the user branch would file an
569
+ // extension's chatter as something the human typed.
570
+ let mut st = TailState::default();
571
+ let line = json!({"type":"message","id":"a","parentId":"b",
572
+ "timestamp":"2026-08-03T08:05:00.000Z",
573
+ "message":{"role":"custom","content":[{"type":"text","text":"hook says hi"}]}})
574
+ .to_string();
575
+ let (ts, ev) = transform::transform_line(&line, &ctx(), 0, &mut st);
576
+ assert!(ts.is_some());
577
+ assert!(ev.is_empty());
578
+ }
579
+
580
+ #[test]
581
+ fn two_identical_records_at_different_offsets_produce_different_events() {
582
+ // The offset is the dedup discriminator, and pi makes it easy to need: a
583
+ // retried prompt is byte-for-byte the same JSON. Without it the server
584
+ // collapses a genuinely repeated turn into one row.
585
+ let mut st = TailState::default();
586
+ let c = ctx();
587
+ let line = user_prompt("2026-08-03T08:03:56.238Z", "same");
588
+ let (_, a) = transform::transform_line(&line, &c, 10, &mut st);
589
+ let (_, b) = transform::transform_line(&line, &c, 999, &mut st);
590
+ assert_eq!(a[0]["pi_line_offset"], 10);
591
+ assert_eq!(b[0]["pi_line_offset"], 999);
592
+ assert_ne!(a[0]["pi_line_offset"], b[0]["pi_line_offset"]);
593
+ }
594
+
595
+ #[test]
596
+ fn a_malformed_line_is_skipped_without_taking_the_file_with_it() {
597
+ let mut st = TailState::default();
598
+ let (ts, ev) = transform::transform_line("{not json", &ctx(), 0, &mut st);
599
+ assert!(ts.is_none());
600
+ assert!(ev.is_empty());
601
+ }
602
+
603
+ #[test]
604
+ fn the_start_event_carries_the_goal_the_cwd_and_the_session_version() {
605
+ let header = vec![
606
+ session_header("2026-08-03T08:03:56.214Z"),
607
+ model_change("2026-08-03T08:03:56.232Z"),
608
+ thinking_level_change("2026-08-03T08:03:56.232Z"),
609
+ user_prompt(
610
+ "2026-08-03T08:03:56.238Z",
611
+ "List the files in this directory",
612
+ ),
613
+ assistant_text("2026-08-03T08:03:58.953Z", "done"),
614
+ ];
615
+ let (event, ts) = transform::agent_start(&header, &ctx(), 0).unwrap();
616
+ assert_eq!(ts.as_deref(), Some("2026-08-03T08:03:56.214Z"));
617
+ assert_eq!(event["type"], "agent_start");
618
+ assert_eq!(event["timestamp"], "2026-08-03T08:03:56.214000Z");
619
+ assert_eq!(event["goal"], "List the files in this directory");
620
+ assert_eq!(event["pi_cwd"], "/tmp/probe-pi");
621
+ // A NUMBER on disk, not a string — reading it as a string yields nothing.
622
+ assert_eq!(event["pi_session_version"], 3);
623
+ assert_eq!(event["pi_model"], "claude-sonnet-4-6");
624
+ assert_eq!(event["pi_provider"], "aikin");
625
+ }
626
+
627
+ #[test]
628
+ fn a_forked_session_records_the_transcript_it_was_cut_from() {
629
+ // Fork and branch write a NEW file and leave the source untouched, so the
630
+ // only on-disk link back is this header field. Dropping it makes a forked
631
+ // session look like it appeared from nowhere.
632
+ let header = vec![
633
+ json!({"type":"session","version":3,"id":UUID,
634
+ "timestamp":"2026-08-03T08:03:56.214Z","cwd":"/tmp/probe-pi",
635
+ "parentSession":"/home/u/.pi/agent/sessions/--tmp-probe-pi--/older.jsonl"})
636
+ .to_string(),
637
+ ];
638
+ let (event, _) = transform::agent_start(&header, &ctx(), 0).unwrap();
639
+ assert_eq!(
640
+ event["pi_parent_session"],
641
+ "/home/u/.pi/agent/sessions/--tmp-probe-pi--/older.jsonl"
642
+ );
643
+ }
644
+
645
+ #[test]
646
+ fn the_end_event_sorts_after_every_turn_that_shares_its_timestamp() {
647
+ // The server's tie-break is a random id, so without the index a session can
648
+ // appear to end before its own last turn.
649
+ let end = transform::agent_end(&ctx(), "2026-08-03T08:04:29.565Z", 5017);
650
+ assert_eq!(end["type"], "agent_end");
651
+ // 565 ms + 999 µs, and still inside the same second.
652
+ assert_eq!(end["timestamp"], "2026-08-03T08:04:29.565999Z");
653
+ // The file size, so the one end event of a session is unique to it.
654
+ assert_eq!(end["pi_line_offset"], 5017);
655
+ }
656
+
657
+ // ── format table ─────────────────────────────────────────────────────────
658
+
659
+ #[test]
660
+ fn discovery_claims_session_transcripts_and_nothing_else() {
661
+ let is = pi::FORMAT.is_source_file;
662
+ let dir = format!("/s/{ENCODED_CWD}");
663
+ assert!(is(Path::new(&format!(
664
+ "{dir}/2026-08-03T08-03-56-214Z_{UUID}.jsonl"
665
+ ))));
666
+
667
+ // No underscore-separated uuid: an export, a note, an editor backup.
668
+ assert!(!is(Path::new(&format!("{dir}/{UUID}.jsonl"))));
669
+ assert!(!is(Path::new(&format!("{dir}/notes.jsonl"))));
670
+ assert!(!is(Path::new(&format!(
671
+ "{dir}/2026-08-03T08-03-56-214Z_{UUID}.jsonl~"
672
+ ))));
673
+ assert!(!is(Path::new(&format!(
674
+ "{dir}/2026-08-03T08-03-56-214Z_{UUID}.json"
675
+ ))));
676
+ // A bare uuid with nothing before the underscore is not pi's naming.
677
+ assert!(!is(Path::new(&format!("{dir}/_{UUID}.jsonl"))));
678
+ }
679
+
680
+ #[test]
681
+ fn the_session_id_is_the_uuid_after_the_underscore() {
682
+ // Split from the right: the timestamp half is `2026-08-03T08-03-56-214Z`,
683
+ // full of the same `-` the uuid uses, so only the underscore separates them.
684
+ assert_eq!(
685
+ (pi::FORMAT.session_id_from_path)(Path::new(&format!(
686
+ "/s/{ENCODED_CWD}/2026-08-03T08-03-56-214Z_{UUID}.jsonl"
687
+ )))
688
+ .as_deref(),
689
+ Some(UUID)
690
+ );
691
+ assert!((pi::FORMAT.session_id_from_path)(Path::new("/s/x/notes.jsonl")).is_none());
692
+ }
693
+
694
+ #[test]
695
+ fn the_agent_id_comes_from_the_header_cwd_not_the_lossy_directory_name() {
696
+ // pi encodes cwd by mapping every separator to `-` and leaving literal `-`
697
+ // alone, so the encoding is not invertible — and the loss is not a corner
698
+ // case. `/tmp/probe-pi` becomes `--tmp-probe-pi--`, which decodes back to
699
+ // `/tmp/probe/pi` and would file the session under `pi-pi`.
700
+ let header = vec![session_header("2026-08-03T08:03:56.214Z")];
701
+ let derived = (pi::FORMAT.agent_id_from_path)(
702
+ Path::new(&format!(
703
+ "/s/{ENCODED_CWD}/2026-08-03T08-03-56-214Z_{UUID}.jsonl"
704
+ )),
705
+ &header,
706
+ );
707
+ assert_eq!(derived.as_deref(), Some("pi-probe-pi"));
708
+ }
709
+
710
+ #[test]
711
+ fn a_header_with_no_cwd_falls_back_rather_than_inventing_an_agent() {
712
+ // The configured default is better than a wrong guess decoded from the
713
+ // directory name.
714
+ let derived = (pi::FORMAT.agent_id_from_path)(
715
+ Path::new(&format!(
716
+ "/s/{ENCODED_CWD}/2026-08-03T08-03-56-214Z_{UUID}.jsonl"
717
+ )),
718
+ &[r#"{"type":"model_change","timestamp":"2026-08-03T08:03:56.232Z"}"#.to_string()],
719
+ );
720
+ assert!(derived.is_none());
721
+ }
722
+
723
+ #[test]
724
+ fn pi_seeds_no_carried_state_from_its_header() {
725
+ // Seeding is the one part of the format table that is NOT dedup-safe
726
+ // between a live tail and a later full re-read, and pi does not need it:
727
+ // its own record order puts `model_change` ahead of the first prompt. If
728
+ // this ever becomes non-empty, that tradeoff is being taken on knowingly.
729
+ let header = vec![
730
+ session_header("2026-08-03T08:03:56.214Z"),
731
+ model_change("2026-08-03T08:03:56.232Z"),
732
+ assistant_text("2026-08-03T08:03:58.953Z", "hi"),
733
+ ];
734
+ let mut st = TailState::default();
735
+ (pi::FORMAT.seed_state)(&header, &mut st);
736
+ assert_eq!(st, TailState::default());
737
+ }
738
+
739
+ #[test]
740
+ fn pi_declares_itself_byte_tailable() {
741
+ // Steady-state turns are appendFileSync, so a byte cursor is correct. The
742
+ // whole-file `_rewriteFile()` paths shrink the file, which the engine
743
+ // detects on its own — see the shrink test below.
744
+ assert_eq!(pi::FORMAT.reread, RereadPolicy::ByteCursor);
745
+ assert_eq!(pi::FORMAT.kind, "pi");
746
+ }
747
+
748
+ #[test]
749
+ fn the_sessions_root_follows_pis_own_agent_directory_override() {
750
+ // A relocated agent dir that we did not follow means capturing nothing,
751
+ // silently — the same failure mode as an absent root.
752
+ //
753
+ // Serialized against the other env-var assertions in this test by being the
754
+ // only one that touches PI_CODING_AGENT_DIR.
755
+ let saved = std::env::var_os("PI_CODING_AGENT_DIR");
756
+ unsafe { std::env::set_var("PI_CODING_AGENT_DIR", "/opt/pi-agent") };
757
+ assert_eq!(pi::sessions_root(), PathBuf::from("/opt/pi-agent/sessions"));
758
+
759
+ // pi expands the tilde itself; taking it literally would have us watching a
760
+ // directory named `~` under the process cwd.
761
+ unsafe { std::env::set_var("PI_CODING_AGENT_DIR", "~/elsewhere") };
762
+ let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
763
+ assert_eq!(pi::sessions_root(), home.join("elsewhere").join("sessions"));
764
+
765
+ match saved {
766
+ Some(v) => unsafe { std::env::set_var("PI_CODING_AGENT_DIR", v) },
767
+ None => unsafe { std::env::remove_var("PI_CODING_AGENT_DIR") },
768
+ }
769
+ }
770
+
771
+ // ── engine ───────────────────────────────────────────────────────────────
772
+
773
+ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
774
+ Spec {
775
+ format: pi::FORMAT,
776
+ roots: vec![root],
777
+ spool_dir: spool,
778
+ state_dir: state,
779
+ poll_interval: Duration::from_millis(200),
780
+ params: Params {
781
+ agent_id: pi::DEFAULT_AGENT_ID.into(),
782
+ environment: "local".into(),
783
+ end_idle_mins: 0, // end immediately, so the test need not wait
784
+ max_read_bytes: 8 * 1024 * 1024,
785
+ max_batch_bytes: 8 * 1024 * 1024,
786
+ since_days: None,
787
+ },
788
+ }
789
+ }
790
+
791
+ fn spooled(dir: &Path) -> Vec<Value> {
792
+ let mut out = Vec::new();
793
+ let Ok(rd) = fs::read_dir(dir) else {
794
+ return out;
795
+ };
796
+ for e in rd.filter_map(|e| e.ok()) {
797
+ let p = e.path();
798
+ if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
799
+ continue;
800
+ }
801
+ for l in fs::read_to_string(&p).unwrap().lines() {
802
+ if !l.trim().is_empty() {
803
+ out.push(serde_json::from_str(l).unwrap());
804
+ }
805
+ }
806
+ }
807
+ out
808
+ }
809
+
810
+ fn clear(dir: &Path) {
811
+ for e in fs::read_dir(dir).unwrap().filter_map(|e| e.ok()) {
812
+ fs::remove_file(e.path()).ok();
813
+ }
814
+ }
815
+
816
+ async fn run_briefly(s: Spec, ms: u64) {
817
+ let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
818
+ let _ = tokio::time::timeout(Duration::from_millis(ms), filetail::run(s, sd)).await;
819
+ }
820
+
821
+ fn write_session(root: &Path, lines: &[String]) -> PathBuf {
822
+ let proj = root.join(ENCODED_CWD);
823
+ fs::create_dir_all(&proj).unwrap();
824
+ let p = proj.join(format!("2026-08-03T08-03-56-214Z_{UUID}.jsonl"));
825
+ fs::write(&p, lines.join("\n") + "\n").unwrap();
826
+ p
827
+ }
828
+
829
+ fn cleanup(dirs: [&Path; 3]) {
830
+ for d in dirs {
831
+ fs::remove_dir_all(d).ok();
832
+ }
833
+ }
834
+
835
+ /// The real 0.73.1 tool-using session, in file order.
836
+ fn session_0_73_1() -> Vec<String> {
837
+ vec![
838
+ session_header("2026-08-03T08:03:56.214Z"),
839
+ model_change("2026-08-03T08:03:56.232Z"),
840
+ thinking_level_change("2026-08-03T08:03:56.232Z"),
841
+ user_prompt(
842
+ "2026-08-03T08:03:56.238Z",
843
+ "List the files in this directory, then read README.md and tell me what it says.",
844
+ ),
845
+ assistant_tool_calls(
846
+ "2026-08-03T08:03:58.953Z",
847
+ &[
848
+ ("toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP", "bash"),
849
+ ("toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe", "read"),
850
+ ],
851
+ ),
852
+ tool_result(
853
+ "2026-08-03T08:03:58.967Z",
854
+ "toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP",
855
+ "bash",
856
+ "total 144\n",
857
+ false,
858
+ ),
859
+ tool_result(
860
+ "2026-08-03T08:03:58.967Z",
861
+ "toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe",
862
+ "read",
863
+ "# Probe Pi\n",
864
+ false,
865
+ ),
866
+ assistant_text("2026-08-03T08:04:02.076Z", "Here's what I found: …"),
867
+ ]
868
+ }
869
+
870
+ /// The real 0.83.0 session — same grammar, prose alongside the calls.
871
+ fn session_0_83_0() -> Vec<String> {
872
+ vec![
873
+ session_header("2026-08-03T08:13:38.646Z"),
874
+ model_change("2026-08-03T08:13:38.678Z"),
875
+ thinking_level_change("2026-08-03T08:13:38.678Z"),
876
+ user_prompt(
877
+ "2026-08-03T08:13:38.690Z",
878
+ "List the files in this directory, then read README.md and tell me what it says.",
879
+ ),
880
+ assistant_prose_then_calls(
881
+ "2026-08-03T08:13:41.674Z",
882
+ "I'll do both at the same time!",
883
+ &[
884
+ ("toolu_bdrk_01BLAbzzkxkrZS6nPF6nNQQy", "bash"),
885
+ ("toolu_bdrk_01BBuVKvhJ7McsBpy9txBJLC", "read"),
886
+ ],
887
+ ),
888
+ tool_result(
889
+ "2026-08-03T08:13:41.683Z",
890
+ "toolu_bdrk_01BLAbzzkxkrZS6nPF6nNQQy",
891
+ "bash",
892
+ "total 144\n",
893
+ false,
894
+ ),
895
+ tool_result(
896
+ "2026-08-03T08:13:41.683Z",
897
+ "toolu_bdrk_01BBuVKvhJ7McsBpy9txBJLC",
898
+ "read",
899
+ "# Probe Pi83\n",
900
+ false,
901
+ ),
902
+ assistant_text("2026-08-03T08:13:45.502Z", "Here's what I found: …"),
903
+ ]
904
+ }
905
+
906
+ #[tokio::test(flavor = "multi_thread")]
907
+ async fn a_real_0_73_1_session_produces_a_start_its_turns_and_an_end() {
908
+ let root = tmpdir("full-root");
909
+ let spool = tmpdir("full-spool");
910
+ let state = tmpdir("full-state");
911
+
912
+ write_session(&root, &session_0_73_1());
913
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
914
+
915
+ let ev = spooled(&spool);
916
+ let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
917
+ for want in [
918
+ "agent_start",
919
+ "model_request",
920
+ "tool_use",
921
+ "tool_result",
922
+ "model_response",
923
+ "agent_end",
924
+ ] {
925
+ assert!(types.contains(&want), "missing {want}: {types:?}");
926
+ }
927
+ assert_eq!(of_type(&ev, "tool_use").len(), 2);
928
+ assert_eq!(of_type(&ev, "tool_result").len(), 2);
929
+
930
+ // Every event carries the session id from the filename, so hook events for
931
+ // the same run land on this timeline — and the agent id from the header
932
+ // cwd, not the lossy directory name.
933
+ for e in &ev {
934
+ assert_eq!(e["session_id"], UUID);
935
+ assert_eq!(e["agent_id"], "pi-probe-pi");
936
+ assert_eq!(e["environment"], "local");
937
+ assert!(e["timestamp"].as_str().unwrap().ends_with('Z'));
938
+ }
939
+
940
+ cleanup([&root, &spool, &state]);
941
+ }
942
+
943
+ #[tokio::test(flavor = "multi_thread")]
944
+ async fn a_0_83_0_session_needs_no_second_format() {
945
+ // The two releases write the same grammar; only the shape of an assistant
946
+ // turn's content differs. A source that needed a second `Format` would be
947
+ // a source that had assumed content was homogeneous.
948
+ let root = tmpdir("v83-root");
949
+ let spool = tmpdir("v83-spool");
950
+ let state = tmpdir("v83-state");
951
+
952
+ write_session(&root, &session_0_83_0());
953
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
954
+
955
+ let ev = spooled(&spool);
956
+ assert_eq!(of_type(&ev, "agent_start").len(), 1);
957
+ assert_eq!(of_type(&ev, "model_request").len(), 1);
958
+ assert_eq!(of_type(&ev, "tool_use").len(), 2);
959
+ assert_eq!(of_type(&ev, "tool_result").len(), 2);
960
+ // The prose that shipped alongside the calls, plus the closing turn.
961
+ assert_eq!(of_type(&ev, "model_response").len(), 2);
962
+ assert!(
963
+ of_type(&ev, "model_response")
964
+ .iter()
965
+ .any(|e| e["content"] == "I'll do both at the same time!"),
966
+ "the leading prose of a 0.83.0 tool turn must ship"
967
+ );
968
+
969
+ cleanup([&root, &spool, &state]);
970
+ }
971
+
972
+ #[tokio::test(flavor = "multi_thread")]
973
+ async fn a_second_pass_ships_nothing_new() {
974
+ let root = tmpdir("resume-root");
975
+ let spool = tmpdir("resume-spool");
976
+ let state = tmpdir("resume-state");
977
+ write_session(&root, &session_0_73_1());
978
+
979
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
980
+ assert!(!spooled(&spool).is_empty());
981
+ clear(&spool);
982
+
983
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
984
+ assert!(
985
+ spooled(&spool).is_empty(),
986
+ "a resumed pass must ship nothing"
987
+ );
988
+
989
+ cleanup([&root, &spool, &state]);
990
+ }
991
+
992
+ #[tokio::test(flavor = "multi_thread")]
993
+ async fn appended_turns_are_picked_up_without_re_shipping_earlier_ones() {
994
+ let root = tmpdir("append-root");
995
+ let spool = tmpdir("append-spool");
996
+ let state = tmpdir("append-state");
997
+ let path = write_session(&root, &session_0_73_1());
998
+
999
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
1000
+ let first = spooled(&spool).len();
1001
+ clear(&spool);
1002
+
1003
+ // Steady-state pi: one appendFileSync per entry.
1004
+ let mut body = fs::read_to_string(&path).unwrap();
1005
+ body.push_str(&(user_prompt("2026-08-03T08:04:26.109Z", "Now run: wc -l calc.py") + "\n"));
1006
+ body.push_str(&(assistant_text("2026-08-03T08:04:29.565Z", "`calc.py` has 6 lines.") + "\n"));
1007
+ fs::write(&path, body).unwrap();
1008
+
1009
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
1010
+ let ev = spooled(&spool);
1011
+ assert!(!ev.is_empty(), "the appended turns must ship");
1012
+ assert!(
1013
+ ev.len() < first,
1014
+ "earlier turns must not be re-shipped: {} vs {first}",
1015
+ ev.len()
1016
+ );
1017
+ assert!(
1018
+ ev.iter()
1019
+ .any(|e| e["messages"][0]["content"] == "Now run: wc -l calc.py"),
1020
+ "the new prompt must be present"
1021
+ );
1022
+
1023
+ cleanup([&root, &spool, &state]);
1024
+ }
1025
+
1026
+ #[tokio::test(flavor = "multi_thread")]
1027
+ async fn a_rewritten_transcript_that_shrank_is_re_read_rather_than_seeked_past() {
1028
+ // `_rewriteFile()` does a full writeFileSync over the existing file when a
1029
+ // resumed session's entries all failed to parse — the transcript is
1030
+ // replaced by a lone fresh header, far shorter than what was there. Left
1031
+ // alone the cursor would sit past EOF and the session would silently stop
1032
+ // updating for the rest of its life.
1033
+ let root = tmpdir("rewrite-root");
1034
+ let spool = tmpdir("rewrite-spool");
1035
+ let state = tmpdir("rewrite-state");
1036
+ let path = write_session(&root, &session_0_73_1());
1037
+ let big = fs::metadata(&path).unwrap().len();
1038
+
1039
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
1040
+ clear(&spool);
1041
+
1042
+ let rewritten = [
1043
+ session_header("2026-08-03T08:20:00.000Z"),
1044
+ model_change("2026-08-03T08:20:00.010Z"),
1045
+ user_prompt("2026-08-03T08:20:00.020Z", "after the rewrite"),
1046
+ assistant_text("2026-08-03T08:20:02.000Z", "fresh"),
1047
+ ];
1048
+ fs::write(&path, rewritten.join("\n") + "\n").unwrap();
1049
+ assert!(
1050
+ fs::metadata(&path).unwrap().len() < big,
1051
+ "the rewrite must actually shrink the file for this test to mean anything"
1052
+ );
1053
+
1054
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
1055
+ let ev = spooled(&spool);
1056
+ assert!(
1057
+ ev.iter()
1058
+ .any(|e| e["messages"][0]["content"] == "after the rewrite"),
1059
+ "a shrunk transcript must be re-read, got {} events",
1060
+ ev.len()
1061
+ );
1062
+
1063
+ cleanup([&root, &spool, &state]);
1064
+ }
1065
+
1066
+ #[tokio::test(flavor = "multi_thread")]
1067
+ async fn a_partially_written_final_line_is_held_back_then_picked_up() {
1068
+ let root = tmpdir("partial-root");
1069
+ let spool = tmpdir("partial-spool");
1070
+ let state = tmpdir("partial-state");
1071
+ let proj = root.join(ENCODED_CWD);
1072
+ fs::create_dir_all(&proj).unwrap();
1073
+ let path = proj.join(format!("2026-08-03T08-03-56-214Z_{UUID}.jsonl"));
1074
+
1075
+ let head = [
1076
+ session_header("2026-08-03T08:03:56.214Z"),
1077
+ model_change("2026-08-03T08:03:56.232Z"),
1078
+ user_prompt("2026-08-03T08:03:56.238Z", "complete"),
1079
+ ]
1080
+ .join("\n");
1081
+ fs::write(&path, format!("{head}\n{{\"type\":\"message\",\"timest")).unwrap();
1082
+
1083
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
1084
+ let n1 = of_type(&spooled(&spool), "model_request").len();
1085
+ assert_eq!(n1, 1, "only the complete records should ship");
1086
+ clear(&spool);
1087
+
1088
+ fs::write(
1089
+ &path,
1090
+ format!(
1091
+ "{head}\n{}\n",
1092
+ user_prompt("2026-08-03T08:04:26.109Z", "finished")
1093
+ ),
1094
+ )
1095
+ .unwrap();
1096
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
1097
+ assert!(
1098
+ spooled(&spool)
1099
+ .iter()
1100
+ .any(|e| e["messages"][0]["content"] == "finished"),
1101
+ "the completed record must ship on a later pass"
1102
+ );
1103
+
1104
+ cleanup([&root, &spool, &state]);
1105
+ }
1106
+
1107
+ #[tokio::test(flavor = "multi_thread")]
1108
+ async fn a_session_directory_with_no_file_in_it_yet_is_not_an_error() {
1109
+ // pi's `_persist()` short-circuits until the first assistant message
1110
+ // completes, then flushes everything at once — so a run killed before that
1111
+ // leaves ZERO trace on disk, and a live run leaves an EMPTY directory for
1112
+ // as long as its first turn takes. Discovery finding nothing here is
1113
+ // normal, and must not be logged, retried or failed as if it were not.
1114
+ let root = tmpdir("empty-root");
1115
+ let spool = tmpdir("empty-spool");
1116
+ let state = tmpdir("empty-state");
1117
+ fs::create_dir_all(root.join(ENCODED_CWD)).unwrap();
1118
+
1119
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
1120
+ assert!(spooled(&spool).is_empty());
1121
+
1122
+ // And once the flush lands, the whole prefix is picked up in one pass —
1123
+ // including the `agent_start` built from a header that appeared complete.
1124
+ write_session(&root, &session_0_73_1());
1125
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
1126
+ let ev = spooled(&spool);
1127
+ assert_eq!(of_type(&ev, "agent_start").len(), 1, "got {ev:#?}");
1128
+ assert_eq!(of_type(&ev, "model_request").len(), 1);
1129
+
1130
+ cleanup([&root, &spool, &state]);
1131
+ }
1132
+
1133
+ #[tokio::test(flavor = "multi_thread")]
1134
+ async fn a_live_tail_and_a_full_re_read_agree_event_for_event() {
1135
+ // The invariant the whole engine rests on: every event is a pure function
1136
+ // of one record plus its byte offset, so a tail that happens to split a
1137
+ // turn across two polls must produce byte-identical events to a single
1138
+ // full read. Only that lets the server's content hash collapse a re-read
1139
+ // instead of storing the session twice. The tool-name carry is the part
1140
+ // that could break it, which is why it is persisted in the cursor.
1141
+ let lines = session_0_73_1();
1142
+
1143
+ let tail_root = tmpdir("tail-root");
1144
+ let tail_spool = tmpdir("tail-spool");
1145
+ let tail_state = tmpdir("tail-state");
1146
+ // Half the session, then the rest — the call lands in one pass and its
1147
+ // results in the next.
1148
+ let path = write_session(&tail_root, &lines[..5]);
1149
+ run_briefly(
1150
+ spec(tail_root.clone(), tail_spool.clone(), tail_state.clone()),
1151
+ 1000,
1152
+ )
1153
+ .await;
1154
+ fs::write(&path, lines.join("\n") + "\n").unwrap();
1155
+ run_briefly(
1156
+ spec(tail_root.clone(), tail_spool.clone(), tail_state.clone()),
1157
+ 1200,
1158
+ )
1159
+ .await;
1160
+
1161
+ let once_root = tmpdir("once-root");
1162
+ let once_spool = tmpdir("once-spool");
1163
+ let once_state = tmpdir("once-state");
1164
+ write_session(&once_root, &lines);
1165
+ run_briefly(
1166
+ spec(once_root.clone(), once_spool.clone(), once_state.clone()),
1167
+ 1200,
1168
+ )
1169
+ .await;
1170
+
1171
+ let mut tailed = spooled(&tail_spool);
1172
+ let mut at_once = spooled(&once_spool);
1173
+ // `agent_end` is emitted per pass and carries the file size, so the split
1174
+ // run legitimately has an extra one for the shorter file.
1175
+ tailed.retain(|e| e["type"] != "agent_end");
1176
+ at_once.retain(|e| e["type"] != "agent_end");
1177
+ let key = |e: &Value| format!("{}|{}", e["type"], e["pi_line_offset"]);
1178
+ tailed.sort_by_key(key);
1179
+ at_once.sort_by_key(key);
1180
+ assert!(
1181
+ !at_once.is_empty(),
1182
+ "two empty runs would make this comparison vacuous"
1183
+ );
1184
+ assert_eq!(
1185
+ tailed, at_once,
1186
+ "a split tail must produce the same events as one full read"
1187
+ );
1188
+
1189
+ cleanup([&tail_root, &tail_spool, &tail_state]);
1190
+ cleanup([&once_root, &once_spool, &once_state]);
1191
+ }