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,1019 @@
1
+ //! Copilot source: transform correctness and engine behaviour.
2
+ //!
3
+ //! Record shapes are verbatim from real transcripts under
4
+ //! `~/.copilot/session-state/<sessionId>/events.jsonl` (Copilot CLI 1.0.77),
5
+ //! including a captured resumed session. Values are trimmed for readability
6
+ //! but every key, nesting level and type is as it appears on disk.
7
+
8
+ use std::fs;
9
+ use std::path::{Path, PathBuf};
10
+ use std::sync::Arc;
11
+ use std::sync::atomic::AtomicBool;
12
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
+
14
+ use fpai_collect::cursor::TailState;
15
+ use fpai_collect::filetail::{self, Ctx, Params, RereadPolicy, Spec};
16
+ use fpai_collect::sources::copilot::{self, transform};
17
+ use fpai_collect::supervisor::Shutdown;
18
+ use serde_json::{Value, json};
19
+
20
+ const SESSION: &str = "1666969e-2c67-4534-957d-914b83d83834";
21
+
22
+ fn tmpdir(name: &str) -> PathBuf {
23
+ let d = std::env::temp_dir().join(format!(
24
+ "fpai-cp-{}-{}-{}",
25
+ name,
26
+ std::process::id(),
27
+ SystemTime::now()
28
+ .duration_since(UNIX_EPOCH)
29
+ .unwrap()
30
+ .as_nanos()
31
+ ));
32
+ fs::create_dir_all(&d).unwrap();
33
+ d
34
+ }
35
+
36
+ fn ctx() -> Ctx {
37
+ Ctx {
38
+ session_id: SESSION.into(),
39
+ agent_id: "copilot-probe-copilot".into(),
40
+ environment: "local".into(),
41
+ }
42
+ }
43
+
44
+ // ── real record shapes ───────────────────────────────────────────────────
45
+
46
+ fn session_start(ts: &str) -> String {
47
+ json!({"type":"session.start","id":"9312c4d4","timestamp":ts,"parentId":null,
48
+ "data":{"sessionId":SESSION,"version":1,"producer":"copilot-agent",
49
+ "copilotVersion":"1.0.77","startTime":"2026-08-03T07:56:17.439Z",
50
+ "contextTier":null,"context":{"cwd":"/tmp/probe-copilot"},
51
+ "alreadyInUse":false,"remoteSteerable":false}})
52
+ .to_string()
53
+ }
54
+
55
+ fn model_change(ts: &str, model: &str) -> String {
56
+ json!({"type":"session.model_change","id":"34013127","timestamp":ts,"parentId":"9312c4d4",
57
+ "data":{"contextTier":null,"newModel":model,"reasoningEffort":null}})
58
+ .to_string()
59
+ }
60
+
61
+ /// The ~31 KB system prompt. Size is the point of this fixture.
62
+ fn system_message(ts: &str) -> String {
63
+ let body = "You are the GitHub Copilot CLI, a terminal assistant built by GitHub. ".repeat(450);
64
+ json!({"type":"system.message","id":"cb87ade7","timestamp":ts,"parentId":"34013127",
65
+ "data":{"role":"system","content":body,"interactionId":"ba093147"}})
66
+ .to_string()
67
+ }
68
+
69
+ fn user_message(ts: &str, text: &str) -> String {
70
+ json!({"type":"user.message","id":"0bd38b01","timestamp":ts,"parentId":"cb87ade7",
71
+ "data":{"content":text,
72
+ "transformedContent":format!("<current_datetime>2026-08-03T13:26:20.850+05:30</current_datetime>\n\n{text}\n\n<system_reminder>\n<sql_tables>Available tables: todos</sql_tables>\n</system_reminder>"),
73
+ "attachments":[],"supportedNativeDocumentMimeTypes":[],"delivery":"idle",
74
+ "interactionId":"ba093147","parentAgentTaskId":"765268e2"}})
75
+ .to_string()
76
+ }
77
+
78
+ /// An assistant turn that only calls tools: `content` is empty, `outputTokens`
79
+ /// is not.
80
+ fn assistant_tool_turn(ts: &str, calls: &[(&str, &str)], output_tokens: u64) -> String {
81
+ let requests: Vec<Value> = calls
82
+ .iter()
83
+ .map(|(id, name)| {
84
+ json!({"toolCallId":id,"name":name,"arguments":{"pattern":"*"},
85
+ "type":"function","intentionSummary":"*"})
86
+ })
87
+ .collect();
88
+ json!({"type":"assistant.message","id":"2d856b49","timestamp":ts,"parentId":"7e94eb05",
89
+ "data":{"messageId":"e5946eb0","model":"claude-sonnet-4-6","content":"",
90
+ "toolRequests":requests,"interactionId":"ba093147","turnId":"0",
91
+ "reasoningOpaque":"EvsBCmcIEBABGAIqQArMmtpV6zYayCjF7Cr",
92
+ "reasoningText":"Let me list files and read README.md in parallel.",
93
+ "outputTokens":output_tokens,"rte":false,"apiCallId":"msg_bdrk_018Wa"}})
94
+ .to_string()
95
+ }
96
+
97
+ fn assistant_text(ts: &str, text: &str, output_tokens: u64) -> String {
98
+ json!({"type":"assistant.message","id":"d528e4c5","timestamp":ts,"parentId":"686d1d03",
99
+ "data":{"messageId":"4cab63e4","model":"claude-sonnet-4-6","content":text,
100
+ "toolRequests":[],"interactionId":"ba093147","turnId":"1",
101
+ "reasoningOpaque":"EvsBCmcIEBABGAIqQArMmtpV6zYayCjF7Cr",
102
+ "reasoningText":"Let me list files and read README.md in parallel.",
103
+ "outputTokens":output_tokens,"rte":false,"apiCallId":"msg_bdrk_01Qfsh"}})
104
+ .to_string()
105
+ }
106
+
107
+ fn execution_start(ts: &str, call_id: &str, tool: &str) -> String {
108
+ json!({"type":"tool.execution_start","id":"b484dc13","timestamp":ts,"parentId":"2d856b49",
109
+ "data":{"toolCallId":call_id,"toolName":tool,"arguments":{"pattern":"*"},
110
+ "turnId":"0","model":"claude-sonnet-4","rte":false}})
111
+ .to_string()
112
+ }
113
+
114
+ fn execution_complete(ts: &str, call_id: &str, content: &str, success: bool) -> String {
115
+ json!({"type":"tool.execution_complete","id":"0ac03a9e","timestamp":ts,"parentId":"3fec806b",
116
+ "data":{"toolCallId":call_id,"model":"claude-sonnet-4","interactionId":"ba093147",
117
+ "turnId":"0","rte":false,"success":success,
118
+ "result":{"content":content,"detailedContent":format!("\ndiff --git a/x b/x\n{content}")},
119
+ "toolTelemetry":{"properties":{"command":"view"},
120
+ "metrics":{"resultLength":195,"responseTokenLimit":32000},
121
+ "restrictedProperties":{}}}})
122
+ .to_string()
123
+ }
124
+
125
+ fn turn_start(ts: &str, turn: &str) -> String {
126
+ json!({"type":"assistant.turn_start","id":"7e94eb05","timestamp":ts,"parentId":"0bd38b01",
127
+ "data":{"turnId":turn,"interactionId":"ba093147"}})
128
+ .to_string()
129
+ }
130
+
131
+ fn turn_end(ts: &str, turn: &str) -> String {
132
+ json!({"type":"assistant.turn_end","id":"62dedc37","timestamp":ts,"parentId":"4d5681d4",
133
+ "data":{"turnId":turn}})
134
+ .to_string()
135
+ }
136
+
137
+ fn shutdown(ts: &str) -> String {
138
+ json!({"type":"session.shutdown","id":"4a8e25b2","timestamp":ts,"parentId":"ab6f3d0f",
139
+ "data":{"shutdownType":"routine","totalPremiumRequests":0,"totalApiDurationMs":7099,
140
+ "sessionStartTime":1785743777439i64,"eventsFileSizeBytes":39068,
141
+ "codeChanges":{"linesAdded":0,"linesRemoved":0,"filesModified":[]},
142
+ "currentModel":"claude-sonnet-4-6","currentTokens":15534}})
143
+ .to_string()
144
+ }
145
+
146
+ fn session_resume(ts: &str) -> String {
147
+ json!({"type":"session.resume","id":"c9a841c2","timestamp":ts,"parentId":"4a8e25b2",
148
+ "data":{"resumeTime":ts,"eventCount":15,"eventsFileSizeBytes":39797,
149
+ "selectedModel":"claude-sonnet-4","contextTier":null,"sessionLimits":null,
150
+ "context":{"cwd":"/tmp/probe-copilot"},"alreadyInUse":false,"remoteSteerable":false}})
151
+ .to_string()
152
+ }
153
+
154
+ /// One complete session, in the exact record order captured on disk.
155
+ fn full_session() -> Vec<String> {
156
+ vec![
157
+ session_start("2026-08-03T07:56:17.824Z"),
158
+ model_change("2026-08-03T07:56:19.516Z", "claude-sonnet-4"),
159
+ system_message("2026-08-03T07:56:20.842Z"),
160
+ user_message(
161
+ "2026-08-03T07:56:20.851Z",
162
+ "List the files in this directory, then read README.md.",
163
+ ),
164
+ turn_start("2026-08-03T07:56:20.929Z", "0"),
165
+ assistant_tool_turn(
166
+ "2026-08-03T07:56:25.285Z",
167
+ &[("toolu_glob", "glob"), ("toolu_view", "view")],
168
+ 123,
169
+ ),
170
+ execution_start("2026-08-03T07:56:25.287Z", "toolu_glob", "glob"),
171
+ execution_start("2026-08-03T07:56:25.288Z", "toolu_view", "view"),
172
+ // Completions land in the OPPOSITE order — captured verbatim.
173
+ execution_complete("2026-08-03T07:56:25.301Z", "toolu_view", "1. # probe", true),
174
+ execution_complete(
175
+ "2026-08-03T07:56:25.348Z",
176
+ "toolu_glob",
177
+ "./README.md\n./src/fib.py",
178
+ true,
179
+ ),
180
+ turn_end("2026-08-03T07:56:25.385Z", "0"),
181
+ turn_start("2026-08-03T07:56:25.386Z", "1"),
182
+ assistant_text("2026-08-03T07:56:28.171Z", "**Files:** README.md", 80),
183
+ turn_end("2026-08-03T07:56:28.173Z", "1"),
184
+ shutdown("2026-08-03T07:56:28.241Z"),
185
+ ]
186
+ }
187
+
188
+ fn run_lines(lines: &[String], state: &mut TailState) -> Vec<Value> {
189
+ let c = ctx();
190
+ let mut out = Vec::new();
191
+ let mut offset = 0u64;
192
+ for line in lines {
193
+ let (_, events) = transform::transform_line(line, &c, offset, state);
194
+ offset += line.len() as u64 + 1;
195
+ out.extend(events);
196
+ }
197
+ out
198
+ }
199
+
200
+ // ── transform ────────────────────────────────────────────────────────────
201
+
202
+ #[test]
203
+ fn a_millisecond_timestamp_is_padded_to_microseconds() {
204
+ // Copilot writes milliseconds on every record; the hook source emits
205
+ // microseconds. Both share one session timeline, and the two string forms
206
+ // do not compare correctly against each other.
207
+ assert_eq!(
208
+ transform::with_index("2026-08-03T07:56:17.824Z", 0).unwrap(),
209
+ "2026-08-03T07:56:17.824000Z"
210
+ );
211
+ // The index offset orders several events derived from ONE line.
212
+ assert_eq!(
213
+ transform::with_index("2026-08-03T07:56:17.824Z", 2).unwrap(),
214
+ "2026-08-03T07:56:17.824002Z"
215
+ );
216
+ }
217
+
218
+ #[test]
219
+ fn the_index_offset_saturates_inside_its_second() {
220
+ // Carrying into the next second could reorder an event past a genuinely
221
+ // later line, which is worse than the tie it is fixing.
222
+ let s = transform::with_index("2026-08-03T07:56:17.999999Z", 999).unwrap();
223
+ assert_eq!(s, "2026-08-03T07:56:17.999999Z");
224
+ }
225
+
226
+ #[test]
227
+ fn a_prompt_becomes_a_model_request_carrying_the_model_from_the_model_change_record() {
228
+ // A `user.message` names no model, and the server builds this row's
229
+ // summary from the model alone. Copilot writes `session.model_change`
230
+ // ahead of the first prompt, which is why this source needs no seed_state.
231
+ let mut st = TailState::default();
232
+ let events = run_lines(
233
+ &[
234
+ model_change("2026-08-03T07:56:19.516Z", "claude-sonnet-4"),
235
+ user_message("2026-08-03T07:56:20.851Z", "list the files"),
236
+ ],
237
+ &mut st,
238
+ );
239
+ assert_eq!(events.len(), 1);
240
+ assert_eq!(events[0]["type"], "model_request");
241
+ assert_eq!(events[0]["model"], "claude-sonnet-4");
242
+ assert_eq!(events[0]["messages"][0]["content"], "list the files");
243
+ assert_eq!(events[0]["messages"][0]["role"], "user");
244
+ }
245
+
246
+ #[test]
247
+ fn a_prompt_carries_what_the_human_typed_not_the_transformed_form() {
248
+ // `transformedContent` prepends an injected <current_datetime> and a
249
+ // <system_reminder>. Shipping it would put machinery in the product where
250
+ // the user's words belong.
251
+ let mut st = TailState::default();
252
+ let events = run_lines(
253
+ &[user_message("2026-08-03T07:56:20.851Z", "read README.md")],
254
+ &mut st,
255
+ );
256
+ let content = events[0]["messages"][0]["content"].as_str().unwrap();
257
+ assert_eq!(content, "read README.md");
258
+ assert!(!content.contains("current_datetime"));
259
+ assert!(!content.contains("system_reminder"));
260
+ }
261
+
262
+ #[test]
263
+ fn a_tool_call_is_remembered_so_its_result_is_not_a_blank_row() {
264
+ // The tool's name appears on NO `tool.execution_complete` record. This is
265
+ // the whole reason TailState carries pending tools.
266
+ let mut st = TailState::default();
267
+ let c = ctx();
268
+ let (_, calls) = transform::transform_line(
269
+ &execution_start("2026-08-03T07:56:25.287Z", "toolu_glob", "glob"),
270
+ &c,
271
+ 100,
272
+ &mut st,
273
+ );
274
+ assert_eq!(calls[0]["type"], "tool_use");
275
+ assert_eq!(calls[0]["tool_name"], "glob");
276
+ assert_eq!(calls[0]["tool_call_id"], "toolu_glob");
277
+ assert_eq!(calls[0]["input"]["pattern"], "*");
278
+
279
+ let (_, results) = transform::transform_line(
280
+ &execution_complete(
281
+ "2026-08-03T07:56:25.348Z",
282
+ "toolu_glob",
283
+ "./README.md",
284
+ true,
285
+ ),
286
+ &c,
287
+ 200,
288
+ &mut st,
289
+ );
290
+ assert_eq!(results[0]["type"], "tool_result");
291
+ assert_eq!(
292
+ results[0]["tool_name"], "glob",
293
+ "the name must survive from the call"
294
+ );
295
+ assert_eq!(results[0]["output"], "./README.md");
296
+ }
297
+
298
+ #[test]
299
+ fn parallel_tool_completions_that_arrive_out_of_order_still_name_the_right_tool() {
300
+ // Captured verbatim: a turn starts (glob, view) and completes (view, glob),
301
+ // 47 ms apart. Pairing by position rather than by toolCallId would label
302
+ // every row in the turn with the wrong tool — which is worse than labelling
303
+ // none of them, because a wrong name is indistinguishable from a right one.
304
+ let mut st = TailState::default();
305
+ let events = run_lines(
306
+ &[
307
+ execution_start("2026-08-03T07:56:25.287Z", "toolu_glob", "glob"),
308
+ execution_start("2026-08-03T07:56:25.288Z", "toolu_view", "view"),
309
+ execution_complete("2026-08-03T07:56:25.301Z", "toolu_view", "1. # probe", true),
310
+ execution_complete(
311
+ "2026-08-03T07:56:25.348Z",
312
+ "toolu_glob",
313
+ "./README.md",
314
+ true,
315
+ ),
316
+ ],
317
+ &mut st,
318
+ );
319
+ let results: Vec<&Value> = events
320
+ .iter()
321
+ .filter(|e| e["type"] == "tool_result")
322
+ .collect();
323
+ assert_eq!(results.len(), 2);
324
+ assert_eq!(results[0]["tool_call_id"], "toolu_view");
325
+ assert_eq!(results[0]["tool_name"], "view");
326
+ assert_eq!(results[1]["tool_call_id"], "toolu_glob");
327
+ assert_eq!(results[1]["tool_name"], "glob");
328
+ }
329
+
330
+ #[test]
331
+ fn a_failed_tool_result_carries_a_non_empty_error() {
332
+ // The server's is_error is a truthiness check, so a failure with no output
333
+ // must not render as a success.
334
+ let mut st = TailState::default();
335
+ let c = ctx();
336
+ transform::transform_line(
337
+ &execution_start("2026-08-03T07:56:25.287Z", "t1", "bash"),
338
+ &c,
339
+ 0,
340
+ &mut st,
341
+ );
342
+ let (_, ev) = transform::transform_line(
343
+ &execution_complete("2026-08-03T07:56:25.301Z", "t1", "", false),
344
+ &c,
345
+ 10,
346
+ &mut st,
347
+ );
348
+ assert_eq!(ev[0]["error_type"], "copilot_tool_error");
349
+ assert!(!ev[0]["error"].as_str().unwrap().is_empty());
350
+
351
+ // A failure that DOES have output reports that output as the error.
352
+ let (_, ev) = transform::transform_line(
353
+ &execution_complete("2026-08-03T07:56:25.400Z", "t1", "exit code 127", false),
354
+ &c,
355
+ 20,
356
+ &mut st,
357
+ );
358
+ assert_eq!(ev[0]["error"], "exit code 127");
359
+ }
360
+
361
+ #[test]
362
+ fn a_contentless_tool_calling_turn_still_reports_its_output_tokens() {
363
+ // outputTokens appears ONLY on assistant.message — tool.execution_start
364
+ // carries none — so skipping empty-content turns the way the Claude source
365
+ // skips empty text blocks would drop most of a session's output tokens
366
+ // (measured: 209 of 320 in one captured session, 188 of 304 in another).
367
+ let mut st = TailState::default();
368
+ let events = run_lines(
369
+ &[assistant_tool_turn(
370
+ "2026-08-03T07:56:25.285Z",
371
+ &[("toolu_glob", "glob"), ("toolu_view", "view")],
372
+ 123,
373
+ )],
374
+ &mut st,
375
+ );
376
+ assert_eq!(events.len(), 1);
377
+ assert_eq!(events[0]["type"], "model_response");
378
+ assert_eq!(events[0]["output_tokens"], 123);
379
+ assert_eq!(events[0]["copilot_tool_request_count"], 2);
380
+ // Omitted rather than "", so "said nothing, called tools" stays
381
+ // distinguishable from "said the empty string".
382
+ assert!(events[0].get("content").is_none());
383
+ }
384
+
385
+ #[test]
386
+ fn tool_requests_on_an_assistant_message_do_not_become_a_second_tool_use() {
387
+ // Every call is announced twice: in assistant.message.toolRequests[] and
388
+ // in its own tool.execution_start. Across all 7 captured transcripts the
389
+ // two sets matched exactly, so emitting both would double every tool call.
390
+ let mut st = TailState::default();
391
+ let events = run_lines(&full_session(), &mut st);
392
+ let uses: Vec<&Value> = events.iter().filter(|e| e["type"] == "tool_use").collect();
393
+ assert_eq!(uses.len(), 2, "one tool_use per execution_start, no more");
394
+ let ids: Vec<&str> = uses
395
+ .iter()
396
+ .map(|e| e["tool_call_id"].as_str().unwrap())
397
+ .collect();
398
+ assert_eq!(ids, vec!["toolu_glob", "toolu_view"]);
399
+ }
400
+
401
+ #[test]
402
+ fn the_thirty_kilobyte_system_prompt_never_becomes_an_event() {
403
+ // A resume re-emits it verbatim, so this is not a one-off cost. It is
404
+ // skipped rather than truncated: it is not conversation, and a truncated
405
+ // copy would still render as a message the user never sent.
406
+ let mut st = TailState::default();
407
+ let line = system_message("2026-08-03T07:56:20.842Z");
408
+ assert!(line.len() > 30_000, "fixture must be the real size");
409
+
410
+ let (ts, events) = transform::transform_line(&line, &ctx(), 0, &mut st);
411
+ assert!(events.is_empty());
412
+ // The timestamp is still returned, so a session that ends on a system
413
+ // message still gets an agent_end at the right time.
414
+ assert_eq!(ts.as_deref(), Some("2026-08-03T07:56:20.842Z"));
415
+
416
+ // And nothing anywhere in a full session's events approaches that size.
417
+ let events = run_lines(&full_session(), &mut st);
418
+ for e in &events {
419
+ assert!(
420
+ e.to_string().len() < 4096,
421
+ "an event grew to {} bytes: {}",
422
+ e.to_string().len(),
423
+ e["type"]
424
+ );
425
+ }
426
+ }
427
+
428
+ #[test]
429
+ fn reasoning_text_is_dropped_because_copilot_repeats_it_on_later_turns() {
430
+ // Captured: turn 1 of an interaction carries turn 0's reasoningText and
431
+ // reasoningOpaque byte-identically. Emitting it would attribute thinking
432
+ // to a turn that did not produce it.
433
+ let mut st = TailState::default();
434
+ let events = run_lines(&full_session(), &mut st);
435
+ for e in &events {
436
+ let text = e.to_string();
437
+ assert!(!text.contains("reasoning"), "leaked reasoning: {text}");
438
+ assert!(!text.contains("in parallel"), "leaked reasoning: {text}");
439
+ }
440
+ }
441
+
442
+ #[test]
443
+ fn a_tool_result_ships_the_content_but_not_its_duplicated_diff_rendering() {
444
+ // result.detailedContent equals result.content exactly for bash, and is a
445
+ // synthetic `git diff` of the same bytes for view. Either way it doubles
446
+ // the payload for no information.
447
+ let mut st = TailState::default();
448
+ let events = run_lines(
449
+ &[
450
+ execution_start("2026-08-03T07:56:25.287Z", "t1", "view"),
451
+ execution_complete("2026-08-03T07:56:25.301Z", "t1", "1. # probe", true),
452
+ ],
453
+ &mut st,
454
+ );
455
+ let result = events.iter().find(|e| e["type"] == "tool_result").unwrap();
456
+ assert_eq!(result["output"], "1. # probe");
457
+ assert!(!result.to_string().contains("diff --git"));
458
+ }
459
+
460
+ #[test]
461
+ fn hook_records_produce_nothing_because_the_hook_source_already_ships_them() {
462
+ // Re-deriving them here would double-count every hook on the /hooks page,
463
+ // and their `input` embeds whole tool results.
464
+ let mut st = TailState::default();
465
+ let line = json!({"type":"hook.start","id":"h1","timestamp":"2026-08-03T08:13:31.333Z",
466
+ "parentId":"p1","data":{"hookInvocationId":"c3dbb71f","hookType":"preToolUse",
467
+ "input":{"sessionId":SESSION,"cwd":"/tmp/probe-copilot",
468
+ "toolCalls":[{"id":"t1","name":"view","args":"{\"path\":\"/x\"}"}]}}})
469
+ .to_string();
470
+ let (ts, events) = transform::transform_line(&line, &ctx(), 0, &mut st);
471
+ assert!(events.is_empty());
472
+ assert_eq!(ts.as_deref(), Some("2026-08-03T08:13:31.333Z"));
473
+ }
474
+
475
+ #[test]
476
+ fn an_unknown_record_type_costs_nothing_and_still_advances_the_session_clock() {
477
+ // Every Copilot record has a timestamp, so unlike the Claude source there
478
+ // is no "missing timestamp means metadata" signal to lean on — the type
479
+ // dispatch is an allowlist, and anything new must fall through cleanly.
480
+ let mut st = TailState::default();
481
+ let line =
482
+ json!({"type":"session.future_thing","id":"x","timestamp":"2026-08-03T07:56:30.000Z",
483
+ "parentId":"p","data":{"whatever":true}})
484
+ .to_string();
485
+ let (ts, events) = transform::transform_line(&line, &ctx(), 0, &mut st);
486
+ assert!(events.is_empty());
487
+ assert_eq!(
488
+ ts.as_deref(),
489
+ Some("2026-08-03T07:56:30.000Z"),
490
+ "a new record type must still move agent_end forward"
491
+ );
492
+ }
493
+
494
+ #[test]
495
+ fn a_truncated_line_yields_neither_a_timestamp_nor_an_event() {
496
+ // Half a JSON object is what a live tail sees mid-append. Returning a
497
+ // timestamp for it would advance agent_end past a record we never read.
498
+ let mut st = TailState::default();
499
+ let (ts, events) =
500
+ transform::transform_line(r#"{"type":"user.message","timest"#, &ctx(), 0, &mut st);
501
+ assert!(ts.is_none());
502
+ assert!(events.is_empty());
503
+ }
504
+
505
+ #[test]
506
+ fn two_identical_lines_at_different_offsets_produce_different_events() {
507
+ // The offset is the dedup discriminator. Without it the server's content
508
+ // hash would collapse a genuinely repeated prompt into one row.
509
+ let mut st = TailState::default();
510
+ let c = ctx();
511
+ let line = user_message("2026-08-03T07:56:20.851Z", "same");
512
+ let (_, a) = transform::transform_line(&line, &c, 10, &mut st);
513
+ let (_, b) = transform::transform_line(&line, &c, 999, &mut st);
514
+ assert_ne!(a[0]["copilot_line_offset"], b[0]["copilot_line_offset"]);
515
+ assert_ne!(a[0], b[0]);
516
+ }
517
+
518
+ #[test]
519
+ fn the_same_bytes_read_at_the_same_offsets_produce_identical_events() {
520
+ // The invariant the whole engine rests on: a live tail split across polls
521
+ // and a single full re-read must be byte-identical, or the server stores
522
+ // the re-read twice instead of collapsing it.
523
+ let lines = full_session();
524
+
525
+ let mut one_pass = TailState::default();
526
+ let whole = run_lines(&lines, &mut one_pass);
527
+
528
+ // Split: transform the head, carry the state, transform the tail at the
529
+ // offsets it really occupies.
530
+ let head_len: u64 = lines[..6].iter().map(|l| l.len() as u64 + 1).sum();
531
+ let mut split = TailState::default();
532
+ let c = ctx();
533
+ let mut pieces = run_lines(&lines[..6], &mut split);
534
+ let mut offset = head_len;
535
+ for line in &lines[6..] {
536
+ let (_, events) = transform::transform_line(line, &c, offset, &mut split);
537
+ offset += line.len() as u64 + 1;
538
+ pieces.extend(events);
539
+ }
540
+
541
+ assert_eq!(whole, pieces);
542
+ assert_eq!(one_pass, split, "carried state must also converge");
543
+ }
544
+
545
+ // ── resume ───────────────────────────────────────────────────────────────
546
+
547
+ #[test]
548
+ fn a_resumed_session_continues_on_one_timeline_without_a_second_start() {
549
+ // A resume appends `session.resume` to the SAME file under the SAME
550
+ // session id. The engine emits one agent_start per file, so a resume that
551
+ // produced another would create a second session row for one session.
552
+ let mut lines = full_session();
553
+ lines.extend([
554
+ session_resume("2026-08-03T07:57:34.498Z"),
555
+ model_change("2026-08-03T07:57:35.963Z", "claude-sonnet-4"),
556
+ system_message("2026-08-03T07:57:36.059Z"), // re-emitted verbatim
557
+ user_message("2026-08-03T07:57:36.142Z", "now read src/fib.py"),
558
+ assistant_text("2026-08-03T07:57:46.996Z", "The function is fib.", 31),
559
+ shutdown("2026-08-03T07:57:47.068Z"),
560
+ ]);
561
+
562
+ let mut st = TailState::default();
563
+ let events = run_lines(&lines, &mut st);
564
+ let starts = events.iter().filter(|e| e["type"] == "agent_start").count();
565
+ assert_eq!(starts, 0, "agent_start comes from the header, never a line");
566
+
567
+ let prompts: Vec<&str> = events
568
+ .iter()
569
+ .filter(|e| e["type"] == "model_request")
570
+ .map(|e| e["messages"][0]["content"].as_str().unwrap())
571
+ .collect();
572
+ assert_eq!(
573
+ prompts,
574
+ vec![
575
+ "List the files in this directory, then read README.md.",
576
+ "now read src/fib.py"
577
+ ],
578
+ "both halves of a resumed session must land on one timeline"
579
+ );
580
+ }
581
+
582
+ #[test]
583
+ fn a_resume_record_refreshes_the_model_so_the_next_prompt_is_not_modelless() {
584
+ // `session.resume` carries `selectedModel`. Without reading it, a resumed
585
+ // read that started mid-file would file the next prompt with no model.
586
+ let mut st = TailState::default();
587
+ let events = run_lines(
588
+ &[
589
+ session_resume("2026-08-03T07:57:34.498Z"),
590
+ user_message("2026-08-03T07:57:36.142Z", "carry on"),
591
+ ],
592
+ &mut st,
593
+ );
594
+ assert_eq!(events[0]["model"], "claude-sonnet-4");
595
+ }
596
+
597
+ // ── format table ─────────────────────────────────────────────────────────
598
+
599
+ #[test]
600
+ fn discovery_claims_transcripts_and_none_of_their_siblings() {
601
+ let is = copilot::FORMAT.is_source_file;
602
+ assert!(is(Path::new(&format!(
603
+ "/h/.copilot/session-state/{SESSION}/events.jsonl"
604
+ ))));
605
+
606
+ // Rewritten in place on every resume, and YAML.
607
+ assert!(!is(Path::new(&format!(
608
+ "/h/.copilot/session-state/{SESSION}/workspace.yaml"
609
+ ))));
610
+ // Rewritten in place as checkpoints are added, and Markdown.
611
+ assert!(!is(Path::new(&format!(
612
+ "/h/.copilot/session-state/{SESSION}/checkpoints/index.md"
613
+ ))));
614
+ // SQLite: a byte cursor over a page-rewriting binary file emits garbage
615
+ // indefinitely.
616
+ assert!(!is(Path::new(&format!(
617
+ "/h/.copilot/session-state/{SESSION}/session.db"
618
+ ))));
619
+ assert!(!is(Path::new("/h/.copilot/session-store.db")));
620
+ assert!(!is(Path::new("/h/.copilot/session-store.db-wal")));
621
+ assert!(!is(Path::new("/h/.copilot/session-store.db-shm")));
622
+ // Process-scoped, not session-scoped: one file spans every session a
623
+ // `copilot` process ran, so its lines have no single session id.
624
+ assert!(!is(Path::new(
625
+ "/h/.copilot/logs/process-1785743776226-130839.log"
626
+ )));
627
+ }
628
+
629
+ #[test]
630
+ fn the_session_id_is_the_directory_name_not_the_filename() {
631
+ // Every transcript is called events.jsonl, so the filename carries
632
+ // nothing. Verified on disk: the directory name equals session.start's
633
+ // data.sessionId in all 4 live sessions, including one started with an
634
+ // explicitly chosen --session-id.
635
+ assert_eq!(
636
+ (copilot::FORMAT.session_id_from_path)(Path::new(&format!(
637
+ "/h/.copilot/session-state/{SESSION}/events.jsonl"
638
+ )))
639
+ .as_deref(),
640
+ Some(SESSION)
641
+ );
642
+ // A transcript loose in the root has no session directory; filing it under
643
+ // the root's name would merge every such file into one session.
644
+ assert!(
645
+ (copilot::FORMAT.session_id_from_path)(Path::new("/h/.copilot/session-state/events.jsonl"))
646
+ .is_none()
647
+ );
648
+ }
649
+
650
+ #[test]
651
+ fn the_agent_id_comes_from_the_real_cwd_in_the_transcript() {
652
+ // The directory name is a bare session UUID and encodes no path at all,
653
+ // so the cwd has to come from session.start's data.context.cwd.
654
+ let header = vec![session_start("2026-08-03T07:56:17.824Z")];
655
+ let derived = (copilot::FORMAT.agent_id_from_path)(
656
+ Path::new(&format!("/h/.copilot/session-state/{SESSION}/events.jsonl")),
657
+ &header,
658
+ );
659
+ assert_eq!(derived.as_deref(), Some("copilot-probe-copilot"));
660
+
661
+ // A resume-only header (a cursor rebuilt mid-session) still finds it.
662
+ let header = vec![session_resume("2026-08-03T07:57:34.498Z")];
663
+ let derived = (copilot::FORMAT.agent_id_from_path)(Path::new("/h/x/events.jsonl"), &header);
664
+ assert_eq!(derived.as_deref(), Some("copilot-probe-copilot"));
665
+ }
666
+
667
+ #[test]
668
+ fn the_agent_id_scheme_matches_the_claude_sources_for_the_same_directory() {
669
+ // The two sources hold separate copies of sanitize_id_part on purpose, so
670
+ // this is the tripwire that keeps them in agreement. If they diverge, a
671
+ // hook event and a transcript event for one run land under two agent ids
672
+ // that look unrelated. `openclaw-local` is the cwd that broke the naive
673
+ // decoder in the Claude source, so it is the right shape to pin.
674
+ let cwd = "/home/u/src/openclaw-local";
675
+ let copilot_header = vec![
676
+ json!({"type":"session.start","id":"a","timestamp":"2026-08-03T07:56:17.824Z",
677
+ "parentId":null,"data":{"context":{"cwd":cwd}}})
678
+ .to_string(),
679
+ ];
680
+ let claude_header =
681
+ vec![json!({"type":"user","timestamp":"2026-08-03T07:56:17.824Z","cwd":cwd}).to_string()];
682
+
683
+ let from_copilot =
684
+ (copilot::FORMAT.agent_id_from_path)(Path::new("/x/events.jsonl"), &copilot_header)
685
+ .unwrap();
686
+ let from_claude = (fpai_collect::sources::claude::FORMAT.agent_id_from_path)(
687
+ Path::new("/x/a.jsonl"),
688
+ &claude_header,
689
+ )
690
+ .unwrap();
691
+
692
+ assert_eq!(from_copilot, "copilot-openclaw-local");
693
+ assert_eq!(
694
+ from_copilot.trim_start_matches("copilot-"),
695
+ from_claude.trim_start_matches("claude-"),
696
+ "the project half of the id must be identical across sources"
697
+ );
698
+ }
699
+
700
+ #[test]
701
+ fn an_agent_start_is_built_from_the_header_including_the_goal_behind_the_system_prompt() {
702
+ // The first prompt is the 4th record, sitting behind ~31 KB of system
703
+ // prompt — still inside the engine's header budget, which is the only
704
+ // reason `goal` is reachable at all.
705
+ let header = full_session();
706
+ let (event, ts) = transform::agent_start(&header, &ctx(), 0).unwrap();
707
+ assert_eq!(event["type"], "agent_start");
708
+ assert_eq!(event["timestamp"], "2026-08-03T07:56:17.824000Z");
709
+ assert_eq!(ts.as_deref(), Some("2026-08-03T07:56:17.824Z"));
710
+ assert_eq!(
711
+ event["goal"],
712
+ "List the files in this directory, then read README.md."
713
+ );
714
+ assert_eq!(event["copilot_cwd"], "/tmp/probe-copilot");
715
+ assert_eq!(event["copilot_version"], "1.0.77");
716
+ }
717
+
718
+ #[test]
719
+ fn an_agent_end_sorts_after_every_event_that_shares_its_timestamp() {
720
+ // A crashed session has no session.shutdown record, so agent_end lands on
721
+ // the exact timestamp of the last real event. The server's tie-break is a
722
+ // random id, so without the index the session appears to end before its
723
+ // own last turn.
724
+ let end = transform::agent_end(&ctx(), "2026-08-03T07:56:28.241Z", 39068);
725
+ assert_eq!(end["timestamp"], "2026-08-03T07:56:28.241999Z");
726
+ assert_eq!(end["type"], "agent_end");
727
+ assert_eq!(end["copilot_line_offset"], 39068);
728
+ }
729
+
730
+ #[test]
731
+ fn copilot_declares_itself_byte_tailable() {
732
+ // Proven, not assumed: a session was captured, resumed, and captured
733
+ // again — same inode, and the first 39,797 bytes of the resumed file are
734
+ // md5-identical to the whole pre-resume file. Two of the CLIs probed for
735
+ // this engine rewrite their transcripts silently, so this is the switch if
736
+ // Copilot ever joins them.
737
+ assert_eq!(copilot::FORMAT.reread, RereadPolicy::ByteCursor);
738
+ }
739
+
740
+ #[test]
741
+ fn the_session_state_root_honours_copilot_home() {
742
+ // Same override the CLI and lib/copilot-sessions.ts honour, so a relocated
743
+ // Copilot home stays covered by both halves of the product.
744
+ //
745
+ // Serialised into one test because env vars are process-global.
746
+ let root = copilot::session_state_root();
747
+ assert!(root.ends_with("session-state"), "got {}", root.display());
748
+
749
+ // SAFETY: single-threaded within this test, and restored before it ends.
750
+ let previous = std::env::var_os("COPILOT_HOME");
751
+ unsafe { std::env::set_var("COPILOT_HOME", "/custom/copilot") };
752
+ assert_eq!(
753
+ copilot::session_state_root(),
754
+ PathBuf::from("/custom/copilot/session-state")
755
+ );
756
+ match previous {
757
+ Some(v) => unsafe { std::env::set_var("COPILOT_HOME", v) },
758
+ None => unsafe { std::env::remove_var("COPILOT_HOME") },
759
+ }
760
+ }
761
+
762
+ // ── engine ───────────────────────────────────────────────────────────────
763
+
764
+ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
765
+ Spec {
766
+ format: copilot::FORMAT,
767
+ roots: vec![root],
768
+ spool_dir: spool,
769
+ state_dir: state,
770
+ poll_interval: Duration::from_millis(200),
771
+ params: Params {
772
+ agent_id: copilot::DEFAULT_AGENT_ID.into(),
773
+ environment: "local".into(),
774
+ end_idle_mins: 0, // end immediately, so the test need not wait
775
+ max_read_bytes: 8 * 1024 * 1024,
776
+ max_batch_bytes: 8 * 1024 * 1024,
777
+ since_days: None,
778
+ },
779
+ }
780
+ }
781
+
782
+ fn spooled(dir: &Path) -> Vec<Value> {
783
+ let mut out = Vec::new();
784
+ let Ok(rd) = fs::read_dir(dir) else {
785
+ return out;
786
+ };
787
+ for e in rd.filter_map(|e| e.ok()) {
788
+ let p = e.path();
789
+ if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
790
+ continue;
791
+ }
792
+ for l in fs::read_to_string(&p).unwrap().lines() {
793
+ if !l.trim().is_empty() {
794
+ out.push(serde_json::from_str(l).unwrap());
795
+ }
796
+ }
797
+ }
798
+ out
799
+ }
800
+
801
+ fn clear(dir: &Path) {
802
+ for e in fs::read_dir(dir).unwrap().filter_map(|e| e.ok()) {
803
+ fs::remove_file(e.path()).ok();
804
+ }
805
+ }
806
+
807
+ async fn run_briefly(s: Spec, ms: u64) {
808
+ let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
809
+ let _ = tokio::time::timeout(Duration::from_millis(ms), filetail::run(s, sd)).await;
810
+ }
811
+
812
+ /// Lay out a session exactly as Copilot does, siblings and all.
813
+ fn write_session(root: &Path, lines: &[String]) -> PathBuf {
814
+ let dir = root.join(SESSION);
815
+ fs::create_dir_all(dir.join("checkpoints")).unwrap();
816
+ fs::create_dir_all(dir.join("files")).unwrap();
817
+ fs::write(
818
+ dir.join("workspace.yaml"),
819
+ format!("id: {SESSION}\ncwd: /tmp/probe-copilot\n"),
820
+ )
821
+ .unwrap();
822
+ fs::write(dir.join("checkpoints/index.md"), "# Checkpoint History\n").unwrap();
823
+ fs::write(dir.join("session.db"), b"SQLite format 3\0garbage").unwrap();
824
+ let p = dir.join("events.jsonl");
825
+ fs::write(&p, lines.join("\n") + "\n").unwrap();
826
+ p
827
+ }
828
+
829
+ #[tokio::test(flavor = "multi_thread")]
830
+ async fn a_session_produces_a_start_its_turns_and_an_end() {
831
+ let root = tmpdir("full-root");
832
+ let spool = tmpdir("full-spool");
833
+ let state = tmpdir("full-state");
834
+ write_session(&root, &full_session());
835
+
836
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
837
+
838
+ let ev = spooled(&spool);
839
+ let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
840
+ for want in [
841
+ "agent_start",
842
+ "model_request",
843
+ "model_response",
844
+ "tool_use",
845
+ "tool_result",
846
+ "agent_end",
847
+ ] {
848
+ assert!(types.contains(&want), "missing {want}, got {types:?}");
849
+ }
850
+
851
+ // Every event carries the session id from the directory name, so hook
852
+ // events for the same run land on this timeline.
853
+ for e in &ev {
854
+ assert_eq!(e["session_id"], SESSION);
855
+ assert_eq!(
856
+ e["agent_id"], "copilot-probe-copilot",
857
+ "agent id must come from the transcript's cwd"
858
+ );
859
+ // Ingest parses one timestamp shape.
860
+ let ts = e["timestamp"].as_str().unwrap();
861
+ assert!(ts.ends_with('Z') && ts.len() == 27, "bad timestamp {ts}");
862
+ }
863
+
864
+ fs::remove_dir_all(&root).ok();
865
+ fs::remove_dir_all(&spool).ok();
866
+ fs::remove_dir_all(&state).ok();
867
+ }
868
+
869
+ #[tokio::test(flavor = "multi_thread")]
870
+ async fn the_siblings_in_a_session_directory_are_never_tailed() {
871
+ // workspace.yaml and checkpoints/index.md are rewritten in place and
872
+ // session.db is SQLite; tailing any of them emits garbage forever.
873
+ let root = tmpdir("siblings-root");
874
+ let spool = tmpdir("siblings-spool");
875
+ let state = tmpdir("siblings-state");
876
+ write_session(&root, &full_session());
877
+ fs::create_dir_all(root.join("logs")).unwrap();
878
+ fs::write(
879
+ root.join("logs/process-1785743776226-130839.log"),
880
+ "[INFO] x\n",
881
+ )
882
+ .unwrap();
883
+ fs::write(root.join("session-store.db-wal"), b"\0\0binary").unwrap();
884
+
885
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
886
+
887
+ let ev = spooled(&spool);
888
+ assert!(!ev.is_empty(), "the transcript itself must still ship");
889
+ for e in &ev {
890
+ let text = e.to_string();
891
+ assert!(!text.contains("Checkpoint History"), "checkpoint leaked");
892
+ assert!(!text.contains("[INFO]"), "process log leaked");
893
+ assert!(!text.contains("SQLite"), "session.db leaked");
894
+ assert!(!text.contains("cwd: /tmp"), "workspace.yaml leaked");
895
+ }
896
+
897
+ fs::remove_dir_all(&root).ok();
898
+ fs::remove_dir_all(&spool).ok();
899
+ fs::remove_dir_all(&state).ok();
900
+ }
901
+
902
+ #[tokio::test(flavor = "multi_thread")]
903
+ async fn a_second_pass_ships_nothing_new() {
904
+ let root = tmpdir("resume-root");
905
+ let spool = tmpdir("resume-spool");
906
+ let state = tmpdir("resume-state");
907
+ write_session(&root, &full_session());
908
+
909
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
910
+ assert!(!spooled(&spool).is_empty());
911
+ clear(&spool);
912
+
913
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
914
+ assert!(
915
+ spooled(&spool).is_empty(),
916
+ "a resumed pass must ship nothing"
917
+ );
918
+
919
+ fs::remove_dir_all(&root).ok();
920
+ fs::remove_dir_all(&spool).ok();
921
+ fs::remove_dir_all(&state).ok();
922
+ }
923
+
924
+ #[tokio::test(flavor = "multi_thread")]
925
+ async fn a_resume_appends_and_only_the_appended_records_ship() {
926
+ // The real resume sequence, byte-for-byte: the pre-resume prefix is
927
+ // untouched (verified on disk by md5), and the second half is appended.
928
+ // Re-shipping the prefix — including its second 31 KB system prompt —
929
+ // is exactly what a byte cursor is here to avoid.
930
+ let root = tmpdir("append-root");
931
+ let spool = tmpdir("append-spool");
932
+ let state = tmpdir("append-state");
933
+ let path = write_session(&root, &full_session());
934
+
935
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
936
+ let first = spooled(&spool);
937
+ assert!(first.iter().any(|e| e["type"] == "agent_start"));
938
+ clear(&spool);
939
+
940
+ let mut body = fs::read_to_string(&path).unwrap();
941
+ for line in [
942
+ session_resume("2026-08-03T07:57:34.498Z"),
943
+ model_change("2026-08-03T07:57:35.963Z", "claude-sonnet-4"),
944
+ system_message("2026-08-03T07:57:36.059Z"),
945
+ user_message("2026-08-03T07:57:36.142Z", "now read src/fib.py"),
946
+ assistant_text("2026-08-03T07:57:46.996Z", "The function is fib.", 31),
947
+ shutdown("2026-08-03T07:57:47.068Z"),
948
+ ] {
949
+ body.push_str(&line);
950
+ body.push('\n');
951
+ }
952
+ fs::write(&path, body).unwrap();
953
+
954
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
955
+ let ev = spooled(&spool);
956
+ assert!(
957
+ ev.iter()
958
+ .any(|e| e["messages"][0]["content"] == "now read src/fib.py"),
959
+ "the post-resume prompt must ship"
960
+ );
961
+ assert!(
962
+ !ev.iter().any(|e| e["messages"][0]["content"]
963
+ == "List the files in this directory, then read README.md."),
964
+ "the pre-resume prompt must NOT be re-shipped"
965
+ );
966
+ assert!(
967
+ !ev.iter().any(|e| e["type"] == "agent_start"),
968
+ "a resume must not open a second session"
969
+ );
970
+
971
+ fs::remove_dir_all(&root).ok();
972
+ fs::remove_dir_all(&spool).ok();
973
+ fs::remove_dir_all(&state).ok();
974
+ }
975
+
976
+ #[tokio::test(flavor = "multi_thread")]
977
+ async fn a_partially_written_final_line_is_held_back_then_picked_up() {
978
+ // Half a JSON object parses as nothing, and consuming it would skip the
979
+ // record when it completes — which for Copilot means losing a whole turn.
980
+ let root = tmpdir("partial-root");
981
+ let spool = tmpdir("partial-spool");
982
+ let state = tmpdir("partial-state");
983
+
984
+ let mut head = full_session();
985
+ head.truncate(4); // through the first user.message
986
+ let path = write_session(&root, &head);
987
+ let complete = fs::read_to_string(&path).unwrap();
988
+ fs::write(
989
+ &path,
990
+ format!("{complete}{{\"type\":\"assistant.message\",\"timest"),
991
+ )
992
+ .unwrap();
993
+
994
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
995
+ let n1 = spooled(&spool)
996
+ .iter()
997
+ .filter(|e| e["type"] == "model_response")
998
+ .count();
999
+ assert_eq!(n1, 0, "the half-written assistant turn must not ship");
1000
+ clear(&spool);
1001
+
1002
+ fs::write(
1003
+ &path,
1004
+ format!(
1005
+ "{complete}{}\n",
1006
+ assistant_text("2026-08-03T07:56:28.171Z", "finished", 80)
1007
+ ),
1008
+ )
1009
+ .unwrap();
1010
+ run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
1011
+ assert!(
1012
+ spooled(&spool).iter().any(|e| e["content"] == "finished"),
1013
+ "the completed line must ship on a later pass"
1014
+ );
1015
+
1016
+ fs::remove_dir_all(&root).ok();
1017
+ fs::remove_dir_all(&spool).ok();
1018
+ fs::remove_dir_all(&state).ok();
1019
+ }