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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- 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
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /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
|
+
}
|