failproofai 1.0.0-beta.2 → 1.0.0-beta.3

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 (196) 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.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2195 -770
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
@@ -0,0 +1,854 @@
1
+ //! OpenClaw source: transform correctness and engine behaviour.
2
+ //!
3
+ //! Record shapes are verbatim from a live containerised probe capture of
4
+ //! `<state>/agents/main/sessions/<uuid>.jsonl`.
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::openclaw::{self, transform};
15
+ use fpai_collect::supervisor::Shutdown;
16
+ use serde_json::{Value, json};
17
+
18
+ const UUID: &str = "0c751d66-8f74-429d-a604-b29855d36c41";
19
+
20
+ fn tmpdir(name: &str) -> PathBuf {
21
+ let d = std::env::temp_dir().join(format!(
22
+ "fpai-oc-{}-{}-{}",
23
+ name,
24
+ std::process::id(),
25
+ SystemTime::now()
26
+ .duration_since(UNIX_EPOCH)
27
+ .unwrap()
28
+ .as_nanos()
29
+ ));
30
+ fs::create_dir_all(&d).unwrap();
31
+ d
32
+ }
33
+
34
+ fn ctx() -> Ctx {
35
+ Ctx {
36
+ session_id: UUID.into(),
37
+ agent_id: "openclaw-main".into(),
38
+ environment: "local".into(),
39
+ }
40
+ }
41
+
42
+ // ── record builders, mirroring the capture field for field ───────────────
43
+
44
+ fn session_header(ts: &str) -> String {
45
+ json!({"type":"session","version":3,"id":UUID,"timestamp":ts,
46
+ "cwd":"/root/.openclaw/workspace"})
47
+ .to_string()
48
+ }
49
+
50
+ fn model_change(ts: &str, model: &str) -> String {
51
+ json!({"type":"model_change","id":"aeabf817","parentId":null,"timestamp":ts,
52
+ "provider":"aikin","modelId":model})
53
+ .to_string()
54
+ }
55
+
56
+ fn user_prompt(ts: &str, text: &str) -> String {
57
+ json!({"type":"message","id":"95bd1c5a","parentId":"cafc9e4c","timestamp":ts,
58
+ "message":{"role":"user","content":[{"type":"text","text":text}],
59
+ "timestamp":1785744100097u64}})
60
+ .to_string()
61
+ }
62
+
63
+ fn assistant_text(ts: &str, text: &str) -> String {
64
+ json!({"type":"message","id":"c1770cd4","parentId":"22dfa2b0","timestamp":ts,
65
+ "message":{"role":"assistant","content":[{"type":"text","text":text}],
66
+ "api":"openai-completions","provider":"aikin","model":"claude-sonnet-4-6",
67
+ "usage":{"input":11,"output":22,"cacheRead":0,"cacheWrite":0,"totalTokens":33,
68
+ "cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},
69
+ "stopReason":"stop","timestamp":1785744339885u64,
70
+ "responseId":"chatcmpl-f0ca3642-a5c8-42ad-bda4-8df7dd16ce3e"}})
71
+ .to_string()
72
+ }
73
+
74
+ fn assistant_tool_call(ts: &str, call_id: &str, name: &str) -> String {
75
+ json!({"type":"message","id":"58002e1a","parentId":"b180b92b","timestamp":ts,
76
+ "message":{"role":"assistant","content":[
77
+ {"type":"toolCall","id":call_id,"name":name,
78
+ "arguments":{"command":"wc -l /work/README.md"},
79
+ "partialArgs":"{\"command\": \"wc -l /work/README.md\"}"}],
80
+ "api":"openai-completions","provider":"aikin","model":"claude-sonnet-4-6",
81
+ "usage":{"input":7,"output":13,"cacheRead":0,"cacheWrite":0,"totalTokens":20,
82
+ "cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},
83
+ "stopReason":"toolUse","timestamp":1785744336347u64,
84
+ "responseId":"chatcmpl-3536f98e-9ba4-40f5-9819-3ca070850abf"}})
85
+ .to_string()
86
+ }
87
+
88
+ fn tool_result(ts: &str, call_id: &str, name: &str, out: &str, is_error: bool) -> String {
89
+ json!({"type":"message","id":"22dfa2b0","parentId":"58002e1a","timestamp":ts,
90
+ "message":{"role":"toolResult","toolCallId":call_id,"toolName":name,
91
+ "content":[{"type":"text","text":out}],
92
+ "details":{"status":"completed","exitCode":0,"durationMs":10,
93
+ "aggregated":out,"cwd":"/root/.openclaw/workspace"},
94
+ "isError":is_error,"timestamp":1785744339880u64}})
95
+ .to_string()
96
+ }
97
+
98
+ // Copied byte for byte out of the capture. The builders above are only useful
99
+ // if they describe the same records the real agent writes, and a hand-written
100
+ // json! literal can drift from the format without anything noticing.
101
+ const REAL_ASSISTANT_TWO_CALLS: &str = r#"{"type":"message","id":"9dcb612e","parentId":"95bd1c5a","timestamp":"2026-08-03T08:01:44.024Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"tooluse_7pfybztVIIqiHrh9doZ0aY","name":"exec","arguments":{"command":"ls /work"},"partialArgs":"{\"command\": \"ls /work\"}"},{"type":"toolCall","id":"tooluse_qjN8Jtj5TN3aJsCncAGmN7","name":"read","arguments":{"path":"/work/README.md","limit":20},"partialArgs":"{\"path\": \"/work/README.md\", \"limit\": 20}"}],"api":"openai-completions","provider":"aikin","model":"claude-sonnet-4-6","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1785744100111,"responseId":"chatcmpl-fb143f6c-794d-4514-8e98-6a7407be9be2"}}"#;
102
+
103
+ const REAL_RESULT_WITH_DETAILS: &str = r#"{"type":"message","id":"7b87bad5","parentId":"9dcb612e","timestamp":"2026-08-03T08:01:44.179Z","message":{"role":"toolResult","toolCallId":"tooluse_7pfybztVIIqiHrh9doZ0aY","toolName":"exec","content":[{"type":"text","text":"README.md\na.txt"}],"details":{"status":"completed","exitCode":0,"durationMs":9,"aggregated":"README.md\na.txt","cwd":"/root/.openclaw/workspace"},"isError":false,"timestamp":1785744104177}}"#;
104
+
105
+ // The `read` result in the capture carries NO `details` block at all.
106
+ const REAL_RESULT_WITHOUT_DETAILS: &str = r##"{"type":"message","id":"fe9653e8","parentId":"7b87bad5","timestamp":"2026-08-03T08:01:44.181Z","message":{"role":"toolResult","toolCallId":"tooluse_qjN8Jtj5TN3aJsCncAGmN7","toolName":"read","content":[{"type":"text","text":"# Demo Project\nThis is the README for the probe test.\n"}],"isError":false,"timestamp":1785744104180}}"##;
107
+
108
+ const REAL_CUSTOM_TRAILER: &str = r#"{"type":"custom","customType":"openclaw:bootstrap-context:full","data":{"timestamp":1785744342907,"runId":"875db66f-36d8-4270-8b71-14955caadf00","sessionId":"0c751d66-8f74-429d-a604-b29855d36c41"},"id":"f11d1d2e","parentId":"c1770cd4","timestamp":"2026-08-03T08:05:42.907Z"}"#;
109
+
110
+ // ── transform ────────────────────────────────────────────────────────────
111
+
112
+ #[test]
113
+ fn a_millisecond_timestamp_is_padded_to_microseconds() {
114
+ // OpenClaw writes milliseconds; the hook source emits microseconds. Both
115
+ // streams share a session timeline, so they must sort against each other.
116
+ assert_eq!(
117
+ transform::with_index("2026-08-03T08:01:44.024Z", 0).unwrap(),
118
+ "2026-08-03T08:01:44.024000Z"
119
+ );
120
+ // The index offset orders several events derived from ONE record.
121
+ assert_eq!(
122
+ transform::with_index("2026-08-03T08:01:44.024Z", 1).unwrap(),
123
+ "2026-08-03T08:01:44.024001Z"
124
+ );
125
+ }
126
+
127
+ #[test]
128
+ fn the_index_offset_saturates_inside_its_second() {
129
+ // Carrying into the next second could reorder an event past a genuinely
130
+ // later record, which is worse than the tie it is fixing.
131
+ let s = transform::with_index("2026-08-03T08:01:44.999999Z", 999).unwrap();
132
+ assert_eq!(s, "2026-08-03T08:01:44.999999Z");
133
+ }
134
+
135
+ #[test]
136
+ fn the_record_level_clock_wins_over_the_inner_epoch_millis() {
137
+ // Every record carries two clocks. On this REAL assistant record the outer
138
+ // one reads 08:01:44.024 and the inner `message.timestamp` (1785744100111)
139
+ // reads 08:01:40.111 — the moment the request was issued, 3.9s earlier.
140
+ // Taking the inner one here and the outer one on the tool results would
141
+ // reorder the session by seconds.
142
+ let mut st = TailState::default();
143
+ let (ts, ev) = transform::transform_line(REAL_ASSISTANT_TWO_CALLS, &ctx(), 0, &mut st);
144
+ assert_eq!(ts.as_deref(), Some("2026-08-03T08:01:44.024Z"));
145
+ assert_eq!(ev[0]["timestamp"], "2026-08-03T08:01:44.024000Z");
146
+ assert!(
147
+ !ev[0]["timestamp"].as_str().unwrap().contains("08:01:40"),
148
+ "the inner request clock must never become the event time"
149
+ );
150
+ }
151
+
152
+ #[test]
153
+ fn a_model_change_record_supplies_the_model_for_the_very_first_prompt() {
154
+ // OpenClaw announces its model in a record of its own, written before the
155
+ // first prompt. That is why this source needs no `seed_state` — and it is
156
+ // the only thing keeping the opening `model_request` row, the row most
157
+ // likely to be looked at, from rendering with no model at all.
158
+ let mut st = TailState::default();
159
+ let c = ctx();
160
+ let (_, none) = transform::transform_line(
161
+ &model_change("2026-08-03T08:01:39.826Z", "claude-sonnet-4-6"),
162
+ &c,
163
+ 0,
164
+ &mut st,
165
+ );
166
+ assert!(none.is_empty(), "a model_change record emits no event");
167
+
168
+ let (_, ev) = transform::transform_line(
169
+ &user_prompt("2026-08-03T08:01:40.102Z", "List the files in /work"),
170
+ &c,
171
+ 200,
172
+ &mut st,
173
+ );
174
+ assert_eq!(ev[0]["type"], "model_request");
175
+ assert_eq!(ev[0]["model"], "claude-sonnet-4-6");
176
+ assert_eq!(ev[0]["messages"][0]["content"], "List the files in /work");
177
+ }
178
+
179
+ #[test]
180
+ fn the_three_message_roles_each_produce_their_own_event_type() {
181
+ // OpenClaw has a THIRD role: `toolResult` is its own record rather than a
182
+ // block inside a user turn. A two-role reader silently drops every tool
183
+ // result in every session.
184
+ let mut st = TailState::default();
185
+ let c = ctx();
186
+ let roles = [
187
+ (
188
+ user_prompt("2026-08-03T08:01:40.102Z", "hi"),
189
+ "model_request",
190
+ ),
191
+ (
192
+ assistant_text("2026-08-03T08:05:42.900Z", "done"),
193
+ "model_response",
194
+ ),
195
+ (
196
+ tool_result("2026-08-03T08:05:39.882Z", "tooluse_z", "exec", "2", false),
197
+ "tool_result",
198
+ ),
199
+ ];
200
+ for (line, want) in roles {
201
+ let (_, ev) = transform::transform_line(&line, &c, 0, &mut st);
202
+ assert_eq!(ev.len(), 1, "{want}");
203
+ assert_eq!(ev[0]["type"], want);
204
+ }
205
+ }
206
+
207
+ #[test]
208
+ fn a_tool_call_is_remembered_so_a_result_that_names_no_tool_is_not_a_blank_row() {
209
+ // The server builds a result row's summary from the tool name alone. A
210
+ // result missing `toolName` and with nothing carried from the call is a
211
+ // blank row in the product.
212
+ let mut st = TailState::default();
213
+ let c = ctx();
214
+ let (_, calls) = transform::transform_line(
215
+ &assistant_tool_call("2026-08-03T08:05:39.743Z", "tooluse_zo9X", "exec"),
216
+ &c,
217
+ 100,
218
+ &mut st,
219
+ );
220
+ assert_eq!(calls[0]["type"], "tool_use");
221
+ assert_eq!(calls[0]["tool_name"], "exec");
222
+ assert_eq!(calls[0]["tool_call_id"], "tooluse_zo9X");
223
+ assert_eq!(calls[0]["input"]["command"], "wc -l /work/README.md");
224
+
225
+ let nameless = json!({"type":"message","id":"x","timestamp":"2026-08-03T08:05:39.882Z",
226
+ "message":{"role":"toolResult","toolCallId":"tooluse_zo9X",
227
+ "content":[{"type":"text","text":"2 /work/README.md"}],"isError":false}})
228
+ .to_string();
229
+ let (_, results) = transform::transform_line(&nameless, &c, 200, &mut st);
230
+ assert_eq!(
231
+ results[0]["tool_name"], "exec",
232
+ "the name must survive from the call"
233
+ );
234
+ assert_eq!(results[0]["output"], "2 /work/README.md");
235
+ }
236
+
237
+ #[test]
238
+ fn a_result_that_names_its_own_tool_is_trusted_over_the_carried_name() {
239
+ // Ids pair exactly, so a carried name is normally the same answer. Where
240
+ // they disagree the record on disk is the fact and the carried value is an
241
+ // inference, so the record wins.
242
+ let mut st = TailState::default();
243
+ let c = ctx();
244
+ transform::transform_line(
245
+ &assistant_tool_call("2026-08-03T08:05:39.743Z", "tooluse_zo9X", "stale"),
246
+ &c,
247
+ 0,
248
+ &mut st,
249
+ );
250
+ let (_, ev) = transform::transform_line(
251
+ &tool_result(
252
+ "2026-08-03T08:05:39.882Z",
253
+ "tooluse_zo9X",
254
+ "exec",
255
+ "2",
256
+ false,
257
+ ),
258
+ &c,
259
+ 50,
260
+ &mut st,
261
+ );
262
+ assert_eq!(ev[0]["tool_name"], "exec");
263
+ }
264
+
265
+ #[test]
266
+ fn tool_calls_and_results_pair_on_the_id_openclaw_writes() {
267
+ // `toolCall.id` == `toolResult.toolCallId`, verbatim from the capture. The
268
+ // whole call/result pairing in the product hangs off this equality.
269
+ let mut st = TailState::default();
270
+ let c = ctx();
271
+ let (_, calls) = transform::transform_line(REAL_ASSISTANT_TWO_CALLS, &c, 0, &mut st);
272
+ let (_, r1) = transform::transform_line(REAL_RESULT_WITH_DETAILS, &c, 900, &mut st);
273
+ let (_, r2) = transform::transform_line(REAL_RESULT_WITHOUT_DETAILS, &c, 1200, &mut st);
274
+
275
+ assert_eq!(calls[0]["tool_call_id"], r1[0]["tool_call_id"]);
276
+ assert_eq!(calls[1]["tool_call_id"], r2[0]["tool_call_id"]);
277
+ assert_eq!(calls[0]["tool_name"], "exec");
278
+ assert_eq!(calls[1]["tool_name"], "read");
279
+ assert_eq!(r1[0]["tool_name"], "exec");
280
+ assert_eq!(r2[0]["tool_name"], "read");
281
+ }
282
+
283
+ #[test]
284
+ fn one_assistant_record_carrying_two_tool_calls_keeps_them_in_order() {
285
+ // Both calls share the record's millisecond, and the server sorts by
286
+ // `(ts, random id)`. Without the index offset the session appears to read
287
+ // the file before it listed the directory.
288
+ let mut st = TailState::default();
289
+ let (_, ev) = transform::transform_line(REAL_ASSISTANT_TWO_CALLS, &ctx(), 0, &mut st);
290
+ assert_eq!(ev.len(), 2);
291
+ assert_eq!(ev[0]["timestamp"], "2026-08-03T08:01:44.024000Z");
292
+ assert_eq!(ev[1]["timestamp"], "2026-08-03T08:01:44.024001Z");
293
+ assert_eq!(ev[1]["openclaw_block_index"], 1);
294
+ assert!(
295
+ ev[0].get("openclaw_block_index").is_none(),
296
+ "the first block keeps the record's exact time and needs no marker"
297
+ );
298
+ }
299
+
300
+ #[test]
301
+ fn a_tool_result_without_a_details_block_still_ships() {
302
+ // The capture's `read` result has no `details` at all while its `exec`
303
+ // results do. Requiring it would silently drop every non-shell result.
304
+ let mut st = TailState::default();
305
+ let c = ctx();
306
+ let (_, with) = transform::transform_line(REAL_RESULT_WITH_DETAILS, &c, 0, &mut st);
307
+ let (_, without) = transform::transform_line(REAL_RESULT_WITHOUT_DETAILS, &c, 300, &mut st);
308
+
309
+ assert_eq!(with[0]["type"], "tool_result");
310
+ assert_eq!(with[0]["duration_ms"], 9.0);
311
+ assert_eq!(with[0]["openclaw_exit_code"], 0);
312
+ assert_eq!(with[0]["openclaw_tool_status"], "completed");
313
+
314
+ assert_eq!(without[0]["type"], "tool_result");
315
+ assert!(without[0].get("duration_ms").is_none());
316
+ assert!(
317
+ without[0]["output"]
318
+ .as_str()
319
+ .unwrap()
320
+ .contains("Demo Project"),
321
+ "the output must survive the missing details block"
322
+ );
323
+ }
324
+
325
+ #[test]
326
+ fn an_errored_tool_result_carries_a_non_empty_error() {
327
+ // The server's `is_error` is a truthiness check, so an empty string would
328
+ // render a failed tool call as a success.
329
+ let mut st = TailState::default();
330
+ let c = ctx();
331
+ let (_, ev) = transform::transform_line(
332
+ &tool_result(
333
+ "2026-08-03T08:05:39.882Z",
334
+ "tooluse_z",
335
+ "exec",
336
+ "wc: /work/nope: No such file",
337
+ true,
338
+ ),
339
+ &c,
340
+ 0,
341
+ &mut st,
342
+ );
343
+ assert_eq!(ev[0]["error_type"], "openclaw_tool_error");
344
+ assert!(!ev[0]["error"].as_str().unwrap().is_empty());
345
+
346
+ // …and an error with no message at all still reads as a failure.
347
+ let silent = json!({"type":"message","id":"x","timestamp":"2026-08-03T08:05:39.882Z",
348
+ "message":{"role":"toolResult","toolCallId":"t","toolName":"exec",
349
+ "content":[],"isError":true}})
350
+ .to_string();
351
+ let (_, ev) = transform::transform_line(&silent, &c, 10, &mut st);
352
+ assert_eq!(ev[0]["error_type"], "openclaw_tool_error");
353
+ assert!(!ev[0]["error"].as_str().unwrap().trim().is_empty());
354
+ }
355
+
356
+ #[test]
357
+ fn token_usage_is_attributed_once_per_assistant_record() {
358
+ // A record yielding two events must report its tokens once, or every
359
+ // multi-call turn doubles the session's totals.
360
+ let mut st = TailState::default();
361
+ let (_, ev) = transform::transform_line(
362
+ &assistant_tool_call("2026-08-03T08:05:39.743Z", "t1", "exec"),
363
+ &ctx(),
364
+ 0,
365
+ &mut st,
366
+ );
367
+ assert_eq!(ev[0]["input_tokens"], 7);
368
+ assert_eq!(ev[0]["output_tokens"], 13);
369
+ assert_eq!(ev[0]["openclaw_usage"]["totalTokens"], 20);
370
+
371
+ let mut st = TailState::default();
372
+ let (_, two) = transform::transform_line(REAL_ASSISTANT_TWO_CALLS, &ctx(), 0, &mut st);
373
+ assert!(two[0].get("openclaw_usage").is_some());
374
+ assert!(
375
+ two[1].get("openclaw_usage").is_none(),
376
+ "usage belongs to the record, not to each block of it"
377
+ );
378
+ }
379
+
380
+ #[test]
381
+ fn harness_records_advance_the_clock_without_emitting_events() {
382
+ // `session`, `thinking_level_change` and `custom` describe the harness. The
383
+ // capture ENDS on a `custom` record, so a reader that skipped them outright
384
+ // would leave `agent_end` stuck 4 seconds before the session really ended.
385
+ let mut st = TailState::default();
386
+ let c = ctx();
387
+ for line in [
388
+ session_header("2026-08-03T08:01:39.794Z"),
389
+ r#"{"type":"thinking_level_change","id":"4f980063","parentId":"aeabf817","timestamp":"2026-08-03T08:01:39.826Z","thinkingLevel":"off"}"#.to_string(),
390
+ REAL_CUSTOM_TRAILER.to_string(),
391
+ ] {
392
+ let (ts, ev) = transform::transform_line(&line, &c, 0, &mut st);
393
+ assert!(ts.is_some(), "the record must still advance the clock");
394
+ assert!(ev.is_empty(), "but emit nothing: {line}");
395
+ }
396
+ }
397
+
398
+ #[test]
399
+ fn an_unparseable_line_is_skipped_without_advancing_the_clock() {
400
+ let mut st = TailState::default();
401
+ let (ts, ev) = transform::transform_line("{not json", &ctx(), 0, &mut st);
402
+ assert!(ts.is_none());
403
+ assert!(ev.is_empty());
404
+ }
405
+
406
+ #[test]
407
+ fn two_identical_records_at_different_offsets_produce_different_events() {
408
+ // The offset is the dedup discriminator. Without it the server collapses a
409
+ // genuinely repeated prompt into one row.
410
+ let mut st = TailState::default();
411
+ let c = ctx();
412
+ let line = user_prompt("2026-08-03T08:01:40.102Z", "same question");
413
+ let (_, a) = transform::transform_line(&line, &c, 10, &mut st);
414
+ let (_, b) = transform::transform_line(&line, &c, 999, &mut st);
415
+ assert_ne!(a[0]["openclaw_line_offset"], b[0]["openclaw_line_offset"]);
416
+ assert_eq!(
417
+ a[0]["timestamp"], b[0]["timestamp"],
418
+ "same record, same time"
419
+ );
420
+ }
421
+
422
+ #[test]
423
+ fn the_redundant_mirrors_of_the_payload_are_not_shipped() {
424
+ // `partialArgs` re-encodes `arguments` as a JSON string and
425
+ // `details.aggregated` copies the result text. Shipping either doubles the
426
+ // payload to say the same thing twice, and this source's whole cost
427
+ // argument is bytes per session.
428
+ let mut st = TailState::default();
429
+ let c = ctx();
430
+ let (_, calls) = transform::transform_line(REAL_ASSISTANT_TWO_CALLS, &c, 0, &mut st);
431
+ let rendered = serde_json::to_string(&calls).unwrap();
432
+ assert!(!rendered.contains("partialArgs"), "{rendered}");
433
+
434
+ let (_, res) = transform::transform_line(REAL_RESULT_WITH_DETAILS, &c, 500, &mut st);
435
+ let rendered = serde_json::to_string(&res[0]).unwrap();
436
+ assert!(!rendered.contains("aggregated"), "{rendered}");
437
+ }
438
+
439
+ #[test]
440
+ fn the_session_header_becomes_a_start_event_naming_its_goal_and_model() {
441
+ let header = vec![
442
+ session_header("2026-08-03T08:01:39.794Z"),
443
+ model_change("2026-08-03T08:01:39.826Z", "claude-sonnet-4-6"),
444
+ user_prompt("2026-08-03T08:01:40.102Z", "List the files in /work"),
445
+ ];
446
+ let (event, ts) = transform::agent_start(&header, &ctx(), 0).unwrap();
447
+ assert_eq!(ts.as_deref(), Some("2026-08-03T08:01:39.794Z"));
448
+ assert_eq!(event["type"], "agent_start");
449
+ assert_eq!(event["goal"], "List the files in /work");
450
+ assert_eq!(event["model"], "claude-sonnet-4-6");
451
+ assert_eq!(event["openclaw_provider"], "aikin");
452
+ assert_eq!(event["openclaw_session_version"], 3);
453
+ // Recorded for provenance only — never used to group sessions.
454
+ assert_eq!(event["openclaw_cwd"], "/root/.openclaw/workspace");
455
+ }
456
+
457
+ // ── format table ─────────────────────────────────────────────────────────
458
+
459
+ #[test]
460
+ fn discovery_never_claims_the_trajectory_sibling() {
461
+ // THE expensive mistake. The trajectory shares the transcript's stem and
462
+ // its `.jsonl` extension, sits in the same directory, and is 334,468 bytes
463
+ // against the transcript's 5,640 — 59x, for information already in the
464
+ // transcript. Claiming it is the difference between ~6 KB and ~340 KB per
465
+ // session shipped.
466
+ let is = openclaw::FORMAT.is_source_file;
467
+ let dir = format!("/root/.openclaw/agents/main/sessions/{UUID}");
468
+ assert!(
469
+ !is(Path::new(&format!("{dir}.trajectory.jsonl"))),
470
+ "the trajectory trace must never be tailed"
471
+ );
472
+ // And its pointer file.
473
+ assert!(!is(Path::new(&format!("{dir}.trajectory-path.json"))));
474
+ // The transcript itself, for contrast.
475
+ assert!(is(Path::new(&format!("{dir}.jsonl"))));
476
+ }
477
+
478
+ #[test]
479
+ fn discovery_claims_transcripts_and_nothing_else() {
480
+ let is = openclaw::FORMAT.is_source_file;
481
+ assert!(is(Path::new(&format!(
482
+ "/root/.openclaw/agents/main/sessions/{UUID}.jsonl"
483
+ ))));
484
+
485
+ // Rewritten in place on every interaction — a byte cursor would re-ship it
486
+ // forever.
487
+ assert!(!is(Path::new(
488
+ "/root/.openclaw/agents/main/sessions/sessions.json"
489
+ )));
490
+ // Content-addressed prompt blobs nested BELOW the sessions directory.
491
+ assert!(!is(Path::new(
492
+ "/root/.openclaw/agents/main/sessions/skills-prompts/sha256/ba/baee2cb204a2aeac788b61f02baa616efe3dc589240732fb38057ebec73a2a68.txt"
493
+ )));
494
+ // …and would still be rejected if such a blob were ever named `.jsonl`,
495
+ // because it does not sit directly in `sessions/`.
496
+ assert!(!is(Path::new(&format!(
497
+ "/root/.openclaw/agents/main/sessions/skills-prompts/sha256/ba/{UUID}.jsonl"
498
+ ))));
499
+ // The state database.
500
+ assert!(!is(Path::new("/root/.openclaw/state/openclaw.sqlite")));
501
+ }
502
+
503
+ #[test]
504
+ fn the_agent_id_comes_from_the_agents_path_component_not_the_workspace_cwd() {
505
+ // OpenClaw's `cwd` is the agent workspace — `/root/.openclaw/workspace`,
506
+ // identical for every session that agent ever runs — so grouping on it
507
+ // would file the whole install under one meaningless project. The header
508
+ // is passed in and deliberately ignored.
509
+ let header = vec![session_header("2026-08-03T08:01:39.794Z")];
510
+ let derived = (openclaw::FORMAT.agent_id_from_path)(
511
+ Path::new(&format!(
512
+ "/root/.openclaw/agents/main/sessions/{UUID}.jsonl"
513
+ )),
514
+ &header,
515
+ );
516
+ assert_eq!(derived.as_deref(), Some("openclaw-main"));
517
+
518
+ // A second agent on the same install is a different agent id, even though
519
+ // both report the same workspace cwd.
520
+ let other = (openclaw::FORMAT.agent_id_from_path)(
521
+ Path::new(&format!(
522
+ "/root/.openclaw/agents/research-bot/sessions/{UUID}.jsonl"
523
+ )),
524
+ &header,
525
+ );
526
+ assert_eq!(other.as_deref(), Some("openclaw-research-bot"));
527
+
528
+ // A layout without the `agents/<id>/sessions/` shape yields nothing, so the
529
+ // engine falls back to a plainly-generic id rather than a confidently wrong
530
+ // one.
531
+ assert!(
532
+ (openclaw::FORMAT.agent_id_from_path)(
533
+ Path::new(&format!("/somewhere/sessions/{UUID}.jsonl")),
534
+ &header
535
+ )
536
+ .is_none()
537
+ );
538
+ }
539
+
540
+ #[test]
541
+ fn the_session_id_is_the_filename_stem() {
542
+ assert_eq!(
543
+ (openclaw::FORMAT.session_id_from_path)(Path::new(&format!(
544
+ "/root/.openclaw/agents/main/sessions/{UUID}.jsonl"
545
+ )))
546
+ .as_deref(),
547
+ Some(UUID)
548
+ );
549
+ }
550
+
551
+ #[test]
552
+ fn openclaw_declares_itself_byte_tailable() {
553
+ // Proven append-only against the capture: `cmp` showed a byte-exact prefix
554
+ // across turns. If OpenClaw ever starts rewriting mid-file the way droid
555
+ // does, this is the switch — the engine already supports the other policy.
556
+ assert_eq!(openclaw::FORMAT.reread, RereadPolicy::ByteCursor);
557
+ }
558
+
559
+ // ── engine ───────────────────────────────────────────────────────────────
560
+
561
+ /// `base` stands in for the OpenClaw state directory, so the configured root is
562
+ /// `base/agents` — exactly what `default_roots()` resolves to. The layout is
563
+ /// load-bearing rather than cosmetic: the agent id is read off the
564
+ /// `agents/<agentId>/sessions/` path shape, so a flattened test tree would
565
+ /// silently exercise the fallback id instead of the real one.
566
+ fn spec(base: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
567
+ Spec {
568
+ format: openclaw::FORMAT,
569
+ roots: vec![base.join("agents")],
570
+ spool_dir: spool,
571
+ state_dir: state,
572
+ poll_interval: Duration::from_millis(200),
573
+ params: Params {
574
+ agent_id: openclaw::DEFAULT_AGENT_ID.into(),
575
+ environment: "local".into(),
576
+ end_idle_mins: 0, // end immediately, so the test need not wait
577
+ max_read_bytes: 8 * 1024 * 1024,
578
+ max_batch_bytes: 8 * 1024 * 1024,
579
+ since_days: None,
580
+ },
581
+ }
582
+ }
583
+
584
+ fn spooled(dir: &Path) -> Vec<Value> {
585
+ let mut out = Vec::new();
586
+ let Ok(rd) = fs::read_dir(dir) else {
587
+ return out;
588
+ };
589
+ for e in rd.filter_map(|e| e.ok()) {
590
+ let p = e.path();
591
+ if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
592
+ continue;
593
+ }
594
+ for l in fs::read_to_string(&p).unwrap().lines() {
595
+ if !l.trim().is_empty() {
596
+ out.push(serde_json::from_str(l).unwrap());
597
+ }
598
+ }
599
+ }
600
+ out
601
+ }
602
+
603
+ fn clear(dir: &Path) {
604
+ for e in fs::read_dir(dir).unwrap().filter_map(|e| e.ok()) {
605
+ fs::remove_file(e.path()).ok();
606
+ }
607
+ }
608
+
609
+ async fn run_briefly(s: Spec, ms: u64) {
610
+ let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
611
+ let _ = tokio::time::timeout(Duration::from_millis(ms), filetail::run(s, sd)).await;
612
+ }
613
+
614
+ /// A tree laid out exactly like OpenClaw's: `<state>/agents/<agentId>/sessions/`.
615
+ fn sessions_dir(base: &Path) -> PathBuf {
616
+ let d = base.join("agents").join("main").join("sessions");
617
+ fs::create_dir_all(&d).unwrap();
618
+ d
619
+ }
620
+
621
+ fn write_session(root: &Path, lines: &[String]) -> PathBuf {
622
+ let p = sessions_dir(root).join(format!("{UUID}.jsonl"));
623
+ fs::write(&p, lines.join("\n") + "\n").unwrap();
624
+ p
625
+ }
626
+
627
+ /// The capture's own opening turn, verbatim where it matters.
628
+ fn full_session() -> Vec<String> {
629
+ vec![
630
+ session_header("2026-08-03T08:01:39.794Z"),
631
+ model_change("2026-08-03T08:01:39.826Z", "claude-sonnet-4-6"),
632
+ user_prompt("2026-08-03T08:01:40.102Z", "List the files in /work"),
633
+ REAL_ASSISTANT_TWO_CALLS.to_string(),
634
+ REAL_RESULT_WITH_DETAILS.to_string(),
635
+ REAL_RESULT_WITHOUT_DETAILS.to_string(),
636
+ assistant_text("2026-08-03T08:01:48.937Z", "The title is Demo Project."),
637
+ REAL_CUSTOM_TRAILER.to_string(),
638
+ ]
639
+ }
640
+
641
+ #[tokio::test(flavor = "multi_thread")]
642
+ async fn a_session_produces_a_start_its_turns_and_an_end() {
643
+ let root = tmpdir("full-root");
644
+ let spool = tmpdir("full-spool");
645
+ let state = tmpdir("full-state");
646
+ write_session(&root, &full_session());
647
+
648
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
649
+
650
+ let ev = spooled(&spool);
651
+ let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
652
+ for want in [
653
+ "agent_start",
654
+ "model_request",
655
+ "model_response",
656
+ "tool_use",
657
+ "tool_result",
658
+ "agent_end",
659
+ ] {
660
+ assert!(types.contains(&want), "missing {want}: got {types:?}");
661
+ }
662
+ // The capture's opening turn packs BOTH tool calls into a SINGLE assistant
663
+ // record, and each is answered by a `toolResult` record of its own. A
664
+ // reader that emitted one event per record rather than one per content
665
+ // block would lose half the tool calls in the session.
666
+ assert_eq!(
667
+ types.iter().filter(|t| **t == "tool_use").count(),
668
+ 2,
669
+ "both calls in the one assistant record must ship: {types:?}"
670
+ );
671
+ assert_eq!(types.iter().filter(|t| **t == "tool_result").count(), 2);
672
+
673
+ for e in &ev {
674
+ assert_eq!(e["session_id"], UUID, "the stem is the session id");
675
+ assert_eq!(
676
+ e["agent_id"], "openclaw-main",
677
+ "grouped by agent, not by the workspace cwd"
678
+ );
679
+ }
680
+
681
+ // The trailing `custom` record is the last thing in the file, so the
682
+ // session ends when it really ended rather than at the last message.
683
+ let end = ev.iter().find(|e| e["type"] == "agent_end").unwrap();
684
+ assert!(
685
+ end["timestamp"]
686
+ .as_str()
687
+ .unwrap()
688
+ .starts_with("2026-08-03T08:05:42."),
689
+ "got {}",
690
+ end["timestamp"]
691
+ );
692
+
693
+ fs::remove_dir_all(&root).ok();
694
+ fs::remove_dir_all(&spool).ok();
695
+ fs::remove_dir_all(&state).ok();
696
+ }
697
+
698
+ #[tokio::test(flavor = "multi_thread")]
699
+ async fn the_trajectory_sibling_is_never_read_end_to_end() {
700
+ // The predicate is unit-tested above; this proves the engine's recursive
701
+ // walk agrees, because that walk is where the 59x actually gets paid.
702
+ let root = tmpdir("traj-root");
703
+ let spool = tmpdir("traj-spool");
704
+ let state = tmpdir("traj-state");
705
+
706
+ write_session(&root, &full_session());
707
+ let dir = sessions_dir(&root);
708
+ fs::write(
709
+ dir.join(format!("{UUID}.trajectory.jsonl")),
710
+ format!(
711
+ "{}\n",
712
+ json!({"traceSchema":"openclaw-trajectory","schemaVersion":1,
713
+ "traceId":UUID,"type":"session.started",
714
+ "ts":"2026-08-03T08:01:39.830Z","seq":1,
715
+ "data":{"marker":"TRAJECTORY-ONLY-STRING"}})
716
+ ),
717
+ )
718
+ .unwrap();
719
+ fs::write(
720
+ dir.join(format!("{UUID}.trajectory-path.json")),
721
+ r#"{"traceSchema":"openclaw-trajectory-pointer","schemaVersion":1}"#,
722
+ )
723
+ .unwrap();
724
+ fs::write(dir.join("sessions.json"), r#"{"agent:main:main":{}}"#).unwrap();
725
+
726
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
727
+
728
+ let ev = spooled(&spool);
729
+ assert!(
730
+ !ev.is_empty(),
731
+ "the transcript itself must still be shipped"
732
+ );
733
+ let all = serde_json::to_string(&ev).unwrap();
734
+ assert!(
735
+ !all.contains("TRAJECTORY-ONLY-STRING"),
736
+ "the trajectory trace leaked into the spool"
737
+ );
738
+ assert!(!all.contains("openclaw-trajectory"), "{all}");
739
+
740
+ fs::remove_dir_all(&root).ok();
741
+ fs::remove_dir_all(&spool).ok();
742
+ fs::remove_dir_all(&state).ok();
743
+ }
744
+
745
+ #[tokio::test(flavor = "multi_thread")]
746
+ async fn a_second_pass_ships_nothing_new() {
747
+ let root = tmpdir("resume-root");
748
+ let spool = tmpdir("resume-spool");
749
+ let state = tmpdir("resume-state");
750
+ write_session(&root, &full_session());
751
+
752
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
753
+ assert!(!spooled(&spool).is_empty());
754
+ clear(&spool);
755
+
756
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
757
+ assert!(
758
+ spooled(&spool).is_empty(),
759
+ "a resumed pass must ship nothing"
760
+ );
761
+
762
+ fs::remove_dir_all(&root).ok();
763
+ fs::remove_dir_all(&spool).ok();
764
+ fs::remove_dir_all(&state).ok();
765
+ }
766
+
767
+ #[tokio::test(flavor = "multi_thread")]
768
+ async fn appended_turns_are_picked_up_without_re_shipping_earlier_ones() {
769
+ // OpenClaw appends: a byte-exact prefix survives every turn, which is the
770
+ // whole basis for `RereadPolicy::ByteCursor`.
771
+ let root = tmpdir("append-root");
772
+ let spool = tmpdir("append-spool");
773
+ let state = tmpdir("append-state");
774
+ let path = write_session(&root, &full_session());
775
+
776
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
777
+ let first = spooled(&spool).len();
778
+ assert!(first > 0);
779
+ clear(&spool);
780
+
781
+ let mut body = fs::read_to_string(&path).unwrap();
782
+ body.push_str(&(user_prompt("2026-08-03T08:09:00.000Z", "second turn") + "\n"));
783
+ fs::write(&path, body).unwrap();
784
+
785
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
786
+ let ev = spooled(&spool);
787
+ assert!(
788
+ ev.iter()
789
+ .any(|e| e["messages"][0]["content"] == "second turn"),
790
+ "the appended turn must ship"
791
+ );
792
+ assert!(
793
+ ev.len() < first,
794
+ "earlier turns must not be re-shipped: {} vs {first}",
795
+ ev.len()
796
+ );
797
+ // The model still comes from the `model_change` record read on the FIRST
798
+ // pass, which is only true because it is carried in the persisted cursor.
799
+ let prompt = ev
800
+ .iter()
801
+ .find(|e| e["messages"][0]["content"] == "second turn")
802
+ .unwrap();
803
+ assert_eq!(prompt["model"], "claude-sonnet-4-6");
804
+
805
+ fs::remove_dir_all(&root).ok();
806
+ fs::remove_dir_all(&spool).ok();
807
+ fs::remove_dir_all(&state).ok();
808
+ }
809
+
810
+ #[tokio::test(flavor = "multi_thread")]
811
+ async fn a_partially_written_final_line_is_held_back_then_picked_up() {
812
+ // Half a JSON object parses as nothing, and consuming it would skip the
813
+ // record for good once it completes.
814
+ let root = tmpdir("partial-root");
815
+ let spool = tmpdir("partial-spool");
816
+ let state = tmpdir("partial-state");
817
+ let path = sessions_dir(&root).join(format!("{UUID}.jsonl"));
818
+
819
+ let head = [
820
+ session_header("2026-08-03T08:01:39.794Z"),
821
+ model_change("2026-08-03T08:01:39.826Z", "claude-sonnet-4-6"),
822
+ user_prompt("2026-08-03T08:01:40.102Z", "complete"),
823
+ ]
824
+ .join("\n");
825
+ fs::write(&path, format!("{head}\n{{\"type\":\"message\",\"time")).unwrap();
826
+
827
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
828
+ let n = spooled(&spool)
829
+ .iter()
830
+ .filter(|e| e["type"] == "model_request")
831
+ .count();
832
+ assert_eq!(n, 1, "only the complete records should ship");
833
+ clear(&spool);
834
+
835
+ fs::write(
836
+ &path,
837
+ format!(
838
+ "{head}\n{}\n",
839
+ user_prompt("2026-08-03T08:03:00.000Z", "finished")
840
+ ),
841
+ )
842
+ .unwrap();
843
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
844
+ assert!(
845
+ spooled(&spool)
846
+ .iter()
847
+ .any(|e| e["messages"][0]["content"] == "finished"),
848
+ "the completed record must ship on a later pass"
849
+ );
850
+
851
+ fs::remove_dir_all(&root).ok();
852
+ fs::remove_dir_all(&spool).ok();
853
+ fs::remove_dir_all(&state).ok();
854
+ }