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,975 @@
1
+ //! Codex source: transform correctness and engine behaviour.
2
+ //!
3
+ //! Record shapes are verbatim from the 13 real rollouts under
4
+ //! `~/.codex/sessions/` (Codex CLI 0.145.0 / 0.146.0) — 6,520 lines, from which
5
+ //! every claim about the format in these tests was measured.
6
+
7
+ use std::fs;
8
+ use std::path::{Path, PathBuf};
9
+ use std::sync::Arc;
10
+ use std::sync::atomic::AtomicBool;
11
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
+
13
+ use fpai_collect::cursor::TailState;
14
+ use fpai_collect::filetail::{self, Ctx, Params, RereadPolicy, Spec};
15
+ use fpai_collect::sources::codex::{self, transform};
16
+ use fpai_collect::sources::hooks::transform::{HookRow, agent_id as hook_agent_id};
17
+ use fpai_collect::supervisor::Shutdown;
18
+ use serde_json::{Value, json};
19
+
20
+ fn tmpdir(name: &str) -> PathBuf {
21
+ let d = std::env::temp_dir().join(format!(
22
+ "fpai-cx-{}-{}-{}",
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
+ const UUID: &str = "019fb217-14a2-7193-8ceb-504b318e00ad";
35
+ const ROLLOUT: &str = "rollout-2026-07-30T13-44-44-019fb217-14a2-7193-8ceb-504b318e00ad.jsonl";
36
+
37
+ fn ctx() -> Ctx {
38
+ Ctx {
39
+ session_id: UUID.into(),
40
+ agent_id: "codex-cli".into(),
41
+ environment: "local".into(),
42
+ }
43
+ }
44
+
45
+ // ── real record shapes, trimmed only of bulk (base_instructions etc.) ────────
46
+
47
+ fn session_meta(ts: &str, cwd: &str) -> String {
48
+ json!({"timestamp":ts,"type":"session_meta","payload":{
49
+ "session_id":UUID,"id":UUID,"timestamp":"2026-07-30T08:14:44.900Z","cwd":cwd,
50
+ "originator":"codex-tui","cli_version":"0.146.0","source":"cli","thread_source":"user",
51
+ "model_provider":"aikin","history_mode":"legacy",
52
+ "git":{"commit_hash":"1a1f9622357464eb465b9be03b0f415591866ff2","branch":"luv-479",
53
+ "repository_url":"git@github.com:FailproofAI/agenteye.git"}}})
54
+ .to_string()
55
+ }
56
+
57
+ fn turn_context(ts: &str, model: &str, cwd: &str) -> String {
58
+ json!({"timestamp":ts,"type":"turn_context","payload":{
59
+ "model":model,"cwd":cwd,"approval_policy":"on-request","sandbox_policy":"workspace-write",
60
+ "turn_id":"019fb1ce-11f7-7411-9a81-8064d135df51","timezone":"Asia/Kolkata"}})
61
+ .to_string()
62
+ }
63
+
64
+ fn user_message_item(ts: &str, texts: &[&str]) -> String {
65
+ let content: Vec<Value> = texts
66
+ .iter()
67
+ .map(|t| json!({"type":"input_text","text":t}))
68
+ .collect();
69
+ json!({"timestamp":ts,"type":"response_item","payload":{
70
+ "type":"message","role":"user","id":null,"content":content}})
71
+ .to_string()
72
+ }
73
+
74
+ fn user_message_event(ts: &str, text: &str) -> String {
75
+ json!({"timestamp":ts,"type":"event_msg","payload":{
76
+ "type":"user_message","message":text,"images":null,"audio":null,
77
+ "local_images":null,"local_audio":null,"text_elements":[]}})
78
+ .to_string()
79
+ }
80
+
81
+ fn assistant_message(ts: &str, text: &str) -> String {
82
+ json!({"timestamp":ts,"type":"response_item","payload":{
83
+ "type":"message","role":"assistant","phase":"final",
84
+ "content":[{"type":"output_text","text":text}]}})
85
+ .to_string()
86
+ }
87
+
88
+ fn custom_tool_call(ts: &str, call_id: &str, name: &str, input: &str) -> String {
89
+ json!({"timestamp":ts,"type":"response_item","payload":{
90
+ "type":"custom_tool_call","name":name,"call_id":call_id,"input":input,
91
+ "id":"ctc_0963a002d749d7f6016a6b080729608195b2a2bcf644068c66","status":"completed"}})
92
+ .to_string()
93
+ }
94
+
95
+ fn custom_tool_call_output(ts: &str, call_id: &str, out: &str) -> String {
96
+ json!({"timestamp":ts,"type":"response_item","payload":{
97
+ "type":"custom_tool_call_output","call_id":call_id,
98
+ "output":[{"type":"input_text","text":"Script completed\nWall time 0.1 seconds\nOutput:\n"},
99
+ {"type":"input_text","text":out}]}})
100
+ .to_string()
101
+ }
102
+
103
+ fn token_count(ts: &str, input_tokens: u64, output_tokens: u64) -> String {
104
+ json!({"timestamp":ts,"type":"event_msg","payload":{"type":"token_count","info":{
105
+ "last_token_usage":{"input_tokens":input_tokens,"cached_input_tokens":0,
106
+ "cache_write_input_tokens":17116,"output_tokens":output_tokens,
107
+ "reasoning_output_tokens":29,"total_tokens":input_tokens+output_tokens},
108
+ "model_context_window":272000}}})
109
+ .to_string()
110
+ }
111
+
112
+ fn one(line: &str, st: &mut TailState) -> (Option<String>, Vec<Value>) {
113
+ transform::transform_line(line, &ctx(), 0, st)
114
+ }
115
+
116
+ // ── timestamps ──────────────────────────────────────────────────────────────
117
+
118
+ #[test]
119
+ fn a_millisecond_timestamp_is_padded_to_six_digits() {
120
+ // Codex writes milliseconds on every one of the 6,520 lines measured; the
121
+ // hook source emits microseconds. Both streams share a session timeline, so
122
+ // they must sort against each other.
123
+ assert_eq!(
124
+ transform::with_index("2026-07-30T08:14:54.521Z", 0).unwrap(),
125
+ "2026-07-30T08:14:54.521000Z"
126
+ );
127
+ // The index offset orders several events sharing one timestamp.
128
+ assert_eq!(
129
+ transform::with_index("2026-07-30T08:14:54.521Z", 2).unwrap(),
130
+ "2026-07-30T08:14:54.521002Z"
131
+ );
132
+ // A timestamp with no fraction at all still gets the six digits, so the
133
+ // server never sees two shapes.
134
+ assert_eq!(
135
+ transform::with_index("2026-07-30T08:14:54Z", 0).unwrap(),
136
+ "2026-07-30T08:14:54.000000Z"
137
+ );
138
+ }
139
+
140
+ #[test]
141
+ fn the_index_offset_saturates_inside_its_second() {
142
+ // Carrying into the next second could reorder an event past a genuinely
143
+ // later line, which is worse than the tie it is fixing.
144
+ let s = transform::with_index("2026-07-30T08:14:54.999999Z", 999).unwrap();
145
+ assert_eq!(s, "2026-07-30T08:14:54.999999Z");
146
+ assert!(s.starts_with("2026-07-30T08:14:54."));
147
+ }
148
+
149
+ #[test]
150
+ fn every_emitted_event_carries_a_six_digit_microsecond_timestamp() {
151
+ // The server parses one timestamp shape. A line that slipped through with
152
+ // millisecond precision would sort against hook events incorrectly.
153
+ let mut st = TailState::default();
154
+ let c = ctx();
155
+ let lines = [
156
+ turn_context("2026-07-30T08:14:54.526Z", "gpt-5.6-sol", "/w/repo"),
157
+ user_message_item("2026-07-30T08:14:54.535Z", &["hi codex"]),
158
+ assistant_message("2026-07-30T08:14:58.715Z", "sure"),
159
+ custom_tool_call("2026-07-30T08:14:59.392Z", "call_1", "exec", "{}"),
160
+ custom_tool_call_output("2026-07-30T08:14:59.462Z", "call_1", "ok"),
161
+ token_count("2026-07-30T08:14:59.463Z", 17119, 149),
162
+ ];
163
+ let mut seen = 0;
164
+ for l in &lines {
165
+ let (_, evs) = transform::transform_line(l, &c, 0, &mut st);
166
+ for e in evs {
167
+ let ts = e["timestamp"].as_str().unwrap();
168
+ let frac = ts.rsplit_once('.').unwrap().1.trim_end_matches('Z');
169
+ assert_eq!(frac.len(), 6, "{ts} in {e}");
170
+ seen += 1;
171
+ }
172
+ }
173
+ assert!(seen >= 5, "expected content events, got {seen}");
174
+ // The end event shares that shape, and lands after content in the same ms.
175
+ let end = transform::agent_end(&c, "2026-07-30T08:14:59.463Z", 4096);
176
+ assert_eq!(end["timestamp"], "2026-07-30T08:14:59.463999Z");
177
+ assert_eq!(end["codex_block_index"], 999);
178
+ }
179
+
180
+ // ── conversation ────────────────────────────────────────────────────────────
181
+
182
+ #[test]
183
+ fn a_user_message_becomes_a_model_request_carrying_the_turn_context_model() {
184
+ // A message item names no model — Codex records it once per turn on a
185
+ // `turn_context` line. Without carrying it forward the row renders with no
186
+ // model at all, which is the whole summary the product shows for a request.
187
+ let mut st = TailState::default();
188
+ let (ts, ev) = one(
189
+ &turn_context("2026-07-30T08:14:54.526Z", "gpt-5.6-sol", "/w/repo"),
190
+ &mut st,
191
+ );
192
+ assert_eq!(ts.as_deref(), Some("2026-07-30T08:14:54.526Z"));
193
+ assert!(ev.is_empty(), "turn_context is state, not an event");
194
+
195
+ let (_, ev) = one(
196
+ &user_message_item("2026-07-30T08:14:54.535Z", &["hi codex can you login!"]),
197
+ &mut st,
198
+ );
199
+ assert_eq!(ev.len(), 1);
200
+ assert_eq!(ev[0]["type"], "model_request");
201
+ assert_eq!(ev[0]["model"], "gpt-5.6-sol");
202
+ assert_eq!(ev[0]["messages"][0]["role"], "user");
203
+ assert_eq!(ev[0]["messages"][0]["content"], "hi codex can you login!");
204
+ }
205
+
206
+ #[test]
207
+ fn a_multi_part_message_stays_one_request_with_one_entry_per_part() {
208
+ // Content is an array of parts of ONE message, not several messages — the
209
+ // largest on disk has 52 parts. Splitting them into 52 events would bury
210
+ // the turn they belong to; dropping all but the first would lose the
211
+ // prompt, since the AGENTS.md injection is part 1 and the ask is part 2.
212
+ let mut st = TailState::default();
213
+ let (_, ev) = one(
214
+ &user_message_item(
215
+ "2026-07-30T08:14:54.526Z",
216
+ &["# AGENTS.md instructions", "please review the diff"],
217
+ ),
218
+ &mut st,
219
+ );
220
+ assert_eq!(ev.len(), 1, "one message must stay one event");
221
+ let msgs = ev[0]["messages"].as_array().unwrap();
222
+ assert_eq!(msgs.len(), 2);
223
+ assert_eq!(msgs[1]["content"], "please review the diff");
224
+ }
225
+
226
+ #[test]
227
+ fn an_assistant_message_becomes_a_model_response() {
228
+ let mut st = TailState::default();
229
+ let (_, ev) = one(
230
+ &assistant_message("2026-07-30T08:14:58.715Z", "I’ll use the skill."),
231
+ &mut st,
232
+ );
233
+ assert_eq!(ev.len(), 1);
234
+ assert_eq!(ev[0]["type"], "model_response");
235
+ assert_eq!(ev[0]["role"], "assistant");
236
+ assert_eq!(ev[0]["content"], "I’ll use the skill.");
237
+ }
238
+
239
+ #[test]
240
+ fn the_duplicate_event_msg_conversation_records_are_not_emitted_a_second_time() {
241
+ // Codex writes every prompt and reply TWICE: once as a `response_item` and
242
+ // once as the UI-facing `event_msg`. Measured 127 `user_message` events
243
+ // against 145 user response items and 284 `agent_message` against 292
244
+ // assistant ones — the response-item stream is the superset, so this side
245
+ // is the one to drop. Emitting both doubles the whole conversation.
246
+ let mut st = TailState::default();
247
+ let (ts, ev) = one(
248
+ &user_message_event("2026-07-30T08:14:54.535Z", "hi codex can you login!"),
249
+ &mut st,
250
+ );
251
+ assert!(ev.is_empty(), "got {ev:?}");
252
+ assert!(ts.is_some(), "the line is still real activity");
253
+
254
+ let agent_msg = json!({"timestamp":"2026-07-30T08:14:58.715Z","type":"event_msg",
255
+ "payload":{"type":"agent_message","message":"I’ll use the skill.","phase":"final"}})
256
+ .to_string();
257
+ assert!(one(&agent_msg, &mut st).1.is_empty());
258
+ }
259
+
260
+ // ── tools ───────────────────────────────────────────────────────────────────
261
+
262
+ #[test]
263
+ fn a_custom_tool_call_is_remembered_so_its_result_is_not_a_blank_row() {
264
+ // `custom_tool_call_output` has NO `name` key at all, and it is the
265
+ // dominant result shape on this machine (1,130 of 1,263 tool calls). This
266
+ // is the whole reason TailState carries pending tools.
267
+ let mut st = TailState::default();
268
+ let c = ctx();
269
+ let (_, calls) = transform::transform_line(
270
+ &custom_tool_call(
271
+ "2026-07-30T08:14:59.392Z",
272
+ "call_zUMrvZYyXV042pXRdtNBt0Au",
273
+ "exec",
274
+ r#"const r = await tools.exec_command({"cmd":"agenteye login","workdir":"/w"})"#,
275
+ ),
276
+ &c,
277
+ 100,
278
+ &mut st,
279
+ );
280
+ assert_eq!(calls[0]["type"], "tool_use");
281
+ assert_eq!(calls[0]["tool_name"], "exec");
282
+ assert_eq!(calls[0]["tool_call_id"], "call_zUMrvZYyXV042pXRdtNBt0Au");
283
+
284
+ let (_, results) = transform::transform_line(
285
+ &custom_tool_call_output(
286
+ "2026-07-30T08:14:59.462Z",
287
+ "call_zUMrvZYyXV042pXRdtNBt0Au",
288
+ "signed in",
289
+ ),
290
+ &c,
291
+ 200,
292
+ &mut st,
293
+ );
294
+ assert_eq!(results[0]["type"], "tool_result");
295
+ assert_eq!(
296
+ results[0]["tool_name"], "exec",
297
+ "the name must survive from the call"
298
+ );
299
+ // The array output is flattened to text: Codex splits it into a wall-time
300
+ // preamble block and the real output block.
301
+ let out = results[0]["output"].as_str().unwrap();
302
+ assert!(out.contains("Wall time"), "{out}");
303
+ assert!(out.ends_with("signed in"), "{out}");
304
+ }
305
+
306
+ #[test]
307
+ fn a_function_call_result_also_carries_the_name_from_its_call() {
308
+ // `function_call_output.name` is literally `null` on disk, so the payload
309
+ // cannot supply it either.
310
+ let mut st = TailState::default();
311
+ let c = ctx();
312
+ let call = json!({"timestamp":"2026-07-30T06:55:38.017Z","type":"response_item","payload":{
313
+ "type":"function_call","id":"fc_0f953c1fbd9acac4016a6af56e396481959973ed2ef18bce95",
314
+ "name":"wait","arguments":"{\"cell_id\":\"2\",\"yield_time_ms\":30000,\"max_tokens\":30000}",
315
+ "call_id":"call_uJ7ajxc9wWEJMSflYvLev9Y5"}})
316
+ .to_string();
317
+ let (_, calls) = transform::transform_line(&call, &c, 10, &mut st);
318
+ assert_eq!(calls[0]["tool_name"], "wait");
319
+ // `arguments` is a JSON-encoded STRING; it must land as structured input.
320
+ assert_eq!(calls[0]["input"]["cell_id"], "2");
321
+ assert_eq!(calls[0]["input"]["yield_time_ms"], 30000);
322
+
323
+ let out = json!({"timestamp":"2026-07-30T06:55:38.059Z","type":"response_item","payload":{
324
+ "type":"function_call_output","call_id":"call_uJ7ajxc9wWEJMSflYvLev9Y5","name":null,
325
+ "output":[{"type":"input_text","text":"Script completed"}]}})
326
+ .to_string();
327
+ let (_, results) = transform::transform_line(&out, &c, 20, &mut st);
328
+ assert_eq!(results[0]["tool_name"], "wait");
329
+ assert_eq!(results[0]["tool_call_id"], "call_uJ7ajxc9wWEJMSflYvLev9Y5");
330
+ }
331
+
332
+ #[test]
333
+ fn a_result_whose_call_was_never_seen_still_ships_rather_than_being_dropped() {
334
+ // A tail that starts mid-session, or a session compacted past its own
335
+ // calls, has results with nothing to name them. A named-only rule would
336
+ // silently drop the output; shipping it unnamed keeps the transcript whole.
337
+ let mut st = TailState::default();
338
+ let (_, ev) = one(
339
+ &custom_tool_call_output("2026-07-30T08:14:59.462Z", "call_orphan", "output"),
340
+ &mut st,
341
+ );
342
+ assert_eq!(ev.len(), 1);
343
+ assert_eq!(ev[0]["type"], "tool_result");
344
+ assert_eq!(ev[0]["tool_call_id"], "call_orphan");
345
+ assert!(ev[0].get("tool_name").is_none());
346
+ }
347
+
348
+ #[test]
349
+ fn a_javascript_tool_input_still_yields_structured_arguments() {
350
+ // `custom_tool_call.input` is a JS snippet, not JSON — and the model quotes
351
+ // keys inconsistently inside one object. Measured over the 1,130 custom
352
+ // calls on this machine: 0 parse whole, and only 58 lift without repairing
353
+ // bare keys. Without this the other 931 render as an opaque blob.
354
+ let mut st = TailState::default();
355
+ let (_, ev) = one(
356
+ &custom_tool_call(
357
+ "2026-07-30T08:14:59.392Z",
358
+ "c1",
359
+ "exec",
360
+ r#"const r = await tools.exec_command({cmd:"sed -n '1,260p' SKILL.md","workdir":"/w/cli"});text(r.output);"#,
361
+ ),
362
+ &mut st,
363
+ );
364
+ assert_eq!(ev[0]["input"]["cmd"], "sed -n '1,260p' SKILL.md");
365
+ assert_eq!(ev[0]["input"]["workdir"], "/w/cli");
366
+ }
367
+
368
+ #[test]
369
+ fn a_destructuring_wrapper_does_not_hide_the_real_arguments() {
370
+ // `let{output,...rest}=await tools.exec_command({...})` puts the arguments
371
+ // in the SECOND brace group. Taking the first would report the
372
+ // destructuring pattern as the tool's input.
373
+ let mut st = TailState::default();
374
+ let (_, ev) = one(
375
+ &custom_tool_call(
376
+ "2026-07-30T08:14:59.392Z",
377
+ "c2",
378
+ "exec",
379
+ r#"let{output,...rest}=await tools.exec_command({"cmd":"pwd","workdir":"/tmp"});text(rest)"#,
380
+ ),
381
+ &mut st,
382
+ );
383
+ assert_eq!(ev[0]["input"]["cmd"], "pwd");
384
+ assert_eq!(ev[0]["input"]["workdir"], "/tmp");
385
+ }
386
+
387
+ #[test]
388
+ fn braces_and_colons_inside_a_quoted_value_are_not_mistaken_for_structure() {
389
+ // A shell command routinely contains `{`, `}` and `:`. Repairing keys
390
+ // inside a string literal would corrupt the command actually run.
391
+ let mut st = TailState::default();
392
+ let (_, ev) = one(
393
+ &custom_tool_call(
394
+ "2026-07-30T08:14:59.392Z",
395
+ "c3",
396
+ "exec",
397
+ r#"const r = await tools.exec_command({cmd:"awk '{print $1: \"x\"}' f","workdir":"/w"})"#,
398
+ ),
399
+ &mut st,
400
+ );
401
+ assert_eq!(ev[0]["input"]["cmd"], r#"awk '{print $1: "x"}' f"#);
402
+ assert_eq!(ev[0]["input"]["workdir"], "/w");
403
+ }
404
+
405
+ #[test]
406
+ fn an_unparseable_tool_input_is_kept_verbatim_rather_than_dropped() {
407
+ // 141 of the custom calls on disk are apply_patch scripts whose arguments
408
+ // never parse. Losing the text would leave the row with no input at all.
409
+ let mut st = TailState::default();
410
+ let script = "const patch = \"*** Begin Patch\\n*** Update File: a.rs\";";
411
+ let (_, ev) = one(
412
+ &custom_tool_call("2026-07-30T08:14:59.392Z", "c4", "exec", script),
413
+ &mut st,
414
+ );
415
+ assert_eq!(ev[0]["input"]["raw"], script);
416
+ }
417
+
418
+ #[test]
419
+ fn a_token_count_line_becomes_a_model_response_carrying_usage() {
420
+ // Codex bills a turn on its own line, in the same millisecond as the item
421
+ // it bills. Attaching it to that item would need cross-line buffering,
422
+ // which is exactly what a re-read cannot reproduce.
423
+ let mut st = TailState::default();
424
+ let (_, ev) = one(
425
+ &turn_context("2026-07-30T08:14:54.526Z", "gpt-5.6-sol", "/w"),
426
+ &mut st,
427
+ );
428
+ assert!(ev.is_empty());
429
+ let (_, ev) = one(
430
+ &token_count("2026-07-30T08:14:59.463Z", 17119, 149),
431
+ &mut st,
432
+ );
433
+ assert_eq!(ev.len(), 1);
434
+ assert_eq!(ev[0]["type"], "model_response");
435
+ assert_eq!(ev[0]["input_tokens"], 17119);
436
+ assert_eq!(ev[0]["output_tokens"], 149);
437
+ assert_eq!(ev[0]["model"], "gpt-5.6-sol");
438
+ assert_eq!(ev[0]["codex_usage"]["total_tokens"], 17268);
439
+ }
440
+
441
+ // ── skipping, purity, dedup ─────────────────────────────────────────────────
442
+
443
+ #[test]
444
+ fn an_unrecognised_record_is_skipped_without_needing_a_type_allowlist() {
445
+ // Real rollouts carry world_state, compacted, task_started,
446
+ // thread_settings_applied and reasoning records, and each Codex release
447
+ // adds more. Every branch falls through to "no events", so a new record
448
+ // type costs nothing — but the line still reports its timestamp, because
449
+ // it IS activity and `agent_end` should reflect when the file last moved.
450
+ let mut st = TailState::default();
451
+ for line in [
452
+ r#"{"timestamp":"2026-07-30T08:14:54.526Z","type":"world_state","payload":{"full":true,"state":{}}}"#,
453
+ r#"{"timestamp":"2026-07-30T08:14:58.219Z","type":"response_item","payload":{"type":"reasoning","encrypted_content":"gAAAA","summary":[]}}"#,
454
+ r#"{"timestamp":"2026-07-30T08:14:54.521Z","type":"event_msg","payload":{"type":"task_started","turn_id":"t1"}}"#,
455
+ r#"{"timestamp":"2026-07-30T08:14:54.521Z","type":"a_type_from_a_future_release","payload":{"x":1}}"#,
456
+ ] {
457
+ let (ts, ev) = one(line, &mut st);
458
+ assert!(ev.is_empty(), "{line} produced {ev:?}");
459
+ assert!(ts.is_some(), "{line} must still advance the clock");
460
+ }
461
+ }
462
+
463
+ #[test]
464
+ fn session_meta_yields_no_event_here_because_the_engine_owns_agent_start() {
465
+ // Emitting it from both places would give the session two start events at
466
+ // the same offset, which the server cannot dedup because they differ.
467
+ let mut st = TailState::default();
468
+ let (ts, ev) = one(
469
+ &session_meta("2026-07-30T08:14:54.521Z", "/home/u/repo"),
470
+ &mut st,
471
+ );
472
+ assert!(ev.is_empty());
473
+ assert_eq!(ts.as_deref(), Some("2026-07-30T08:14:54.521Z"));
474
+ }
475
+
476
+ #[test]
477
+ fn a_malformed_or_untimestamped_line_yields_nothing_at_all() {
478
+ // A truncated final line parses as nothing and must not advance the clock:
479
+ // recording its timestamp would seed `agent_end` from a record that does
480
+ // not exist yet.
481
+ let mut st = TailState::default();
482
+ for line in [
483
+ r#"{"timestamp":"2026-07-30T08:14:54.5"#,
484
+ r#"{"type":"response_item","payload":{"type":"message"}}"#,
485
+ r#"{"timestamp":"2026-07-30 08:14:54","type":"event_msg","payload":{}}"#,
486
+ "not json at all",
487
+ ] {
488
+ let (ts, ev) = one(line, &mut st);
489
+ assert!(ts.is_none(), "{line}");
490
+ assert!(ev.is_empty(), "{line}");
491
+ }
492
+ }
493
+
494
+ #[test]
495
+ fn two_identical_lines_at_different_offsets_produce_different_events() {
496
+ // The offset is the dedup discriminator. Without it the server would
497
+ // collapse a genuinely repeated turn into one row — and Codex repeats
498
+ // turns verbatim when a command is retried.
499
+ let mut st = TailState::default();
500
+ let c = ctx();
501
+ let line = user_message_item("2026-07-30T08:14:54.535Z", &["same"]);
502
+ let (_, a) = transform::transform_line(&line, &c, 10, &mut st);
503
+ let (_, b) = transform::transform_line(&line, &c, 999, &mut st);
504
+ assert_ne!(a[0]["codex_line_offset"], b[0]["codex_line_offset"]);
505
+ assert_eq!(a[0]["codex_line_offset"], 10);
506
+ assert_eq!(b[0]["codex_line_offset"], 999);
507
+ }
508
+
509
+ #[test]
510
+ fn the_transform_is_pure_so_a_re_read_reproduces_byte_identical_events() {
511
+ // The server dedups on a content hash. A live tail and a later full
512
+ // re-read must therefore agree exactly, or the same turn is stored twice.
513
+ let c = ctx();
514
+ let lines = [
515
+ session_meta("2026-07-30T08:14:54.521Z", "/w/repo"),
516
+ turn_context("2026-07-30T08:14:54.526Z", "gpt-5.6-sol", "/w/repo"),
517
+ user_message_item("2026-07-30T08:14:54.535Z", &["hello"]),
518
+ custom_tool_call("2026-07-30T08:14:59.392Z", "c1", "exec", "{cmd:\"ls\"}"),
519
+ custom_tool_call_output("2026-07-30T08:14:59.462Z", "c1", "a b c"),
520
+ token_count("2026-07-30T08:14:59.463Z", 10, 2),
521
+ ];
522
+ let render = || {
523
+ let mut st = TailState::default();
524
+ let mut out = Vec::new();
525
+ for (i, l) in lines.iter().enumerate() {
526
+ let (_, ev) = transform::transform_line(l, &c, (i * 400) as u64, &mut st);
527
+ out.extend(ev);
528
+ }
529
+ serde_json::to_string(&out).unwrap()
530
+ };
531
+ assert_eq!(render(), render());
532
+ // …and the carried state is reproduced too, not just the events.
533
+ let mut st = TailState::default();
534
+ for (i, l) in lines.iter().enumerate() {
535
+ transform::transform_line(l, &c, (i * 400) as u64, &mut st);
536
+ }
537
+ assert_eq!(st.last_model.as_deref(), Some("gpt-5.6-sol"));
538
+ assert_eq!(st.tool_name("c1"), Some("exec"));
539
+ }
540
+
541
+ // ── format table ────────────────────────────────────────────────────────────
542
+
543
+ #[test]
544
+ fn discovery_claims_rollout_files_and_rejects_everything_else() {
545
+ let is = codex::FORMAT.is_source_file;
546
+ assert!(is(Path::new(&format!("/s/2026/07/30/{ROLLOUT}"))));
547
+
548
+ // Archived rollouts are compressed and in-progress writes land on `.tmp`;
549
+ // a byte cursor over either would ship garbage or re-ship forever.
550
+ assert!(!is(Path::new(&format!("/s/2026/07/30/{ROLLOUT}.zst"))));
551
+ assert!(!is(Path::new(&format!("/s/2026/07/30/{ROLLOUT}.tmp"))));
552
+ // Codex's other JSONL files. They live outside `sessions/`, but a widened
553
+ // root must not turn them into sessions under a synthetic id.
554
+ assert!(!is(Path::new("/home/u/.codex/history.jsonl")));
555
+ assert!(!is(Path::new("/home/u/.codex/session_index.jsonl")));
556
+ // Right prefix, no uuid — a future sibling in the same tree.
557
+ assert!(!is(Path::new("/s/2026/07/30/rollout-index.jsonl")));
558
+ // The date half of the name is also digits and dashes; a loose uuid check
559
+ // would slice a session id out of the middle of it.
560
+ assert!(!is(Path::new(
561
+ "/s/2026/07/30/rollout-2026-07-30T13-44-44.jsonl"
562
+ )));
563
+ }
564
+
565
+ #[test]
566
+ fn the_session_id_is_the_uuid_embedded_in_the_filename() {
567
+ assert_eq!(
568
+ (codex::FORMAT.session_id_from_path)(Path::new(&format!("/s/2026/07/30/{ROLLOUT}")))
569
+ .as_deref(),
570
+ Some(UUID)
571
+ );
572
+ }
573
+
574
+ #[test]
575
+ fn the_session_id_comes_from_the_filename_not_from_session_meta() {
576
+ // On a subagent rollout `session_meta.session_id` is the PARENT thread's
577
+ // id while `payload.id` is this file's — measured on the one such rollout
578
+ // on this machine. Keying on `session_id` would merge a subagent's whole
579
+ // transcript into its parent's timeline.
580
+ let child = "019f99a1-e351-7c51-847e-afa614efe23e";
581
+ let parent = "019f999d-3d83-7ff2-a783-846cc1cbecd1";
582
+ let path = format!("/s/2026/07/25/rollout-2026-07-25T19-45-51-{child}.jsonl");
583
+ assert_eq!(
584
+ (codex::FORMAT.session_id_from_path)(Path::new(&path)).as_deref(),
585
+ Some(child)
586
+ );
587
+
588
+ let header = vec![
589
+ json!({"timestamp":"2026-07-25T14:15:51.414Z","type":"session_meta","payload":{
590
+ "id":child,"session_id":parent,"parent_thread_id":parent,
591
+ "cwd":"/home/u/src/agenteye","cli_version":"0.145.0"}})
592
+ .to_string(),
593
+ ];
594
+ let c = Ctx {
595
+ session_id: child.into(),
596
+ ..ctx()
597
+ };
598
+ let (ev, _) = (codex::FORMAT.agent_start)(&header, &c, 0).unwrap();
599
+ assert_eq!(ev["session_id"], child);
600
+ assert_eq!(ev["parent_id"], parent, "the parent is carried, not merged");
601
+ }
602
+
603
+ #[test]
604
+ fn the_agent_id_comes_from_the_real_cwd_because_the_path_is_only_a_date() {
605
+ // `sessions/2026/07/30/` records the day and nothing about the work, so
606
+ // without reading the header every project on the machine files under one
607
+ // agent id and the product cannot tell them apart.
608
+ let header = vec![session_meta(
609
+ "2026-07-30T08:14:54.521Z",
610
+ "/home/u/Desktop/work/agenteye/cli",
611
+ )];
612
+ let derived =
613
+ (codex::FORMAT.agent_id_from_path)(Path::new(&format!("/s/2026/07/30/{ROLLOUT}")), &header);
614
+ assert_eq!(derived.as_deref(), Some("codex-cli"));
615
+ }
616
+
617
+ #[test]
618
+ fn the_agent_id_falls_back_to_turn_context_when_session_meta_is_unreadable() {
619
+ // A rollout discovered while its first line is still being written has no
620
+ // parseable `session_meta`, but `turn_context` repeats the same cwd.
621
+ let header = vec![
622
+ "{\"timestamp\":\"2026-07-30T08:14:54.5".to_string(),
623
+ turn_context("2026-07-30T08:14:54.526Z", "gpt-5.6-sol", "/home/u/repo/"),
624
+ ];
625
+ assert_eq!(
626
+ (codex::FORMAT.agent_id_from_path)(Path::new(&format!("/s/{ROLLOUT}")), &header).as_deref(),
627
+ Some("codex-repo")
628
+ );
629
+ }
630
+
631
+ #[test]
632
+ fn the_derived_agent_id_matches_the_scheme_the_hook_source_uses() {
633
+ // A hook event and the rollout events for the same run must land under one
634
+ // agent. The two sources derive the id independently, so this is the only
635
+ // thing keeping them from drifting into two agents that look unrelated.
636
+ let cwd = "/home/u/src/openclaw-local";
637
+ let header = vec![session_meta("2026-07-30T08:14:54.521Z", cwd)];
638
+ let from_rollout =
639
+ (codex::FORMAT.agent_id_from_path)(Path::new(&format!("/s/{ROLLOUT}")), &header).unwrap();
640
+ let from_hooks = hook_agent_id(&HookRow {
641
+ timestamp: 1_754_000_000_000,
642
+ event_type: Some("PreToolUse".into()),
643
+ integration: Some("codex".into()),
644
+ tool_name: Some("Bash".into()),
645
+ decision: Some("allow".into()),
646
+ session_id: Some(UUID.into()),
647
+ cwd: Some(cwd.into()),
648
+ // The agent id is derived from cwd and integration alone; the rest is
649
+ // irrelevant to it, and spelling it out invites this to break on every
650
+ // field the store gains.
651
+ ..Default::default()
652
+ });
653
+ assert_eq!(from_rollout, from_hooks);
654
+ assert_eq!(from_rollout, "codex-openclaw-local");
655
+ }
656
+
657
+ #[test]
658
+ fn the_agent_start_carries_the_first_human_prompt_as_the_goal() {
659
+ // Taken from `event_msg.user_message`, not the user response items around
660
+ // it: those also carry the injected AGENTS.md and permission preamble, so
661
+ // the response-item stream would make a config file the session's goal.
662
+ let header = vec![
663
+ session_meta("2026-07-30T08:14:54.521Z", "/home/u/repo"),
664
+ user_message_item("2026-07-30T08:14:54.526Z", &["<permissions instructions>"]),
665
+ user_message_item("2026-07-30T08:14:54.526Z", &["# AGENTS.md instructions"]),
666
+ turn_context("2026-07-30T08:14:54.526Z", "gpt-5.6-sol", "/home/u/repo"),
667
+ user_message_item("2026-07-30T08:14:54.535Z", &["log into agenteye-cli"]),
668
+ user_message_event("2026-07-30T08:14:54.535Z", "log into agenteye-cli"),
669
+ ];
670
+ let (ev, ts) = (codex::FORMAT.agent_start)(&header, &ctx(), 0).unwrap();
671
+ assert_eq!(ev["type"], "agent_start");
672
+ assert_eq!(ev["goal"], "log into agenteye-cli");
673
+ assert_eq!(ev["codex_cwd"], "/home/u/repo");
674
+ assert_eq!(ev["codex_cli_version"], "0.146.0");
675
+ assert_eq!(ev["codex_git_branch"], "luv-479");
676
+ assert_eq!(ev["timestamp"], "2026-07-30T08:14:54.521000Z");
677
+ assert_eq!(ts.as_deref(), Some("2026-07-30T08:14:54.521Z"));
678
+ }
679
+
680
+ #[test]
681
+ fn a_rollout_with_no_session_meta_still_gets_an_agent_start_not_an_end() {
682
+ // The upstream collector emitted an `agent_end` from this fallback while
683
+ // still latching the "started" flag. The server selects sessions on
684
+ // `agent_start`, so the session never appeared in `GET /sessions` — and
685
+ // because the flag was latched it could never start, and so could never
686
+ // end either. One wrong constant made the session permanently invisible.
687
+ let header = vec![
688
+ "{\"timestamp\":\"2026-07-30T08:14:5".to_string(), // truncated line 1
689
+ turn_context("2026-07-30T08:14:54.526Z", "gpt-5.6-sol", "/home/u/repo"),
690
+ user_message_event("2026-07-30T08:14:54.535Z", "carry on"),
691
+ ];
692
+ let (ev, ts) = (codex::FORMAT.agent_start)(&header, &ctx(), 0).unwrap();
693
+ assert_eq!(ev["type"], "agent_start", "NEVER agent_end: {ev}");
694
+ assert_eq!(ev["codex_cwd"], "/home/u/repo");
695
+ assert_eq!(ev["goal"], "carry on");
696
+ assert_eq!(ts.as_deref(), Some("2026-07-30T08:14:54.526Z"));
697
+ }
698
+
699
+ #[test]
700
+ fn a_header_with_no_usable_line_produces_no_start_so_it_can_be_retried() {
701
+ // The engine retries `agent_start` every poll until it succeeds. Inventing
702
+ // one from an empty header would latch the flag against a timestamp that
703
+ // does not exist.
704
+ assert!((codex::FORMAT.agent_start)(&[], &ctx(), 0).is_none());
705
+ assert!((codex::FORMAT.agent_start)(&["{ broken".to_string()], &ctx(), 0).is_none());
706
+ }
707
+
708
+ #[test]
709
+ fn codex_declares_itself_byte_tailable() {
710
+ // Rollouts are strictly appended — unlike factory/droid, Codex never
711
+ // rewrites line 1 to name the session, because the name is in the path.
712
+ assert_eq!(codex::FORMAT.reread, RereadPolicy::ByteCursor);
713
+ assert_eq!(codex::FORMAT.kind, "codex");
714
+ }
715
+
716
+ // ── engine ──────────────────────────────────────────────────────────────────
717
+
718
+ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
719
+ Spec {
720
+ format: codex::FORMAT,
721
+ roots: vec![root],
722
+ spool_dir: spool,
723
+ state_dir: state,
724
+ poll_interval: Duration::from_millis(200),
725
+ params: Params {
726
+ agent_id: codex::DEFAULT_AGENT_ID.into(),
727
+ environment: "local".into(),
728
+ end_idle_mins: 0, // end immediately, so the test need not wait
729
+ max_read_bytes: 8 * 1024 * 1024,
730
+ max_batch_bytes: 8 * 1024 * 1024,
731
+ since_days: None,
732
+ },
733
+ }
734
+ }
735
+
736
+ fn spooled(dir: &Path) -> Vec<Value> {
737
+ let mut out = Vec::new();
738
+ let Ok(rd) = fs::read_dir(dir) else {
739
+ return out;
740
+ };
741
+ for e in rd.filter_map(|e| e.ok()) {
742
+ let p = e.path();
743
+ if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
744
+ continue;
745
+ }
746
+ for l in fs::read_to_string(&p).unwrap().lines() {
747
+ if !l.trim().is_empty() {
748
+ out.push(serde_json::from_str(l).unwrap());
749
+ }
750
+ }
751
+ }
752
+ out
753
+ }
754
+
755
+ async fn run_briefly(s: Spec, ms: u64) {
756
+ let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
757
+ let _ = tokio::time::timeout(Duration::from_millis(ms), filetail::run(s, sd)).await;
758
+ }
759
+
760
+ fn clear(spool: &Path) {
761
+ for e in fs::read_dir(spool).unwrap().filter_map(|e| e.ok()) {
762
+ fs::remove_file(e.path()).ok();
763
+ }
764
+ }
765
+
766
+ /// A rollout in the real `sessions/<YYYY>/<MM>/<DD>/` tree.
767
+ fn write_rollout(root: &Path, lines: &[String]) -> PathBuf {
768
+ let day = root.join("2026").join("07").join("30");
769
+ fs::create_dir_all(&day).unwrap();
770
+ let p = day.join(ROLLOUT);
771
+ fs::write(&p, lines.join("\n") + "\n").unwrap();
772
+ p
773
+ }
774
+
775
+ fn full_session() -> Vec<String> {
776
+ vec![
777
+ session_meta("2026-07-30T08:14:54.521Z", "/home/u/repo/cli"),
778
+ turn_context(
779
+ "2026-07-30T08:14:54.526Z",
780
+ "gpt-5.6-sol",
781
+ "/home/u/repo/cli",
782
+ ),
783
+ user_message_item("2026-07-30T08:14:54.535Z", &["list the files"]),
784
+ user_message_event("2026-07-30T08:14:54.535Z", "list the files"),
785
+ assistant_message("2026-07-30T08:14:58.715Z", "running it now"),
786
+ custom_tool_call(
787
+ "2026-07-30T08:14:59.392Z",
788
+ "call_1",
789
+ "exec",
790
+ r#"const r = await tools.exec_command({cmd:"ls -la","workdir":"/home/u/repo/cli"})"#,
791
+ ),
792
+ custom_tool_call_output("2026-07-30T08:14:59.462Z", "call_1", "total 4"),
793
+ token_count("2026-07-30T08:14:59.463Z", 17119, 149),
794
+ ]
795
+ }
796
+
797
+ #[tokio::test(flavor = "multi_thread")]
798
+ async fn a_session_produces_a_start_its_turns_and_an_end() {
799
+ let root = tmpdir("full-root");
800
+ let spool = tmpdir("full-spool");
801
+ let state = tmpdir("full-state");
802
+ write_rollout(&root, &full_session());
803
+
804
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
805
+
806
+ let ev = spooled(&spool);
807
+ let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
808
+ for want in [
809
+ "agent_start",
810
+ "model_request",
811
+ "model_response",
812
+ "tool_use",
813
+ "tool_result",
814
+ "agent_end",
815
+ ] {
816
+ assert!(types.contains(&want), "missing {want}: got {types:?}");
817
+ }
818
+
819
+ // Every event carries the session id from the FILENAME, so hook events for
820
+ // the same run land on this timeline, and the agent id from the real cwd.
821
+ for e in &ev {
822
+ assert_eq!(e["session_id"], UUID);
823
+ assert_eq!(e["agent_id"], "codex-cli", "agent id must come from cwd");
824
+ assert!(e.get("codex_line_offset").is_some(), "{e}");
825
+ }
826
+ // The tool result is named, which is the point of carrying the name.
827
+ let result = ev.iter().find(|e| e["type"] == "tool_result").unwrap();
828
+ assert_eq!(result["tool_name"], "exec");
829
+ // The prompt is emitted once, not twice, despite Codex writing it twice.
830
+ let prompts = ev
831
+ .iter()
832
+ .filter(|e| e["messages"][0]["content"] == "list the files")
833
+ .count();
834
+ assert_eq!(prompts, 1, "the event_msg duplicate must not ship");
835
+
836
+ fs::remove_dir_all(&root).ok();
837
+ fs::remove_dir_all(&spool).ok();
838
+ fs::remove_dir_all(&state).ok();
839
+ }
840
+
841
+ #[tokio::test(flavor = "multi_thread")]
842
+ async fn a_second_pass_ships_nothing_new() {
843
+ let root = tmpdir("resume-root");
844
+ let spool = tmpdir("resume-spool");
845
+ let state = tmpdir("resume-state");
846
+ write_rollout(&root, &full_session());
847
+
848
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
849
+ assert!(!spooled(&spool).is_empty());
850
+ clear(&spool);
851
+
852
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
853
+ assert!(
854
+ spooled(&spool).is_empty(),
855
+ "a resumed pass must ship nothing"
856
+ );
857
+
858
+ fs::remove_dir_all(&root).ok();
859
+ fs::remove_dir_all(&spool).ok();
860
+ fs::remove_dir_all(&state).ok();
861
+ }
862
+
863
+ #[tokio::test(flavor = "multi_thread")]
864
+ async fn appended_turns_are_picked_up_without_re_shipping_earlier_ones() {
865
+ let root = tmpdir("append-root");
866
+ let spool = tmpdir("append-spool");
867
+ let state = tmpdir("append-state");
868
+ let path = write_rollout(&root, &full_session());
869
+
870
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
871
+ let first = spooled(&spool).len();
872
+ clear(&spool);
873
+
874
+ let mut body = fs::read_to_string(&path).unwrap();
875
+ body.push_str(&(user_message_item("2026-07-30T08:20:00.000Z", &["second"]) + "\n"));
876
+ fs::write(&path, body).unwrap();
877
+
878
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
879
+ let ev = spooled(&spool);
880
+ assert!(!ev.is_empty(), "the appended turn must ship");
881
+ assert!(
882
+ ev.len() < first,
883
+ "earlier turns must not be re-shipped: {} of {first}",
884
+ ev.len()
885
+ );
886
+ assert!(
887
+ ev.iter().any(|e| e["messages"][0]["content"] == "second"),
888
+ "the new prompt must be present"
889
+ );
890
+ assert!(
891
+ !ev.iter()
892
+ .any(|e| e["messages"][0]["content"] == "list the files"),
893
+ "the earlier prompt must not repeat"
894
+ );
895
+
896
+ fs::remove_dir_all(&root).ok();
897
+ fs::remove_dir_all(&spool).ok();
898
+ fs::remove_dir_all(&state).ok();
899
+ }
900
+
901
+ #[tokio::test(flavor = "multi_thread")]
902
+ async fn a_partially_written_final_line_is_held_back_then_picked_up() {
903
+ // Codex appends a line at a time; a poll landing mid-write sees half a JSON
904
+ // object. Consuming it would skip the record when it completes.
905
+ let root = tmpdir("partial-root");
906
+ let spool = tmpdir("partial-spool");
907
+ let state = tmpdir("partial-state");
908
+ let day = root.join("2026").join("07").join("30");
909
+ fs::create_dir_all(&day).unwrap();
910
+ let path = day.join(ROLLOUT);
911
+
912
+ let mut complete = full_session();
913
+ let tail = user_message_item("2026-07-30T08:20:00.000Z", &["finished"]);
914
+ fs::write(
915
+ &path,
916
+ format!(
917
+ "{}\n{}",
918
+ complete.join("\n"),
919
+ &tail[..tail.len() / 2] // torn mid-object
920
+ ),
921
+ )
922
+ .unwrap();
923
+
924
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
925
+ assert!(
926
+ !spooled(&spool)
927
+ .iter()
928
+ .any(|e| e["messages"][0]["content"] == "finished"),
929
+ "a torn line must not ship"
930
+ );
931
+ clear(&spool);
932
+
933
+ complete.push(tail);
934
+ fs::write(&path, complete.join("\n") + "\n").unwrap();
935
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
936
+ assert!(
937
+ spooled(&spool)
938
+ .iter()
939
+ .any(|e| e["messages"][0]["content"] == "finished"),
940
+ "the completed line must ship on a later pass"
941
+ );
942
+
943
+ fs::remove_dir_all(&root).ok();
944
+ fs::remove_dir_all(&spool).ok();
945
+ fs::remove_dir_all(&state).ok();
946
+ }
947
+
948
+ #[tokio::test(flavor = "multi_thread")]
949
+ async fn a_rollout_whose_header_is_still_being_written_is_not_lost() {
950
+ // `agent_start` is retried every poll. A file discovered before its
951
+ // `session_meta` is flushed must still register once it lands, or the
952
+ // session is absent from the product for good.
953
+ let root = tmpdir("late-root");
954
+ let spool = tmpdir("late-spool");
955
+ let state = tmpdir("late-state");
956
+ let day = root.join("2026").join("07").join("30");
957
+ fs::create_dir_all(&day).unwrap();
958
+ let path = day.join(ROLLOUT);
959
+ fs::write(&path, "").unwrap();
960
+
961
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
962
+ assert!(spooled(&spool).is_empty(), "nothing to start from yet");
963
+ clear(&spool);
964
+
965
+ fs::write(&path, full_session().join("\n") + "\n").unwrap();
966
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
967
+ let ev = spooled(&spool);
968
+ let start = ev.iter().find(|e| e["type"] == "agent_start");
969
+ assert!(start.is_some(), "got {:?}", ev.len());
970
+ assert_eq!(start.unwrap()["goal"], "list the files");
971
+
972
+ fs::remove_dir_all(&root).ok();
973
+ fs::remove_dir_all(&spool).ok();
974
+ fs::remove_dir_all(&state).ok();
975
+ }