diff --git a/CHANGELOG.md b/CHANGELOG.md index 107c4d4..91baf74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,11 @@ Each release is published to [crates.io](https://crates.io/crates/txcript), - Add read-side session lineage (`Meta::lineage`): fork/continue/spawn relationships read from Codex's `session_meta` (subagent spawns, guardian review, `/fork`), pi/Campfire's `parentSession` (`/fork`, `/clone`), OpenCode's `session.parent_id`, and Claude Code - teammates' `teamName`/`agentName`. Filled at discovery time, not only on load. Writing - lineage into native formats is not implemented yet. + teammates' `teamName`/`agentName`. Filled at discovery time, not only on load. +- Write fork lineage back into Codex rollouts as `session_meta.forked_from_id`. A `continue + #range` or `crop` copy into the source's own harness is now recorded as a fork of the + source; a copy into another harness no longer carries the source's lineage. Other harnesses + don't write lineage yet. ## [0.14.4](https://github.com/skillsynchq/txcript/compare/v0.14.3...v0.14.4) - 2026-09-13 diff --git a/cli/src/lib.rs b/cli/src/lib.rs index fadca96..e4db472 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -54,6 +54,7 @@ use std::path::PathBuf; use std::process::ExitCode; use clap::{CommandFactory, Parser, Subcommand}; +use txcript::common::{Lineage, Relation}; use txcript::harness::{amp, chatgpt, claude_chat, cowork_remote, simple}; use txcript::{Codec, Common, HarnessId, Store, TextCodec, Transcript, local}; @@ -1095,8 +1096,12 @@ mod crop_command_tests { #[cfg(test)] mod identity_tests { - use super::{Common, HarnessId, Transcript, ensure_resumable_source, fresh_identity}; + use super::{ + Codec, Common, HarnessId, Lineage, Relation, TextCodec, Transcript, + ensure_resumable_source, fresh_identity, + }; use txcript::common::Meta; + use txcript::harness::codex; pub(crate) fn transcript() -> Transcript { Transcript::new( @@ -1118,7 +1123,12 @@ mod identity_tests { fn a_copy_into_a_live_store_becomes_its_own_session() { let mut copy = transcript(); let (id, ts) = (copy.meta.id.clone(), copy.meta.timestamp); - fresh_identity(&mut copy, HarnessId::ClaudeCode, None); + fresh_identity( + &mut copy, + HarnessId::ClaudeCode, + HarnessId::ClaudeCode, + None, + ); // Writing under the source id would land on the source's own file. assert_ne!(copy.meta.id, id); assert!(copy.meta.timestamp > ts, "the copy is filed under today"); @@ -1130,7 +1140,7 @@ mod identity_tests { #[test] fn codex_copies_get_the_v7_shape_codex_mints_itself() { let mut copy = transcript(); - fresh_identity(&mut copy, HarnessId::Codex, None); + fresh_identity(&mut copy, HarnessId::Codex, HarnessId::Codex, None); assert_eq!(©.meta.id[14..15], "7"); } @@ -1141,12 +1151,123 @@ mod identity_tests { fresh_identity( &mut copy, HarnessId::Codex, + HarnessId::Codex, Some(std::path::Path::new("/tmp/x")), ); assert_eq!(copy.meta.id, id); assert_eq!(copy.meta.timestamp, ts); } + fn lineage(parent: &str, relation: Relation) -> Lineage { + Lineage { + parent: parent.into(), + relation, + } + } + + #[test] + fn a_copy_into_the_source_harness_is_a_fork_of_the_source() { + let mut copy = transcript(); + let source_id = copy.meta.id.clone(); + fresh_identity(&mut copy, HarnessId::Codex, HarnessId::Codex, None); + assert_eq!(copy.meta.lineage, Some(lineage(&source_id, Relation::Fork))); + } + + #[test] + fn the_source_lineage_is_replaced_not_inherited() { + // A spawned session's link names its own parent; the copy's link + // names the session it was copied from. + let mut copy = transcript(); + copy.meta.lineage = Some(lineage( + "019a0000-0000-7000-8000-000000000000", + Relation::Spawn, + )); + let source_id = copy.meta.id.clone(); + fresh_identity(&mut copy, HarnessId::Codex, HarnessId::Codex, None); + assert_eq!(copy.meta.lineage, Some(lineage(&source_id, Relation::Fork))); + } + + #[test] + fn a_copy_into_another_harness_carries_no_lineage() { + // The source id, and any parent the source names, belong to the + // source's harness; neither can be the parent of a Codex session. + let mut copy = transcript(); + copy.meta.lineage = Some(lineage( + "019a0000-0000-7000-8000-000000000000", + Relation::Fork, + )); + fresh_identity(&mut copy, HarnessId::Pi, HarnessId::Codex, None); + assert_eq!(copy.meta.lineage, None); + } + + #[test] + fn a_copy_of_a_copy_names_only_its_direct_source() { + let mut first = transcript(); + fresh_identity(&mut first, HarnessId::Codex, HarnessId::Codex, None); + let first_id = first.meta.id.clone(); + let mut second = first.clone(); + fresh_identity(&mut second, HarnessId::Codex, HarnessId::Codex, None); + assert_eq!( + second.meta.lineage, + Some(lineage(&first_id, Relation::Fork)) + ); + } + + #[test] + fn a_source_without_an_id_gives_no_lineage() { + // A Simple document read from stdin may carry no id at all. + let mut copy = transcript(); + copy.meta.id.clear(); + fresh_identity(&mut copy, HarnessId::Simple, HarnessId::Simple, None); + assert_eq!(copy.meta.lineage, None); + } + + #[test] + fn an_out_export_keeps_the_source_lineage() { + let mut copy = transcript(); + copy.meta.lineage = Some(lineage( + "019a0000-0000-7000-8000-000000000000", + Relation::Fork, + )); + let before = copy.meta.lineage.clone(); + fresh_identity( + &mut copy, + HarnessId::Codex, + HarnessId::Codex, + Some(std::path::Path::new("/tmp/x")), + ); + assert_eq!(copy.meta.lineage, before); + } + + /// The whole path a `#range` continue into Codex takes: new identity, + /// then `from_common`. The rollout names its source as `forked_from_id`, + /// which reads back as the same fork. + #[test] + fn a_codex_copy_writes_its_source_as_forked_from_id() { + let mut copy = transcript(); + let source_id = copy.meta.id.clone(); + fresh_identity(&mut copy, HarnessId::Codex, HarnessId::Codex, None); + let text = codex::Codex::to_text(&codex::Codex::from_common(©).unwrap()).unwrap(); + assert!(text.contains(&format!(r#""forked_from_id":"{source_id}""#))); + let reread = codex::Codex::from_text(&text).unwrap(); + assert_eq!( + reread.meta.lineage, + Some(lineage(&source_id, Relation::Fork)) + ); + } + + #[test] + fn a_cross_harness_codex_copy_writes_no_forked_from_id() { + let mut copy = transcript(); + copy.meta.lineage = Some(lineage( + "019a0000-0000-7000-8000-000000000000", + Relation::Fork, + )); + fresh_identity(&mut copy, HarnessId::Pi, HarnessId::Codex, None); + let text = codex::Codex::to_text(&codex::Codex::from_common(©).unwrap()).unwrap(); + assert!(!text.contains("forked_from_id")); + } + #[test] fn cowork_remote_is_refused_even_for_an_in_place_continue() { let error = @@ -1408,7 +1529,7 @@ fn crop_loaded( .common .crop_to(&outcome.spans) .map_err(|error| error.to_string())?; - fresh_identity(&mut cropped, target, None); + fresh_identity(&mut cropped, source, target, None); stamp_live_cwd(&mut cropped, None); let cropped_id = write_and_report(source, target, &cropped, None, None)?; let edited = match outcome.edited { @@ -1657,7 +1778,7 @@ fn continue_document( Some(req) => fragment::sliced(&common, req)?, None => common, }; - fresh_identity(&mut copy, target, out); + fresh_identity(&mut copy, HarnessId::Simple, target, out); if copy.meta.id.is_empty() { // An `--out` export keeps the source identity — which stdin may not // carry at all. The target store still needs a file name. @@ -1815,7 +1936,7 @@ fn continue_amp_server_thread( (None, true) => id.to_string(), (None, false) => { let mut copy = common.clone(); - fresh_identity(&mut copy, target, out); + fresh_identity(&mut copy, HarnessId::Amp, target, out); stamp_live_cwd(&mut copy, out); write_and_report(HarnessId::Amp, target, ©, out, metadata)? } @@ -1823,7 +1944,7 @@ fn continue_amp_server_thread( // a subset of itself in place. (Some(req), _) => { let mut copy = fragment::sliced(&common, req)?; - fresh_identity(&mut copy, target, out); + fresh_identity(&mut copy, HarnessId::Amp, target, out); stamp_live_cwd(&mut copy, out); write_and_report(HarnessId::Amp, target, ©, out, metadata)? } @@ -1852,14 +1973,14 @@ fn continue_session( (None, true) => found.meta.id.clone(), (None, false) => { let mut common = found.read().map_err(|e| e.to_string())?; - fresh_identity(&mut common, target, out); + fresh_identity(&mut common, found.harness, target, out); stamp_live_cwd(&mut common, out); write_and_report(found.harness, target, &common, out, metadata)? } (Some(req), _) => { let common = found.read().map_err(|e| e.to_string())?; let mut copy = fragment::sliced(&common, req)?; - fresh_identity(&mut copy, target, out); + fresh_identity(&mut copy, found.harness, target, out); stamp_live_cwd(&mut copy, out); write_and_report(found.harness, target, ©, out, metadata)? } @@ -1882,7 +2003,7 @@ fn continue_loaded_remote( Some(request) => fragment::sliced(&common, request)?, None => common, }; - fresh_identity(&mut copy, target, out); + fresh_identity(&mut copy, source, target, out); stamp_live_cwd(&mut copy, out); let resume_id = write_and_report(source, target, ©, out, metadata)?; launch(target, &resume_id, cwd.as_deref(), resume) @@ -1933,16 +2054,29 @@ fn ensure_resumable_source(source: HarnessId, target: HarnessId) -> Result<(), S /// cross-harness copy would be filed under the original's date instead of /// today's. /// +/// The copy's lineage is replaced too, since the source's own link no longer +/// describes it. A copy into the source's harness is a fork of the source: a +/// new session holding some or all of its history, the source left as it +/// was. A copy into another harness gets no lineage, because +/// [`Lineage::parent`](txcript::common::Lineage::parent) names a session in +/// the same harness. +/// /// `--out` is exempt: it redirects to a scratch root rather than a live store, /// where preserving the source identity makes the write a faithful export. fn fresh_identity( common: &mut Transcript, + source: HarnessId, target: HarnessId, out: Option<&std::path::Path>, ) { if out.is_some() { return; } + let parent = std::mem::take(&mut common.meta.id); + common.meta.lineage = (source == target && !parent.is_empty()).then_some(Lineage { + parent, + relation: Relation::Fork, + }); // Codex stamps its rollouts with v7 UUIDs; matching the shape keeps the // copy out of any version-aware code path. v4 everywhere else. Harnesses // that need a different spelling (opencode's `ses_` prefix) re-shape this diff --git a/docs/formats/codex.md b/docs/formats/codex.md index c312d7b..fb619e6 100644 --- a/docs/formats/codex.md +++ b/docs/formats/codex.md @@ -103,8 +103,14 @@ applies, at discovery time as well as on load — reading the header line is all paired with `thread_source: "guardian_review"`) and any other subagent shape. No `source.subagent` but a top-level `parent_thread_id` is also a spawn. Otherwise, `forked_from_id` is a fork. A subagent thread forked from its parent's history at spawn time carries both fields; spawn wins. -Writing lineage back into a rollout is not implemented yet — `from_common` never emits -`source`/`parent_thread_id`/`forked_from_id`. +`from_common` writes a Fork lineage back as `forked_from_id`, so a fork survives a round trip +through Common. It writes nothing for a spawn, since `source.subagent` would turn the copy into +a subagent thread, and nothing for a continue, which Codex has no field for. It also skips a +parent that isn't a UUID, because Codex rejects a `session_meta` whose `forked_from_id` isn't +one. `source` is always `"cli"` and `parent_thread_id` is never written. When `continue` or +`crop` writes a copy into Codex from a Codex session, the copy is a fork of that session and +names it in `forked_from_id`. A copy from another harness gets no lineage, because its source +id doesn't name a Codex thread. ## Caveats diff --git a/src/common.rs b/src/common.rs index e804876..3d2539c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -42,11 +42,10 @@ pub struct Meta { pub model: Option, /// How this session relates to another, when its own records say so. /// - /// Read-side only: a harness's discovery and load paths fill this from - /// whatever header or early record carries the signal (see - /// `docs/formats/*.md` for the per-harness field). No codec's - /// `from_common` writes it back into a native session yet — round- - /// tripping provenance into native formats is a separate, later change. + /// A harness's discovery and load paths fill this from whatever header + /// or early record carries the signal (see `docs/formats/*.md` for the + /// per-harness field). Only Codex's `from_common` writes it back, and + /// only a fork (as `forked_from_id`); every other codec ignores it. #[serde(default, skip_serializing_if = "Option::is_none")] pub lineage: Option, } diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 85d0b4c..cf8e343 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -531,6 +531,11 @@ fn messages_to_lines(meta: &Meta, messages: &[Message]) -> Vec { { obj.insert("git".into(), json!({ "branch": branch })); } + if let Some(parent) = forked_from_id(meta) + && let Value::Object(obj) = &mut payload + { + obj.insert("forked_from_id".into(), json!(parent)); + } lines.push(meta_line(&meta.timestamp, "session_meta", payload)); // Only pending patch calls need custom-tool results. Completed IDs can @@ -1143,6 +1148,20 @@ fn lineage_from_session_meta(payload: &Value) -> Option { }) } +/// The `forked_from_id` to write for `meta.lineage`, the inverse of +/// [`lineage_from_session_meta`]'s fork case. Only a fork is written: a +/// spawn's `source.subagent` would turn the rollout into a subagent thread, +/// and Codex has no continue field. Codex parses `forked_from_id` as a thread +/// id (a UUID) and rejects the whole `session_meta` otherwise, so a parent +/// that isn't one is left out. +fn forked_from_id(meta: &Meta) -> Option<&str> { + meta.lineage + .as_ref() + .filter(|lineage| lineage.relation == Relation::Fork) + .map(|lineage| lineage.parent.as_str()) + .filter(|parent| uuid::Uuid::parse_str(parent).is_ok()) +} + fn collect_rollouts(dir: &Path, out: &mut Vec) { // An unreadable directory contributes no rollouts. if let Ok(entries) = fs::read_dir(dir) { diff --git a/tests/integration/codex.rs b/tests/integration/codex.rs index df7b44c..8f4e7d2 100644 --- a/tests/integration/codex.rs +++ b/tests/integration/codex.rs @@ -715,3 +715,148 @@ fn discover_resolves_lineage_from_session_meta() { assert!(by_id("plain").meta.lineage.is_none()); } + +// ── lineage write-back ────────────────────────────────────────────────── + +/// A Codex thread id, the only shape Codex accepts in `forked_from_id`. +const PARENT: &str = "019a1b2c-3d4e-7f60-8a7b-9c0d1e2f3a4b"; + +fn with_lineage(parent: &str, relation: common::Relation) -> Transcript { + let mut common = sample_common(); + common.meta.lineage = Some(common::Lineage { + parent: parent.into(), + relation, + }); + common +} + +fn rollout_text(common: &Transcript) -> String { + use txcript::TextCodec; + codex::Codex::to_text(&codex::Codex::from_common(common).unwrap()).unwrap() +} + +fn session_meta_payload(text: &str) -> serde_json::Value { + let first: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert_eq!(first["type"], "session_meta"); + first["payload"].clone() +} + +/// A fork writes `forked_from_id`, and the codec fixpoint now holds for it: +/// the lineage comes back along with every message and tool call. +#[test] +fn from_common_writes_a_fork_as_forked_from_id() { + let common = with_lineage(PARENT, common::Relation::Fork); + let text = rollout_text(&common); + assert_eq!(session_meta_payload(&text)["forked_from_id"], PARENT); + + let back = codex::Codex::to_common(&codex::Codex::from_common(&common).unwrap()).unwrap(); + assert_eq!(common, back); +} + +/// Through a real store: save, then `discover` and `load` both read the fork. +#[test] +fn a_written_fork_is_discovered_and_loaded_as_a_fork() { + let dir = tempfile::tempdir().unwrap(); + let store = codex::CodexStore::new(dir.path()); + let common = with_lineage(PARENT, common::Relation::Fork); + let saved = store + .save(&codex::Codex::from_common(&common).unwrap()) + .unwrap(); + + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.lineage, common.meta.lineage); + let loaded = codex::Codex::to_common(&store.load(&saved.reference).unwrap()).unwrap(); + assert_eq!(loaded.meta.lineage, common.meta.lineage); + assert_eq!(loaded.body, common.body); +} + +/// Writing the field touches nothing else: drop `forked_from_id` from the +/// fork's `session_meta` and the rollout is byte-for-byte the one written +/// without lineage. +#[test] +fn forked_from_id_is_the_only_difference_lineage_makes() { + let plain = rollout_text(&sample_common()); + assert!(!plain.contains("forked_from_id")); + + let forked = rollout_text(&with_lineage(PARENT, common::Relation::Fork)); + let (forked_meta, forked_rest) = forked.split_once('\n').unwrap(); + let (plain_meta, plain_rest) = plain.split_once('\n').unwrap(); + assert_eq!(forked_rest, plain_rest); + + let mut forked_meta: serde_json::Value = serde_json::from_str(forked_meta).unwrap(); + let removed = forked_meta["payload"] + .as_object_mut() + .unwrap() + .remove("forked_from_id"); + assert_eq!(removed, Some(serde_json::json!(PARENT))); + let plain_meta: serde_json::Value = serde_json::from_str(plain_meta).unwrap(); + assert_eq!(forked_meta, plain_meta); +} + +/// Lineage Codex can't represent is left out, and the rollout is exactly the +/// one written without lineage. A spawn would need `source.subagent`, which +/// makes the thread a subagent; Codex has no continue field; and Codex +/// rejects a `session_meta` whose `forked_from_id` isn't a UUID. +#[test] +fn lineage_codex_cannot_represent_is_not_written() { + let plain = rollout_text(&sample_common()); + for (parent, relation) in [ + (PARENT, common::Relation::Spawn), + (PARENT, common::Relation::Continue), + ("origin", common::Relation::Fork), + ("", common::Relation::Fork), + ] { + let text = rollout_text(&with_lineage(parent, relation)); + assert_eq!(text, plain, "{relation:?} of {parent:?}"); + } +} + +/// Writing, reading back, and writing again yields the same rollout, with +/// the field written once. +#[test] +fn writing_a_fork_twice_is_idempotent() { + use txcript::TextCodec; + let first = rollout_text(&with_lineage(PARENT, common::Relation::Fork)); + let reread = codex::Codex::to_common(&codex::Codex::from_text(&first).unwrap()).unwrap(); + let second = rollout_text(&reread); + assert_eq!(first, second); + assert_eq!(second.matches("forked_from_id").count(), 1); +} + +/// `session_meta` is rebuilt from `Meta` on every `from_common`, so a native +/// rollout's own parent fields never leak through. Re-pointing the lineage +/// replaces `forked_from_id` rather than adding a second one, and a native +/// spawn (`source.subagent` plus `forked_from_id`; spawn wins on read) is +/// written back as a plain session, as before this change. +#[test] +fn from_common_writes_only_the_lineage_meta_carries() { + use txcript::TextCodec; + let other = "019a1b2c-3d4e-7f60-8a7b-000000000001"; + let forked = format!( + "{}\n", + r#"{"timestamp":"2026-01-02T03:04:05.000Z","type":"session_meta","payload":{"id":"019a1b2c-3d4e-7f60-8a7b-000000000002","forked_from_id":"019a1b2c-3d4e-7f60-8a7b-9c0d1e2f3a4b"}}"#, + ); + let mut common = codex::Codex::to_common(&codex::Codex::from_text(&forked).unwrap()).unwrap(); + common.meta.lineage = Some(common::Lineage { + parent: other.into(), + relation: common::Relation::Fork, + }); + let text = rollout_text(&common); + assert_eq!(text.matches("forked_from_id").count(), 1); + assert_eq!(session_meta_payload(&text)["forked_from_id"], other); + + let spawned = format!( + "{}\n", + r#"{"timestamp":"2026-01-02T03:04:05.000Z","type":"session_meta","payload":{"id":"019a1b2c-3d4e-7f60-8a7b-000000000003","forked_from_id":"019a1b2c-3d4e-7f60-8a7b-9c0d1e2f3a4b","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019a1b2c-3d4e-7f60-8a7b-9c0d1e2f3a4b"}}}}}"#, + ); + let common = codex::Codex::to_common(&codex::Codex::from_text(&spawned).unwrap()).unwrap(); + assert_eq!( + common.meta.lineage.as_ref().map(|l| l.relation), + Some(common::Relation::Spawn) + ); + let payload = session_meta_payload(&rollout_text(&common)); + assert!(payload.get("forked_from_id").is_none()); + assert!(payload.get("parent_thread_id").is_none()); + assert_eq!(payload["source"], "cli"); +} diff --git a/tests/integration/cross_harness.rs b/tests/integration/cross_harness.rs index 7a80ba5..e41de14 100644 --- a/tests/integration/cross_harness.rs +++ b/tests/integration/cross_harness.rs @@ -9,7 +9,7 @@ use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, simple, }; -use txcript::{Codec, Common, Transcript, convert}; +use txcript::{Codec, Common, TextCodec, Transcript, convert}; fn ts(s: &str) -> DateTime { s.parse().unwrap() @@ -348,3 +348,61 @@ fn claude_tools_become_openai_safe_when_moved_to_codex() { assert_eq!(name, Some(expected), "source name: {source:?}"); } } + +fn written(common: &Transcript) -> String { + H::to_text(&H::from_common(common).unwrap()).unwrap() +} + +/// Codex is the only harness that writes `Meta::lineage` back. Every other +/// writing harness produces the same bytes with or without it, including +/// pi/Campfire (`parentSession` is a file path, not an id), `OpenCode` +/// (`parent_id` is spawn-only and written by `opencode import`), and Claude +/// Code (`teamName`/`agentName` are spawn-only). +#[test] +fn only_codex_writes_lineage_back() { + let plain = sample(); + for relation in [ + common::Relation::Fork, + common::Relation::Continue, + common::Relation::Spawn, + ] { + let mut linked = sample(); + linked.meta.lineage = Some(common::Lineage { + parent: "019a1b2c-3d4e-7f60-8a7b-9c0d1e2f3a4b".into(), + relation, + }); + macro_rules! same_bytes { + ($($name:literal => $harness:ty),+ $(,)?) => {$( + assert_eq!( + written::<$harness>(&linked), + written::<$harness>(&plain), + "{}: {relation:?}", + $name + ); + )+}; + } + same_bytes! { + "claude_code" => claude_code::ClaudeCode, + "opencode" => opencode::OpenCode, + "pi" => pi::Pi, + "campfire" => campfire::Campfire, + "cursor_desktop" => cursor_desktop::CursorDesktop, + "grok" => grok::Grok, + "grok_bot" => grok_bot::GrokBot, + "fx" => fx::Fx, + "hermes" => hermes::Hermes, + "amp" => amp::Amp, + "antigravity" => antigravity::Antigravity, + "simple" => simple::Simple, + "cowork" => cowork::Cowork, + } + + // Cursor stamps the wall clock into what it writes, so compare what + // reads back instead of the bytes. + let reread = |common: &Transcript| { + let text = written::(common); + cursor::Cursor::to_common(&cursor::Cursor::from_text(&text).unwrap()).unwrap() + }; + assert_eq!(reread(&linked), reread(&plain), "cursor: {relation:?}"); + } +}