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