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..68b955fc 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 +``` + +`--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 ### 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 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 ` | 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..49ec848e 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,11 @@ pub fn drain_pending(pending_path: &Path) -> Result, io::Error> { Ok(lines) } -pub async fn run_stream(event_type: &str) -> Result<(), Box> { +// 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(); io::stdin().read_to_string(&mut input)?; @@ -114,12 +117,13 @@ 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. + // Resolve the adapter once; it owns the wire protocol version and the + // canonical tool name so user-supplied aliases (e.g. "claude" → "claude-code") + // produce the same wire bytes as the canonical name. + let registry = AgentAdapterRegistry::new(); + let adapter = registry.get(agent); + let stream_event_type = adapter.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 +132,8 @@ pub async fn run_stream(event_type: &str) -> Result<(), Box Result<(), Box, }, /// Show current session status Status, @@ -35,6 +39,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 +111,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 +119,11 @@ async fn main() { server_url.as_deref(), claude_settings, no_gitignore, + if agents.is_empty() { + None + } else { + Some(&agents) + }, ) .await { @@ -118,6 +131,20 @@ 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 { + 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)"); println!("Added .tracevault/ and {entry} to .gitignore"); println!( @@ -137,8 +164,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..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).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 +26,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 +39,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 +57,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(); @@ -69,6 +70,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] @@ -80,7 +82,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 +95,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 +127,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 +144,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 +163,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 +180,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 +204,7 @@ async fn init_local_target_writes_to_settings_local_json() { None, Some(ClaudeSettingsTarget::Local), false, + None, ) .await .unwrap(); @@ -236,6 +231,7 @@ async fn init_local_target_gitignores_settings_local_json() { None, Some(ClaudeSettingsTarget::Local), false, + None, ) .await .unwrap(); @@ -263,6 +259,7 @@ async fn init_local_target_merges_into_existing_settings_local_json() { None, Some(ClaudeSettingsTarget::Local), false, + None, ) .await .unwrap(); @@ -282,6 +279,7 @@ async fn init_writes_server_url_to_config() { Some("https://tv.example.com"), None, false, + None, ) .await .unwrap(); @@ -295,7 +293,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(); @@ -314,3 +312,102 @@ 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 + // (the Claude path stays byte-equivalent with the single-agent behaviour). + 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_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(); + + 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 new file mode 100644 index 00000000..a81ab57c --- /dev/null +++ b/crates/tracevault-core/src/agent_adapter/claude_code.rs @@ -0,0 +1,479 @@ +use chrono::{DateTime, Utc}; +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, FileChangeRecord, 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 display_name(&self) -> &str { + "Claude Code" + } + + fn hooks_install_path(&self) -> &str { + ".claude/settings.json" + } + + fn wire_protocol_version(&self) -> 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. + 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 file_changes_from_hook( + &self, + tool_name: &str, + tool_input: &serde_json::Value, + 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(), + 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"); + 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); + ExtractedFileChange { + file_path, + change_type: "edit".to_string(), + diff_text: Some(diff_text), + content_hash: None, + } + } + _ => 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 { + // 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") + .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()) + .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 = 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()) + .map(|s| s.to_string()); + + 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 { + 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()); + } + if block_type == "tool_use" && !seen_tool_use { + seen_tool_use = true; + 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.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\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: Option = None; + let mut tool_name: Option = None; + + // 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()); + } + 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()); + } + } + } + } + } + _ => {} + } + + // 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, + 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())) + .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: hook_name, + 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()) + .unwrap_or("unknown"); + let text = match subtype { + "turn_duration" => { + let ms = chunk + .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 = chunk.get("hookCount").and_then(|v| v.as_i64()).unwrap_or(0); + Some(format!("stop_hook_summary: {} hooks", count)) + } + _ => Some(subtype.to_string()), + }; + + Some(ParsedTranscriptRecord { + record_type, + timestamp, + content_types: vec![subtype.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, + }) + } +} 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..b8005e79 --- /dev/null +++ b/crates/tracevault-core/src/agent_adapter/codex.rs @@ -0,0 +1,536 @@ +use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io; +use std::path::Path; + +use crate::streaming::{ExtractedFileChange, StreamEventType}; + +use super::{AgentAdapter, FileChangeRecord, 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": "", + "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": [{ + "matcher": "", + "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 display_name(&self) -> &str { + "Codex" + } + + fn hooks_install_path(&self) -> &str { + ".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, + "Stop" => StreamEventType::SessionEnd, + _ => StreamEventType::ToolUse, + } + } + + /// 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 + } + + 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 file_changes_from_transcript( + &self, + chunk: &serde_json::Value, + fallback_timestamp: DateTime, + ) -> 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("unknown"); + if name != "apply_patch" { + return vec![]; + } + + let input = match payload.get("input").and_then(|v| v.as_str()) { + Some(s) => s, + 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 { + 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())?; + // 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()) + } 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, + } + } +} + +/// 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(); + 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..85e25316 --- /dev/null +++ b/crates/tracevault-core/src/agent_adapter/default.rs @@ -0,0 +1,38 @@ +use crate::streaming::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_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..ae60aa3b --- /dev/null +++ b/crates/tracevault-core/src/agent_adapter/mod.rs @@ -0,0 +1,158 @@ +pub mod claude_code; +pub mod codex; +mod default; + +use chrono::{DateTime, Utc}; +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; + +/// 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, + 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; + /// 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 { + "" + } + /// 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). + /// 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, + _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, + _fallback_timestamp: DateTime, + ) -> 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..92e5d039 --- /dev/null +++ b/crates/tracevault-core/tests/agent_adapter_test.rs @@ -0,0 +1,697 @@ +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(); + 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_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 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_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 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] +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 + .file_changes_from_hook("Read", &input, ts()) + .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_read() { + 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_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(); + 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")); + 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_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_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] +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_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(); + 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_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(); + 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_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 + .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. + 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_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 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_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 + .file_changes_from_transcript(&chunk, ts()) + .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_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 + .file_changes_from_transcript(&chunk, ts()) + .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..d15b8d88 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,39 +83,68 @@ 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); + // 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, + &crate::repo::events::InsertToolEvent { + session_id: session_db_id, + 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?; + } } } + + // 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 + // 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 { + 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 @@ -156,7 +185,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, @@ -179,20 +208,26 @@ impl StreamService { if let Some(eid) = inserted_id { event_db_id = Some(eid); - // Extract file changes for file-modifying tools - if is_file_modifying_tool(tool_name) { + // 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 { - if let Some(change) = extract_file_change(tool_name, tool_input) { + 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?; 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)} +