failproofai 1.0.0-beta.2 → 1.0.0-beta.4
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 +2 -2
- 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 +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 +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 +1 -1
- 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 +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/[externals]__0h9_83k._.js +3 -0
- package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.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/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
- 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/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
- 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]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.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]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
- 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]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.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]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
- 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_1q9xxca.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -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/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
- package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
- package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
- package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
- 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 +15 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2255 -779
- package/dist/worker.mjs +365 -52
- package/package.json +15 -9
- package/scripts/build-daemon-packages.mjs +210 -0
- package/scripts/daemon-platforms.mjs +42 -0
- package/scripts/prune-standalone.mjs +6 -0
- package/scripts/publish-aliases.mjs +62 -41
- 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/daemon-download.ts +137 -27
- package/src/hooks/daemon-service.ts +23 -8
- 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/server/chunks/[externals]__14odj07._.js +0 -3
- package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,1191 @@
|
|
|
1
|
+
//! Pi source: transform correctness and engine behaviour.
|
|
2
|
+
//!
|
|
3
|
+
//! Record shapes are verbatim from real transcripts captured under
|
|
4
|
+
//! `~/.pi/agent/sessions/`, from BOTH pi-coding-agent 0.73.1 and 0.83.0. The
|
|
5
|
+
//! two releases write the same grammar with one visible difference — 0.83.0
|
|
6
|
+
//! emits leading prose alongside its tool calls where 0.73.1 emitted calls
|
|
7
|
+
//! alone — so both are exercised here rather than one standing in for the
|
|
8
|
+
//! other.
|
|
9
|
+
|
|
10
|
+
use std::fs;
|
|
11
|
+
use std::path::{Path, PathBuf};
|
|
12
|
+
use std::sync::Arc;
|
|
13
|
+
use std::sync::atomic::AtomicBool;
|
|
14
|
+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
15
|
+
|
|
16
|
+
use fpai_collect::cursor::TailState;
|
|
17
|
+
use fpai_collect::filetail::{self, Ctx, Params, RereadPolicy, Spec};
|
|
18
|
+
use fpai_collect::sources::pi::{self, transform};
|
|
19
|
+
use fpai_collect::supervisor::Shutdown;
|
|
20
|
+
use serde_json::{Value, json};
|
|
21
|
+
|
|
22
|
+
fn tmpdir(name: &str) -> PathBuf {
|
|
23
|
+
let d = std::env::temp_dir().join(format!(
|
|
24
|
+
"fpai-pi-{}-{}-{}",
|
|
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
|
+
/// The session uuid of the real 0.73.1 tool-using transcript.
|
|
37
|
+
const UUID: &str = "019fc6a6-9eb6-744b-a700-58ea5b3e8ff9";
|
|
38
|
+
/// pi's encoding of `/tmp/probe-pi`. Decoding it naively yields `/tmp/probe/pi`
|
|
39
|
+
/// and therefore the project name `pi` — which is exactly the mistake the
|
|
40
|
+
/// header `cwd` exists to prevent.
|
|
41
|
+
const ENCODED_CWD: &str = "--tmp-probe-pi--";
|
|
42
|
+
|
|
43
|
+
fn ctx() -> Ctx {
|
|
44
|
+
Ctx {
|
|
45
|
+
session_id: UUID.into(),
|
|
46
|
+
agent_id: "pi-probe-pi".into(),
|
|
47
|
+
environment: "local".into(),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── real record shapes ───────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
fn session_header(ts: &str) -> String {
|
|
54
|
+
json!({"type":"session","version":3,"id":UUID,"timestamp":ts,"cwd":"/tmp/probe-pi"}).to_string()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn model_change(ts: &str) -> String {
|
|
58
|
+
json!({"type":"model_change","id":"3c651a37","parentId":null,"timestamp":ts,
|
|
59
|
+
"provider":"aikin","modelId":"claude-sonnet-4-6"})
|
|
60
|
+
.to_string()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fn thinking_level_change(ts: &str) -> String {
|
|
64
|
+
json!({"type":"thinking_level_change","id":"b42de093","parentId":"3c651a37",
|
|
65
|
+
"timestamp":ts,"thinkingLevel":"off"})
|
|
66
|
+
.to_string()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn user_prompt(ts: &str, text: &str) -> String {
|
|
70
|
+
json!({"type":"message","id":"0ecb7564","parentId":"b42de093","timestamp":ts,
|
|
71
|
+
"message":{"role":"user","content":[{"type":"text","text":text}],
|
|
72
|
+
"timestamp":1785744236236u64}})
|
|
73
|
+
.to_string()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// A 0.73.1 assistant turn: tool calls only, no prose.
|
|
77
|
+
fn assistant_tool_calls(ts: &str, calls: &[(&str, &str)]) -> String {
|
|
78
|
+
let content: Vec<Value> = calls
|
|
79
|
+
.iter()
|
|
80
|
+
.map(|(id, name)| {
|
|
81
|
+
json!({"type":"toolCall","id":id,"name":name,
|
|
82
|
+
"arguments":{"command":"ls -la /tmp/probe-pi"}})
|
|
83
|
+
})
|
|
84
|
+
.collect();
|
|
85
|
+
json!({"type":"message","id":"81470a2e","parentId":"0ecb7564","timestamp":ts,
|
|
86
|
+
"message":{"role":"assistant","content":content,"api":"anthropic-messages",
|
|
87
|
+
"provider":"aikin","model":"claude-sonnet-4-6",
|
|
88
|
+
"usage":{"input":3,"output":103,"cacheRead":2547,"cacheWrite":339,
|
|
89
|
+
"totalTokens":2992,"cost":{"input":0,"output":0,"cacheRead":0,
|
|
90
|
+
"cacheWrite":0,"total":0}},
|
|
91
|
+
"stopReason":"toolUse","timestamp":1785744236260u64,
|
|
92
|
+
"responseId":"msg_bdrk_01Y324EoS5bmSs86938K3grV"}})
|
|
93
|
+
.to_string()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// A 0.83.0 assistant turn: prose FIRST, then the calls, plus the fields that
|
|
97
|
+
/// release added (`cacheWrite1h`, `rawStopReason`).
|
|
98
|
+
fn assistant_prose_then_calls(ts: &str, prose: &str, calls: &[(&str, &str)]) -> String {
|
|
99
|
+
let mut content = vec![json!({"type":"text","text":prose})];
|
|
100
|
+
content.extend(calls.iter().map(|(id, name)| {
|
|
101
|
+
json!({"type":"toolCall","id":id,"name":name,
|
|
102
|
+
"arguments":{"path":"/tmp/probe-pi83/README.md"}})
|
|
103
|
+
}));
|
|
104
|
+
json!({"type":"message","id":"d3794c08","parentId":"f0536fe0","timestamp":ts,
|
|
105
|
+
"message":{"role":"assistant","content":content,"api":"anthropic-messages",
|
|
106
|
+
"provider":"aikin","model":"claude-sonnet-4-6",
|
|
107
|
+
"usage":{"input":3,"output":114,"cacheRead":0,"cacheWrite":2930,
|
|
108
|
+
"totalTokens":3047,"cost":{"input":0,"output":0,"cacheRead":0,
|
|
109
|
+
"cacheWrite":0,"total":0},"cacheWrite1h":0},
|
|
110
|
+
"stopReason":"toolUse","timestamp":1785744818713u64,
|
|
111
|
+
"responseId":"msg_bdrk_01MkBvo6F9ViUeCuGGvnsTcj","rawStopReason":"tool_use"}})
|
|
112
|
+
.to_string()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn assistant_text(ts: &str, text: &str) -> String {
|
|
116
|
+
json!({"type":"message","id":"0436eda2","parentId":"f6ce1248","timestamp":ts,
|
|
117
|
+
"message":{"role":"assistant","content":[{"type":"text","text":text}],
|
|
118
|
+
"api":"anthropic-messages","provider":"aikin","model":"claude-sonnet-4-6",
|
|
119
|
+
"usage":{"input":1,"output":14,"cacheRead":3392,"cacheWrite":78,
|
|
120
|
+
"totalTokens":3485,"cost":{"input":0,"output":0,"cacheRead":0,
|
|
121
|
+
"cacheWrite":0,"total":0}},
|
|
122
|
+
"stopReason":"stop","timestamp":1785744268202u64,
|
|
123
|
+
"responseId":"msg_bdrk_01VdNUJ8bNnNiJvvfDVTD8J6"}})
|
|
124
|
+
.to_string()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// The real failed turn: EMPTY content, `stopReason:"error"`, no `responseId`.
|
|
128
|
+
fn assistant_error(ts: &str, message: &str) -> String {
|
|
129
|
+
json!({"type":"message","id":"95487fe5","parentId":"c1e2d2d2","timestamp":ts,
|
|
130
|
+
"message":{"role":"assistant","content":[],"api":"anthropic-messages",
|
|
131
|
+
"provider":"aikin","model":"claude-sonnet-4-6",
|
|
132
|
+
"usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,
|
|
133
|
+
"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,
|
|
134
|
+
"cacheWrite":0,"total":0}},
|
|
135
|
+
"stopReason":"error","timestamp":1785743864654u64,
|
|
136
|
+
"errorMessage":message}})
|
|
137
|
+
.to_string()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn tool_result(ts: &str, call_id: &str, name: &str, out: &str, is_error: bool) -> String {
|
|
141
|
+
json!({"type":"message","id":"fe29ac29","parentId":"81470a2e","timestamp":ts,
|
|
142
|
+
"message":{"role":"toolResult","toolCallId":call_id,"toolName":name,
|
|
143
|
+
"content":[{"type":"text","text":out}],"isError":is_error,
|
|
144
|
+
"timestamp":1785744238967u64}})
|
|
145
|
+
.to_string()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Same record with `toolName` stripped — the shape the carried name has to
|
|
149
|
+
/// cover.
|
|
150
|
+
fn tool_result_unnamed(ts: &str, call_id: &str, out: &str) -> String {
|
|
151
|
+
json!({"type":"message","id":"fe29ac29","parentId":"81470a2e","timestamp":ts,
|
|
152
|
+
"message":{"role":"toolResult","toolCallId":call_id,
|
|
153
|
+
"content":[{"type":"text","text":out}],"isError":false,
|
|
154
|
+
"timestamp":1785744238967u64}})
|
|
155
|
+
.to_string()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn of_type<'a>(events: &'a [Value], kind: &str) -> Vec<&'a Value> {
|
|
159
|
+
events.iter().filter(|e| e["type"] == kind).collect()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── transform ────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
#[test]
|
|
165
|
+
fn a_millisecond_timestamp_is_padded_to_microseconds() {
|
|
166
|
+
// pi writes `new Date().toISOString()`, i.e. milliseconds; the hook source
|
|
167
|
+
// emits microseconds. Both streams share a session timeline, so they must
|
|
168
|
+
// sort against each other.
|
|
169
|
+
assert_eq!(
|
|
170
|
+
transform::with_index("2026-08-03T08:03:56.214Z", 0).unwrap(),
|
|
171
|
+
"2026-08-03T08:03:56.214000Z"
|
|
172
|
+
);
|
|
173
|
+
// The index offset orders several events derived from ONE record.
|
|
174
|
+
assert_eq!(
|
|
175
|
+
transform::with_index("2026-08-03T08:03:56.214Z", 2).unwrap(),
|
|
176
|
+
"2026-08-03T08:03:56.214002Z"
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn the_index_offset_saturates_inside_its_second() {
|
|
182
|
+
// Carrying into the next second could reorder an event past a genuinely
|
|
183
|
+
// later record, which is worse than the tie it is fixing.
|
|
184
|
+
let s = transform::with_index("2026-08-03T08:03:56.999999Z", 999).unwrap();
|
|
185
|
+
assert_eq!(s, "2026-08-03T08:03:56.999999Z");
|
|
186
|
+
assert!(s.starts_with("2026-08-03T08:03:56."));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[test]
|
|
190
|
+
fn a_model_change_record_primes_the_model_for_the_first_prompt() {
|
|
191
|
+
// This is why this format needs no `seed_state`. pi writes `model_change`
|
|
192
|
+
// BEFORE the session's first user message, so the opening row already has
|
|
193
|
+
// a model — and the server builds a model_request row's summary from the
|
|
194
|
+
// model alone, so without one the very first row of every session renders
|
|
195
|
+
// blank. Seeding out of band (what the Claude source has to do) is the one
|
|
196
|
+
// part of the format table that is not dedup-safe across a re-read.
|
|
197
|
+
let mut st = TailState::default();
|
|
198
|
+
let c = ctx();
|
|
199
|
+
|
|
200
|
+
let (ts, ev) =
|
|
201
|
+
transform::transform_line(&model_change("2026-08-03T08:03:56.232Z"), &c, 140, &mut st);
|
|
202
|
+
assert_eq!(ts.as_deref(), Some("2026-08-03T08:03:56.232Z"));
|
|
203
|
+
assert!(ev.is_empty(), "a model change is not itself a turn");
|
|
204
|
+
assert_eq!(st.last_model.as_deref(), Some("claude-sonnet-4-6"));
|
|
205
|
+
|
|
206
|
+
let (_, ev) = transform::transform_line(
|
|
207
|
+
&user_prompt("2026-08-03T08:03:56.238Z", "hello"),
|
|
208
|
+
&c,
|
|
209
|
+
300,
|
|
210
|
+
&mut st,
|
|
211
|
+
);
|
|
212
|
+
assert_eq!(ev.len(), 1);
|
|
213
|
+
assert_eq!(ev[0]["type"], "model_request");
|
|
214
|
+
assert_eq!(ev[0]["model"], "claude-sonnet-4-6");
|
|
215
|
+
assert_eq!(ev[0]["messages"][0]["content"], "hello");
|
|
216
|
+
assert_eq!(ev[0]["messages"][0]["role"], "user");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[test]
|
|
220
|
+
fn a_user_prompt_spread_over_several_text_blocks_is_one_request() {
|
|
221
|
+
// pi's user content is an ARRAY, unlike Claude Code's bare string. Emitting
|
|
222
|
+
// one row per block would split a single prompt into several turns.
|
|
223
|
+
let mut st = TailState::default();
|
|
224
|
+
let line = json!({"type":"message","id":"a","parentId":null,
|
|
225
|
+
"timestamp":"2026-08-03T08:03:56.238Z",
|
|
226
|
+
"message":{"role":"user","content":[
|
|
227
|
+
{"type":"text","text":"first"},{"type":"text","text":"second"}]}})
|
|
228
|
+
.to_string();
|
|
229
|
+
let (_, ev) = transform::transform_line(&line, &ctx(), 0, &mut st);
|
|
230
|
+
assert_eq!(ev.len(), 1);
|
|
231
|
+
assert_eq!(ev[0]["messages"][0]["content"], "first\nsecond");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn a_tool_call_and_its_result_are_paired_by_id_not_by_arrival_order() {
|
|
236
|
+
// One assistant record issues SEVERAL calls, and each result is its own
|
|
237
|
+
// later record. Pairing positionally happens to work on the transcripts we
|
|
238
|
+
// captured and breaks the moment two results are written out of order —
|
|
239
|
+
// silently, by attributing one tool's output to another tool's name.
|
|
240
|
+
let mut st = TailState::default();
|
|
241
|
+
let c = ctx();
|
|
242
|
+
|
|
243
|
+
let (_, calls) = transform::transform_line(
|
|
244
|
+
&assistant_tool_calls(
|
|
245
|
+
"2026-08-03T08:03:58.953Z",
|
|
246
|
+
&[
|
|
247
|
+
("toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP", "bash"),
|
|
248
|
+
("toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe", "read"),
|
|
249
|
+
],
|
|
250
|
+
),
|
|
251
|
+
&c,
|
|
252
|
+
420,
|
|
253
|
+
&mut st,
|
|
254
|
+
);
|
|
255
|
+
assert_eq!(calls.len(), 2);
|
|
256
|
+
assert_eq!(calls[0]["tool_name"], "bash");
|
|
257
|
+
assert_eq!(calls[1]["tool_name"], "read");
|
|
258
|
+
|
|
259
|
+
// Results delivered in the REVERSE of the call order.
|
|
260
|
+
let (_, second) = transform::transform_line(
|
|
261
|
+
&tool_result_unnamed(
|
|
262
|
+
"2026-08-03T08:03:58.967Z",
|
|
263
|
+
"toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe",
|
|
264
|
+
"# Probe Pi\n",
|
|
265
|
+
),
|
|
266
|
+
&c,
|
|
267
|
+
900,
|
|
268
|
+
&mut st,
|
|
269
|
+
);
|
|
270
|
+
let (_, first) = transform::transform_line(
|
|
271
|
+
&tool_result_unnamed(
|
|
272
|
+
"2026-08-03T08:03:58.967Z",
|
|
273
|
+
"toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP",
|
|
274
|
+
"total 144\n",
|
|
275
|
+
),
|
|
276
|
+
&c,
|
|
277
|
+
1200,
|
|
278
|
+
&mut st,
|
|
279
|
+
);
|
|
280
|
+
assert_eq!(second[0]["tool_name"], "read", "paired by id, not position");
|
|
281
|
+
assert_eq!(first[0]["tool_name"], "bash", "paired by id, not position");
|
|
282
|
+
assert_eq!(second[0]["output"], "# Probe Pi\n");
|
|
283
|
+
assert_eq!(first[0]["output"], "total 144\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[test]
|
|
287
|
+
fn a_tool_result_missing_its_tool_name_is_still_not_a_blank_row() {
|
|
288
|
+
// pi does name the tool on the result record, so the carried name is pure
|
|
289
|
+
// defence — but the server builds a result row's summary from the tool name
|
|
290
|
+
// alone, so the day a writer omits it every result becomes an unreadable
|
|
291
|
+
// row. This is what `TailState::pending_tools` is for.
|
|
292
|
+
let mut st = TailState::default();
|
|
293
|
+
let c = ctx();
|
|
294
|
+
transform::transform_line(
|
|
295
|
+
&assistant_tool_calls("2026-08-03T08:19:37.583Z", &[("toolu_01", "bash")]),
|
|
296
|
+
&c,
|
|
297
|
+
0,
|
|
298
|
+
&mut st,
|
|
299
|
+
);
|
|
300
|
+
let (_, ev) = transform::transform_line(
|
|
301
|
+
&tool_result_unnamed("2026-08-03T08:19:37.592Z", "toolu_01", "TIMING_MARKER\n"),
|
|
302
|
+
&c,
|
|
303
|
+
500,
|
|
304
|
+
&mut st,
|
|
305
|
+
);
|
|
306
|
+
assert_eq!(ev[0]["type"], "tool_result");
|
|
307
|
+
assert_eq!(ev[0]["tool_name"], "bash", "the name must survive the call");
|
|
308
|
+
assert_eq!(ev[0]["tool_call_id"], "toolu_01");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#[test]
|
|
312
|
+
fn a_carried_tool_name_survives_the_cursor_round_trip() {
|
|
313
|
+
// The call and its result can land in different polls, and between them the
|
|
314
|
+
// carried state is serialized into the cursor file. If it did not round
|
|
315
|
+
// trip, a live tail and a full re-read would disagree at the same offset
|
|
316
|
+
// and the server could no longer collapse the re-read.
|
|
317
|
+
let mut st = TailState::default();
|
|
318
|
+
transform::transform_line(
|
|
319
|
+
&assistant_tool_calls("2026-08-03T08:19:37.583Z", &[("toolu_01", "bash")]),
|
|
320
|
+
&ctx(),
|
|
321
|
+
0,
|
|
322
|
+
&mut st,
|
|
323
|
+
);
|
|
324
|
+
let encoded = serde_json::to_string(&st).unwrap();
|
|
325
|
+
let mut restored: TailState = serde_json::from_str(&encoded).unwrap();
|
|
326
|
+
assert_eq!(restored.tool_name("toolu_01"), Some("bash"));
|
|
327
|
+
|
|
328
|
+
let (_, ev) = transform::transform_line(
|
|
329
|
+
&tool_result_unnamed("2026-08-03T08:19:37.592Z", "toolu_01", "ok"),
|
|
330
|
+
&ctx(),
|
|
331
|
+
500,
|
|
332
|
+
&mut restored,
|
|
333
|
+
);
|
|
334
|
+
assert_eq!(ev[0]["tool_name"], "bash");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn an_assistant_turn_that_mixes_prose_with_tool_calls_ships_all_of_it() {
|
|
339
|
+
// The 0.83.0 case: `["text","toolCall","toolCall"]` where 0.73.1 wrote
|
|
340
|
+
// `["toolCall","toolCall"]` for the same prompt. Anything that inspects
|
|
341
|
+
// content[0] to classify the turn drops the prose on one release and the
|
|
342
|
+
// calls on the other.
|
|
343
|
+
let mut st = TailState::default();
|
|
344
|
+
let (_, ev) = transform::transform_line(
|
|
345
|
+
&assistant_prose_then_calls(
|
|
346
|
+
"2026-08-03T08:13:41.674Z",
|
|
347
|
+
"I'll do both at the same time!",
|
|
348
|
+
&[
|
|
349
|
+
("toolu_bdrk_01BLAbzzkxkrZS6nPF6nNQQy", "bash"),
|
|
350
|
+
("toolu_bdrk_01BBuVKvhJ7McsBpy9txBJLC", "read"),
|
|
351
|
+
],
|
|
352
|
+
),
|
|
353
|
+
&ctx(),
|
|
354
|
+
700,
|
|
355
|
+
&mut st,
|
|
356
|
+
);
|
|
357
|
+
assert_eq!(ev.len(), 3, "got {ev:#?}");
|
|
358
|
+
assert_eq!(ev[0]["type"], "model_response");
|
|
359
|
+
assert_eq!(ev[0]["content"], "I'll do both at the same time!");
|
|
360
|
+
assert_eq!(ev[0]["pi_stop_reason"], "toolUse");
|
|
361
|
+
assert_eq!(ev[1]["type"], "tool_use");
|
|
362
|
+
assert_eq!(ev[1]["tool_name"], "bash");
|
|
363
|
+
assert_eq!(ev[2]["type"], "tool_use");
|
|
364
|
+
assert_eq!(ev[2]["tool_name"], "read");
|
|
365
|
+
// pi names the payload `arguments`; ingest reads `input`.
|
|
366
|
+
assert_eq!(ev[2]["input"]["path"], "/tmp/probe-pi83/README.md");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#[test]
|
|
370
|
+
fn several_events_from_one_record_stay_in_order_on_the_timeline() {
|
|
371
|
+
// They all share one millisecond, and the server's tie-break is a random
|
|
372
|
+
// id — so without the index offset a turn comes back with its tool calls
|
|
373
|
+
// ahead of the sentence that introduced them.
|
|
374
|
+
let mut st = TailState::default();
|
|
375
|
+
let (_, ev) = transform::transform_line(
|
|
376
|
+
&assistant_prose_then_calls(
|
|
377
|
+
"2026-08-03T08:13:41.674Z",
|
|
378
|
+
"doing both",
|
|
379
|
+
&[("a", "bash"), ("b", "read")],
|
|
380
|
+
),
|
|
381
|
+
&ctx(),
|
|
382
|
+
700,
|
|
383
|
+
&mut st,
|
|
384
|
+
);
|
|
385
|
+
let stamps: Vec<&str> = ev
|
|
386
|
+
.iter()
|
|
387
|
+
.map(|e| e["timestamp"].as_str().unwrap())
|
|
388
|
+
.collect();
|
|
389
|
+
assert_eq!(
|
|
390
|
+
stamps,
|
|
391
|
+
vec![
|
|
392
|
+
"2026-08-03T08:13:41.674000Z",
|
|
393
|
+
"2026-08-03T08:13:41.674001Z",
|
|
394
|
+
"2026-08-03T08:13:41.674002Z"
|
|
395
|
+
]
|
|
396
|
+
);
|
|
397
|
+
assert!(ev[0].get("pi_block_index").is_none(), "index 0 is implicit");
|
|
398
|
+
assert_eq!(ev[1]["pi_block_index"], 1);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[test]
|
|
402
|
+
fn parallel_calls_that_carry_no_id_do_not_collapse_into_one_row() {
|
|
403
|
+
// Two identical calls in one record with no id would hash to the same
|
|
404
|
+
// content and the server would keep only one, hiding half the work.
|
|
405
|
+
let mut st = TailState::default();
|
|
406
|
+
let line = json!({"type":"message","id":"a","parentId":null,
|
|
407
|
+
"timestamp":"2026-08-03T08:13:41.674Z",
|
|
408
|
+
"message":{"role":"assistant","model":"claude-sonnet-4-6","content":[
|
|
409
|
+
{"type":"toolCall","name":"bash","arguments":{"command":"ls"}},
|
|
410
|
+
{"type":"toolCall","name":"bash","arguments":{"command":"ls"}}]}})
|
|
411
|
+
.to_string();
|
|
412
|
+
let (_, ev) = transform::transform_line(&line, &ctx(), 700, &mut st);
|
|
413
|
+
assert_eq!(ev.len(), 2);
|
|
414
|
+
assert_ne!(ev[0]["tool_call_id"], ev[1]["tool_call_id"]);
|
|
415
|
+
assert_eq!(ev[0]["tool_call_id"], "pi-700-0");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#[test]
|
|
419
|
+
fn an_errored_tool_result_carries_a_non_empty_error() {
|
|
420
|
+
let mut st = TailState::default();
|
|
421
|
+
let (_, ev) = transform::transform_line(
|
|
422
|
+
&tool_result(
|
|
423
|
+
"2026-08-03T08:03:58.967Z",
|
|
424
|
+
"toolu_01",
|
|
425
|
+
"bash",
|
|
426
|
+
"exit code 127: command not found",
|
|
427
|
+
true,
|
|
428
|
+
),
|
|
429
|
+
&ctx(),
|
|
430
|
+
0,
|
|
431
|
+
&mut st,
|
|
432
|
+
);
|
|
433
|
+
assert_eq!(ev[0]["error_type"], "pi_tool_error");
|
|
434
|
+
assert!(!ev[0]["error"].as_str().unwrap().is_empty());
|
|
435
|
+
assert_eq!(ev[0]["tool_name"], "bash");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[test]
|
|
439
|
+
fn a_failed_turn_with_no_content_still_produces_a_visible_row() {
|
|
440
|
+
// Verbatim from a real 404: `content:[]`, `stopReason:"error"`, an
|
|
441
|
+
// `errorMessage`, and no `responseId`. Emitting only from content blocks
|
|
442
|
+
// leaves the prompt that triggered it as the last thing in the session and
|
|
443
|
+
// the failure entirely invisible.
|
|
444
|
+
let mut st = TailState::default();
|
|
445
|
+
let (_, ev) = transform::transform_line(
|
|
446
|
+
&assistant_error("2026-08-03T07:58:00.520Z", "404 {\"detail\":\"Not Found\"}"),
|
|
447
|
+
&ctx(),
|
|
448
|
+
0,
|
|
449
|
+
&mut st,
|
|
450
|
+
);
|
|
451
|
+
assert_eq!(ev.len(), 1, "got {ev:#?}");
|
|
452
|
+
assert_eq!(ev[0]["type"], "model_response");
|
|
453
|
+
assert_eq!(ev[0]["error_type"], "pi_model_error");
|
|
454
|
+
assert_eq!(ev[0]["content"], "404 {\"detail\":\"Not Found\"}");
|
|
455
|
+
assert_eq!(ev[0]["pi_stop_reason"], "error");
|
|
456
|
+
// Index 0 because there were no blocks to sort behind.
|
|
457
|
+
assert_eq!(ev[0]["timestamp"], "2026-08-03T07:58:00.520000Z");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#[test]
|
|
461
|
+
fn token_usage_is_billed_once_per_record_including_on_a_failed_turn() {
|
|
462
|
+
// pi writes exactly ONE record per API response, so usage is billed
|
|
463
|
+
// unconditionally — unlike the Claude source, which must gate on a message
|
|
464
|
+
// id because Claude Code repeats one usage object across several lines.
|
|
465
|
+
// Gating here would silently drop the usage of every errored turn, since
|
|
466
|
+
// those carry no `responseId` to gate on.
|
|
467
|
+
let mut st = TailState::default();
|
|
468
|
+
let c = ctx();
|
|
469
|
+
|
|
470
|
+
let (_, a) = transform::transform_line(
|
|
471
|
+
&assistant_text("2026-08-03T08:04:29.565Z", "six lines"),
|
|
472
|
+
&c,
|
|
473
|
+
0,
|
|
474
|
+
&mut st,
|
|
475
|
+
);
|
|
476
|
+
assert_eq!(a[0]["input_tokens"], 1);
|
|
477
|
+
assert_eq!(a[0]["output_tokens"], 14);
|
|
478
|
+
assert_eq!(a[0]["pi_usage"]["totalTokens"], 3485);
|
|
479
|
+
|
|
480
|
+
// A different record at a different offset bills again — two responses are
|
|
481
|
+
// two bills.
|
|
482
|
+
let (_, b) = transform::transform_line(
|
|
483
|
+
&assistant_text("2026-08-03T08:04:31.000Z", "again"),
|
|
484
|
+
&c,
|
|
485
|
+
900,
|
|
486
|
+
&mut st,
|
|
487
|
+
);
|
|
488
|
+
assert_eq!(b[0]["input_tokens"], 1);
|
|
489
|
+
|
|
490
|
+
let (_, err) = transform::transform_line(
|
|
491
|
+
&assistant_error("2026-08-03T07:58:00.520Z", "404"),
|
|
492
|
+
&c,
|
|
493
|
+
1800,
|
|
494
|
+
&mut st,
|
|
495
|
+
);
|
|
496
|
+
assert!(
|
|
497
|
+
err[0].get("pi_usage").is_some(),
|
|
498
|
+
"an errored turn still reports what it cost"
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#[test]
|
|
503
|
+
fn usage_lands_on_only_the_first_event_of_a_multi_event_record() {
|
|
504
|
+
// Three events from one response, each carrying the same usage, would
|
|
505
|
+
// triple that response's token total.
|
|
506
|
+
let mut st = TailState::default();
|
|
507
|
+
let (_, ev) = transform::transform_line(
|
|
508
|
+
&assistant_prose_then_calls(
|
|
509
|
+
"2026-08-03T08:13:41.674Z",
|
|
510
|
+
"doing both",
|
|
511
|
+
&[("a", "bash"), ("b", "read")],
|
|
512
|
+
),
|
|
513
|
+
&ctx(),
|
|
514
|
+
0,
|
|
515
|
+
&mut st,
|
|
516
|
+
);
|
|
517
|
+
assert_eq!(ev.len(), 3);
|
|
518
|
+
assert!(ev[0].get("pi_usage").is_some());
|
|
519
|
+
assert!(ev[1].get("pi_usage").is_none());
|
|
520
|
+
assert!(ev[2].get("pi_usage").is_none());
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#[test]
|
|
524
|
+
fn the_session_header_yields_no_events_but_still_starts_the_clock() {
|
|
525
|
+
// `agent_start` is built from the header lines by the engine, not from this
|
|
526
|
+
// dispatch — emitting here too would give every session two start events.
|
|
527
|
+
// The timestamp is still returned so an otherwise-empty file can end.
|
|
528
|
+
let mut st = TailState::default();
|
|
529
|
+
let (ts, ev) = transform::transform_line(
|
|
530
|
+
&session_header("2026-08-03T08:03:56.214Z"),
|
|
531
|
+
&ctx(),
|
|
532
|
+
0,
|
|
533
|
+
&mut st,
|
|
534
|
+
);
|
|
535
|
+
assert_eq!(ts.as_deref(), Some("2026-08-03T08:03:56.214Z"));
|
|
536
|
+
assert!(ev.is_empty());
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
#[test]
|
|
540
|
+
fn records_this_source_does_not_model_still_advance_the_session_clock() {
|
|
541
|
+
// EVERY pi record carries a timestamp, so the Claude source's "no timestamp
|
|
542
|
+
// means metadata" shortcut does not exist here. Returning None for these
|
|
543
|
+
// would freeze `agent_end` at the last *message*, dating a session that
|
|
544
|
+
// ended with a compaction or a label minutes before it really did.
|
|
545
|
+
let mut st = TailState::default();
|
|
546
|
+
let c = ctx();
|
|
547
|
+
for line in [
|
|
548
|
+
thinking_level_change("2026-08-03T08:03:56.232Z"),
|
|
549
|
+
json!({"type":"label","id":"a","parentId":"b","timestamp":"2026-08-03T08:05:00.000Z",
|
|
550
|
+
"targetId":"c","label":"checkpoint"})
|
|
551
|
+
.to_string(),
|
|
552
|
+
json!({"type":"compaction","id":"d","parentId":"c",
|
|
553
|
+
"timestamp":"2026-08-03T08:06:00.000Z","message":{"role":"user","content":[]}})
|
|
554
|
+
.to_string(),
|
|
555
|
+
json!({"type":"session_info","id":"e","parentId":"d",
|
|
556
|
+
"timestamp":"2026-08-03T08:07:00.000Z","title":"probe"})
|
|
557
|
+
.to_string(),
|
|
558
|
+
] {
|
|
559
|
+
let (ts, ev) = transform::transform_line(&line, &c, 0, &mut st);
|
|
560
|
+
assert!(ts.is_some(), "{line} must report its timestamp");
|
|
561
|
+
assert!(ev.is_empty(), "{line} must not become a turn");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
#[test]
|
|
566
|
+
fn a_custom_message_is_not_mistaken_for_a_conversation_turn() {
|
|
567
|
+
// `custom` (and the pre-v3 `hookMessage` the v2→v3 migration renames to it)
|
|
568
|
+
// is extension output. Falling through to the user branch would file an
|
|
569
|
+
// extension's chatter as something the human typed.
|
|
570
|
+
let mut st = TailState::default();
|
|
571
|
+
let line = json!({"type":"message","id":"a","parentId":"b",
|
|
572
|
+
"timestamp":"2026-08-03T08:05:00.000Z",
|
|
573
|
+
"message":{"role":"custom","content":[{"type":"text","text":"hook says hi"}]}})
|
|
574
|
+
.to_string();
|
|
575
|
+
let (ts, ev) = transform::transform_line(&line, &ctx(), 0, &mut st);
|
|
576
|
+
assert!(ts.is_some());
|
|
577
|
+
assert!(ev.is_empty());
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#[test]
|
|
581
|
+
fn two_identical_records_at_different_offsets_produce_different_events() {
|
|
582
|
+
// The offset is the dedup discriminator, and pi makes it easy to need: a
|
|
583
|
+
// retried prompt is byte-for-byte the same JSON. Without it the server
|
|
584
|
+
// collapses a genuinely repeated turn into one row.
|
|
585
|
+
let mut st = TailState::default();
|
|
586
|
+
let c = ctx();
|
|
587
|
+
let line = user_prompt("2026-08-03T08:03:56.238Z", "same");
|
|
588
|
+
let (_, a) = transform::transform_line(&line, &c, 10, &mut st);
|
|
589
|
+
let (_, b) = transform::transform_line(&line, &c, 999, &mut st);
|
|
590
|
+
assert_eq!(a[0]["pi_line_offset"], 10);
|
|
591
|
+
assert_eq!(b[0]["pi_line_offset"], 999);
|
|
592
|
+
assert_ne!(a[0]["pi_line_offset"], b[0]["pi_line_offset"]);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
#[test]
|
|
596
|
+
fn a_malformed_line_is_skipped_without_taking_the_file_with_it() {
|
|
597
|
+
let mut st = TailState::default();
|
|
598
|
+
let (ts, ev) = transform::transform_line("{not json", &ctx(), 0, &mut st);
|
|
599
|
+
assert!(ts.is_none());
|
|
600
|
+
assert!(ev.is_empty());
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
#[test]
|
|
604
|
+
fn the_start_event_carries_the_goal_the_cwd_and_the_session_version() {
|
|
605
|
+
let header = vec![
|
|
606
|
+
session_header("2026-08-03T08:03:56.214Z"),
|
|
607
|
+
model_change("2026-08-03T08:03:56.232Z"),
|
|
608
|
+
thinking_level_change("2026-08-03T08:03:56.232Z"),
|
|
609
|
+
user_prompt(
|
|
610
|
+
"2026-08-03T08:03:56.238Z",
|
|
611
|
+
"List the files in this directory",
|
|
612
|
+
),
|
|
613
|
+
assistant_text("2026-08-03T08:03:58.953Z", "done"),
|
|
614
|
+
];
|
|
615
|
+
let (event, ts) = transform::agent_start(&header, &ctx(), 0).unwrap();
|
|
616
|
+
assert_eq!(ts.as_deref(), Some("2026-08-03T08:03:56.214Z"));
|
|
617
|
+
assert_eq!(event["type"], "agent_start");
|
|
618
|
+
assert_eq!(event["timestamp"], "2026-08-03T08:03:56.214000Z");
|
|
619
|
+
assert_eq!(event["goal"], "List the files in this directory");
|
|
620
|
+
assert_eq!(event["pi_cwd"], "/tmp/probe-pi");
|
|
621
|
+
// A NUMBER on disk, not a string — reading it as a string yields nothing.
|
|
622
|
+
assert_eq!(event["pi_session_version"], 3);
|
|
623
|
+
assert_eq!(event["pi_model"], "claude-sonnet-4-6");
|
|
624
|
+
assert_eq!(event["pi_provider"], "aikin");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
#[test]
|
|
628
|
+
fn a_forked_session_records_the_transcript_it_was_cut_from() {
|
|
629
|
+
// Fork and branch write a NEW file and leave the source untouched, so the
|
|
630
|
+
// only on-disk link back is this header field. Dropping it makes a forked
|
|
631
|
+
// session look like it appeared from nowhere.
|
|
632
|
+
let header = vec![
|
|
633
|
+
json!({"type":"session","version":3,"id":UUID,
|
|
634
|
+
"timestamp":"2026-08-03T08:03:56.214Z","cwd":"/tmp/probe-pi",
|
|
635
|
+
"parentSession":"/home/u/.pi/agent/sessions/--tmp-probe-pi--/older.jsonl"})
|
|
636
|
+
.to_string(),
|
|
637
|
+
];
|
|
638
|
+
let (event, _) = transform::agent_start(&header, &ctx(), 0).unwrap();
|
|
639
|
+
assert_eq!(
|
|
640
|
+
event["pi_parent_session"],
|
|
641
|
+
"/home/u/.pi/agent/sessions/--tmp-probe-pi--/older.jsonl"
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
#[test]
|
|
646
|
+
fn the_end_event_sorts_after_every_turn_that_shares_its_timestamp() {
|
|
647
|
+
// The server's tie-break is a random id, so without the index a session can
|
|
648
|
+
// appear to end before its own last turn.
|
|
649
|
+
let end = transform::agent_end(&ctx(), "2026-08-03T08:04:29.565Z", 5017);
|
|
650
|
+
assert_eq!(end["type"], "agent_end");
|
|
651
|
+
// 565 ms + 999 µs, and still inside the same second.
|
|
652
|
+
assert_eq!(end["timestamp"], "2026-08-03T08:04:29.565999Z");
|
|
653
|
+
// The file size, so the one end event of a session is unique to it.
|
|
654
|
+
assert_eq!(end["pi_line_offset"], 5017);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── format table ─────────────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
#[test]
|
|
660
|
+
fn discovery_claims_session_transcripts_and_nothing_else() {
|
|
661
|
+
let is = pi::FORMAT.is_source_file;
|
|
662
|
+
let dir = format!("/s/{ENCODED_CWD}");
|
|
663
|
+
assert!(is(Path::new(&format!(
|
|
664
|
+
"{dir}/2026-08-03T08-03-56-214Z_{UUID}.jsonl"
|
|
665
|
+
))));
|
|
666
|
+
|
|
667
|
+
// No underscore-separated uuid: an export, a note, an editor backup.
|
|
668
|
+
assert!(!is(Path::new(&format!("{dir}/{UUID}.jsonl"))));
|
|
669
|
+
assert!(!is(Path::new(&format!("{dir}/notes.jsonl"))));
|
|
670
|
+
assert!(!is(Path::new(&format!(
|
|
671
|
+
"{dir}/2026-08-03T08-03-56-214Z_{UUID}.jsonl~"
|
|
672
|
+
))));
|
|
673
|
+
assert!(!is(Path::new(&format!(
|
|
674
|
+
"{dir}/2026-08-03T08-03-56-214Z_{UUID}.json"
|
|
675
|
+
))));
|
|
676
|
+
// A bare uuid with nothing before the underscore is not pi's naming.
|
|
677
|
+
assert!(!is(Path::new(&format!("{dir}/_{UUID}.jsonl"))));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
#[test]
|
|
681
|
+
fn the_session_id_is_the_uuid_after_the_underscore() {
|
|
682
|
+
// Split from the right: the timestamp half is `2026-08-03T08-03-56-214Z`,
|
|
683
|
+
// full of the same `-` the uuid uses, so only the underscore separates them.
|
|
684
|
+
assert_eq!(
|
|
685
|
+
(pi::FORMAT.session_id_from_path)(Path::new(&format!(
|
|
686
|
+
"/s/{ENCODED_CWD}/2026-08-03T08-03-56-214Z_{UUID}.jsonl"
|
|
687
|
+
)))
|
|
688
|
+
.as_deref(),
|
|
689
|
+
Some(UUID)
|
|
690
|
+
);
|
|
691
|
+
assert!((pi::FORMAT.session_id_from_path)(Path::new("/s/x/notes.jsonl")).is_none());
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
#[test]
|
|
695
|
+
fn the_agent_id_comes_from_the_header_cwd_not_the_lossy_directory_name() {
|
|
696
|
+
// pi encodes cwd by mapping every separator to `-` and leaving literal `-`
|
|
697
|
+
// alone, so the encoding is not invertible — and the loss is not a corner
|
|
698
|
+
// case. `/tmp/probe-pi` becomes `--tmp-probe-pi--`, which decodes back to
|
|
699
|
+
// `/tmp/probe/pi` and would file the session under `pi-pi`.
|
|
700
|
+
let header = vec![session_header("2026-08-03T08:03:56.214Z")];
|
|
701
|
+
let derived = (pi::FORMAT.agent_id_from_path)(
|
|
702
|
+
Path::new(&format!(
|
|
703
|
+
"/s/{ENCODED_CWD}/2026-08-03T08-03-56-214Z_{UUID}.jsonl"
|
|
704
|
+
)),
|
|
705
|
+
&header,
|
|
706
|
+
);
|
|
707
|
+
assert_eq!(derived.as_deref(), Some("pi-probe-pi"));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
#[test]
|
|
711
|
+
fn a_header_with_no_cwd_falls_back_rather_than_inventing_an_agent() {
|
|
712
|
+
// The configured default is better than a wrong guess decoded from the
|
|
713
|
+
// directory name.
|
|
714
|
+
let derived = (pi::FORMAT.agent_id_from_path)(
|
|
715
|
+
Path::new(&format!(
|
|
716
|
+
"/s/{ENCODED_CWD}/2026-08-03T08-03-56-214Z_{UUID}.jsonl"
|
|
717
|
+
)),
|
|
718
|
+
&[r#"{"type":"model_change","timestamp":"2026-08-03T08:03:56.232Z"}"#.to_string()],
|
|
719
|
+
);
|
|
720
|
+
assert!(derived.is_none());
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
#[test]
|
|
724
|
+
fn pi_seeds_no_carried_state_from_its_header() {
|
|
725
|
+
// Seeding is the one part of the format table that is NOT dedup-safe
|
|
726
|
+
// between a live tail and a later full re-read, and pi does not need it:
|
|
727
|
+
// its own record order puts `model_change` ahead of the first prompt. If
|
|
728
|
+
// this ever becomes non-empty, that tradeoff is being taken on knowingly.
|
|
729
|
+
let header = vec![
|
|
730
|
+
session_header("2026-08-03T08:03:56.214Z"),
|
|
731
|
+
model_change("2026-08-03T08:03:56.232Z"),
|
|
732
|
+
assistant_text("2026-08-03T08:03:58.953Z", "hi"),
|
|
733
|
+
];
|
|
734
|
+
let mut st = TailState::default();
|
|
735
|
+
(pi::FORMAT.seed_state)(&header, &mut st);
|
|
736
|
+
assert_eq!(st, TailState::default());
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
#[test]
|
|
740
|
+
fn pi_declares_itself_byte_tailable() {
|
|
741
|
+
// Steady-state turns are appendFileSync, so a byte cursor is correct. The
|
|
742
|
+
// whole-file `_rewriteFile()` paths shrink the file, which the engine
|
|
743
|
+
// detects on its own — see the shrink test below.
|
|
744
|
+
assert_eq!(pi::FORMAT.reread, RereadPolicy::ByteCursor);
|
|
745
|
+
assert_eq!(pi::FORMAT.kind, "pi");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
#[test]
|
|
749
|
+
fn the_sessions_root_follows_pis_own_agent_directory_override() {
|
|
750
|
+
// A relocated agent dir that we did not follow means capturing nothing,
|
|
751
|
+
// silently — the same failure mode as an absent root.
|
|
752
|
+
//
|
|
753
|
+
// Serialized against the other env-var assertions in this test by being the
|
|
754
|
+
// only one that touches PI_CODING_AGENT_DIR.
|
|
755
|
+
let saved = std::env::var_os("PI_CODING_AGENT_DIR");
|
|
756
|
+
unsafe { std::env::set_var("PI_CODING_AGENT_DIR", "/opt/pi-agent") };
|
|
757
|
+
assert_eq!(pi::sessions_root(), PathBuf::from("/opt/pi-agent/sessions"));
|
|
758
|
+
|
|
759
|
+
// pi expands the tilde itself; taking it literally would have us watching a
|
|
760
|
+
// directory named `~` under the process cwd.
|
|
761
|
+
unsafe { std::env::set_var("PI_CODING_AGENT_DIR", "~/elsewhere") };
|
|
762
|
+
let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
|
|
763
|
+
assert_eq!(pi::sessions_root(), home.join("elsewhere").join("sessions"));
|
|
764
|
+
|
|
765
|
+
match saved {
|
|
766
|
+
Some(v) => unsafe { std::env::set_var("PI_CODING_AGENT_DIR", v) },
|
|
767
|
+
None => unsafe { std::env::remove_var("PI_CODING_AGENT_DIR") },
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ── engine ───────────────────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
|
|
774
|
+
Spec {
|
|
775
|
+
format: pi::FORMAT,
|
|
776
|
+
roots: vec![root],
|
|
777
|
+
spool_dir: spool,
|
|
778
|
+
state_dir: state,
|
|
779
|
+
poll_interval: Duration::from_millis(200),
|
|
780
|
+
params: Params {
|
|
781
|
+
agent_id: pi::DEFAULT_AGENT_ID.into(),
|
|
782
|
+
environment: "local".into(),
|
|
783
|
+
end_idle_mins: 0, // end immediately, so the test need not wait
|
|
784
|
+
max_read_bytes: 8 * 1024 * 1024,
|
|
785
|
+
max_batch_bytes: 8 * 1024 * 1024,
|
|
786
|
+
since_days: None,
|
|
787
|
+
},
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
fn spooled(dir: &Path) -> Vec<Value> {
|
|
792
|
+
let mut out = Vec::new();
|
|
793
|
+
let Ok(rd) = fs::read_dir(dir) else {
|
|
794
|
+
return out;
|
|
795
|
+
};
|
|
796
|
+
for e in rd.filter_map(|e| e.ok()) {
|
|
797
|
+
let p = e.path();
|
|
798
|
+
if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
for l in fs::read_to_string(&p).unwrap().lines() {
|
|
802
|
+
if !l.trim().is_empty() {
|
|
803
|
+
out.push(serde_json::from_str(l).unwrap());
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
out
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
fn clear(dir: &Path) {
|
|
811
|
+
for e in fs::read_dir(dir).unwrap().filter_map(|e| e.ok()) {
|
|
812
|
+
fs::remove_file(e.path()).ok();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async fn run_briefly(s: Spec, ms: u64) {
|
|
817
|
+
let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
|
|
818
|
+
let _ = tokio::time::timeout(Duration::from_millis(ms), filetail::run(s, sd)).await;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
fn write_session(root: &Path, lines: &[String]) -> PathBuf {
|
|
822
|
+
let proj = root.join(ENCODED_CWD);
|
|
823
|
+
fs::create_dir_all(&proj).unwrap();
|
|
824
|
+
let p = proj.join(format!("2026-08-03T08-03-56-214Z_{UUID}.jsonl"));
|
|
825
|
+
fs::write(&p, lines.join("\n") + "\n").unwrap();
|
|
826
|
+
p
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
fn cleanup(dirs: [&Path; 3]) {
|
|
830
|
+
for d in dirs {
|
|
831
|
+
fs::remove_dir_all(d).ok();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/// The real 0.73.1 tool-using session, in file order.
|
|
836
|
+
fn session_0_73_1() -> Vec<String> {
|
|
837
|
+
vec![
|
|
838
|
+
session_header("2026-08-03T08:03:56.214Z"),
|
|
839
|
+
model_change("2026-08-03T08:03:56.232Z"),
|
|
840
|
+
thinking_level_change("2026-08-03T08:03:56.232Z"),
|
|
841
|
+
user_prompt(
|
|
842
|
+
"2026-08-03T08:03:56.238Z",
|
|
843
|
+
"List the files in this directory, then read README.md and tell me what it says.",
|
|
844
|
+
),
|
|
845
|
+
assistant_tool_calls(
|
|
846
|
+
"2026-08-03T08:03:58.953Z",
|
|
847
|
+
&[
|
|
848
|
+
("toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP", "bash"),
|
|
849
|
+
("toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe", "read"),
|
|
850
|
+
],
|
|
851
|
+
),
|
|
852
|
+
tool_result(
|
|
853
|
+
"2026-08-03T08:03:58.967Z",
|
|
854
|
+
"toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP",
|
|
855
|
+
"bash",
|
|
856
|
+
"total 144\n",
|
|
857
|
+
false,
|
|
858
|
+
),
|
|
859
|
+
tool_result(
|
|
860
|
+
"2026-08-03T08:03:58.967Z",
|
|
861
|
+
"toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe",
|
|
862
|
+
"read",
|
|
863
|
+
"# Probe Pi\n",
|
|
864
|
+
false,
|
|
865
|
+
),
|
|
866
|
+
assistant_text("2026-08-03T08:04:02.076Z", "Here's what I found: …"),
|
|
867
|
+
]
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/// The real 0.83.0 session — same grammar, prose alongside the calls.
|
|
871
|
+
fn session_0_83_0() -> Vec<String> {
|
|
872
|
+
vec![
|
|
873
|
+
session_header("2026-08-03T08:13:38.646Z"),
|
|
874
|
+
model_change("2026-08-03T08:13:38.678Z"),
|
|
875
|
+
thinking_level_change("2026-08-03T08:13:38.678Z"),
|
|
876
|
+
user_prompt(
|
|
877
|
+
"2026-08-03T08:13:38.690Z",
|
|
878
|
+
"List the files in this directory, then read README.md and tell me what it says.",
|
|
879
|
+
),
|
|
880
|
+
assistant_prose_then_calls(
|
|
881
|
+
"2026-08-03T08:13:41.674Z",
|
|
882
|
+
"I'll do both at the same time!",
|
|
883
|
+
&[
|
|
884
|
+
("toolu_bdrk_01BLAbzzkxkrZS6nPF6nNQQy", "bash"),
|
|
885
|
+
("toolu_bdrk_01BBuVKvhJ7McsBpy9txBJLC", "read"),
|
|
886
|
+
],
|
|
887
|
+
),
|
|
888
|
+
tool_result(
|
|
889
|
+
"2026-08-03T08:13:41.683Z",
|
|
890
|
+
"toolu_bdrk_01BLAbzzkxkrZS6nPF6nNQQy",
|
|
891
|
+
"bash",
|
|
892
|
+
"total 144\n",
|
|
893
|
+
false,
|
|
894
|
+
),
|
|
895
|
+
tool_result(
|
|
896
|
+
"2026-08-03T08:13:41.683Z",
|
|
897
|
+
"toolu_bdrk_01BBuVKvhJ7McsBpy9txBJLC",
|
|
898
|
+
"read",
|
|
899
|
+
"# Probe Pi83\n",
|
|
900
|
+
false,
|
|
901
|
+
),
|
|
902
|
+
assistant_text("2026-08-03T08:13:45.502Z", "Here's what I found: …"),
|
|
903
|
+
]
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
907
|
+
async fn a_real_0_73_1_session_produces_a_start_its_turns_and_an_end() {
|
|
908
|
+
let root = tmpdir("full-root");
|
|
909
|
+
let spool = tmpdir("full-spool");
|
|
910
|
+
let state = tmpdir("full-state");
|
|
911
|
+
|
|
912
|
+
write_session(&root, &session_0_73_1());
|
|
913
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
914
|
+
|
|
915
|
+
let ev = spooled(&spool);
|
|
916
|
+
let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
|
|
917
|
+
for want in [
|
|
918
|
+
"agent_start",
|
|
919
|
+
"model_request",
|
|
920
|
+
"tool_use",
|
|
921
|
+
"tool_result",
|
|
922
|
+
"model_response",
|
|
923
|
+
"agent_end",
|
|
924
|
+
] {
|
|
925
|
+
assert!(types.contains(&want), "missing {want}: {types:?}");
|
|
926
|
+
}
|
|
927
|
+
assert_eq!(of_type(&ev, "tool_use").len(), 2);
|
|
928
|
+
assert_eq!(of_type(&ev, "tool_result").len(), 2);
|
|
929
|
+
|
|
930
|
+
// Every event carries the session id from the filename, so hook events for
|
|
931
|
+
// the same run land on this timeline — and the agent id from the header
|
|
932
|
+
// cwd, not the lossy directory name.
|
|
933
|
+
for e in &ev {
|
|
934
|
+
assert_eq!(e["session_id"], UUID);
|
|
935
|
+
assert_eq!(e["agent_id"], "pi-probe-pi");
|
|
936
|
+
assert_eq!(e["environment"], "local");
|
|
937
|
+
assert!(e["timestamp"].as_str().unwrap().ends_with('Z'));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
cleanup([&root, &spool, &state]);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
944
|
+
async fn a_0_83_0_session_needs_no_second_format() {
|
|
945
|
+
// The two releases write the same grammar; only the shape of an assistant
|
|
946
|
+
// turn's content differs. A source that needed a second `Format` would be
|
|
947
|
+
// a source that had assumed content was homogeneous.
|
|
948
|
+
let root = tmpdir("v83-root");
|
|
949
|
+
let spool = tmpdir("v83-spool");
|
|
950
|
+
let state = tmpdir("v83-state");
|
|
951
|
+
|
|
952
|
+
write_session(&root, &session_0_83_0());
|
|
953
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
954
|
+
|
|
955
|
+
let ev = spooled(&spool);
|
|
956
|
+
assert_eq!(of_type(&ev, "agent_start").len(), 1);
|
|
957
|
+
assert_eq!(of_type(&ev, "model_request").len(), 1);
|
|
958
|
+
assert_eq!(of_type(&ev, "tool_use").len(), 2);
|
|
959
|
+
assert_eq!(of_type(&ev, "tool_result").len(), 2);
|
|
960
|
+
// The prose that shipped alongside the calls, plus the closing turn.
|
|
961
|
+
assert_eq!(of_type(&ev, "model_response").len(), 2);
|
|
962
|
+
assert!(
|
|
963
|
+
of_type(&ev, "model_response")
|
|
964
|
+
.iter()
|
|
965
|
+
.any(|e| e["content"] == "I'll do both at the same time!"),
|
|
966
|
+
"the leading prose of a 0.83.0 tool turn must ship"
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
cleanup([&root, &spool, &state]);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
973
|
+
async fn a_second_pass_ships_nothing_new() {
|
|
974
|
+
let root = tmpdir("resume-root");
|
|
975
|
+
let spool = tmpdir("resume-spool");
|
|
976
|
+
let state = tmpdir("resume-state");
|
|
977
|
+
write_session(&root, &session_0_73_1());
|
|
978
|
+
|
|
979
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
980
|
+
assert!(!spooled(&spool).is_empty());
|
|
981
|
+
clear(&spool);
|
|
982
|
+
|
|
983
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
|
|
984
|
+
assert!(
|
|
985
|
+
spooled(&spool).is_empty(),
|
|
986
|
+
"a resumed pass must ship nothing"
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
cleanup([&root, &spool, &state]);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
993
|
+
async fn appended_turns_are_picked_up_without_re_shipping_earlier_ones() {
|
|
994
|
+
let root = tmpdir("append-root");
|
|
995
|
+
let spool = tmpdir("append-spool");
|
|
996
|
+
let state = tmpdir("append-state");
|
|
997
|
+
let path = write_session(&root, &session_0_73_1());
|
|
998
|
+
|
|
999
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
1000
|
+
let first = spooled(&spool).len();
|
|
1001
|
+
clear(&spool);
|
|
1002
|
+
|
|
1003
|
+
// Steady-state pi: one appendFileSync per entry.
|
|
1004
|
+
let mut body = fs::read_to_string(&path).unwrap();
|
|
1005
|
+
body.push_str(&(user_prompt("2026-08-03T08:04:26.109Z", "Now run: wc -l calc.py") + "\n"));
|
|
1006
|
+
body.push_str(&(assistant_text("2026-08-03T08:04:29.565Z", "`calc.py` has 6 lines.") + "\n"));
|
|
1007
|
+
fs::write(&path, body).unwrap();
|
|
1008
|
+
|
|
1009
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
1010
|
+
let ev = spooled(&spool);
|
|
1011
|
+
assert!(!ev.is_empty(), "the appended turns must ship");
|
|
1012
|
+
assert!(
|
|
1013
|
+
ev.len() < first,
|
|
1014
|
+
"earlier turns must not be re-shipped: {} vs {first}",
|
|
1015
|
+
ev.len()
|
|
1016
|
+
);
|
|
1017
|
+
assert!(
|
|
1018
|
+
ev.iter()
|
|
1019
|
+
.any(|e| e["messages"][0]["content"] == "Now run: wc -l calc.py"),
|
|
1020
|
+
"the new prompt must be present"
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
cleanup([&root, &spool, &state]);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
1027
|
+
async fn a_rewritten_transcript_that_shrank_is_re_read_rather_than_seeked_past() {
|
|
1028
|
+
// `_rewriteFile()` does a full writeFileSync over the existing file when a
|
|
1029
|
+
// resumed session's entries all failed to parse — the transcript is
|
|
1030
|
+
// replaced by a lone fresh header, far shorter than what was there. Left
|
|
1031
|
+
// alone the cursor would sit past EOF and the session would silently stop
|
|
1032
|
+
// updating for the rest of its life.
|
|
1033
|
+
let root = tmpdir("rewrite-root");
|
|
1034
|
+
let spool = tmpdir("rewrite-spool");
|
|
1035
|
+
let state = tmpdir("rewrite-state");
|
|
1036
|
+
let path = write_session(&root, &session_0_73_1());
|
|
1037
|
+
let big = fs::metadata(&path).unwrap().len();
|
|
1038
|
+
|
|
1039
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
1040
|
+
clear(&spool);
|
|
1041
|
+
|
|
1042
|
+
let rewritten = [
|
|
1043
|
+
session_header("2026-08-03T08:20:00.000Z"),
|
|
1044
|
+
model_change("2026-08-03T08:20:00.010Z"),
|
|
1045
|
+
user_prompt("2026-08-03T08:20:00.020Z", "after the rewrite"),
|
|
1046
|
+
assistant_text("2026-08-03T08:20:02.000Z", "fresh"),
|
|
1047
|
+
];
|
|
1048
|
+
fs::write(&path, rewritten.join("\n") + "\n").unwrap();
|
|
1049
|
+
assert!(
|
|
1050
|
+
fs::metadata(&path).unwrap().len() < big,
|
|
1051
|
+
"the rewrite must actually shrink the file for this test to mean anything"
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
1055
|
+
let ev = spooled(&spool);
|
|
1056
|
+
assert!(
|
|
1057
|
+
ev.iter()
|
|
1058
|
+
.any(|e| e["messages"][0]["content"] == "after the rewrite"),
|
|
1059
|
+
"a shrunk transcript must be re-read, got {} events",
|
|
1060
|
+
ev.len()
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
cleanup([&root, &spool, &state]);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
1067
|
+
async fn a_partially_written_final_line_is_held_back_then_picked_up() {
|
|
1068
|
+
let root = tmpdir("partial-root");
|
|
1069
|
+
let spool = tmpdir("partial-spool");
|
|
1070
|
+
let state = tmpdir("partial-state");
|
|
1071
|
+
let proj = root.join(ENCODED_CWD);
|
|
1072
|
+
fs::create_dir_all(&proj).unwrap();
|
|
1073
|
+
let path = proj.join(format!("2026-08-03T08-03-56-214Z_{UUID}.jsonl"));
|
|
1074
|
+
|
|
1075
|
+
let head = [
|
|
1076
|
+
session_header("2026-08-03T08:03:56.214Z"),
|
|
1077
|
+
model_change("2026-08-03T08:03:56.232Z"),
|
|
1078
|
+
user_prompt("2026-08-03T08:03:56.238Z", "complete"),
|
|
1079
|
+
]
|
|
1080
|
+
.join("\n");
|
|
1081
|
+
fs::write(&path, format!("{head}\n{{\"type\":\"message\",\"timest")).unwrap();
|
|
1082
|
+
|
|
1083
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
1084
|
+
let n1 = of_type(&spooled(&spool), "model_request").len();
|
|
1085
|
+
assert_eq!(n1, 1, "only the complete records should ship");
|
|
1086
|
+
clear(&spool);
|
|
1087
|
+
|
|
1088
|
+
fs::write(
|
|
1089
|
+
&path,
|
|
1090
|
+
format!(
|
|
1091
|
+
"{head}\n{}\n",
|
|
1092
|
+
user_prompt("2026-08-03T08:04:26.109Z", "finished")
|
|
1093
|
+
),
|
|
1094
|
+
)
|
|
1095
|
+
.unwrap();
|
|
1096
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
1097
|
+
assert!(
|
|
1098
|
+
spooled(&spool)
|
|
1099
|
+
.iter()
|
|
1100
|
+
.any(|e| e["messages"][0]["content"] == "finished"),
|
|
1101
|
+
"the completed record must ship on a later pass"
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
cleanup([&root, &spool, &state]);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
1108
|
+
async fn a_session_directory_with_no_file_in_it_yet_is_not_an_error() {
|
|
1109
|
+
// pi's `_persist()` short-circuits until the first assistant message
|
|
1110
|
+
// completes, then flushes everything at once — so a run killed before that
|
|
1111
|
+
// leaves ZERO trace on disk, and a live run leaves an EMPTY directory for
|
|
1112
|
+
// as long as its first turn takes. Discovery finding nothing here is
|
|
1113
|
+
// normal, and must not be logged, retried or failed as if it were not.
|
|
1114
|
+
let root = tmpdir("empty-root");
|
|
1115
|
+
let spool = tmpdir("empty-spool");
|
|
1116
|
+
let state = tmpdir("empty-state");
|
|
1117
|
+
fs::create_dir_all(root.join(ENCODED_CWD)).unwrap();
|
|
1118
|
+
|
|
1119
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 800).await;
|
|
1120
|
+
assert!(spooled(&spool).is_empty());
|
|
1121
|
+
|
|
1122
|
+
// And once the flush lands, the whole prefix is picked up in one pass —
|
|
1123
|
+
// including the `agent_start` built from a header that appeared complete.
|
|
1124
|
+
write_session(&root, &session_0_73_1());
|
|
1125
|
+
run_briefly(spec(root.clone(), spool.clone(), state.clone()), 1200).await;
|
|
1126
|
+
let ev = spooled(&spool);
|
|
1127
|
+
assert_eq!(of_type(&ev, "agent_start").len(), 1, "got {ev:#?}");
|
|
1128
|
+
assert_eq!(of_type(&ev, "model_request").len(), 1);
|
|
1129
|
+
|
|
1130
|
+
cleanup([&root, &spool, &state]);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
#[tokio::test(flavor = "multi_thread")]
|
|
1134
|
+
async fn a_live_tail_and_a_full_re_read_agree_event_for_event() {
|
|
1135
|
+
// The invariant the whole engine rests on: every event is a pure function
|
|
1136
|
+
// of one record plus its byte offset, so a tail that happens to split a
|
|
1137
|
+
// turn across two polls must produce byte-identical events to a single
|
|
1138
|
+
// full read. Only that lets the server's content hash collapse a re-read
|
|
1139
|
+
// instead of storing the session twice. The tool-name carry is the part
|
|
1140
|
+
// that could break it, which is why it is persisted in the cursor.
|
|
1141
|
+
let lines = session_0_73_1();
|
|
1142
|
+
|
|
1143
|
+
let tail_root = tmpdir("tail-root");
|
|
1144
|
+
let tail_spool = tmpdir("tail-spool");
|
|
1145
|
+
let tail_state = tmpdir("tail-state");
|
|
1146
|
+
// Half the session, then the rest — the call lands in one pass and its
|
|
1147
|
+
// results in the next.
|
|
1148
|
+
let path = write_session(&tail_root, &lines[..5]);
|
|
1149
|
+
run_briefly(
|
|
1150
|
+
spec(tail_root.clone(), tail_spool.clone(), tail_state.clone()),
|
|
1151
|
+
1000,
|
|
1152
|
+
)
|
|
1153
|
+
.await;
|
|
1154
|
+
fs::write(&path, lines.join("\n") + "\n").unwrap();
|
|
1155
|
+
run_briefly(
|
|
1156
|
+
spec(tail_root.clone(), tail_spool.clone(), tail_state.clone()),
|
|
1157
|
+
1200,
|
|
1158
|
+
)
|
|
1159
|
+
.await;
|
|
1160
|
+
|
|
1161
|
+
let once_root = tmpdir("once-root");
|
|
1162
|
+
let once_spool = tmpdir("once-spool");
|
|
1163
|
+
let once_state = tmpdir("once-state");
|
|
1164
|
+
write_session(&once_root, &lines);
|
|
1165
|
+
run_briefly(
|
|
1166
|
+
spec(once_root.clone(), once_spool.clone(), once_state.clone()),
|
|
1167
|
+
1200,
|
|
1168
|
+
)
|
|
1169
|
+
.await;
|
|
1170
|
+
|
|
1171
|
+
let mut tailed = spooled(&tail_spool);
|
|
1172
|
+
let mut at_once = spooled(&once_spool);
|
|
1173
|
+
// `agent_end` is emitted per pass and carries the file size, so the split
|
|
1174
|
+
// run legitimately has an extra one for the shorter file.
|
|
1175
|
+
tailed.retain(|e| e["type"] != "agent_end");
|
|
1176
|
+
at_once.retain(|e| e["type"] != "agent_end");
|
|
1177
|
+
let key = |e: &Value| format!("{}|{}", e["type"], e["pi_line_offset"]);
|
|
1178
|
+
tailed.sort_by_key(key);
|
|
1179
|
+
at_once.sort_by_key(key);
|
|
1180
|
+
assert!(
|
|
1181
|
+
!at_once.is_empty(),
|
|
1182
|
+
"two empty runs would make this comparison vacuous"
|
|
1183
|
+
);
|
|
1184
|
+
assert_eq!(
|
|
1185
|
+
tailed, at_once,
|
|
1186
|
+
"a split tail must produce the same events as one full read"
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1189
|
+
cleanup([&tail_root, &tail_spool, &tail_state]);
|
|
1190
|
+
cleanup([&once_root, &once_spool, &once_state]);
|
|
1191
|
+
}
|