From 1919c7c51fe65e4c3103a100f9530ce9b7012304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= <> Date: Mon, 27 Apr 2026 14:33:37 +0200 Subject: [PATCH 1/6] feat: add multi-agent support (Claude Code + Codex CLI) Replace hardcoded Claude Code transcript parsing with an extensible AgentAdapter trait. Each agent gets its own adapter for event mapping, file change extraction, transcript parsing, and token/model extraction. - AgentAdapter trait with ClaudeCode, Codex, and Default adapters - Codex transcript parsing: response_item, custom_tool_call, event_msg, apply_patch file changes from transcript chunks - CLI: protocol v2, repeatable --agent flag for init/stream - tracevault init --agent codex installs .codex/hooks.json - AgentBadge component with per-agent icon on session list/detail - Server uses AgentAdapterRegistry on AppState - Removes old hardcoded extract_file_change/is_file_modifying_tool Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 471 ++++++++++++++++- README.md | 21 +- crates/tracevault-cli/src/commands/init.rs | 21 +- crates/tracevault-cli/src/commands/stream.rs | 28 +- crates/tracevault-cli/src/main.rs | 19 +- crates/tracevault-cli/tests/init_test.rs | 38 +- .../src/agent_adapter/claude_code.rs | 443 ++++++++++++++++ .../src/agent_adapter/codex.rs | 475 ++++++++++++++++++ .../src/agent_adapter/default.rs | 46 ++ .../tracevault-core/src/agent_adapter/mod.rs | 104 ++++ crates/tracevault-core/src/hooks.rs | 7 + crates/tracevault-core/src/lib.rs | 1 + crates/tracevault-core/src/streaming.rs | 44 -- .../tests/agent_adapter_test.rs | 417 +++++++++++++++ .../tracevault-core/tests/streaming_test.rs | 62 --- .../src/api/session_detail.rs | 290 +++-------- .../src/api/traces_ui/sessions.rs | 24 +- crates/tracevault-server/src/lib.rs | 4 + crates/tracevault-server/src/main.rs | 3 + .../tracevault-server/src/service/stream.rs | 88 ++-- web/src/lib/components/AgentBadge.svelte | 69 +++ .../orgs/[slug]/traces/sessions/+page.svelte | 8 +- .../[slug]/traces/sessions/[id]/+page.svelte | 2 + 23 files changed, 2249 insertions(+), 436 deletions(-) create mode 100644 crates/tracevault-core/src/agent_adapter/claude_code.rs create mode 100644 crates/tracevault-core/src/agent_adapter/codex.rs create mode 100644 crates/tracevault-core/src/agent_adapter/default.rs create mode 100644 crates/tracevault-core/src/agent_adapter/mod.rs create mode 100644 crates/tracevault-core/tests/agent_adapter_test.rs create mode 100644 web/src/lib/components/AgentBadge.svelte diff --git a/Cargo.lock b/Cargo.lock index 5f97a7c3..5ad62a5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,12 +370,24 @@ dependencies = [ "syn", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -439,6 +451,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "built" version = "0.8.0" @@ -772,6 +793,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -843,8 +876,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -861,13 +904,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -924,6 +991,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -939,7 +1016,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -1023,6 +1100,26 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1057,6 +1154,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1189,6 +1307,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1403,6 +1531,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1509,6 +1638,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.14" @@ -1521,7 +1661,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1539,6 +1679,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1742,6 +1888,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -1976,6 +2123,17 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -2062,6 +2220,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2547,6 +2714,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -2615,6 +2788,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2666,6 +2859,37 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac 0.12.1", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.80" @@ -2721,6 +2945,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ort" version = "2.0.0-rc.9" @@ -2745,6 +2978,30 @@ dependencies = [ "ureq", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -2935,6 +3192,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2954,6 +3217,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3164,7 +3436,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -3230,7 +3502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ "either", - "itertools", + "itertools 0.14.0", "rayon", ] @@ -3273,6 +3545,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -3327,6 +3619,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3334,6 +3628,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3343,6 +3638,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -3385,6 +3681,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rgb" version = "0.8.53" @@ -3560,12 +3866,50 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3605,6 +3949,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3649,6 +4003,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3661,6 +4024,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3852,7 +4247,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.13.0", "log", "memchr", "once_cell", @@ -4196,6 +4591,37 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4234,7 +4660,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -4363,7 +4789,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.13.0", "pin-project-lite", "slab", "sync_wrapper", @@ -4469,8 +4895,22 @@ dependencies = [ name = "tracevault-enterprise" version = "0.1.0" dependencies = [ + "aes-gcm", "async-trait", + "base64 0.22.1", + "chrono", + "ed25519-dalek", + "glob-match", + "hex", + "openidconnect", + "rand 0.8.6", + "reqwest 0.13.2", + "serde", + "serde_json", + "sha2 0.11.0", "tracevault-core", + "tracing", + "uuid", ] [[package]] @@ -4780,6 +5220,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4966,7 +5407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -4992,7 +5433,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -5508,7 +5949,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -5539,7 +5980,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -5558,7 +5999,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", diff --git a/README.md b/README.md index 45c25572..41d5bd34 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ AI code governance platform for enterprises. Captures what AI coding agents do in your repos — which files they touch, how many tokens they burn, what tools they call, what percentage of code is AI-generated — then enforces policies and produces tamper-evident audit trails for regulatory compliance. +Supports **Claude Code**, **Codex CLI**, and is extensible to other agents via the AgentAdapter architecture. + Built for financial institutions and regulated industries where AI-generated code needs the same audit rigor as human-written code. [Learn more at VirtusLab](https://virtuslab.com/services/tracevault) @@ -67,7 +69,7 @@ See exactly what AI wrote, line by line. The code browser overlays AI attributio Three Rust crates in a Cargo workspace: - **tracevault-core** — domain types, policy engine (7 condition types), attribution engine (tree-sitter based), secret redactor -- **tracevault-cli** — CLI binary that hooks into Claude Code, captures traces locally, checks policies, pushes to server +- **tracevault-cli** — CLI binary that hooks into Claude Code and Codex CLI, captures traces locally, checks policies, pushes to server - **tracevault-server** — axum HTTP server backed by PostgreSQL with Ed25519 signing, audit logging, RBAC, code browser Plus a SvelteKit web dashboard and a GitHub Action for CI verification. @@ -360,6 +362,19 @@ Actions: **Block push** (exit non-zero, prevents `git push`) or **Warn** (logs b Scope: **Session** (evaluate all tool calls in the session) or **Validation** (evaluate only tools called after `tracevault validation-start`). +## Using with Codex CLI + +[Codex CLI](https://github.com/openai/codex) (OpenAI's coding agent) is also supported. Initialize with the `--agent codex` flag to install Codex hooks: + +```sh +npm install -g @openai/codex +cd /path/to/your/repo +tracevault login --server-url https://your-tracevault-server.example.com +tracevault init --agent codex +``` + +This installs hooks in `.codex/hooks.json` in addition to the Claude Code hooks. Codex sessions are traced including transcript parsing, token usage, and file changes via `apply_patch`. The session detail view shows a Codex badge to distinguish agent types. + ## Keys & Secrets ### Encryption key (`TRACEVAULT_ENCRYPTION_KEY`) @@ -412,10 +427,10 @@ export DATABASE_URL=postgres://user:password@host:5432/tracevault?sslmode=requir | Command | Description | |---------|-------------| -| `tracevault init [--server-url URL] [--claude-settings shared\|local]` | Initialize Visdom Trace in current repo, install pre-push hook and Claude Code hooks. `--claude-settings` chooses between `.claude/settings.json` (default) and `.claude/settings.local.json`; prompts interactively if omitted on a TTY | +| `tracevault init [--server-url URL] [--claude-settings shared\|local] [--agent ]...` | Initialize Visdom Trace in current repo, install pre-push hook and agent hooks (Claude Code by default, repeat `--agent` to add others e.g. `codex`). `--claude-settings` chooses between `.claude/settings.json` (default) and `.claude/settings.local.json`; prompts interactively if omitted on a TTY | | `tracevault login --server-url URL [--no-browser]` | Authenticate via device auth flow. Prints the URL and opens a browser when possible; `--no-browser` (or a headless env) skips the auto-open. | | `tracevault logout` | Clear local credentials | -| `tracevault stream --event ` | Handle a Claude Code hook event (reads JSON from stdin) and stream it to the server | +| `tracevault stream --event [--agent ]` | Handle an agent hook event (reads JSON from stdin) and stream it to the server (`--agent`: `claude-code` (default), `codex`) | | `tracevault sync` | Sync repo metadata with the server | | `tracevault check` | Evaluate policies against server rules, exit non-zero if blocked | | `tracevault validation-start [--session-id ID]` | Open a validation window. Call this when work is complete and you are ready to run pre-push validation tools. Validation-scoped policies only evaluate tools called after this point. Calling it again invalidates the previous window. | diff --git a/crates/tracevault-cli/src/commands/init.rs b/crates/tracevault-cli/src/commands/init.rs index 3b275157..24548b7d 100644 --- a/crates/tracevault-cli/src/commands/init.rs +++ b/crates/tracevault-cli/src/commands/init.rs @@ -3,6 +3,7 @@ use crate::config::TracevaultConfig; use std::fs; use std::io::{self, BufRead, IsTerminal, Write}; use std::path::Path; +use tracevault_core::agent_adapter::AgentAdapterRegistry; /// Which Claude Code settings file to install hooks into. #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] @@ -90,6 +91,7 @@ pub async fn init_in_directory( server_url: Option<&str>, claude_settings: Option, no_gitignore: bool, + agents: Option<&[String]>, ) -> Result { // Check for git repository if !project_root.join(".git").exists() { @@ -133,9 +135,26 @@ pub async fn init_in_directory( config.to_toml(), )?; - // Install Claude Code hooks into the chosen settings file + // Install Claude Code hooks into the chosen settings file. + // Claude is always installed via this path so it stays byte-equivalent + // with the single-agent (main) behaviour, including the --claude-settings target. install_claude_hooks(project_root, target)?; + // Install hooks for any additional agents (e.g. codex) requested via --agent. + if let Some(agents) = agents { + let registry = AgentAdapterRegistry::new(); + for agent in agents { + if agent == "claude" || agent == "claude-code" { + // Claude is already installed above via the settings target. + continue; + } + match registry.try_get(agent) { + Some(adapter) => adapter.install_hooks(project_root)?, + None => eprintln!("Warning: unknown agent '{}', skipping hooks", agent), + } + } + } + // Install git hooks install_git_hook(project_root)?; install_post_commit_hook(project_root)?; diff --git a/crates/tracevault-cli/src/commands/stream.rs b/crates/tracevault-cli/src/commands/stream.rs index 67525001..50bf9a0b 100644 --- a/crates/tracevault-cli/src/commands/stream.rs +++ b/crates/tracevault-cli/src/commands/stream.rs @@ -2,10 +2,9 @@ use std::fs::{self, OpenOptions}; use std::io::{self, BufRead, Read, Seek, SeekFrom, Write}; use std::path::Path; -use tracevault_core::hooks::{parse_hook_event, HookResponse}; -use tracevault_core::streaming::{ - extract_is_error_from_transcript, StreamEventRequest, StreamEventType, -}; +use tracevault_core::agent_adapter::AgentAdapterRegistry; +use tracevault_core::hooks::parse_hook_event; +use tracevault_core::streaming::{extract_is_error_from_transcript, StreamEventRequest}; pub fn next_event_index(counter_path: &Path) -> Result { let current = if counter_path.exists() { @@ -84,7 +83,7 @@ pub fn drain_pending(pending_path: &Path) -> Result, io::Error> { Ok(lines) } -pub async fn run_stream(event_type: &str) -> Result<(), Box> { +pub async fn run_stream(_event_type: &str, agent: &str) -> Result<(), Box> { // 1. Read HookEvent from stdin let mut input = String::new(); io::stdin().read_to_string(&mut input)?; @@ -114,12 +113,11 @@ pub async fn run_stream(event_type: &str) -> Result<(), Box StreamEventType::SessionStart, - "stop" => StreamEventType::SessionEnd, - _ => StreamEventType::ToolUse, - }; + // 5. Map hook event to stream event type via the agent adapter + let registry = AgentAdapterRegistry::new(); + let stream_event_type = registry + .get(agent) + .map_event_type(&hook_event.hook_event_name); // Extract is_error from transcript for this tool_use_id let tool_is_error = hook_event @@ -128,8 +126,8 @@ pub async fn run_stream(event_type: &str) -> Result<(), Box Result<(), Box, }, /// Show current session status Status, @@ -35,6 +38,9 @@ enum Cli { Stream { #[arg(long)] event: String, + /// AI coding agent name (claude-code, codex) + #[arg(long, default_value = "claude-code")] + agent: String, }, /// Check session policies before pushing Check, @@ -104,6 +110,7 @@ async fn main() { server_url, claude_settings, no_gitignore, + agents, } => { let cwd = env::current_dir().expect("Cannot determine current directory"); match commands::init::init_in_directory( @@ -111,6 +118,11 @@ async fn main() { server_url.as_deref(), claude_settings, no_gitignore, + if agents.is_empty() { + None + } else { + Some(&agents) + }, ) .await { @@ -118,6 +130,9 @@ async fn main() { let entry = target.gitignore_entry(); println!("TraceVault initialized in {}", cwd.display()); println!("Claude Code hooks installed ({entry})"); + for agent in &agents { + println!("{agent} hooks installed"); + } println!("Git hooks installed (pre-push, post-commit)"); println!("Added .tracevault/ and {entry} to .gitignore"); println!( @@ -137,8 +152,8 @@ async fn main() { std::process::exit(code); } } - Cli::Stream { event } => { - if let Err(e) = commands::stream::run_stream(&event).await { + Cli::Stream { event, agent } => { + if let Err(e) = commands::stream::run_stream(&event, &agent).await { eprintln!("Stream error: {e}"); } } diff --git a/crates/tracevault-cli/tests/init_test.rs b/crates/tracevault-cli/tests/init_test.rs index 850b04e7..17af38d9 100644 --- a/crates/tracevault-cli/tests/init_test.rs +++ b/crates/tracevault-cli/tests/init_test.rs @@ -12,7 +12,7 @@ fn tmp_git_repo() -> TempDir { async fn init_fails_without_git() { let tmp = TempDir::new().unwrap(); let result = - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false).await; + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None).await; assert!(result.is_err()); assert!(result .unwrap_err() @@ -25,7 +25,7 @@ async fn init_creates_tracevault_config() { let tmp = tmp_git_repo(); let config_path = tmp.path().join(".tracevault").join("config.toml"); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -38,7 +38,7 @@ async fn init_creates_tracevault_config() { async fn init_creates_directory_structure() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -56,7 +56,7 @@ async fn init_creates_directory_structure() { async fn init_installs_claude_hooks() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -80,7 +80,7 @@ async fn init_merges_into_existing_settings() { fs::create_dir_all(&claude_dir).unwrap(); fs::write(claude_dir.join("settings.json"), r#"{"model": "opus"}"#).unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -93,19 +93,11 @@ async fn init_merges_into_existing_settings() { assert_eq!(settings.get("model").unwrap(), "opus"); } -#[test] -fn tracevault_hooks_has_pre_post_and_notification() { - let hooks = tracevault_cli::commands::init::tracevault_hooks(); - assert!(hooks.get("PreToolUse").is_some()); - assert!(hooks.get("PostToolUse").is_some()); - assert!(hooks.get("Notification").is_some()); -} - #[tokio::test] async fn init_installs_git_pre_push_hook() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -133,7 +125,7 @@ async fn init_preserves_existing_pre_push_hook() { ) .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -150,10 +142,10 @@ async fn init_preserves_existing_pre_push_hook() { async fn init_does_not_duplicate_hook_on_reinit() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -169,7 +161,7 @@ async fn init_does_not_duplicate_hook_on_reinit() { async fn init_installs_post_commit_hook() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -186,10 +178,10 @@ async fn init_installs_post_commit_hook() { async fn init_does_not_duplicate_post_commit_hook_on_reinit() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) .await .unwrap(); @@ -210,6 +202,7 @@ async fn init_local_target_writes_to_settings_local_json() { None, Some(ClaudeSettingsTarget::Local), false, + None, ) .await .unwrap(); @@ -236,6 +229,7 @@ async fn init_local_target_gitignores_settings_local_json() { None, Some(ClaudeSettingsTarget::Local), false, + None, ) .await .unwrap(); @@ -263,6 +257,7 @@ async fn init_local_target_merges_into_existing_settings_local_json() { None, Some(ClaudeSettingsTarget::Local), false, + None, ) .await .unwrap(); @@ -282,6 +277,7 @@ async fn init_writes_server_url_to_config() { Some("https://tv.example.com"), None, false, + None, ) .await .unwrap(); @@ -295,7 +291,7 @@ async fn init_writes_server_url_to_config() { async fn init_no_gitignore_skips_gitignore_update() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, true) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, true, None) .await .unwrap(); diff --git a/crates/tracevault-core/src/agent_adapter/claude_code.rs b/crates/tracevault-core/src/agent_adapter/claude_code.rs new file mode 100644 index 00000000..2d8db4ab --- /dev/null +++ b/crates/tracevault-core/src/agent_adapter/claude_code.rs @@ -0,0 +1,443 @@ +use sha2::{Digest, Sha256}; +use std::fs; +use std::io; +use std::path::Path; + +use crate::hooks::HookResponse; +use crate::streaming::{ExtractedFileChange, StreamEventType}; + +use super::{AgentAdapter, ParsedTranscriptRecord, TokenUsage}; + +pub struct ClaudeCodeAdapter; + +fn hooks_json() -> serde_json::Value { + serde_json::json!({ + "PreToolUse": [{ + "matcher": "Write|Edit|Bash", + "hooks": [{ + "type": "command", + "command": "tracevault stream --event pre-tool-use", + "timeout": 10, + "statusMessage": "TraceVault: streaming pre-tool event" + }] + }], + "PostToolUse": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "tracevault stream --event post-tool-use", + "timeout": 10, + "statusMessage": "TraceVault: streaming post-tool event" + }] + }], + "Notification": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "tracevault stream --event notification", + "timeout": 10, + "statusMessage": "TraceVault: streaming notification" + }] + }], + "Stop": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": "tracevault stream --event stop", + "timeout": 10, + "statusMessage": "TraceVault: finalizing session" + }] + }] + }) +} + +impl AgentAdapter for ClaudeCodeAdapter { + fn name(&self) -> &str { + "claude-code" + } + + fn map_event_type(&self, hook_event_name: &str) -> StreamEventType { + // Claude Code has no SessionStart hook — Notification is the first + // hook fired and serves as the session-start signal. + match hook_event_name { + "SessionStart" | "Notification" => StreamEventType::SessionStart, + "Stop" => StreamEventType::SessionEnd, + _ => StreamEventType::ToolUse, + } + } + + fn is_file_modifying(&self, tool_name: &str) -> bool { + matches!(tool_name, "Write" | "Edit" | "Bash") + } + + fn extract_file_changes( + &self, + tool_name: &str, + tool_input: &serde_json::Value, + ) -> Vec { + match tool_name { + "Write" => { + let file_path = match tool_input.get("file_path").and_then(|v| v.as_str()) { + Some(p) => p.to_string(), + None => return Vec::new(), + }; + let content = match tool_input.get("content").and_then(|v| v.as_str()) { + Some(c) => c, + None => return Vec::new(), + }; + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + let hash = hex::encode(hasher.finalize()); + let diff_text = content + .lines() + .map(|line| format!("+{}", line)) + .collect::>() + .join("\n"); + vec![ExtractedFileChange { + file_path, + change_type: "create".to_string(), + diff_text: Some(diff_text), + content_hash: Some(hash), + }] + } + "Edit" => { + let file_path = match tool_input.get("file_path").and_then(|v| v.as_str()) { + Some(p) => p.to_string(), + None => return Vec::new(), + }; + let old_string = match tool_input.get("old_string").and_then(|v| v.as_str()) { + Some(s) => s, + None => return Vec::new(), + }; + let new_string = match tool_input.get("new_string").and_then(|v| v.as_str()) { + Some(s) => s, + None => return Vec::new(), + }; + let diff_text = format!("--- {}\n+++ {}", old_string, new_string); + vec![ExtractedFileChange { + file_path, + change_type: "edit".to_string(), + diff_text: Some(diff_text), + content_hash: None, + }] + } + _ => Vec::new(), + } + } + + fn extract_token_usage(&self, chunk: &serde_json::Value) -> Option { + let usage = chunk.get("message")?.get("usage")?; + Some(TokenUsage { + input_tokens: usage.get("input_tokens")?.as_i64()?, + output_tokens: usage.get("output_tokens")?.as_i64()?, + cache_read_tokens: usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + cache_write_tokens: usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + }) + } + + fn extract_model(&self, chunk: &serde_json::Value) -> Option { + chunk + .get("message")? + .get("model")? + .as_str() + .map(|s| s.to_string()) + } + + fn hook_response(&self) -> HookResponse { + HookResponse::allow() + } + + fn install_hooks(&self, project_root: &Path) -> io::Result<()> { + let claude_dir = project_root.join(".claude"); + fs::create_dir_all(&claude_dir)?; + + let settings_path = claude_dir.join("settings.json"); + let mut settings: serde_json::Value = if settings_path.exists() { + let content = fs::read_to_string(&settings_path)?; + serde_json::from_str(&content).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to parse .claude/settings.json: {e}"), + ) + })? + } else { + serde_json::json!({}) + }; + + let settings_obj = settings.as_object_mut().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + ".claude/settings.json is not a JSON object", + ) + })?; + settings_obj.insert("hooks".to_string(), hooks_json()); + + let formatted = serde_json::to_string_pretty(&settings) + .map_err(|e| io::Error::other(format!("Failed to serialize settings: {e}")))?; + fs::write(&settings_path, formatted)?; + Ok(()) + } + + fn parse_transcript_record(&self, chunk: &serde_json::Value) -> Option { + let record_type = chunk.get("type")?.as_str()?.to_string(); + let timestamp = chunk + .get("timestamp") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + match record_type.as_str() { + "assistant" => self.parse_assistant_record(chunk, record_type, timestamp), + "user" => self.parse_user_record(chunk, record_type, timestamp), + "progress" => self.parse_progress_record(chunk, record_type, timestamp), + "system" => self.parse_system_record(chunk, record_type, timestamp), + _ => Some(ParsedTranscriptRecord { + record_type, + timestamp, + content_types: Vec::new(), + tool_name: None, + text: None, + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }), + } + } +} + +impl ClaudeCodeAdapter { + fn parse_assistant_record( + &self, + chunk: &serde_json::Value, + record_type: String, + timestamp: Option, + ) -> Option { + let message = chunk.get("message")?; + let model = message + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let mut content_types = Vec::new(); + let mut text_parts = Vec::new(); + let mut first_tool_name: Option = None; + + if let Some(content) = message.get("content").and_then(|v| v.as_array()) { + for block in content { + if let Some(block_type) = block.get("type").and_then(|v| v.as_str()) { + if !content_types.contains(&block_type.to_string()) { + content_types.push(block_type.to_string()); + } + match block_type { + "text" => { + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + text_parts.push(t.to_string()); + } + } + "thinking" => { + if let Some(t) = block.get("thinking").and_then(|v| v.as_str()) { + text_parts.push(t.to_string()); + } + } + "tool_use" if first_tool_name.is_none() => { + first_tool_name = block + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + _ => {} + } + } + } + } + + let usage = message.get("usage"); + let raw_input_tokens = usage + .and_then(|u| u.get("input_tokens")) + .and_then(|v| v.as_i64()); + let raw_output_tokens = usage + .and_then(|u| u.get("output_tokens")) + .and_then(|v| v.as_i64()); + let raw_cache_read_tokens = usage + .and_then(|u| u.get("cache_read_input_tokens")) + .and_then(|v| v.as_i64()); + let raw_cache_write_tokens = usage + .and_then(|u| u.get("cache_creation_input_tokens")) + .and_then(|v| v.as_i64()); + + let text = if text_parts.is_empty() { + None + } else { + Some(text_parts.join("\n")) + }; + + Some(ParsedTranscriptRecord { + record_type, + timestamp, + content_types, + tool_name: first_tool_name, + text, + raw_input_tokens, + raw_output_tokens, + raw_cache_read_tokens, + raw_cache_write_tokens, + model, + }) + } + + fn parse_user_record( + &self, + chunk: &serde_json::Value, + record_type: String, + timestamp: Option, + ) -> Option { + let mut content_types = Vec::new(); + let mut text_parts = Vec::new(); + let mut tool_name: Option = None; + + // Check for toolUseResult (e.g. Read, Glob, Bash results) + if let Some(tool_result) = chunk.get("toolUseResult") { + if let Some(file_info) = tool_result.get("file") { + let file_path = file_info + .get("filePath") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tool_name = Some(format!("Read: {}", file_path)); + } else if let Some(glob_info) = tool_result.get("glob") { + let pattern = glob_info + .get("pattern") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tool_name = Some(format!("Glob: {}", pattern)); + } else if let Some(bash_info) = tool_result.get("bash") { + let command = bash_info + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tool_name = Some(format!("Bash: {}", command)); + } + } + + // Handle message.content as either a string or an array + if let Some(message) = chunk.get("message") { + if let Some(content) = message.get("content") { + if let Some(text) = content.as_str() { + text_parts.push(text.to_string()); + content_types.push("text".to_string()); + } else if let Some(arr) = content.as_array() { + for block in arr { + if let Some(block_type) = block.get("type").and_then(|v| v.as_str()) { + if !content_types.contains(&block_type.to_string()) { + content_types.push(block_type.to_string()); + } + match block_type { + "tool_result" | "text" => { + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + text_parts.push(t.to_string()); + } + } + _ => {} + } + } + } + } + } + } + + let text = if text_parts.is_empty() { + None + } else { + Some(text_parts.join("\n")) + }; + + Some(ParsedTranscriptRecord { + record_type, + timestamp, + content_types, + tool_name, + text, + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }) + } + + fn parse_progress_record( + &self, + chunk: &serde_json::Value, + record_type: String, + timestamp: Option, + ) -> Option { + let data = chunk.get("data"); + let hook_name = data + .and_then(|d| d.get("hookName")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let hook_event = data + .and_then(|d| d.get("hookEvent")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + let text = format!("{}: {}", hook_event, hook_name); + + Some(ParsedTranscriptRecord { + record_type, + timestamp, + content_types: Vec::new(), + tool_name: None, + text: Some(text), + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }) + } + + fn parse_system_record( + &self, + chunk: &serde_json::Value, + record_type: String, + timestamp: Option, + ) -> Option { + let subtype = chunk.get("subtype").and_then(|v| v.as_str()); + + let text = match subtype { + Some("turn_duration") => { + let duration_ms = chunk + .get("durationMs") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let seconds = duration_ms / 1000.0; + Some(format!("Turn duration: {:.1}s", seconds)) + } + Some("stop_hook_summary") => { + let hook_count = chunk.get("hookCount").and_then(|v| v.as_i64()).unwrap_or(0); + Some(format!("Stop hooks executed: {}", hook_count)) + } + _ => None, + }; + + Some(ParsedTranscriptRecord { + record_type, + timestamp, + content_types: Vec::new(), + tool_name: None, + text, + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }) + } +} diff --git a/crates/tracevault-core/src/agent_adapter/codex.rs b/crates/tracevault-core/src/agent_adapter/codex.rs new file mode 100644 index 00000000..1a4cc4df --- /dev/null +++ b/crates/tracevault-core/src/agent_adapter/codex.rs @@ -0,0 +1,475 @@ +use sha2::{Digest, Sha256}; +use std::fs; +use std::io; +use std::path::Path; + +use crate::streaming::{ExtractedFileChange, StreamEventType}; + +use super::{AgentAdapter, ParsedTranscriptRecord, TokenUsage}; + +/// Adapter for OpenAI Codex CLI. +/// +/// Codex file modifications come exclusively through transcript chunks +/// (custom_tool_call with apply_patch), NOT through hook ToolUse events. +/// The hook events only carry shell commands like `pwd`, `git status`, etc. +pub struct CodexAdapter; + +fn hooks_json() -> serde_json::Value { + serde_json::json!({ + "SessionStart": [{ + "matcher": "startup|resume", + "hooks": [{ + "type": "command", + "command": "tracevault stream --agent codex --event session-start", + "timeout": 10, + "statusMessage": "TraceVault: streaming session start" + }] + }], + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "tracevault stream --agent codex --event pre-tool-use", + "timeout": 10, + "statusMessage": "TraceVault: streaming pre-tool event" + }] + }], + "PostToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "tracevault stream --agent codex --event post-tool-use", + "timeout": 10, + "statusMessage": "TraceVault: streaming post-tool event" + }] + }], + "Stop": [{ + "hooks": [{ + "type": "command", + "command": "tracevault stream --agent codex --event stop", + "timeout": 10, + "statusMessage": "TraceVault: finalizing session" + }] + }] + }) +} + +impl AgentAdapter for CodexAdapter { + fn name(&self) -> &str { + "codex" + } + + fn map_event_type(&self, hook_event_name: &str) -> StreamEventType { + match hook_event_name { + "SessionStart" => StreamEventType::SessionStart, + "Stop" => StreamEventType::SessionEnd, + _ => StreamEventType::ToolUse, + } + } + + /// Codex hook events never carry file-modifying tool calls. + /// File changes are extracted from transcript via `extract_file_changes_from_transcript`. + fn is_file_modifying(&self, _tool_name: &str) -> bool { + false + } + + /// Not used for Codex — file changes come from transcript, not hook events. + fn extract_file_changes( + &self, + _tool_name: &str, + _tool_input: &serde_json::Value, + ) -> Vec { + vec![] + } + + /// Extract file changes from Codex transcript chunks. + /// Handles `response_item` with `payload.type: "custom_tool_call"` and `name: "apply_patch"`. + fn extract_file_changes_from_transcript( + &self, + chunk: &serde_json::Value, + ) -> Vec { + let payload = match chunk.get("payload") { + Some(p) => p, + None => return vec![], + }; + + let payload_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if payload_type != "custom_tool_call" { + return vec![]; + } + + let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name != "apply_patch" { + return vec![]; + } + + let input = match payload.get("input").and_then(|v| v.as_str()) { + Some(s) => s, + None => return vec![], + }; + + parse_codex_patch(input) + } + + fn extract_token_usage(&self, chunk: &serde_json::Value) -> Option { + let top_type = chunk.get("type")?.as_str()?; + if top_type != "event_msg" { + return None; + } + let payload = chunk.get("payload")?; + let payload_type = payload.get("type")?.as_str()?; + if payload_type != "token_count" { + return None; + } + let usage = payload.get("info")?.get("last_token_usage")?; + Some(TokenUsage { + input_tokens: usage.get("input_tokens")?.as_i64()?, + output_tokens: usage.get("output_tokens")?.as_i64()?, + cache_read_tokens: usage + .get("cached_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + cache_write_tokens: 0, + }) + } + + fn extract_model(&self, chunk: &serde_json::Value) -> Option { + let top_type = chunk.get("type")?.as_str()?; + if top_type != "turn_context" { + return None; + } + chunk + .get("payload")? + .get("model")? + .as_str() + .map(|s| s.to_string()) + } + + fn install_hooks(&self, project_root: &Path) -> io::Result<()> { + let codex_dir = project_root.join(".codex"); + fs::create_dir_all(&codex_dir)?; + + let hooks_path = codex_dir.join("hooks.json"); + let mut config: serde_json::Value = if hooks_path.exists() { + let content = fs::read_to_string(&hooks_path)?; + serde_json::from_str(&content).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to parse .codex/hooks.json: {e}"), + ) + })? + } else { + serde_json::json!({}) + }; + + let config_obj = config.as_object_mut().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + ".codex/hooks.json is not a JSON object", + ) + })?; + config_obj.insert("hooks".to_string(), hooks_json()); + + let formatted = serde_json::to_string_pretty(&config) + .map_err(|e| io::Error::other(format!("Failed to serialize hooks: {e}")))?; + fs::write(&hooks_path, formatted)?; + Ok(()) + } + + fn parse_transcript_record(&self, chunk: &serde_json::Value) -> Option { + let top_type = chunk.get("type")?.as_str()?; + let timestamp = chunk + .get("timestamp") + .and_then(|v| v.as_str()) + .map(String::from); + + match top_type { + "event_msg" => self.parse_event_msg(chunk, ×tamp), + "response_item" => self.parse_response_item(chunk, ×tamp), + // turn_context, session_meta — ingestion-only, not for display + _ => None, + } + } +} + +impl CodexAdapter { + fn parse_event_msg( + &self, + chunk: &serde_json::Value, + timestamp: &Option, + ) -> Option { + let payload = chunk.get("payload")?; + let payload_type = payload.get("type")?.as_str()?; + + match payload_type { + "agent_message" => { + let content = payload + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(ParsedTranscriptRecord { + record_type: "assistant".to_string(), + timestamp: timestamp.clone(), + content_types: vec!["text".to_string()], + tool_name: None, + text: content, + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }) + } + "user_message" => { + let content = payload + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(ParsedTranscriptRecord { + record_type: "user".to_string(), + timestamp: timestamp.clone(), + content_types: vec!["text".to_string()], + tool_name: None, + text: content, + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }) + } + // token_count, task_started — ingestion-only + _ => None, + } + } + + fn parse_response_item( + &self, + chunk: &serde_json::Value, + timestamp: &Option, + ) -> Option { + let payload = chunk.get("payload")?; + let payload_type = payload.get("type")?.as_str()?; + + match payload_type { + "local_shell_call" => { + let command = payload + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let output = payload.get("output").and_then(|v| v.as_str()).unwrap_or(""); + let text = format!("$ {}\n{}", command, output); + Some(ParsedTranscriptRecord { + record_type: "assistant".to_string(), + timestamp: timestamp.clone(), + content_types: vec!["tool_use".to_string()], + tool_name: Some("Bash".to_string()), + text: Some(text), + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }) + } + "message" => { + let role = payload.get("role")?.as_str()?; + // Skip system/developer messages (permissions, instructions) + if role == "developer" { + return None; + } + let record_type = if role == "assistant" { + "assistant" + } else { + "user" + }; + let text = payload + .get("content") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|block| { + let block_type = block.get("type").and_then(|v| v.as_str())?; + if block_type == "input_text" || block_type == "output_text" { + let t = block.get("text").and_then(|v| v.as_str())?; + // Skip system prompts (XML tags in user messages) + if t.starts_with('<') && role == "user" { + return None; + } + Some(t.to_string()) + } else { + None + } + }) + .collect::>() + .join("\n\n") + }) + .filter(|s| !s.is_empty()); + // Skip if no meaningful text + text.as_ref()?; + Some(ParsedTranscriptRecord { + record_type: record_type.to_string(), + timestamp: timestamp.clone(), + content_types: vec!["text".to_string()], + tool_name: None, + text, + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }) + } + "custom_tool_call" => { + let name = payload + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("tool"); + let input = payload.get("input").and_then(|v| v.as_str()).unwrap_or(""); + // Truncate long patches for display (char-safe to avoid UTF-8 panic) + let display_input = if input.len() > 500 { + let truncated: String = input.chars().take(500).collect(); + format!("{}...", truncated) + } else { + input.to_string() + }; + Some(ParsedTranscriptRecord { + record_type: "assistant".to_string(), + timestamp: timestamp.clone(), + content_types: vec!["tool_use".to_string()], + tool_name: Some(name.to_string()), + text: Some(display_input), + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }) + } + // reasoning — encrypted, skip + _ => None, + } + } +} + +/// Parse Codex's custom apply_patch format into file changes. +pub fn parse_codex_patch(patch: &str) -> Vec { + let mut changes = Vec::new(); + let mut current_file: Option = None; + let mut current_type: Option = None; + let mut current_lines: Vec = Vec::new(); + + for line in patch.lines() { + if line == "*** Begin Patch" || line == "*** End Patch" { + flush_pending( + &mut changes, + &mut current_file, + &mut current_type, + &mut current_lines, + ); + continue; + } + + if let Some(path) = line.strip_prefix("*** Add File: ") { + flush_pending( + &mut changes, + &mut current_file, + &mut current_type, + &mut current_lines, + ); + current_file = Some(path.to_string()); + current_type = Some("create".to_string()); + } else if let Some(path) = line.strip_prefix("*** Update File: ") { + flush_pending( + &mut changes, + &mut current_file, + &mut current_type, + &mut current_lines, + ); + current_file = Some(path.to_string()); + current_type = Some("edit".to_string()); + } else if let Some(path) = line.strip_prefix("*** Delete File: ") { + flush_pending( + &mut changes, + &mut current_file, + &mut current_type, + &mut current_lines, + ); + current_file = Some(path.to_string()); + current_type = Some("delete".to_string()); + } else if current_file.is_some() { + current_lines.push(line.to_string()); + } + } + + flush_pending( + &mut changes, + &mut current_file, + &mut current_type, + &mut current_lines, + ); + changes +} + +fn flush_pending( + changes: &mut Vec, + file: &mut Option, + kind: &mut Option, + lines: &mut Vec, +) { + if let (Some(file_path), Some(change_type)) = (file.take(), kind.take()) { + changes.push(build_file_change(&file_path, &change_type, lines)); + lines.clear(); + } +} + +fn build_file_change(file_path: &str, change_type: &str, lines: &[String]) -> ExtractedFileChange { + match change_type { + "create" => { + let content: String = lines + .iter() + .map(|l| l.strip_prefix('+').unwrap_or(l)) + .collect::>() + .join("\n"); + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + let hash = hex::encode(hasher.finalize()); + let diff_text = lines.join("\n"); + ExtractedFileChange { + file_path: file_path.to_string(), + change_type: "create".to_string(), + diff_text: if diff_text.is_empty() { + None + } else { + Some(diff_text) + }, + content_hash: Some(hash), + } + } + "edit" => { + let diff_text = lines.join("\n"); + ExtractedFileChange { + file_path: file_path.to_string(), + change_type: "edit".to_string(), + diff_text: if diff_text.is_empty() { + None + } else { + Some(diff_text) + }, + content_hash: None, + } + } + "delete" => ExtractedFileChange { + file_path: file_path.to_string(), + change_type: "delete".to_string(), + diff_text: None, + content_hash: None, + }, + _ => ExtractedFileChange { + file_path: file_path.to_string(), + change_type: change_type.to_string(), + diff_text: None, + content_hash: None, + }, + } +} diff --git a/crates/tracevault-core/src/agent_adapter/default.rs b/crates/tracevault-core/src/agent_adapter/default.rs new file mode 100644 index 00000000..c4e3580e --- /dev/null +++ b/crates/tracevault-core/src/agent_adapter/default.rs @@ -0,0 +1,46 @@ +use crate::streaming::{ExtractedFileChange, StreamEventType}; + +use super::{AgentAdapter, ParsedTranscriptRecord, TokenUsage}; + +pub struct DefaultAdapter; + +impl AgentAdapter for DefaultAdapter { + fn name(&self) -> &str { + "default" + } + + fn map_event_type(&self, hook_event_name: &str) -> StreamEventType { + match hook_event_name { + "SessionStart" => StreamEventType::SessionStart, + "Stop" | "SessionEnd" => StreamEventType::SessionEnd, + _ => StreamEventType::ToolUse, + } + } + + fn is_file_modifying(&self, _tool_name: &str) -> bool { + false + } + + fn extract_file_changes( + &self, + _tool_name: &str, + _tool_input: &serde_json::Value, + ) -> Vec { + Vec::new() + } + + fn extract_token_usage(&self, _chunk: &serde_json::Value) -> Option { + None + } + + fn extract_model(&self, _chunk: &serde_json::Value) -> Option { + None + } + + fn parse_transcript_record( + &self, + _chunk: &serde_json::Value, + ) -> Option { + None + } +} diff --git a/crates/tracevault-core/src/agent_adapter/mod.rs b/crates/tracevault-core/src/agent_adapter/mod.rs new file mode 100644 index 00000000..f673eb47 --- /dev/null +++ b/crates/tracevault-core/src/agent_adapter/mod.rs @@ -0,0 +1,104 @@ +pub mod claude_code; +pub mod codex; +mod default; + +use serde::Serialize; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use crate::hooks::HookResponse; +use crate::streaming::{ExtractedFileChange, StreamEventType}; + +use self::default::DefaultAdapter; + +#[derive(Debug, Clone, Default)] +pub struct TokenUsage { + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ParsedTranscriptRecord { + pub record_type: String, + pub timestamp: Option, + pub content_types: Vec, + pub tool_name: Option, + pub text: Option, + pub raw_input_tokens: Option, + pub raw_output_tokens: Option, + pub raw_cache_read_tokens: Option, + pub raw_cache_write_tokens: Option, + pub model: Option, +} + +pub trait AgentAdapter: Send + Sync { + fn name(&self) -> &str; + fn map_event_type(&self, hook_event_name: &str) -> StreamEventType; + fn is_file_modifying(&self, tool_name: &str) -> bool; + /// Extract file changes from a hook tool event (tool_name + tool_input) + fn extract_file_changes( + &self, + tool_name: &str, + tool_input: &serde_json::Value, + ) -> Vec; + /// Extract file changes from a transcript chunk (e.g. Codex custom_tool_call with apply_patch). + /// Default: no extraction. Override for agents whose file ops appear in transcript, not hook events. + fn extract_file_changes_from_transcript( + &self, + _chunk: &serde_json::Value, + ) -> Vec { + vec![] + } + fn extract_token_usage(&self, chunk: &serde_json::Value) -> Option; + fn extract_model(&self, chunk: &serde_json::Value) -> Option; + fn parse_transcript_record(&self, chunk: &serde_json::Value) -> Option; + /// Install agent-specific hooks into `project_root`. Default: no-op. + fn install_hooks(&self, _project_root: &Path) -> std::io::Result<()> { + Ok(()) + } + /// Response to print on stdout after the hook stream finishes. + /// Default: empty `{}` (e.g. Codex). Claude Code overrides with `suppress_output: true`. + fn hook_response(&self) -> HookResponse { + HookResponse::empty() + } +} + +pub struct AgentAdapterRegistry { + adapters: HashMap>, + default: Arc, +} + +impl AgentAdapterRegistry { + pub fn new() -> Self { + let mut adapters: HashMap> = HashMap::new(); + let claude: Arc = Arc::new(claude_code::ClaudeCodeAdapter); + adapters.insert("claude-code".to_string(), Arc::clone(&claude)); + adapters.insert("claude".to_string(), claude); + adapters.insert("codex".to_string(), Arc::new(codex::CodexAdapter)); + Self { + adapters, + default: Arc::new(DefaultAdapter), + } + } + + pub fn get(&self, name: &str) -> &dyn AgentAdapter { + self.adapters + .get(name) + .map(|a| a.as_ref()) + .unwrap_or(self.default.as_ref()) + } + + /// Returns Some only for explicitly registered agents — None for unknown. + pub fn try_get(&self, name: &str) -> Option<&dyn AgentAdapter> { + self.adapters.get(name).map(|a| a.as_ref()) + } +} + +impl Default for AgentAdapterRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/tracevault-core/src/hooks.rs b/crates/tracevault-core/src/hooks.rs index 79a8f694..5e47d97b 100644 --- a/crates/tracevault-core/src/hooks.rs +++ b/crates/tracevault-core/src/hooks.rs @@ -34,6 +34,13 @@ impl HookResponse { suppress_output: Some(true), } } + + pub fn empty() -> Self { + Self { + r#continue: None, + suppress_output: None, + } + } } #[derive(Debug, Error)] diff --git a/crates/tracevault-core/src/lib.rs b/crates/tracevault-core/src/lib.rs index c505f521..7d106fda 100644 --- a/crates/tracevault-core/src/lib.rs +++ b/crates/tracevault-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent_adapter; pub mod agent_policies; pub mod code_nav; pub mod diff; diff --git a/crates/tracevault-core/src/streaming.rs b/crates/tracevault-core/src/streaming.rs index 87416bf2..395eb6ef 100644 --- a/crates/tracevault-core/src/streaming.rs +++ b/crates/tracevault-core/src/streaming.rs @@ -1,6 +1,5 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] @@ -80,10 +79,6 @@ pub struct ExtractedFileChange { pub content_hash: Option, } -pub fn is_file_modifying_tool(tool_name: &str) -> bool { - matches!(tool_name, "Write" | "Edit" | "Bash") -} - impl StreamEventRequest { /// Drop optional fields largest-first until the serialized payload is /// under 512 KB. Prevents 413 errors on both real-time sends and flush. @@ -131,42 +126,3 @@ pub fn extract_is_error_from_transcript( } None } - -pub fn extract_file_change( - tool_name: &str, - tool_input: &serde_json::Value, -) -> Option { - match tool_name { - "Write" => { - let file_path = tool_input.get("file_path")?.as_str()?.to_string(); - let content = tool_input.get("content")?.as_str()?; - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - let hash = hex::encode(hasher.finalize()); - let diff = content - .lines() - .map(|l| format!("+{l}")) - .collect::>() - .join("\n"); - Some(ExtractedFileChange { - file_path, - change_type: "create".to_string(), - diff_text: Some(diff), - content_hash: Some(hash), - }) - } - "Edit" => { - let file_path = tool_input.get("file_path")?.as_str()?.to_string(); - let old_string = tool_input.get("old_string")?.as_str()?; - let new_string = tool_input.get("new_string")?.as_str()?; - let diff = format!("--- {old_string}\n+++ {new_string}"); - Some(ExtractedFileChange { - file_path, - change_type: "edit".to_string(), - diff_text: Some(diff), - content_hash: None, - }) - } - _ => None, - } -} diff --git a/crates/tracevault-core/tests/agent_adapter_test.rs b/crates/tracevault-core/tests/agent_adapter_test.rs new file mode 100644 index 00000000..129b0850 --- /dev/null +++ b/crates/tracevault-core/tests/agent_adapter_test.rs @@ -0,0 +1,417 @@ +use serde_json::json; +use tracevault_core::agent_adapter::AgentAdapterRegistry; +use tracevault_core::streaming::StreamEventType; + +#[test] +fn registry_unknown_agent_returns_default() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("unknown-agent"); + assert_eq!(adapter.name(), "default"); +} + +#[test] +fn default_adapter_extract_token_usage_returns_none() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("nope"); + let chunk = + serde_json::json!({"type": "assistant", "message": {"usage": {"input_tokens": 100}}}); + assert!(adapter.extract_token_usage(&chunk).is_none()); +} + +#[test] +fn registry_dispatches_to_claude_code() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + assert_eq!(adapter.name(), "claude-code"); +} + +#[test] +fn claude_code_map_event_types() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + assert!(matches!( + adapter.map_event_type("SessionStart"), + StreamEventType::SessionStart + )); + assert!(matches!( + adapter.map_event_type("Notification"), + StreamEventType::SessionStart + )); + assert!(matches!( + adapter.map_event_type("Stop"), + StreamEventType::SessionEnd + )); + assert!(matches!( + adapter.map_event_type("PostToolUse"), + StreamEventType::ToolUse + )); +} + +#[test] +fn claude_code_extract_file_change_write() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let input = json!({"file_path": "src/main.rs", "content": "fn main() {}"}); + let changes = adapter.extract_file_changes("Write", &input); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].file_path, "src/main.rs"); + assert_eq!(changes[0].change_type, "create"); + assert!(changes[0].content_hash.is_some()); +} + +#[test] +fn claude_code_extract_file_change_edit() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let input = json!({"file_path": "src/lib.rs", "old_string": "old", "new_string": "new"}); + let changes = adapter.extract_file_changes("Edit", &input); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].change_type, "edit"); +} + +#[test] +fn claude_code_read_returns_empty() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let input = json!({"file_path": "src/lib.rs"}); + assert!(adapter.extract_file_changes("Read", &input).is_empty()); +} + +#[test] +fn claude_code_is_file_modifying() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + assert!(adapter.is_file_modifying("Write")); + assert!(adapter.is_file_modifying("Edit")); + assert!(adapter.is_file_modifying("Bash")); + assert!(!adapter.is_file_modifying("Read")); +} + +#[test] +fn claude_code_extract_token_usage() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({ + "type": "assistant", + "message": { + "usage": { + "input_tokens": 1000, + "output_tokens": 200, + "cache_read_input_tokens": 500, + "cache_creation_input_tokens": 100 + } + } + }); + let usage = adapter.extract_token_usage(&chunk).unwrap(); + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 200); + assert_eq!(usage.cache_read_tokens, 500); + assert_eq!(usage.cache_write_tokens, 100); +} + +#[test] +fn claude_code_extract_model() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "assistant", "message": {"model": "claude-opus-4-6"}}); + assert_eq!( + adapter.extract_model(&chunk).as_deref(), + Some("claude-opus-4-6") + ); +} + +#[test] +fn claude_code_parse_assistant_record() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({ + "type": "assistant", + "timestamp": "2026-03-23T13:17:16Z", + "message": { + "model": "claude-opus-4-6", + "content": [ + {"type": "text", "text": "Hello world"}, + {"type": "tool_use", "name": "Write", "input": {}} + ], + "usage": { + "input_tokens": 100, "output_tokens": 50, + "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0 + } + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "assistant"); + assert_eq!(record.model.as_deref(), Some("claude-opus-4-6")); + assert!(record.text.as_ref().unwrap().contains("Hello world")); + assert!(record.content_types.contains(&"text".to_string())); + assert!(record.content_types.contains(&"tool_use".to_string())); + assert_eq!(record.tool_name.as_deref(), Some("Write")); + assert_eq!(record.raw_input_tokens, Some(100)); +} + +#[test] +fn claude_code_parse_user_record() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "user", "timestamp": "2026-03-23T13:17:00Z", "message": {"content": "Fix the bug"}}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "user"); + assert_eq!(record.text.as_deref(), Some("Fix the bug")); +} + +#[test] +fn claude_code_parse_user_tool_result() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "user", "toolUseResult": {"file": {"filePath": "src/main.rs"}}}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.tool_name.as_deref(), Some("Read: src/main.rs")); +} + +#[test] +fn claude_code_parse_progress_record() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = + json!({"type": "progress", "data": {"hookName": "tracevault", "hookEvent": "PostToolUse"}}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "progress"); + assert_eq!(record.text.as_deref(), Some("PostToolUse: tracevault")); +} + +#[test] +fn claude_code_parse_system_record() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "system", "subtype": "turn_duration", "durationMs": 5000.0}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "system"); + assert!(record.text.as_ref().unwrap().contains("5.0s")); +} + +#[test] +fn codex_map_event_types() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + assert!(matches!( + adapter.map_event_type("SessionStart"), + StreamEventType::SessionStart + )); + assert!(matches!( + adapter.map_event_type("Stop"), + StreamEventType::SessionEnd + )); + assert!(matches!( + adapter.map_event_type("PostToolUse"), + StreamEventType::ToolUse + )); +} + +#[test] +fn codex_extract_token_usage() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "event_msg", "payload": {"type": "token_count", "info": {"last_token_usage": {"input_tokens": 2000, "output_tokens": 300, "cached_input_tokens": 1500}}}}); + let usage = adapter.extract_token_usage(&chunk).unwrap(); + assert_eq!(usage.input_tokens, 2000); + assert_eq!(usage.output_tokens, 300); + assert_eq!(usage.cache_read_tokens, 1500); + assert_eq!(usage.cache_write_tokens, 0); +} + +#[test] +fn codex_extract_token_usage_non_token_chunk_returns_none() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "event_msg", "payload": {"type": "agent_message"}}); + assert!(adapter.extract_token_usage(&chunk).is_none()); +} + +#[test] +fn codex_extract_model() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "turn_context", "payload": {"model": "codex-mini-latest"}}); + assert_eq!( + adapter.extract_model(&chunk).as_deref(), + Some("codex-mini-latest") + ); +} + +#[test] +fn codex_extract_model_non_turn_context_returns_none() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "event_msg", "payload": {"type": "agent_message"}}); + assert!(adapter.extract_model(&chunk).is_none()); +} + +#[test] +fn codex_parse_agent_message() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "event_msg", "payload": {"type": "agent_message", "content": "I'll fix that bug now."}}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "assistant"); + assert_eq!(record.text.as_deref(), Some("I'll fix that bug now.")); +} + +#[test] +fn codex_parse_user_message() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "event_msg", "payload": {"type": "user_message", "content": "Fix the login bug"}}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "user"); + assert_eq!(record.text.as_deref(), Some("Fix the login bug")); +} + +#[test] +fn codex_parse_shell_call() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "response_item", "payload": {"type": "local_shell_call", "command": "cargo test", "output": "test result: ok. 5 passed"}}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "assistant"); + assert_eq!(record.tool_name.as_deref(), Some("Bash")); + assert!(record.text.as_ref().unwrap().contains("cargo test")); +} + +#[test] +fn codex_parse_token_count_returns_none_for_display() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "event_msg", "payload": {"type": "token_count", "info": {"last_token_usage": {"input_tokens": 100, "output_tokens": 50}}}}); + assert!(adapter.parse_transcript_record(&chunk).is_none()); +} + +// Codex file changes are extracted from transcript, not hook events. +// These tests use parse_codex_patch directly. + +#[test] +fn codex_patch_parse_add_file() { + let changes = tracevault_core::agent_adapter::codex::parse_codex_patch( + "*** Begin Patch\n*** Add File: src/new.rs\n+fn main() {}\n*** End Patch\n", + ); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].file_path, "src/new.rs"); + assert_eq!(changes[0].change_type, "create"); + assert!(changes[0].content_hash.is_some()); +} + +#[test] +fn codex_patch_parse_update_file() { + let changes = tracevault_core::agent_adapter::codex::parse_codex_patch( + "*** Begin Patch\n*** Update File: src/lib.rs\n@@ fn old()\n-fn old()\n+fn new_func()\n*** End Patch\n", + ); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].file_path, "src/lib.rs"); + assert_eq!(changes[0].change_type, "edit"); + assert!(changes[0].diff_text.is_some()); +} + +#[test] +fn codex_patch_parse_delete_file() { + let changes = tracevault_core::agent_adapter::codex::parse_codex_patch( + "*** Begin Patch\n*** Delete File: src/old.rs\n*** End Patch\n", + ); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].file_path, "src/old.rs"); + assert_eq!(changes[0].change_type, "delete"); +} + +#[test] +fn codex_hook_extract_file_changes_returns_empty() { + // Codex does not extract file changes from hook events + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let input = json!({"command": "cargo build"}); + assert!(adapter.extract_file_changes("Bash", &input).is_empty()); +} + +#[test] +fn codex_is_file_modifying_always_false() { + // Codex file changes come from transcript, not hook events + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + assert!(!adapter.is_file_modifying("Bash")); + assert!(!adapter.is_file_modifying("Read")); + assert!(!adapter.is_file_modifying("apply_patch")); +} + +#[test] +fn codex_extract_file_changes_from_transcript_apply_patch() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "response_item", + "payload": { + "type": "custom_tool_call", + "name": "apply_patch", + "input": "*** Begin Patch\n*** Update File: src/main.rs\n@@ fn old()\n-fn old()\n+fn new_func()\n*** End Patch\n" + } + }); + let changes = adapter.extract_file_changes_from_transcript(&chunk); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].file_path, "src/main.rs"); + assert_eq!(changes[0].change_type, "edit"); +} + +#[test] +fn codex_extract_file_changes_from_transcript_non_patch_returns_empty() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "response_item", + "payload": {"type": "message", "role": "assistant", "content": []} + }); + assert!(adapter + .extract_file_changes_from_transcript(&chunk) + .is_empty()); +} + +#[test] +fn codex_reasoning_record_returns_none() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "response_item", + "payload": { + "type": "reasoning", + "content": null, + "summary": [], + "encrypted_content": "gAAAAA..." + } + }); + assert!(adapter.parse_transcript_record(&chunk).is_none()); +} + +#[test] +fn codex_custom_tool_call_display() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "response_item", + "timestamp": "2026-04-03T17:52:42Z", + "payload": { + "type": "custom_tool_call", + "name": "apply_patch", + "input": "*** Begin Patch\n*** Update File: README.md\n@@\n old line\n+new line\n*** End Patch" + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "assistant"); + assert_eq!(record.tool_name.as_deref(), Some("apply_patch")); + assert!(record.text.as_ref().unwrap().contains("Update File")); +} + +#[test] +fn claude_code_extract_file_changes_from_transcript_returns_empty() { + // Claude Code file changes come from hook events, not transcript + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "assistant", "message": {"content": []}}); + assert!(adapter + .extract_file_changes_from_transcript(&chunk) + .is_empty()); +} diff --git a/crates/tracevault-core/tests/streaming_test.rs b/crates/tracevault-core/tests/streaming_test.rs index 26ec0095..742277ac 100644 --- a/crates/tracevault-core/tests/streaming_test.rs +++ b/crates/tracevault-core/tests/streaming_test.rs @@ -30,50 +30,6 @@ fn test_stream_event_request_serialization() { assert_eq!(parsed.event_index, Some(42)); } -#[test] -fn test_extract_file_change_from_edit() { - let tool_input = json!({ - "file_path": "/repo/src/lib.rs", - "old_string": "fn old() {}", - "new_string": "fn new_func() {}" - }); - let change = extract_file_change("Edit", &tool_input); - assert!(change.is_some()); - let c = change.unwrap(); - assert_eq!(c.file_path, "/repo/src/lib.rs"); - assert_eq!(c.change_type, "edit"); - assert!(c.diff_text.is_some()); -} - -#[test] -fn test_extract_file_change_from_write() { - let tool_input = json!({ - "file_path": "/repo/src/new_file.rs", - "content": "fn main() {}" - }); - let change = extract_file_change("Write", &tool_input); - assert!(change.is_some()); - let c = change.unwrap(); - assert_eq!(c.file_path, "/repo/src/new_file.rs"); - assert_eq!(c.change_type, "create"); - assert!(c.content_hash.is_some()); -} - -#[test] -fn test_extract_file_change_from_read_returns_none() { - let tool_input = json!({"file_path": "/repo/src/lib.rs"}); - assert!(extract_file_change("Read", &tool_input).is_none()); -} - -#[test] -fn test_is_file_modifying_tool() { - assert!(is_file_modifying_tool("Write")); - assert!(is_file_modifying_tool("Edit")); - assert!(is_file_modifying_tool("Bash")); - assert!(!is_file_modifying_tool("Read")); - assert!(!is_file_modifying_tool("Grep")); -} - #[test] fn test_commit_push_request_serialization() { let req = CommitPushRequest { @@ -88,21 +44,3 @@ fn test_commit_push_request_serialization() { let parsed: CommitPushRequest = serde_json::from_str(&json_str).unwrap(); assert_eq!(parsed.commit_sha, "abc123"); } - -#[test] -fn extract_file_change_write_missing_content() { - let input = json!({"file_path": "/tmp/test.rs"}); - assert!(extract_file_change("Write", &input).is_none()); -} - -#[test] -fn extract_file_change_edit_missing_old_string() { - let input = json!({"file_path": "/tmp/test.rs", "new_string": "new"}); - assert!(extract_file_change("Edit", &input).is_none()); -} - -#[test] -fn extract_file_change_write_missing_file_path() { - let input = json!({"content": "hello"}); - assert!(extract_file_change("Write", &input).is_none()); -} diff --git a/crates/tracevault-server/src/api/session_detail.rs b/crates/tracevault-server/src/api/session_detail.rs index 57994ed4..f06b2d6d 100644 --- a/crates/tracevault-server/src/api/session_detail.rs +++ b/crates/tracevault-server/src/api/session_detail.rs @@ -4,6 +4,8 @@ use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; +use tracevault_core::agent_adapter::AgentAdapter; + use crate::error::AppError; use crate::extractors::OrgAuth; use crate::pricing::{self, ModelPricing}; @@ -91,230 +93,10 @@ pub struct TranscriptRecord { pub model: Option, } -fn parse_record(record: &serde_json::Value, pricing: &ModelPricing) -> Option { - let record_type = record.get("type")?.as_str()?.to_string(); - let timestamp = record - .get("timestamp") - .and_then(|v| v.as_str()) - .map(String::from); - - match record_type.as_str() { - "assistant" => { - let msg = match record.get("message") { - Some(m) => m, - None => { - return Some(TranscriptRecord { - record_type, - timestamp, - content_types: vec![], - tool_name: None, - text: None, - usage: None, - model: None, - }); - } - }; - let model = msg.get("model").and_then(|v| v.as_str()).map(String::from); - - let mut content_types = Vec::new(); - let mut texts = Vec::new(); - if let Some(content) = msg.get("content").and_then(|v| v.as_array()) { - for block in content { - if let Some(ct) = block.get("type").and_then(|v| v.as_str()) { - if !content_types.contains(&ct.to_string()) { - content_types.push(ct.to_string()); - } - } - if let Some(text) = block.get("text").and_then(|v| v.as_str()) { - texts.push(text.to_string()); - } - if let Some(thinking) = block.get("thinking").and_then(|v| v.as_str()) { - texts.push(format!("[thinking] {}", thinking)); - } - } - } - - let usage = msg.get("usage").map(|u| { - let total_input = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - let output = u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - let cache_read = u - .get("cache_read_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let cache_write = u - .get("cache_creation_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - // input_tokens from the API includes cache_read and cache_write tokens, - // so subtract them to get fresh (non-cached) input tokens only - let fresh_input = (total_input - cache_read - cache_write).max(0); - let cost = pricing::estimate_cost_with_pricing( - pricing, - fresh_input, - output, - cache_read, - cache_write, - ); - RecordUsage { - input_tokens: fresh_input, - output_tokens: output, - cache_read_tokens: cache_read, - cache_write_tokens: cache_write, - cost_usd: cost, - } - }); - - let tool_name = msg - .get("content") - .and_then(|v| v.as_array()) - .and_then(|arr| { - arr.iter() - .find(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_use")) - }) - .and_then(|b| b.get("name").and_then(|v| v.as_str()).map(String::from)); - - Some(TranscriptRecord { - record_type, - timestamp, - content_types, - tool_name, - text: if texts.is_empty() { - None - } else { - Some(texts.join("\n\n")) - }, - usage, - model, - }) - } - "user" => { - let mut content_types = Vec::new(); - let mut text = None; - let mut tool_name = None; - - let msg = record.get("message"); - match msg.and_then(|m| m.get("content")) { - Some(serde_json::Value::String(s)) => { - content_types.push("text".to_string()); - text = Some(s.clone()); - } - Some(serde_json::Value::Array(arr)) => { - for block in arr { - if let Some(ct) = block.get("type").and_then(|v| v.as_str()) { - if !content_types.contains(&ct.to_string()) { - content_types.push(ct.to_string()); - } - if ct == "tool_result" { - if let Some(content) = block.get("content").and_then(|v| v.as_str()) - { - text = Some(content.to_string()); - } - } else if ct == "text" { - if let Some(t) = block.get("text").and_then(|v| v.as_str()) { - text = Some(t.to_string()); - } - } - } - } - } - _ => {} - } - - if let Some(tur) = record.get("toolUseResult") { - if let Some(file) = tur - .get("file") - .and_then(|f| f.get("filePath").and_then(|v| v.as_str())) - { - tool_name = Some(format!("Read: {}", file)); - } else if tur.get("filenames").is_some() { - tool_name = Some("Glob".to_string()); - } else if tur.get("stdout").is_some() { - tool_name = Some("Bash".to_string()); - } - } - - Some(TranscriptRecord { - record_type, - timestamp, - content_types, - tool_name, - text, - usage: None, - model: None, - }) - } - "progress" => { - let data = record.get("data"); - let hook_name = data - .and_then(|d| d.get("hookName").and_then(|v| v.as_str())) - .map(String::from); - let hook_event = data.and_then(|d| d.get("hookEvent").and_then(|v| v.as_str())); - let text = hook_event.map(|e| { - if let Some(ref name) = hook_name { - format!("{}: {}", e, name) - } else { - e.to_string() - } - }); - - Some(TranscriptRecord { - record_type, - timestamp, - content_types: vec![], - tool_name: hook_name, - text, - usage: None, - model: None, - }) - } - "system" => { - let subtype = record - .get("subtype") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let text = match subtype { - "turn_duration" => { - let ms = record - .get("durationMs") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - Some(format!("turn_duration: {:.1}s", ms / 1000.0)) - } - "stop_hook_summary" => { - let count = record - .get("hookCount") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - Some(format!("stop_hook_summary: {} hooks", count)) - } - _ => Some(subtype.to_string()), - }; - - Some(TranscriptRecord { - record_type, - timestamp, - content_types: vec![subtype.to_string()], - tool_name: None, - text, - usage: None, - model: None, - }) - } - _ => Some(TranscriptRecord { - record_type, - timestamp, - content_types: vec![], - tool_name: None, - text: None, - usage: None, - model: None, - }), - } -} - pub fn parse_transcript( transcript: &serde_json::Value, pricing: &ModelPricing, + adapter: &dyn AgentAdapter, ) -> ( Vec, Vec, @@ -335,7 +117,41 @@ pub fn parse_transcript( let mut total_cache_write: i64 = 0; for record in records { - if let Some(tr) = parse_record(record, pricing) { + if let Some(parsed) = adapter.parse_transcript_record(record) { + let usage = if parsed.raw_input_tokens.is_some() { + let total_input_raw = parsed.raw_input_tokens.unwrap_or(0); + let output = parsed.raw_output_tokens.unwrap_or(0); + let cache_read = parsed.raw_cache_read_tokens.unwrap_or(0); + let cache_write = parsed.raw_cache_write_tokens.unwrap_or(0); + let fresh_input = (total_input_raw - cache_read - cache_write).max(0); + let cost = pricing::estimate_cost_with_pricing( + pricing, + fresh_input, + output, + cache_read, + cache_write, + ); + Some(RecordUsage { + input_tokens: fresh_input, + output_tokens: output, + cache_read_tokens: cache_read, + cache_write_tokens: cache_write, + cost_usd: cost, + }) + } else { + None + }; + + let tr = TranscriptRecord { + record_type: parsed.record_type.clone(), + timestamp: parsed.timestamp, + content_types: parsed.content_types, + tool_name: parsed.tool_name, + text: parsed.text, + usage, + model: parsed.model, + }; + if tr.record_type == "assistant" { if let Some(ref usage) = tr.usage { let model = tr.model.as_deref().unwrap_or("unknown"); @@ -414,6 +230,7 @@ pub fn parse_transcript( struct SessionRow { session_id: String, model: Option, + tool: Option, started_at: Option>, ended_at: Option>, duration_ms: Option, @@ -436,7 +253,7 @@ pub async fn get_session_detail( let org_id = auth.org_id; let row = sqlx::query_as::<_, SessionRow>( - "SELECT s.session_id, s.model, s.started_at, s.ended_at, s.duration_ms, + "SELECT s.session_id, s.model, s.tool, s.started_at, s.ended_at, s.duration_ms, s.total_tokens, s.input_tokens, s.output_tokens, s.cache_read_tokens, s.cache_write_tokens, s.estimated_cost_usd, @@ -471,8 +288,11 @@ pub async fn get_session_detail( let transcript_array: Vec = chunks.into_iter().map(|(d,)| d).collect(); let transcript_val = serde_json::Value::Array(transcript_array); + let adapter = state + .agent_registry + .get(row.tool.as_deref().unwrap_or("claude-code")); let (per_call, transcript_records, token_distribution, cost_breakdown, cache_savings) = - parse_transcript(&transcript_val, &pricing); + parse_transcript(&transcript_val, &pricing, adapter); // Count API calls from per_call data since api_calls column doesn't exist on sessions let api_calls = per_call.len() as i32; @@ -505,6 +325,7 @@ pub async fn get_session_detail( #[cfg(test)] mod tests { use super::*; + use tracevault_core::agent_adapter::AgentAdapterRegistry; fn test_pricing() -> ModelPricing { ModelPricing { @@ -518,8 +339,10 @@ mod tests { #[test] fn test_parse_empty_transcript() { let transcript = serde_json::json!([]); + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); let (per_call, records, dist, cost, savings) = - parse_transcript(&transcript, &test_pricing()); + parse_transcript(&transcript, &test_pricing(), adapter); assert!(per_call.is_empty()); assert!(records.is_empty()); assert_eq!(dist.input_tokens, 0); @@ -545,8 +368,10 @@ mod tests { } } ]); + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); let (per_call, records, dist, _cost, _savings) = - parse_transcript(&transcript, &test_pricing()); + parse_transcript(&transcript, &test_pricing(), adapter); assert_eq!(per_call.len(), 1); assert_eq!(per_call[0].index, 1); assert_eq!(per_call[0].cache_read_tokens, 1000); @@ -567,8 +392,10 @@ mod tests { } } ]); + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); let (per_call, records, _dist, _cost, _savings) = - parse_transcript(&transcript, &test_pricing()); + parse_transcript(&transcript, &test_pricing(), adapter); assert!(per_call.is_empty()); assert_eq!(records.len(), 1); } @@ -592,7 +419,10 @@ mod tests { } ]); let pricing = test_pricing(); - let (_per_call, _records, _dist, _cost, savings) = parse_transcript(&transcript, &pricing); + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let (_per_call, _records, _dist, _cost, savings) = + parse_transcript(&transcript, &pricing, adapter); assert!((savings.gross_savings_usd - 13.5).abs() < 0.001); assert!((savings.cache_write_overhead_usd - 0.375).abs() < 0.001); assert!((savings.net_savings_usd - 13.125).abs() < 0.001); @@ -620,7 +450,9 @@ mod tests { } } ]); - let (per_call, _, _, _, _) = parse_transcript(&transcript, &test_pricing()); + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let (per_call, _, _, _, _) = parse_transcript(&transcript, &test_pricing(), adapter); assert_eq!(per_call.len(), 2); assert!((per_call[0].cumulative_cost_usd - 15.0).abs() < 0.001); assert!((per_call[1].cumulative_cost_usd - 30.0).abs() < 0.001); diff --git a/crates/tracevault-server/src/api/traces_ui/sessions.rs b/crates/tracevault-server/src/api/traces_ui/sessions.rs index 7b94fbb2..7536db2f 100644 --- a/crates/tracevault-server/src/api/traces_ui/sessions.rs +++ b/crates/tracevault-server/src/api/traces_ui/sessions.rs @@ -341,17 +341,14 @@ pub async fn get_session_transcript( ) -> Result, AppError> { verify_session_access(&state.pool, session_id, auth.org_id).await?; - let session_model: Option = - sqlx::query_scalar("SELECT model FROM sessions WHERE id = $1") - .bind(session_id) - .fetch_one(&state.pool) - .await?; - - let session_started_at: Option> = - sqlx::query_scalar("SELECT started_at FROM sessions WHERE id = $1") - .bind(session_id) - .fetch_one(&state.pool) - .await?; + let (session_model, session_tool, session_started_at): ( + Option, + Option, + Option>, + ) = sqlx::query_as("SELECT model, tool, started_at FROM sessions WHERE id = $1") + .bind(session_id) + .fetch_one(&state.pool) + .await?; let transcript_chunks = sqlx::query_as::<_, TranscriptChunkRow>(include_str!( "sql/get_session_transcript_chunks.sql" @@ -370,7 +367,10 @@ pub async fn get_session_transcript( let transcript_array: Vec = transcript_chunks.iter().map(|c| c.data.clone()).collect(); let transcript_val = serde_json::Value::Array(transcript_array); - let (_, transcript_records, _, _, _) = parse_transcript(&transcript_val, &pricing); + let adapter = state + .agent_registry + .get(session_tool.as_deref().unwrap_or("claude-code")); + let (_, transcript_records, _, _, _) = parse_transcript(&transcript_val, &pricing, adapter); Ok(Json(TranscriptResponse { transcript_chunks, diff --git a/crates/tracevault-server/src/lib.rs b/crates/tracevault-server/src/lib.rs index 7bc67b03..b31760cb 100644 --- a/crates/tracevault-server/src/lib.rs +++ b/crates/tracevault-server/src/lib.rs @@ -22,6 +22,9 @@ pub mod story; pub use error::AppError; +use std::sync::Arc; +use tracevault_core::agent_adapter::AgentAdapterRegistry; + /// Stable replacement for `str::floor_char_boundary` (nightly-only). /// Returns the largest byte index `<= index` that is a char boundary. pub fn floor_char_boundary(s: &str, index: usize) -> usize { @@ -47,4 +50,5 @@ pub struct AppState { pub invite_expiry_minutes: u64, pub embedding_service: Option>, + pub agent_registry: Arc, } diff --git a/crates/tracevault-server/src/main.rs b/crates/tracevault-server/src/main.rs index ed4a4c15..6ea07f4c 100644 --- a/crates/tracevault-server/src/main.rs +++ b/crates/tracevault-server/src/main.rs @@ -4,10 +4,12 @@ use axum::{ }; use http::Method; use std::net::SocketAddr; +use std::sync::Arc; use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; +use tracevault_core::agent_adapter::AgentAdapterRegistry; use tracevault_server::{api, config, db, extensions, pricing_sync, repo_manager, AppState}; #[tokio::main] @@ -585,6 +587,7 @@ async fn main() { cors_origin: cfg.cors_origin.clone(), invite_expiry_minutes: cfg.invite_expiry_minutes, embedding_service, + agent_registry: Arc::new(AgentAdapterRegistry::new()), }); let listener = tokio::net::TcpListener::bind(&bind_addr).await.unwrap(); diff --git a/crates/tracevault-server/src/service/stream.rs b/crates/tracevault-server/src/service/stream.rs index 5a140057..870e5283 100644 --- a/crates/tracevault-server/src/service/stream.rs +++ b/crates/tracevault-server/src/service/stream.rs @@ -1,8 +1,5 @@ use tracevault_core::software::extract_software; -use tracevault_core::streaming::{ - extract_file_change, is_file_modifying_tool, StreamEventRequest, StreamEventResponse, - StreamEventType, -}; +use tracevault_core::streaming::{StreamEventRequest, StreamEventResponse, StreamEventType}; use uuid::Uuid; use crate::error::AppError; @@ -37,6 +34,9 @@ impl StreamService { Some("claude-code".to_string()) }; + let agent_name = tool.as_deref().unwrap_or("claude-code"); + let adapter = state.agent_registry.get(agent_name); + // 3. Upsert session let session_db_id = SessionRepo::upsert( &state.pool, @@ -83,31 +83,56 @@ impl StreamService { continue; } - // Extract token usage from assistant messages - if let Some(msg) = line.get("message") { - if let Some(usage) = msg.get("usage") { - batch_input += usage - .get("input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - batch_output += usage - .get("output_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - batch_cache_read += usage - .get("cache_read_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - batch_cache_write += usage - .get("cache_creation_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - } - if detected_model.is_none() { - detected_model = - msg.get("model").and_then(|v| v.as_str()).map(String::from); + // Extract file changes from transcript chunks (e.g. Codex apply_patch). + // Each adapter decides which chunk types contain file modifications. + let transcript_file_changes = + adapter.extract_file_changes_from_transcript(line); + for change in transcript_file_changes { + let tool_name = line + .get("payload") + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let event_id = EventRepo::insert_tool_event( + &state.pool, + &crate::repo::events::InsertToolEvent { + session_id: session_db_id, + event_index: chunk_index, + tool_name: Some(tool_name.to_string()), + tool_input: line.get("payload").cloned(), + tool_response: None, + tool_is_error: None, + timestamp: Some(req.timestamp), + }, + ) + .await?; + if let Some(eid) = event_id { + EventRepo::insert_file_change( + &state.pool, + &InsertFileChange { + session_id: session_db_id, + event_id: eid, + file_path: change.file_path, + change_type: change.change_type, + diff_text: change.diff_text, + content_hash: change.content_hash, + timestamp: Some(req.timestamp), + }, + ) + .await?; } } + + // Extract token usage via adapter + if let Some(usage) = adapter.extract_token_usage(line) { + batch_input += usage.input_tokens; + batch_output += usage.output_tokens; + batch_cache_read += usage.cache_read_tokens; + batch_cache_write += usage.cache_write_tokens; + } + if detected_model.is_none() { + detected_model = adapter.extract_model(line); + } } // Update session token counts and cost if we found usage data @@ -115,7 +140,7 @@ impl StreamService { || batch_output > 0 || batch_cache_read > 0 || batch_cache_write > 0; - if has_tokens { + if has_tokens || detected_model.is_some() { let model_name = detected_model.as_deref().unwrap_or("unknown"); // input_tokens from the API includes cache_read and cache_write, // subtract to get fresh (non-cached) input only @@ -156,7 +181,7 @@ impl StreamService { })?; let tool_name = req.tool_name.as_deref().unwrap_or(""); - let store_response = is_file_modifying_tool(tool_name); + let store_response = adapter.is_file_modifying(tool_name); let inserted_id = EventRepo::insert_tool_event( &state.pool, @@ -180,9 +205,10 @@ impl StreamService { event_db_id = Some(eid); // Extract file changes for file-modifying tools - if is_file_modifying_tool(tool_name) { + if adapter.is_file_modifying(tool_name) { if let Some(ref tool_input) = req.tool_input { - if let Some(change) = extract_file_change(tool_name, tool_input) { + let file_changes = adapter.extract_file_changes(tool_name, tool_input); + for change in file_changes { EventRepo::insert_file_change( &state.pool, &InsertFileChange { diff --git a/web/src/lib/components/AgentBadge.svelte b/web/src/lib/components/AgentBadge.svelte new file mode 100644 index 00000000..e283fcaa --- /dev/null +++ b/web/src/lib/components/AgentBadge.svelte @@ -0,0 +1,69 @@ + + +{#if agent} + + {#if tool === 'claude-code'} + + + + + {:else if tool === 'codex'} + + + + + + {:else if tool === 'gemini'} + + + + + {:else if tool === 'cursor'} + + + + + {:else} + + + + + + + + {/if} + {agent.label} + +{/if} diff --git a/web/src/routes/orgs/[slug]/traces/sessions/+page.svelte b/web/src/routes/orgs/[slug]/traces/sessions/+page.svelte index b79b39f0..38e223c5 100644 --- a/web/src/routes/orgs/[slug]/traces/sessions/+page.svelte +++ b/web/src/routes/orgs/[slug]/traces/sessions/+page.svelte @@ -7,6 +7,7 @@ import * as Table from '$lib/components/ui/table/index.js'; import * as Popover from '$lib/components/ui/popover/index.js'; import StatusBadge from '$lib/components/StatusBadge.svelte'; + import AgentBadge from '$lib/components/AgentBadge.svelte'; import LoadingState from '$lib/components/LoadingState.svelte'; import ErrorState from '$lib/components/ErrorState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; @@ -343,7 +344,12 @@ {#each filteredSessions as s (s.id)} - + +
+ + +
+
{String(s.session_id).slice(0, 8)} diff --git a/web/src/routes/orgs/[slug]/traces/sessions/[id]/+page.svelte b/web/src/routes/orgs/[slug]/traces/sessions/[id]/+page.svelte index 8c5ee759..9403bbc3 100644 --- a/web/src/routes/orgs/[slug]/traces/sessions/[id]/+page.svelte +++ b/web/src/routes/orgs/[slug]/traces/sessions/[id]/+page.svelte @@ -15,6 +15,7 @@ import { formatDateTime } from '$lib/utils/date'; import * as Table from '$lib/components/ui/table/index.js'; import StatusBadge from '$lib/components/StatusBadge.svelte'; + import AgentBadge from '$lib/components/AgentBadge.svelte'; import LoadingState from '$lib/components/LoadingState.svelte'; import ErrorState from '$lib/components/ErrorState.svelte'; import SessionTranscript from '$lib/components/session-detail/SessionTranscript.svelte'; @@ -215,6 +216,7 @@ / {session.session_id.slice(0, 8)} + From 8e2e0f2106c46dad9ebd8478810c2d3880405ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= <> Date: Wed, 29 Apr 2026 17:44:50 +0200 Subject: [PATCH 2/6] fix(agent-adapter): port Claude Code parser 1:1 from main, fix Codex hooks and CLI flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code was rewritten in #95 instead of being ported from session_detail.rs::parse_record on main, which introduced display regressions: Bash/Glob toolUseResult lost their tool_name (wrong nested-key lookup), tool_result blocks lost their text body (read `text` instead of `content`), and assistant text formatting lost the \n\n separator and `[thinking] ` prefix. The parser is now a faithful port — same fields, same fallbacks, same format strings. Token extraction now mirrors main: presence of `usage` gates the whole RecordUsage and individual missing fields default to 0, instead of aborting on missing input/output_tokens. Codex adapter: - SessionStart matcher widened from "startup|resume" to "" so the hook also fires on /clear (verified against openai/codex sources). - The user-message system-prompt filter no longer drops every message starting with `<`. It now matches only the seven known Codex injection tags from codex protocol.rs (user_instructions, environment_context, apps_instructions, skills_instructions, plugins_instructions, collaboration_mode, realtime_conversation), preserving legitimate
//-style user questions. - File changes extracted from transcript chunks now use the chunk's own RFC 3339 timestamp (with fallback to the hook delivery time) rather than stamping every batched patch with the hook arrival time. CLI: - `tracevault init --agent ` is now additive: Claude Code hooks are always installed, additional --agent values are appended and deduplicated (with `claude` aliased to `claude-code`). Previously --agent codex replaced rather than augmented the default, so users following the README ended up without Claude hooks. - The success print now reflects which agents were actually installed instead of unconditionally claiming "Claude Code hooks installed". - README CLI table reworded to match the additive behavior. Cleanup: deduplicated adapter.is_file_modifying call in service/stream.rs (the result is already in `store_response`). Tests: 16 new adapter tests cover the regressed Claude Code parser paths (Bash/Glob/tool_result/thinking/system unknown subtype/progress edge cases) plus Codex token_usage edge cases and the Codex system-prompt whitelist. 5 new init tests cover the additive --agent behavior, dedup of `claude`/`claude-code` aliases, and the Codex SessionStart match-all matcher. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- crates/tracevault-cli/src/main.rs | 6 +- crates/tracevault-cli/tests/init_test.rs | 76 ++++++ .../src/agent_adapter/claude_code.rs | 214 ++++++++------- .../src/agent_adapter/codex.rs | 30 ++- .../tests/agent_adapter_test.rs | 252 +++++++++++++++++- .../tracevault-server/src/service/stream.rs | 16 +- 7 files changed, 483 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 41d5bd34..6dae6008 100644 --- a/README.md +++ b/README.md @@ -427,7 +427,7 @@ export DATABASE_URL=postgres://user:password@host:5432/tracevault?sslmode=requir | Command | Description | |---------|-------------| -| `tracevault init [--server-url URL] [--claude-settings shared\|local] [--agent ]...` | Initialize Visdom Trace in current repo, install pre-push hook and agent hooks (Claude Code by default, repeat `--agent` to add others e.g. `codex`). `--claude-settings` chooses between `.claude/settings.json` (default) and `.claude/settings.local.json`; prompts interactively if omitted on a TTY | +| `tracevault init [--server-url URL] [--claude-settings shared\|local] [--agent ]...` | Initialize Visdom Trace in current repo, install pre-push hook and agent hooks. Claude Code hooks are always installed; repeat `--agent ` to additionally install hooks for other agents, e.g. `--agent codex`. `--claude-settings` chooses between `.claude/settings.json` (default) and `.claude/settings.local.json`; prompts interactively if omitted on a TTY | | `tracevault login --server-url URL [--no-browser]` | Authenticate via device auth flow. Prints the URL and opens a browser when possible; `--no-browser` (or a headless env) skips the auto-open. | | `tracevault logout` | Clear local credentials | | `tracevault stream --event [--agent ]` | Handle an agent hook event (reads JSON from stdin) and stream it to the server (`--agent`: `claude-code` (default), `codex`) | diff --git a/crates/tracevault-cli/src/main.rs b/crates/tracevault-cli/src/main.rs index fc4d0cca..af5d07a4 100644 --- a/crates/tracevault-cli/src/main.rs +++ b/crates/tracevault-cli/src/main.rs @@ -131,7 +131,11 @@ async fn main() { println!("TraceVault initialized in {}", cwd.display()); println!("Claude Code hooks installed ({entry})"); for agent in &agents { - println!("{agent} hooks installed"); + match agent.as_str() { + "codex" => println!("Codex hooks installed (.codex/hooks.json)"), + "claude" | "claude-code" => {} + other => println!("{other} hooks installed"), + } } println!("Git hooks installed (pre-push, post-commit)"); println!("Added .tracevault/ and {entry} to .gitignore"); diff --git a/crates/tracevault-cli/tests/init_test.rs b/crates/tracevault-cli/tests/init_test.rs index 17af38d9..95524492 100644 --- a/crates/tracevault-cli/tests/init_test.rs +++ b/crates/tracevault-cli/tests/init_test.rs @@ -69,6 +69,7 @@ async fn init_installs_claude_hooks() { assert!(hooks.get("PreToolUse").is_some()); assert!(hooks.get("PostToolUse").is_some()); assert!(hooks.get("Notification").is_some()); + assert!(hooks.get("Stop").is_some()); } #[tokio::test] @@ -310,3 +311,78 @@ async fn init_no_gitignore_skips_gitignore_update() { assert!(tmp.path().join(".tracevault").exists()); assert!(tmp.path().join(".claude/settings.json").exists()); } + +#[tokio::test] +async fn init_with_codex_agent_also_installs_claude() { + // --agent codex must be additive: Claude Code hooks are still installed. + let tmp = tmp_git_repo(); + let extras = vec!["codex".to_string()]; + + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + None, + false, + Some(extras.as_slice()), + ) + .await + .unwrap(); + + assert!(tmp.path().join(".claude/settings.json").exists()); + assert!(tmp.path().join(".codex/hooks.json").exists()); +} + +#[tokio::test] +async fn init_installs_codex_session_start_with_match_all_matcher() { + // Codex SessionStart matcher must be empty so the hook fires for all + // source variants Codex passes: startup, resume, clear. + let tmp = tmp_git_repo(); + let extras = vec!["codex".to_string()]; + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + None, + false, + Some(extras.as_slice()), + ) + .await + .unwrap(); + + let content = fs::read_to_string(tmp.path().join(".codex/hooks.json")).unwrap(); + let config: serde_json::Value = serde_json::from_str(&content).unwrap(); + let session_start = &config["hooks"]["SessionStart"][0]; + assert_eq!(session_start["matcher"], ""); +} + +#[tokio::test] +async fn init_default_installs_only_claude() { + let tmp = tmp_git_repo(); + + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) + .await + .unwrap(); + + assert!(tmp.path().join(".claude/settings.json").exists()); + assert!(!tmp.path().join(".codex/hooks.json").exists()); +} + +#[tokio::test] +async fn init_dedupes_explicit_claude_alias() { + // Passing both `claude` and `claude-code` must not error or double-install; + // Claude is installed via the always-on path and skipped in the agent loop. + let tmp = tmp_git_repo(); + let extras = vec!["claude".to_string(), "claude-code".to_string()]; + + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + None, + false, + Some(extras.as_slice()), + ) + .await + .unwrap(); + + assert!(tmp.path().join(".claude/settings.json").exists()); + assert!(!tmp.path().join(".codex/hooks.json").exists()); +} diff --git a/crates/tracevault-core/src/agent_adapter/claude_code.rs b/crates/tracevault-core/src/agent_adapter/claude_code.rs index 2d8db4ab..f79f7334 100644 --- a/crates/tracevault-core/src/agent_adapter/claude_code.rs +++ b/crates/tracevault-core/src/agent_adapter/claude_code.rs @@ -126,10 +126,19 @@ impl AgentAdapter for ClaudeCodeAdapter { } fn extract_token_usage(&self, chunk: &serde_json::Value) -> Option { + // Match main's pre-adapter behavior: any missing field defaults to 0 + // rather than aborting the whole extraction. The presence of `usage` is + // the only gating signal. let usage = chunk.get("message")?.get("usage")?; Some(TokenUsage { - input_tokens: usage.get("input_tokens")?.as_i64()?, - output_tokens: usage.get("output_tokens")?.as_i64()?, + input_tokens: usage + .get("input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + output_tokens: usage + .get("output_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0), cache_read_tokens: usage .get("cache_read_input_tokens") .and_then(|v| v.as_i64()) @@ -219,7 +228,23 @@ impl ClaudeCodeAdapter { record_type: String, timestamp: Option, ) -> Option { - let message = chunk.get("message")?; + let message = match chunk.get("message") { + Some(m) => m, + None => { + return Some(ParsedTranscriptRecord { + record_type, + timestamp, + content_types: Vec::new(), + tool_name: None, + text: None, + raw_input_tokens: None, + raw_output_tokens: None, + raw_cache_read_tokens: None, + raw_cache_write_tokens: None, + model: None, + }); + } + }; let model = message .get("model") .and_then(|v| v.as_str()) @@ -235,47 +260,44 @@ impl ClaudeCodeAdapter { if !content_types.contains(&block_type.to_string()) { content_types.push(block_type.to_string()); } - match block_type { - "text" => { - if let Some(t) = block.get("text").and_then(|v| v.as_str()) { - text_parts.push(t.to_string()); - } - } - "thinking" => { - if let Some(t) = block.get("thinking").and_then(|v| v.as_str()) { - text_parts.push(t.to_string()); - } - } - "tool_use" if first_tool_name.is_none() => { - first_tool_name = block - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - } - _ => {} + if block_type == "tool_use" && first_tool_name.is_none() { + first_tool_name = block + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); } } + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + text_parts.push(t.to_string()); + } + if let Some(t) = block.get("thinking").and_then(|v| v.as_str()) { + text_parts.push(format!("[thinking] {}", t)); + } } } + // Match main's parse_record: presence of `usage` field gates the whole + // RecordUsage downstream. Individual missing tokens default to 0. let usage = message.get("usage"); - let raw_input_tokens = usage - .and_then(|u| u.get("input_tokens")) - .and_then(|v| v.as_i64()); - let raw_output_tokens = usage - .and_then(|u| u.get("output_tokens")) - .and_then(|v| v.as_i64()); - let raw_cache_read_tokens = usage - .and_then(|u| u.get("cache_read_input_tokens")) - .and_then(|v| v.as_i64()); - let raw_cache_write_tokens = usage - .and_then(|u| u.get("cache_creation_input_tokens")) - .and_then(|v| v.as_i64()); + let raw_input_tokens = + usage.map(|u| u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0)); + let raw_output_tokens = + usage.map(|u| u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0)); + let raw_cache_read_tokens = usage.map(|u| { + u.get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + }); + let raw_cache_write_tokens = usage.map(|u| { + u.get("cache_creation_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + }); let text = if text_parts.is_empty() { None } else { - Some(text_parts.join("\n")) + Some(text_parts.join("\n\n")) }; Some(ParsedTranscriptRecord { @@ -299,63 +321,49 @@ impl ClaudeCodeAdapter { timestamp: Option, ) -> Option { let mut content_types = Vec::new(); - let mut text_parts = Vec::new(); + let mut text: Option = None; let mut tool_name: Option = None; - // Check for toolUseResult (e.g. Read, Glob, Bash results) - if let Some(tool_result) = chunk.get("toolUseResult") { - if let Some(file_info) = tool_result.get("file") { - let file_path = file_info - .get("filePath") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - tool_name = Some(format!("Read: {}", file_path)); - } else if let Some(glob_info) = tool_result.get("glob") { - let pattern = glob_info - .get("pattern") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - tool_name = Some(format!("Glob: {}", pattern)); - } else if let Some(bash_info) = tool_result.get("bash") { - let command = bash_info - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - tool_name = Some(format!("Bash: {}", command)); + // Handle message.content as either a string or an array of blocks. + match chunk.get("message").and_then(|m| m.get("content")) { + Some(serde_json::Value::String(s)) => { + content_types.push("text".to_string()); + text = Some(s.clone()); } - } - - // Handle message.content as either a string or an array - if let Some(message) = chunk.get("message") { - if let Some(content) = message.get("content") { - if let Some(text) = content.as_str() { - text_parts.push(text.to_string()); - content_types.push("text".to_string()); - } else if let Some(arr) = content.as_array() { - for block in arr { - if let Some(block_type) = block.get("type").and_then(|v| v.as_str()) { - if !content_types.contains(&block_type.to_string()) { - content_types.push(block_type.to_string()); + Some(serde_json::Value::Array(arr)) => { + for block in arr { + if let Some(ct) = block.get("type").and_then(|v| v.as_str()) { + if !content_types.contains(&ct.to_string()) { + content_types.push(ct.to_string()); + } + if ct == "tool_result" { + if let Some(content) = block.get("content").and_then(|v| v.as_str()) { + text = Some(content.to_string()); } - match block_type { - "tool_result" | "text" => { - if let Some(t) = block.get("text").and_then(|v| v.as_str()) { - text_parts.push(t.to_string()); - } - } - _ => {} + } else if ct == "text" { + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + text = Some(t.to_string()); } } } } } + _ => {} } - let text = if text_parts.is_empty() { - None - } else { - Some(text_parts.join("\n")) - }; + // toolUseResult discriminates by which top-level field is present. + if let Some(tur) = chunk.get("toolUseResult") { + if let Some(file) = tur + .get("file") + .and_then(|f| f.get("filePath").and_then(|v| v.as_str())) + { + tool_name = Some(format!("Read: {}", file)); + } else if tur.get("filenames").is_some() { + tool_name = Some("Glob".to_string()); + } else if tur.get("stdout").is_some() { + tool_name = Some("Bash".to_string()); + } + } Some(ParsedTranscriptRecord { record_type, @@ -379,22 +387,23 @@ impl ClaudeCodeAdapter { ) -> Option { let data = chunk.get("data"); let hook_name = data - .and_then(|d| d.get("hookName")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let hook_event = data - .and_then(|d| d.get("hookEvent")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - let text = format!("{}: {}", hook_event, hook_name); + .and_then(|d| d.get("hookName").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + let hook_event = data.and_then(|d| d.get("hookEvent").and_then(|v| v.as_str())); + let text = hook_event.map(|e| { + if let Some(ref name) = hook_name { + format!("{}: {}", e, name) + } else { + e.to_string() + } + }); Some(ParsedTranscriptRecord { record_type, timestamp, content_types: Vec::new(), - tool_name: None, - text: Some(text), + tool_name: hook_name, + text, raw_input_tokens: None, raw_output_tokens: None, raw_cache_read_tokens: None, @@ -409,28 +418,29 @@ impl ClaudeCodeAdapter { record_type: String, timestamp: Option, ) -> Option { - let subtype = chunk.get("subtype").and_then(|v| v.as_str()); - + let subtype = chunk + .get("subtype") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); let text = match subtype { - Some("turn_duration") => { - let duration_ms = chunk + "turn_duration" => { + let ms = chunk .get("durationMs") .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let seconds = duration_ms / 1000.0; - Some(format!("Turn duration: {:.1}s", seconds)) + Some(format!("turn_duration: {:.1}s", ms / 1000.0)) } - Some("stop_hook_summary") => { - let hook_count = chunk.get("hookCount").and_then(|v| v.as_i64()).unwrap_or(0); - Some(format!("Stop hooks executed: {}", hook_count)) + "stop_hook_summary" => { + let count = chunk.get("hookCount").and_then(|v| v.as_i64()).unwrap_or(0); + Some(format!("stop_hook_summary: {} hooks", count)) } - _ => None, + _ => Some(subtype.to_string()), }; Some(ParsedTranscriptRecord { record_type, timestamp, - content_types: Vec::new(), + content_types: vec![subtype.to_string()], tool_name: None, text, raw_input_tokens: None, diff --git a/crates/tracevault-core/src/agent_adapter/codex.rs b/crates/tracevault-core/src/agent_adapter/codex.rs index 1a4cc4df..47a7dfa5 100644 --- a/crates/tracevault-core/src/agent_adapter/codex.rs +++ b/crates/tracevault-core/src/agent_adapter/codex.rs @@ -17,7 +17,7 @@ pub struct CodexAdapter; fn hooks_json() -> serde_json::Value { serde_json::json!({ "SessionStart": [{ - "matcher": "startup|resume", + "matcher": "", "hooks": [{ "type": "command", "command": "tracevault stream --agent codex --event session-start", @@ -292,8 +292,11 @@ impl CodexAdapter { let block_type = block.get("type").and_then(|v| v.as_str())?; if block_type == "input_text" || block_type == "output_text" { let t = block.get("text").and_then(|v| v.as_str())?; - // Skip system prompts (XML tags in user messages) - if t.starts_with('<') && role == "user" { + // Codex injects system context into the user role wrapped + // in known XML tags (see openai/codex protocol.rs). + // Skip only those — a blunt `starts_with('<')` would also + // drop legitimate user questions about HTML/JSX/XML snippets. + if role == "user" && is_codex_system_prompt(t) { return None; } Some(t.to_string()) @@ -352,6 +355,27 @@ impl CodexAdapter { } } +/// Known opening tags Codex uses to inject system context into the user role. +/// Sourced from openai/codex `codex-rs/protocol/src/protocol.rs`. +const CODEX_SYSTEM_PROMPT_TAGS: &[&str] = &[ + "", + "", + "", + "", + "", + "", + "", +]; + +/// Returns true if `text` starts with one of the known Codex system-prompt tags +/// (after trimming leading whitespace). +fn is_codex_system_prompt(text: &str) -> bool { + let trimmed = text.trim_start(); + CODEX_SYSTEM_PROMPT_TAGS + .iter() + .any(|tag| trimmed.starts_with(tag)) +} + /// Parse Codex's custom apply_patch format into file changes. pub fn parse_codex_patch(patch: &str) -> Vec { let mut changes = Vec::new(); diff --git a/crates/tracevault-core/tests/agent_adapter_test.rs b/crates/tracevault-core/tests/agent_adapter_test.rs index 129b0850..4d161ba1 100644 --- a/crates/tracevault-core/tests/agent_adapter_test.rs +++ b/crates/tracevault-core/tests/agent_adapter_test.rs @@ -160,7 +160,7 @@ fn claude_code_parse_user_record() { } #[test] -fn claude_code_parse_user_tool_result() { +fn claude_code_parse_user_tool_result_read() { let registry = AgentAdapterRegistry::new(); let adapter = registry.get("claude-code"); let chunk = json!({"type": "user", "toolUseResult": {"file": {"filePath": "src/main.rs"}}}); @@ -168,6 +168,101 @@ fn claude_code_parse_user_tool_result() { assert_eq!(record.tool_name.as_deref(), Some("Read: src/main.rs")); } +#[test] +fn claude_code_parse_user_tool_result_bash_uses_top_level_stdout() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({ + "type": "user", + "toolUseResult": { + "stdout": "ok\n", + "stderr": "", + "interrupted": false + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.tool_name.as_deref(), Some("Bash")); +} + +#[test] +fn claude_code_parse_user_tool_result_glob_uses_top_level_filenames() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({ + "type": "user", + "toolUseResult": { + "filenames": ["src/main.rs", "src/lib.rs"] + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.tool_name.as_deref(), Some("Glob")); +} + +#[test] +fn claude_code_parse_user_tool_result_block_reads_content_field() { + // tool_result blocks store the body under `content`, not `text`. + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({ + "type": "user", + "message": { + "content": [ + {"type": "tool_result", "tool_use_id": "abc", "content": "command output"} + ] + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.text.as_deref(), Some("command output")); + assert!(record.content_types.contains(&"tool_result".to_string())); +} + +#[test] +fn claude_code_parse_user_text_block() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({ + "type": "user", + "message": { + "content": [{"type": "text", "text": "follow up"}] + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.text.as_deref(), Some("follow up")); +} + +#[test] +fn claude_code_parse_assistant_thinking_uses_prefix_and_double_newline() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({ + "type": "assistant", + "message": { + "model": "claude-opus-4-6", + "content": [ + {"type": "thinking", "thinking": "let me think"}, + {"type": "text", "text": "the answer"} + ] + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!( + record.text.as_deref(), + Some("[thinking] let me think\n\nthe answer") + ); +} + +#[test] +fn claude_code_parse_assistant_missing_message_returns_empty_record() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "assistant", "timestamp": "2026-04-29T10:00:00Z"}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "assistant"); + assert!(record.text.is_none()); + assert!(record.content_types.is_empty()); + assert!(record.model.is_none()); +} + #[test] fn claude_code_parse_progress_record() { let registry = AgentAdapterRegistry::new(); @@ -177,16 +272,57 @@ fn claude_code_parse_progress_record() { let record = adapter.parse_transcript_record(&chunk).unwrap(); assert_eq!(record.record_type, "progress"); assert_eq!(record.text.as_deref(), Some("PostToolUse: tracevault")); + assert_eq!(record.tool_name.as_deref(), Some("tracevault")); +} + +#[test] +fn claude_code_parse_progress_record_event_only() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "progress", "data": {"hookEvent": "PostToolUse"}}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.text.as_deref(), Some("PostToolUse")); + assert!(record.tool_name.is_none()); } #[test] -fn claude_code_parse_system_record() { +fn claude_code_parse_progress_record_missing_event_yields_no_text() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "progress", "data": {"hookName": "tracevault"}}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert!(record.text.is_none()); + assert_eq!(record.tool_name.as_deref(), Some("tracevault")); +} + +#[test] +fn claude_code_parse_system_record_turn_duration() { let registry = AgentAdapterRegistry::new(); let adapter = registry.get("claude-code"); let chunk = json!({"type": "system", "subtype": "turn_duration", "durationMs": 5000.0}); let record = adapter.parse_transcript_record(&chunk).unwrap(); assert_eq!(record.record_type, "system"); - assert!(record.text.as_ref().unwrap().contains("5.0s")); + assert_eq!(record.text.as_deref(), Some("turn_duration: 5.0s")); + assert_eq!(record.content_types, vec!["turn_duration".to_string()]); +} + +#[test] +fn claude_code_parse_system_record_stop_hook_summary() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "system", "subtype": "stop_hook_summary", "hookCount": 3}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.text.as_deref(), Some("stop_hook_summary: 3 hooks")); +} + +#[test] +fn claude_code_parse_system_record_unknown_subtype_keeps_subtype_in_content_types() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("claude-code"); + let chunk = json!({"type": "system", "subtype": "init"}); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.text.as_deref(), Some("init")); + assert_eq!(record.content_types, vec!["init".to_string()]); } #[test] @@ -227,6 +363,27 @@ fn codex_extract_token_usage_non_token_chunk_returns_none() { assert!(adapter.extract_token_usage(&chunk).is_none()); } +#[test] +fn codex_extract_token_usage_token_count_without_info_returns_none() { + // token_count event with no `info` field (e.g. early/empty payload). + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({"type": "event_msg", "payload": {"type": "token_count"}}); + assert!(adapter.extract_token_usage(&chunk).is_none()); +} + +#[test] +fn codex_extract_token_usage_token_count_without_last_token_usage_returns_none() { + // token_count event with `info` but no `last_token_usage` (metadata-only). + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "event_msg", + "payload": {"type": "token_count", "info": {"total_tokens": 0}} + }); + assert!(adapter.extract_token_usage(&chunk).is_none()); +} + #[test] fn codex_extract_model() { let registry = AgentAdapterRegistry::new(); @@ -266,6 +423,95 @@ fn codex_parse_user_message() { assert_eq!(record.text.as_deref(), Some("Fix the login bug")); } +#[test] +fn codex_user_message_with_html_snippet_is_kept() { + // Legitimate user questions starting with `<` (HTML/JSX/XML) must not be + // dropped by the system-prompt filter — only known Codex prompt tags are. + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "
fix this rendering
"} + ] + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!( + record.text.as_deref(), + Some("
fix this rendering
") + ); +} + +#[test] +fn codex_user_message_with_system_prompt_tag_is_dropped() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + for tag in [ + "", + "", + "", + "", + "", + "", + "", + ] { + let body = format!("{tag}some system context\n"); + let chunk = json!({ + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": body}] + } + }); + assert!( + adapter.parse_transcript_record(&chunk).is_none(), + "tag {tag} should be filtered out" + ); + } +} + +#[test] +fn codex_user_message_with_leading_whitespace_then_system_tag_is_dropped() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": " \ncwd: /tmp"} + ] + } + }); + assert!(adapter.parse_transcript_record(&chunk).is_none()); +} + +#[test] +fn codex_assistant_message_with_html_snippet_is_kept_regardless_of_prefix() { + // The system-prompt filter only applies to the user role. + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "example"} + ] + } + }); + let record = adapter.parse_transcript_record(&chunk).unwrap(); + assert_eq!(record.record_type, "assistant"); + assert!(record.text.is_some()); +} + #[test] fn codex_parse_shell_call() { let registry = AgentAdapterRegistry::new(); diff --git a/crates/tracevault-server/src/service/stream.rs b/crates/tracevault-server/src/service/stream.rs index 870e5283..997f9973 100644 --- a/crates/tracevault-server/src/service/stream.rs +++ b/crates/tracevault-server/src/service/stream.rs @@ -87,6 +87,16 @@ impl StreamService { // Each adapter decides which chunk types contain file modifications. let transcript_file_changes = adapter.extract_file_changes_from_transcript(line); + // Prefer the chunk's own timestamp (precise per-line time of the + // event in the transcript) over the hook delivery time, which can + // lag minutes behind for batched transcript ingestion (e.g. Codex + // emits patches mid-turn but the hook fires at Stop). + let chunk_timestamp = line + .get("timestamp") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or(req.timestamp); for change in transcript_file_changes { let tool_name = line .get("payload") @@ -102,7 +112,7 @@ impl StreamService { tool_input: line.get("payload").cloned(), tool_response: None, tool_is_error: None, - timestamp: Some(req.timestamp), + timestamp: Some(chunk_timestamp), }, ) .await?; @@ -116,7 +126,7 @@ impl StreamService { change_type: change.change_type, diff_text: change.diff_text, content_hash: change.content_hash, - timestamp: Some(req.timestamp), + timestamp: Some(chunk_timestamp), }, ) .await?; @@ -205,7 +215,7 @@ impl StreamService { event_db_id = Some(eid); // Extract file changes for file-modifying tools - if adapter.is_file_modifying(tool_name) { + if store_response { if let Some(ref tool_input) = req.tool_input { let file_changes = adapter.extract_file_changes(tool_name, tool_input); for change in file_changes { From d223cb9f5e605c6cb40c3d07e9d6ecccc850057b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= <> Date: Wed, 29 Apr 2026 22:12:38 +0200 Subject: [PATCH 3/6] refactor(agent-adapter): unify file-change extraction into a polymorphic API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull adapter-specific knowledge out of `service/stream.rs`. Previously the stream service hardcoded Codex chunk-shape lookups (`payload.name`, `payload` cloning, RFC 3339 timestamp parsing) and used two extraction methods with different return types (`Vec` vs `Vec`), so the call site had to reach into chunk internals to fill in tool_name / tool_input / timestamp. The trait now exposes two symmetric methods returning the same `FileChangeRecord` type: fn file_changes_from_hook(&self, tool, input, ts) -> Vec fn file_changes_from_transcript(&self, chunk, fallback_ts) -> Vec Each adapter overrides at most one. Defaults return empty. The `FileChangeRecord` carries everything the persistence layer needs (change, tool_name, tool_input, timestamp), so `stream.rs` just iterates and inserts — no chunk shape knowledge anywhere outside the adapter that owns that format. Claude path is preserved bit-for-bit against main: * `is_file_modifying` gate around the hook-extract loop is kept, so Read/Glob/etc. skip the call entirely (matches main's `if is_file_modifying_tool { ... }`). * New `provides_transcript_file_changes()` capability flag (default false) gates the per-line transcript-extract loop. Claude returns false → the `file_changes_from_transcript` method is never invoked for Claude transcript lines, exactly as on main where no equivalent call existed. * `file_changes_from_hook` for Claude wraps the same Write/Edit logic that lived in `extract_file_change` on main; the resulting DB writes have identical fields and timestamps (record.timestamp = req.timestamp). CLI: replace the hardcoded `match agent.as_str() { "claude-code" => ..., "codex" => ... }` in `main.rs` with `adapter.display_name()` and `adapter.hooks_install_path()` from the trait, so adding a new agent no longer requires touching the print-message code. Codex: `file_changes_from_transcript` now resolves the chunk's RFC 3339 timestamp internally and returns it in each record, replacing the duplicated timestamp logic that previously lived in `stream.rs`. The `provides_transcript_file_changes` override is `true`. Tests: 51 adapter tests (was 50), including a new fallback case verifying that a chunk with no top-level timestamp falls back to the hook delivery time. All hook/transcript extraction tests updated to the new method names and return type. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tracevault-cli/src/main.rs | 16 +++- .../src/agent_adapter/claude_code.rs | 36 +++++-- .../src/agent_adapter/codex.rs | 55 ++++++++--- .../src/agent_adapter/default.rs | 10 +- .../tracevault-core/src/agent_adapter/mod.rs | 57 ++++++++++-- .../tests/agent_adapter_test.rs | 84 ++++++++++++----- .../tracevault-server/src/service/stream.rs | 93 +++++++++---------- 7 files changed, 231 insertions(+), 120 deletions(-) diff --git a/crates/tracevault-cli/src/main.rs b/crates/tracevault-cli/src/main.rs index af5d07a4..433a1f45 100644 --- a/crates/tracevault-cli/src/main.rs +++ b/crates/tracevault-cli/src/main.rs @@ -1,5 +1,6 @@ use clap::Parser; use std::env; +use tracevault_core::agent_adapter::AgentAdapterRegistry; mod api_client; mod commands; @@ -130,11 +131,18 @@ async fn main() { let entry = target.gitignore_entry(); println!("TraceVault initialized in {}", cwd.display()); println!("Claude Code hooks installed ({entry})"); + let registry = AgentAdapterRegistry::new(); for agent in &agents { - match agent.as_str() { - "codex" => println!("Codex hooks installed (.codex/hooks.json)"), - "claude" | "claude-code" => {} - other => println!("{other} hooks installed"), + if agent == "claude" || agent == "claude-code" { + // Claude is always installed above via the settings target. + continue; + } + let adapter = registry.get(agent); + let path = adapter.hooks_install_path(); + if path.is_empty() { + println!("{} hooks installed", adapter.display_name()); + } else { + println!("{} hooks installed ({})", adapter.display_name(), path); } } println!("Git hooks installed (pre-push, post-commit)"); diff --git a/crates/tracevault-core/src/agent_adapter/claude_code.rs b/crates/tracevault-core/src/agent_adapter/claude_code.rs index f79f7334..33a6d901 100644 --- a/crates/tracevault-core/src/agent_adapter/claude_code.rs +++ b/crates/tracevault-core/src/agent_adapter/claude_code.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; use std::fs; use std::io; @@ -6,7 +7,7 @@ use std::path::Path; use crate::hooks::HookResponse; use crate::streaming::{ExtractedFileChange, StreamEventType}; -use super::{AgentAdapter, ParsedTranscriptRecord, TokenUsage}; +use super::{AgentAdapter, FileChangeRecord, ParsedTranscriptRecord, TokenUsage}; pub struct ClaudeCodeAdapter; @@ -56,6 +57,14 @@ impl AgentAdapter for ClaudeCodeAdapter { "claude-code" } + fn display_name(&self) -> &str { + "Claude Code" + } + + fn hooks_install_path(&self) -> &str { + ".claude/settings.json" + } + fn map_event_type(&self, hook_event_name: &str) -> StreamEventType { // Claude Code has no SessionStart hook — Notification is the first // hook fired and serves as the session-start signal. @@ -70,12 +79,13 @@ impl AgentAdapter for ClaudeCodeAdapter { matches!(tool_name, "Write" | "Edit" | "Bash") } - fn extract_file_changes( + fn file_changes_from_hook( &self, tool_name: &str, tool_input: &serde_json::Value, - ) -> Vec { - match tool_name { + timestamp: DateTime, + ) -> Vec { + let change = match tool_name { "Write" => { let file_path = match tool_input.get("file_path").and_then(|v| v.as_str()) { Some(p) => p.to_string(), @@ -93,12 +103,12 @@ impl AgentAdapter for ClaudeCodeAdapter { .map(|line| format!("+{}", line)) .collect::>() .join("\n"); - vec![ExtractedFileChange { + ExtractedFileChange { file_path, change_type: "create".to_string(), diff_text: Some(diff_text), content_hash: Some(hash), - }] + } } "Edit" => { let file_path = match tool_input.get("file_path").and_then(|v| v.as_str()) { @@ -114,15 +124,21 @@ impl AgentAdapter for ClaudeCodeAdapter { None => return Vec::new(), }; let diff_text = format!("--- {}\n+++ {}", old_string, new_string); - vec![ExtractedFileChange { + ExtractedFileChange { file_path, change_type: "edit".to_string(), diff_text: Some(diff_text), content_hash: None, - }] + } } - _ => Vec::new(), - } + _ => return Vec::new(), + }; + vec![FileChangeRecord { + change, + tool_name: tool_name.to_string(), + tool_input: Some(tool_input.clone()), + timestamp, + }] } fn extract_token_usage(&self, chunk: &serde_json::Value) -> Option { diff --git a/crates/tracevault-core/src/agent_adapter/codex.rs b/crates/tracevault-core/src/agent_adapter/codex.rs index 47a7dfa5..ea7f2c4a 100644 --- a/crates/tracevault-core/src/agent_adapter/codex.rs +++ b/crates/tracevault-core/src/agent_adapter/codex.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; use std::fs; use std::io; @@ -5,7 +6,7 @@ use std::path::Path; use crate::streaming::{ExtractedFileChange, StreamEventType}; -use super::{AgentAdapter, ParsedTranscriptRecord, TokenUsage}; +use super::{AgentAdapter, FileChangeRecord, ParsedTranscriptRecord, TokenUsage}; /// Adapter for OpenAI Codex CLI. /// @@ -59,6 +60,14 @@ impl AgentAdapter for CodexAdapter { "codex" } + fn display_name(&self) -> &str { + "Codex" + } + + fn hooks_install_path(&self) -> &str { + ".codex/hooks.json" + } + fn map_event_type(&self, hook_event_name: &str) -> StreamEventType { match hook_event_name { "SessionStart" => StreamEventType::SessionStart, @@ -67,27 +76,23 @@ impl AgentAdapter for CodexAdapter { } } - /// Codex hook events never carry file-modifying tool calls. - /// File changes are extracted from transcript via `extract_file_changes_from_transcript`. + /// Codex hook events never carry file-modifying tool calls. File changes + /// come from transcript chunks via `file_changes_from_transcript`. fn is_file_modifying(&self, _tool_name: &str) -> bool { false } - /// Not used for Codex — file changes come from transcript, not hook events. - fn extract_file_changes( - &self, - _tool_name: &str, - _tool_input: &serde_json::Value, - ) -> Vec { - vec![] + fn provides_transcript_file_changes(&self) -> bool { + true } /// Extract file changes from Codex transcript chunks. /// Handles `response_item` with `payload.type: "custom_tool_call"` and `name: "apply_patch"`. - fn extract_file_changes_from_transcript( + fn file_changes_from_transcript( &self, chunk: &serde_json::Value, - ) -> Vec { + fallback_timestamp: DateTime, + ) -> Vec { let payload = match chunk.get("payload") { Some(p) => p, None => return vec![], @@ -98,7 +103,10 @@ impl AgentAdapter for CodexAdapter { return vec![]; } - let name = payload.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let name = payload + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); if name != "apply_patch" { return vec![]; } @@ -108,7 +116,28 @@ impl AgentAdapter for CodexAdapter { None => return vec![], }; + // Codex chunks carry their own RFC 3339 timestamp at the top level — + // use it for precise per-patch ordering instead of the hook delivery + // time (which can lag minutes if patches are batched and the hook + // only fires at Stop). + let timestamp = chunk + .get("timestamp") + .and_then(|v| v.as_str()) + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or(fallback_timestamp); + + let tool_input = Some(payload.clone()); + parse_codex_patch(input) + .into_iter() + .map(|change| FileChangeRecord { + change, + tool_name: name.to_string(), + tool_input: tool_input.clone(), + timestamp, + }) + .collect() } fn extract_token_usage(&self, chunk: &serde_json::Value) -> Option { diff --git a/crates/tracevault-core/src/agent_adapter/default.rs b/crates/tracevault-core/src/agent_adapter/default.rs index c4e3580e..85e25316 100644 --- a/crates/tracevault-core/src/agent_adapter/default.rs +++ b/crates/tracevault-core/src/agent_adapter/default.rs @@ -1,4 +1,4 @@ -use crate::streaming::{ExtractedFileChange, StreamEventType}; +use crate::streaming::StreamEventType; use super::{AgentAdapter, ParsedTranscriptRecord, TokenUsage}; @@ -21,14 +21,6 @@ impl AgentAdapter for DefaultAdapter { false } - fn extract_file_changes( - &self, - _tool_name: &str, - _tool_input: &serde_json::Value, - ) -> Vec { - Vec::new() - } - fn extract_token_usage(&self, _chunk: &serde_json::Value) -> Option { None } diff --git a/crates/tracevault-core/src/agent_adapter/mod.rs b/crates/tracevault-core/src/agent_adapter/mod.rs index f673eb47..db31cec0 100644 --- a/crates/tracevault-core/src/agent_adapter/mod.rs +++ b/crates/tracevault-core/src/agent_adapter/mod.rs @@ -2,6 +2,7 @@ pub mod claude_code; pub mod codex; mod default; +use chrono::{DateTime, Utc}; use serde::Serialize; use std::collections::HashMap; use std::path::Path; @@ -12,6 +13,17 @@ use crate::streaming::{ExtractedFileChange, StreamEventType}; use self::default::DefaultAdapter; +/// File change with all metadata `stream.rs` needs to persist it. Both +/// hook-sourced and transcript-sourced extractions return this same shape so +/// the persistence layer doesn't need to know which mechanism produced it. +#[derive(Debug, Clone)] +pub struct FileChangeRecord { + pub change: ExtractedFileChange, + pub tool_name: String, + pub tool_input: Option, + pub timestamp: DateTime, +} + #[derive(Debug, Clone, Default)] pub struct TokenUsage { pub input_tokens: i64, @@ -36,20 +48,47 @@ pub struct ParsedTranscriptRecord { pub trait AgentAdapter: Send + Sync { fn name(&self) -> &str; + /// Human-readable label shown in CLI/UI (e.g. "Claude Code", "Codex"). + /// Defaults to `name()` for adapters that don't override. + fn display_name(&self) -> &str { + self.name() + } + /// Repo-relative path of the file `install_hooks` writes to + /// (e.g. ".claude/settings.json"). Empty for adapters that don't install + /// hooks (the default adapter). + fn hooks_install_path(&self) -> &str { + "" + } fn map_event_type(&self, hook_event_name: &str) -> StreamEventType; fn is_file_modifying(&self, tool_name: &str) -> bool; - /// Extract file changes from a hook tool event (tool_name + tool_input) - fn extract_file_changes( + /// File changes derived from a hook ToolUse event (Claude Write/Edit). + /// Default: none. Override for adapters whose file ops appear in the hook's + /// `tool_input` payload itself. + fn file_changes_from_hook( &self, - tool_name: &str, - tool_input: &serde_json::Value, - ) -> Vec; - /// Extract file changes from a transcript chunk (e.g. Codex custom_tool_call with apply_patch). - /// Default: no extraction. Override for agents whose file ops appear in transcript, not hook events. - fn extract_file_changes_from_transcript( + _tool_name: &str, + _tool_input: &serde_json::Value, + _timestamp: DateTime, + ) -> Vec { + vec![] + } + /// Capability flag: does this adapter source file changes from transcript + /// chunks? When `false` (default), `stream.rs` skips + /// `file_changes_from_transcript` entirely — preserving the pre-multi-agent + /// code path for adapters like Claude Code that have no transcript-side + /// file extraction. + fn provides_transcript_file_changes(&self) -> bool { + false + } + /// File changes discovered inside a transcript chunk (Codex apply_patch). + /// Only called when `provides_transcript_file_changes()` returns `true`. + /// `fallback_timestamp` is used when the chunk itself has no parseable + /// timestamp. + fn file_changes_from_transcript( &self, _chunk: &serde_json::Value, - ) -> Vec { + _fallback_timestamp: DateTime, + ) -> Vec { vec![] } fn extract_token_usage(&self, chunk: &serde_json::Value) -> Option; diff --git a/crates/tracevault-core/tests/agent_adapter_test.rs b/crates/tracevault-core/tests/agent_adapter_test.rs index 4d161ba1..92e5d039 100644 --- a/crates/tracevault-core/tests/agent_adapter_test.rs +++ b/crates/tracevault-core/tests/agent_adapter_test.rs @@ -1,7 +1,12 @@ +use chrono::{TimeZone, Utc}; use serde_json::json; use tracevault_core::agent_adapter::AgentAdapterRegistry; use tracevault_core::streaming::StreamEventType; +fn ts() -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 4, 29, 10, 0, 0).unwrap() +} + #[test] fn registry_unknown_agent_returns_default() { let registry = AgentAdapterRegistry::new(); @@ -48,25 +53,28 @@ fn claude_code_map_event_types() { } #[test] -fn claude_code_extract_file_change_write() { +fn claude_code_file_changes_from_hook_write() { let registry = AgentAdapterRegistry::new(); let adapter = registry.get("claude-code"); let input = json!({"file_path": "src/main.rs", "content": "fn main() {}"}); - let changes = adapter.extract_file_changes("Write", &input); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].file_path, "src/main.rs"); - assert_eq!(changes[0].change_type, "create"); - assert!(changes[0].content_hash.is_some()); + let records = adapter.file_changes_from_hook("Write", &input, ts()); + assert_eq!(records.len(), 1); + assert_eq!(records[0].change.file_path, "src/main.rs"); + assert_eq!(records[0].change.change_type, "create"); + assert!(records[0].change.content_hash.is_some()); + assert_eq!(records[0].tool_name, "Write"); + assert_eq!(records[0].timestamp, ts()); } #[test] -fn claude_code_extract_file_change_edit() { +fn claude_code_file_changes_from_hook_edit() { let registry = AgentAdapterRegistry::new(); let adapter = registry.get("claude-code"); let input = json!({"file_path": "src/lib.rs", "old_string": "old", "new_string": "new"}); - let changes = adapter.extract_file_changes("Edit", &input); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].change_type, "edit"); + let records = adapter.file_changes_from_hook("Edit", &input, ts()); + assert_eq!(records.len(), 1); + assert_eq!(records[0].change.change_type, "edit"); + assert_eq!(records[0].tool_name, "Edit"); } #[test] @@ -74,7 +82,9 @@ fn claude_code_read_returns_empty() { let registry = AgentAdapterRegistry::new(); let adapter = registry.get("claude-code"); let input = json!({"file_path": "src/lib.rs"}); - assert!(adapter.extract_file_changes("Read", &input).is_empty()); + assert!(adapter + .file_changes_from_hook("Read", &input, ts()) + .is_empty()); } #[test] @@ -567,17 +577,19 @@ fn codex_patch_parse_delete_file() { } #[test] -fn codex_hook_extract_file_changes_returns_empty() { - // Codex does not extract file changes from hook events +fn codex_file_changes_from_hook_returns_empty() { + // Codex hook events don't carry file modifications. let registry = AgentAdapterRegistry::new(); let adapter = registry.get("codex"); let input = json!({"command": "cargo build"}); - assert!(adapter.extract_file_changes("Bash", &input).is_empty()); + assert!(adapter + .file_changes_from_hook("Bash", &input, ts()) + .is_empty()); } #[test] fn codex_is_file_modifying_always_false() { - // Codex file changes come from transcript, not hook events + // Codex file changes come from transcript, not hook events. let registry = AgentAdapterRegistry::new(); let adapter = registry.get("codex"); assert!(!adapter.is_file_modifying("Bash")); @@ -586,25 +598,47 @@ fn codex_is_file_modifying_always_false() { } #[test] -fn codex_extract_file_changes_from_transcript_apply_patch() { +fn codex_file_changes_from_transcript_apply_patch() { let registry = AgentAdapterRegistry::new(); let adapter = registry.get("codex"); let chunk = json!({ "type": "response_item", + "timestamp": "2026-04-29T11:30:00Z", "payload": { "type": "custom_tool_call", "name": "apply_patch", "input": "*** Begin Patch\n*** Update File: src/main.rs\n@@ fn old()\n-fn old()\n+fn new_func()\n*** End Patch\n" } }); - let changes = adapter.extract_file_changes_from_transcript(&chunk); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].file_path, "src/main.rs"); - assert_eq!(changes[0].change_type, "edit"); + let records = adapter.file_changes_from_transcript(&chunk, ts()); + assert_eq!(records.len(), 1); + assert_eq!(records[0].change.file_path, "src/main.rs"); + assert_eq!(records[0].change.change_type, "edit"); + assert_eq!(records[0].tool_name, "apply_patch"); + assert!(records[0].tool_input.is_some()); + // chunk timestamp wins over fallback. + assert_ne!(records[0].timestamp, ts()); +} + +#[test] +fn codex_file_changes_from_transcript_falls_back_when_chunk_has_no_timestamp() { + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get("codex"); + let chunk = json!({ + "type": "response_item", + "payload": { + "type": "custom_tool_call", + "name": "apply_patch", + "input": "*** Begin Patch\n*** Add File: x.rs\n+x\n*** End Patch\n" + } + }); + let records = adapter.file_changes_from_transcript(&chunk, ts()); + assert_eq!(records.len(), 1); + assert_eq!(records[0].timestamp, ts()); } #[test] -fn codex_extract_file_changes_from_transcript_non_patch_returns_empty() { +fn codex_file_changes_from_transcript_non_patch_returns_empty() { let registry = AgentAdapterRegistry::new(); let adapter = registry.get("codex"); let chunk = json!({ @@ -612,7 +646,7 @@ fn codex_extract_file_changes_from_transcript_non_patch_returns_empty() { "payload": {"type": "message", "role": "assistant", "content": []} }); assert!(adapter - .extract_file_changes_from_transcript(&chunk) + .file_changes_from_transcript(&chunk, ts()) .is_empty()); } @@ -652,12 +686,12 @@ fn codex_custom_tool_call_display() { } #[test] -fn claude_code_extract_file_changes_from_transcript_returns_empty() { - // Claude Code file changes come from hook events, not transcript +fn claude_code_file_changes_from_transcript_returns_empty() { + // Claude Code file changes come from hook events, not transcript. let registry = AgentAdapterRegistry::new(); let adapter = registry.get("claude-code"); let chunk = json!({"type": "assistant", "message": {"content": []}}); assert!(adapter - .extract_file_changes_from_transcript(&chunk) + .file_changes_from_transcript(&chunk, ts()) .is_empty()); } diff --git a/crates/tracevault-server/src/service/stream.rs b/crates/tracevault-server/src/service/stream.rs index 997f9973..d5f2addd 100644 --- a/crates/tracevault-server/src/service/stream.rs +++ b/crates/tracevault-server/src/service/stream.rs @@ -83,53 +83,41 @@ impl StreamService { continue; } - // Extract file changes from transcript chunks (e.g. Codex apply_patch). - // Each adapter decides which chunk types contain file modifications. - let transcript_file_changes = - adapter.extract_file_changes_from_transcript(line); - // Prefer the chunk's own timestamp (precise per-line time of the - // event in the transcript) over the hook delivery time, which can - // lag minutes behind for batched transcript ingestion (e.g. Codex - // emits patches mid-turn but the hook fires at Stop). - let chunk_timestamp = line - .get("timestamp") - .and_then(|v| v.as_str()) - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.with_timezone(&chrono::Utc)) - .unwrap_or(req.timestamp); - for change in transcript_file_changes { - let tool_name = line - .get("payload") - .and_then(|p| p.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let event_id = EventRepo::insert_tool_event( - &state.pool, - &crate::repo::events::InsertToolEvent { - session_id: session_db_id, - event_index: chunk_index, - tool_name: Some(tool_name.to_string()), - tool_input: line.get("payload").cloned(), - tool_response: None, - tool_is_error: None, - timestamp: Some(chunk_timestamp), - }, - ) - .await?; - if let Some(eid) = event_id { - EventRepo::insert_file_change( + // Transcript-sourced file changes (Codex apply_patch) come + // with their own synthetic tool_event. The capability flag + // gates the call entirely so adapters without this feature + // (Claude Code) don't even invoke the method — keeping the + // pre-multi-agent code path for them bit-for-bit. + if adapter.provides_transcript_file_changes() { + for record in adapter.file_changes_from_transcript(line, req.timestamp) { + let event_id = EventRepo::insert_tool_event( &state.pool, - &InsertFileChange { + &crate::repo::events::InsertToolEvent { session_id: session_db_id, - event_id: eid, - file_path: change.file_path, - change_type: change.change_type, - diff_text: change.diff_text, - content_hash: change.content_hash, - timestamp: Some(chunk_timestamp), + event_index: chunk_index, + tool_name: Some(record.tool_name), + tool_input: record.tool_input, + tool_response: None, + tool_is_error: None, + timestamp: Some(record.timestamp), }, ) .await?; + if let Some(eid) = event_id { + EventRepo::insert_file_change( + &state.pool, + &InsertFileChange { + session_id: session_db_id, + event_id: eid, + file_path: record.change.file_path, + change_type: record.change.change_type, + diff_text: record.change.diff_text, + content_hash: record.change.content_hash, + timestamp: Some(record.timestamp), + }, + ) + .await?; + } } } @@ -214,21 +202,26 @@ impl StreamService { if let Some(eid) = inserted_id { event_db_id = Some(eid); - // Extract file changes for file-modifying tools + // Hook-sourced file changes attach to the tool_event that + // was just inserted. Gated by `is_file_modifying` so the + // Claude path matches main exactly: Read/Glob/etc. skip + // the call, only Write/Edit/Bash enter (Bash returns empty + // because there's no file_path/content to extract). if store_response { if let Some(ref tool_input) = req.tool_input { - let file_changes = adapter.extract_file_changes(tool_name, tool_input); - for change in file_changes { + for record in + adapter.file_changes_from_hook(tool_name, tool_input, req.timestamp) + { EventRepo::insert_file_change( &state.pool, &InsertFileChange { session_id: session_db_id, event_id: eid, - file_path: change.file_path, - change_type: change.change_type, - diff_text: change.diff_text, - content_hash: change.content_hash, - timestamp: Some(req.timestamp), + file_path: record.change.file_path, + change_type: record.change.change_type, + diff_text: record.change.diff_text, + content_hash: record.change.content_hash, + timestamp: Some(record.timestamp), }, ) .await?; From 7ff1c1fde0043299700870535445c0ffaf51b348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= <> Date: Thu, 30 Apr 2026 09:41:26 +0200 Subject: [PATCH 4/6] refactor(init): drop manual claude alias, gate default-claude on missing --agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the "claude"/"claude-code" alias resolution and dedup off the CLI and onto the AgentAdapter::name() canonical id — the registry already maps both strings to the same adapter, so the manual match was redundant. Dedup now runs against the adapter's own id, not the user-provided string. Change semantics: --agent codex installs only Codex hooks. Claude Code is installed only when --agent is omitted entirely (default), instead of being appended unconditionally to every --agent invocation. .gitignore entries are derived from each installed adapter's hooks_install_path(), so a codex-only init no longer pins .claude/settings.json into the ignore list. --- crates/tracevault-cli/tests/init_test.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tracevault-cli/tests/init_test.rs b/crates/tracevault-cli/tests/init_test.rs index 95524492..9d2c4bfe 100644 --- a/crates/tracevault-cli/tests/init_test.rs +++ b/crates/tracevault-cli/tests/init_test.rs @@ -314,7 +314,8 @@ async fn init_no_gitignore_skips_gitignore_update() { #[tokio::test] async fn init_with_codex_agent_also_installs_claude() { - // --agent codex must be additive: Claude Code hooks are still installed. + // --agent codex must be additive: Claude Code hooks are still installed + // (the Claude path stays byte-equivalent with the single-agent behaviour). let tmp = tmp_git_repo(); let extras = vec!["codex".to_string()]; From d94f156d3c4f9bde4104609458f02fa2bd070be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= <> Date: Thu, 30 Apr 2026 10:24:09 +0200 Subject: [PATCH 5/6] fix(agent-adapter): preserve Claude path byte-equivalence with main Multi-agent split caused subtle drift on the Claude code path. Restore parity with pre-multi-agent main: - wire_protocol_version() trait method (default v2); Claude overrides to v1 so request bytes match main - persists_model_without_usage() capability flag (default false); Codex sets it true. Server stream gate becomes has_tokens || (flag && model.is_some()), so Claude's update_tokens stays token-presence-only as in main - ClaudeCodeAdapter parser locks onto first tool_use block via seen_tool_use flag (matches main's arr.iter().find() semantics) - CLI stream uses adapter.wire_protocol_version() / adapter.name() for protocol_version + tool fields - init.rs installs hooks after .gitignore update (matches main order) Also: CLI init prints actually-installed gitignore entries instead of hardcoded paths, and a comment marks _event_type as unused (routing is via hook_event_name from stdin). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tracevault-cli/src/commands/stream.rs | 20 ++++++++++++------- .../src/agent_adapter/claude_code.rs | 12 ++++++++++- .../src/agent_adapter/codex.rs | 7 +++++++ .../tracevault-core/src/agent_adapter/mod.rs | 15 ++++++++++++++ .../tracevault-server/src/service/stream.rs | 10 ++++++++-- 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/crates/tracevault-cli/src/commands/stream.rs b/crates/tracevault-cli/src/commands/stream.rs index 50bf9a0b..49ec848e 100644 --- a/crates/tracevault-cli/src/commands/stream.rs +++ b/crates/tracevault-cli/src/commands/stream.rs @@ -83,6 +83,10 @@ pub fn drain_pending(pending_path: &Path) -> Result, io::Error> { Ok(lines) } +// Routing is driven by `hook_event.hook_event_name` from stdin (see +// `adapter.map_event_type` below). The `--event` CLI flag is kept only because +// the installed hooks pass it for shell-log readability. `project_root` is +// resolved internally from `hook_event.cwd`, matching single-agent (main) behaviour. pub async fn run_stream(_event_type: &str, agent: &str) -> Result<(), Box> { // 1. Read HookEvent from stdin let mut input = String::new(); @@ -113,11 +117,13 @@ pub async fn run_stream(_event_type: &str, agent: &str) -> Result<(), Box Result<(), Box Result<(), Box u32 { + 1 + } + fn map_event_type(&self, hook_event_name: &str) -> StreamEventType { // Claude Code has no SessionStart hook — Notification is the first // hook fired and serves as the session-start signal. @@ -269,6 +273,11 @@ impl ClaudeCodeAdapter { let mut content_types = Vec::new(); let mut text_parts = Vec::new(); let mut first_tool_name: Option = None; + // Match main's `arr.iter().find(|b| type==tool_use).and_then(|b| name)`: + // we lock onto the first tool_use block regardless of whether it had a + // `name` field, so a missing name yields `None` (not a name from a + // later block). + let mut seen_tool_use = false; if let Some(content) = message.get("content").and_then(|v| v.as_array()) { for block in content { @@ -276,7 +285,8 @@ impl ClaudeCodeAdapter { if !content_types.contains(&block_type.to_string()) { content_types.push(block_type.to_string()); } - if block_type == "tool_use" && first_tool_name.is_none() { + if block_type == "tool_use" && !seen_tool_use { + seen_tool_use = true; first_tool_name = block .get("name") .and_then(|v| v.as_str()) diff --git a/crates/tracevault-core/src/agent_adapter/codex.rs b/crates/tracevault-core/src/agent_adapter/codex.rs index ea7f2c4a..df41cc8c 100644 --- a/crates/tracevault-core/src/agent_adapter/codex.rs +++ b/crates/tracevault-core/src/agent_adapter/codex.rs @@ -68,6 +68,13 @@ impl AgentAdapter for CodexAdapter { ".codex/hooks.json" } + /// Codex transcripts can carry a model name in chunks that have no token + /// usage yet (e.g. a session-start chunk preceding any assistant reply). + /// Persist the model anyway so `sessions.model` is populated promptly. + fn persists_model_without_usage(&self) -> bool { + true + } + fn map_event_type(&self, hook_event_name: &str) -> StreamEventType { match hook_event_name { "SessionStart" => StreamEventType::SessionStart, diff --git a/crates/tracevault-core/src/agent_adapter/mod.rs b/crates/tracevault-core/src/agent_adapter/mod.rs index db31cec0..ae60aa3b 100644 --- a/crates/tracevault-core/src/agent_adapter/mod.rs +++ b/crates/tracevault-core/src/agent_adapter/mod.rs @@ -59,6 +59,21 @@ pub trait AgentAdapter: Send + Sync { fn hooks_install_path(&self) -> &str { "" } + /// Wire protocol version the CLI should send for this adapter. + /// Claude Code stays on v1 to keep its request bytes identical to the + /// pre-multi-agent main; new adapters use v2 (which carries `tool` over + /// the wire instead of the server hardcoding "claude-code"). + fn wire_protocol_version(&self) -> u32 { + 2 + } + /// Capability flag: should the server fire `update_tokens` when a + /// transcript batch contained a model but zero token usage? Defaults to + /// `false` to preserve main's Claude path bit-for-bit (where the gate was + /// solely on token presence). Codex sets this to `true` because its + /// model-only chunks can legitimately precede usage. + fn persists_model_without_usage(&self) -> bool { + false + } fn map_event_type(&self, hook_event_name: &str) -> StreamEventType; fn is_file_modifying(&self, tool_name: &str) -> bool; /// File changes derived from a hook ToolUse event (Claude Write/Edit). diff --git a/crates/tracevault-server/src/service/stream.rs b/crates/tracevault-server/src/service/stream.rs index d5f2addd..d15b8d88 100644 --- a/crates/tracevault-server/src/service/stream.rs +++ b/crates/tracevault-server/src/service/stream.rs @@ -133,12 +133,18 @@ impl StreamService { } } - // Update session token counts and cost if we found usage data + // Update session token counts and cost if we found usage data. + // The `persists_model_without_usage` capability lets adapters + // (Codex) extend the gate to also fire on a model-only batch; + // Claude leaves it `false` so the gate stays bit-identical to + // pre-multi-agent main (token presence only). let has_tokens = batch_input > 0 || batch_output > 0 || batch_cache_read > 0 || batch_cache_write > 0; - if has_tokens || detected_model.is_some() { + let persist_model_only = + adapter.persists_model_without_usage() && detected_model.is_some(); + if has_tokens || persist_model_only { let model_name = detected_model.as_deref().unwrap_or("unknown"); // input_tokens from the API includes cache_read and cache_write, // subtract to get fresh (non-cached) input only From d858db42be3267dff96f35887cbe49b05d12bf76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= <> Date: Thu, 30 Apr 2026 11:15:55 +0200 Subject: [PATCH 6/6] fix(agent-adapter): add empty matcher to Codex Stop hook, align README with --agent semantics Codex Stop hook entry was missing the `matcher` field that all other lifecycle hooks (SessionStart, PreToolUse, PostToolUse) already carry, risking a silent no-op if Codex requires the field. README also still described `--agent` as additive ("in addition to the Claude Code hooks") even though the flag has been replacement-only since 6fad80f. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- crates/tracevault-cli/tests/init_test.rs | 26 ++++++++++++++++++- .../src/agent_adapter/codex.rs | 1 + 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6dae6008..68b955fc 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ tracevault login --server-url https://your-tracevault-server.example.com tracevault init --agent codex ``` -This installs hooks in `.codex/hooks.json` in addition to the Claude Code hooks. Codex sessions are traced including transcript parsing, token usage, and file changes via `apply_patch`. The session detail view shows a Codex badge to distinguish agent types. +`--agent` selects exactly which agents to install — passing it replaces the default. `tracevault init --agent codex` installs Codex hooks in `.codex/hooks.json` only; to enable both agents in the same repo, pass each one explicitly: `tracevault init --agent claude-code --agent codex`. Codex sessions are traced including transcript parsing, token usage, and file changes via `apply_patch`. The session detail view shows a Codex badge to distinguish agent types. ## Keys & Secrets diff --git a/crates/tracevault-cli/tests/init_test.rs b/crates/tracevault-cli/tests/init_test.rs index 9d2c4bfe..677cbb86 100644 --- a/crates/tracevault-cli/tests/init_test.rs +++ b/crates/tracevault-cli/tests/init_test.rs @@ -12,7 +12,8 @@ fn tmp_git_repo() -> TempDir { async fn init_fails_without_git() { let tmp = TempDir::new().unwrap(); let result = - tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None).await; + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None, false, None) + .await; assert!(result.is_err()); assert!(result .unwrap_err() @@ -355,6 +356,29 @@ async fn init_installs_codex_session_start_with_match_all_matcher() { assert_eq!(session_start["matcher"], ""); } +#[tokio::test] +async fn init_installs_codex_stop_with_empty_matcher() { + // Stop is a session-lifecycle hook with no tool to match — its matcher + // must be present and empty, matching SessionStart's shape so Codex + // accepts the entry. + let tmp = tmp_git_repo(); + let extras = vec!["codex".to_string()]; + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + None, + false, + Some(extras.as_slice()), + ) + .await + .unwrap(); + + let content = fs::read_to_string(tmp.path().join(".codex/hooks.json")).unwrap(); + let config: serde_json::Value = serde_json::from_str(&content).unwrap(); + let stop = &config["hooks"]["Stop"][0]; + assert_eq!(stop["matcher"], ""); +} + #[tokio::test] async fn init_default_installs_only_claude() { let tmp = tmp_git_repo(); diff --git a/crates/tracevault-core/src/agent_adapter/codex.rs b/crates/tracevault-core/src/agent_adapter/codex.rs index df41cc8c..b8005e79 100644 --- a/crates/tracevault-core/src/agent_adapter/codex.rs +++ b/crates/tracevault-core/src/agent_adapter/codex.rs @@ -45,6 +45,7 @@ fn hooks_json() -> serde_json::Value { }] }], "Stop": [{ + "matcher": "", "hooks": [{ "type": "command", "command": "tracevault stream --agent codex --event stop",