Skip to main content

tapes_harnesses/transcript/
codex_anchors.rs

1//! Codex sub-agent-activity anchors: the spawn edge that never reaches the
2//! wire.
3//!
4//! Codex never puts the (spawn call_id ↔ child thread id) join on the wire:
5//! `spawn_agent` tool arguments are an encrypted blob and the tool result names
6//! only the task. The exact join exists solely in the PARENT rollout file as an
7//! `event_msg` / `sub_agent_activity` record:
8//!
9//! ```json
10//! {"timestamp":"…","type":"event_msg","payload":{
11//!   "type":"sub_agent_activity","event_id":"call_…",
12//!   "occurred_at_ms":…,"agent_thread_id":"<child thread id>",
13//!   "agent_path":"/root/…","kind":"started"}}
14//! ```
15//!
16//! tapes needs that join to stamp `ParentToolUseID` on the child thread's chain
17//! root, which is what parents the child's `agent` span under the exact
18//! `spawn_agent` tool span (otherwise root-keyed child turns flatten under the
19//! trace root). The evidence ships the same way Claude's fork edge ships — as a
20//! transcript-source raw row whose meta carries `{transcript: true, agent_id:
21//! <child thread id>, tool_use_id: <spawn call_id>}` — so tapes' identity-first
22//! `ReconcileTranscripts` join applies verbatim.
23//!
24//! Rollouts also carry `kind:"interacted"` records — one per directed re-entry
25//! (`send_message` / `followup_task`), in the SENDER's rollout, with `event_id`
26//! = the triggering function_call's call_id and `agent_thread_id` = the TARGET
27//! thread (which may be the sender's parent or the root itself: upward/sideways
28//! messaging is real). Those are anchors too, marked
29//! [`crate::transcript::KIND_INTERACTED`] in payload/meta. They are NOT spawn
30//! evidence — tapes' deriver ignores them, only `started` rows anchor threads —
31//! but banking them means future re-entry rendering needs no rollout-file
32//! backfill.
33//!
34//! # Why this is here rather than in a client
35//!
36//! Every line above is a statement about Codex's own on-disk format, and it was
37//! true of exactly one client for as long as only one client read rollouts. A
38//! standalone capture of the same Codex session then reconstructed into a
39//! *flatter* tree than that client's capture of it — two capture paths
40//! observably disagreeing about the same harness. The derivation belongs
41//! wherever every client can reach it, which is here.
42//!
43//! # Where it sits
44//!
45//! Under [`crate::transcript`] rather than [`crate::attribution::codex`]
46//! because what it produces is a transcript-lane payload: it composes the
47//! sibling modules here ([`super::files::fingerprint`],
48//! [`super::files::jsonl_to_records`], [`super::payload::TranscriptPayload`]),
49//! and attribution answers a different question — who sent *this request* —
50//! with no dependency on the ingest lane. The harness-named module inside a
51//! lane-named parent follows [`crate::attribution`]'s rule that harness
52//! specifics carry their harness's name; if Codex ever grows a second
53//! transcript-lane concern, this becomes `transcript/codex/anchors.rs`.
54//!
55//! # The anchor-row wire contract (shared with tapes)
56//!
57//! One `POST {base}/v1/ingest/transcript` per anchor, body:
58//!
59//! ```json
60//! {
61//!   "session": {
62//!     "org_id": "",
63//!     "auth_subject": "",
64//!     "harness_id": "codex",
65//!     "harness_session_id": "<ROOT session id>",
66//!     "harness_version": "<parent rollout cli_version>",
67//!     "cwd": "<parent rollout cwd>"
68//!   },
69//!   "agent_id": "<child thread id>",
70//!   "agent_type": "<last agent_path segment>",
71//!   "description": "<agent_path>",
72//!   "tool_use_id": "<spawn call_id (event_id)>",
73//!   "records": [ <the verbatim kind:"started" rollout line> ]
74//! }
75//! ```
76//!
77//! `harness_session_id` is always the ROOT session id
78//! (`session_meta.session_id`, falling back to the rollout's own id for root
79//! rollouts) — even when the spawning parent is itself a subagent — because
80//! tapes groups reconcile inputs by session key and every re-keyed child turn
81//! lives under the root. `records` holds the single rollout line verbatim,
82//! which keeps the server-side content hash
83//! (`transcript:<sid>:<agent>:<sha256[..8]>`) stable so re-pushes dedup instead
84//! of appending versions.
85//!
86//! Interacted rows reuse the same body shape with `agent_id` = the TARGET
87//! thread id, `tool_use_id` = the triggering call id, the verbatim
88//! `kind:"interacted"` line as the single record, and one extra top-level
89//! field: `"kind":"interacted"` (started rows omit it — absent means spawn
90//! evidence, the legacy default).
91//!
92//! # What stays with each client
93//!
94//! The same split the rest of [`crate::transcript`] makes: **delivery, auth,
95//! retry, and scope**. Which rollouts are *ours* is a per-client question — each
96//! capture client declares its own Codex provider id and filters the watcher
97//! snapshot with [`crate::attribution::CodexProviderFilter`] — and so are the
98//! HTTP call, the credential, the tick cadence, and the failure backoff. What
99//! [`CodexAnchorScanner`] owns is only the part that must not fork: which
100//! anchors a rollout currently offers that have not been delivered yet, and
101//! when a rollout is worth re-reading at all.
102
103use std::collections::{HashMap, HashSet};
104use std::path::{Path, PathBuf};
105
106use serde::Deserialize;
107use serde_json::value::RawValue;
108
109use crate::attribution::CodexSessionFile;
110
111use super::files::{self, FileFingerprint};
112use super::payload::{IngestEnvelope, KIND_INTERACTED, TranscriptPayload};
113
114/// The `sub_agent_activity` lifecycle kinds a rollout states.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum AnchorKind {
117    /// A spawn: `agent_thread_id` is a freshly created child thread and
118    /// `event_id` is the `spawn_agent` call that created it. The anchor tapes'
119    /// deriver joins on.
120    Started,
121    /// A directed re-entry (`send_message` / `followup_task`):
122    /// `agent_thread_id` is the TARGET thread — possibly the sender's parent or
123    /// the root — and `event_id` is the triggering call. Carried for
124    /// durability; inert in derivation.
125    Interacted,
126}
127
128/// One `sub_agent_activity` record extracted from a rollout.
129///
130/// `line` is the rollout line **verbatim** — the ingest server content-hashes
131/// the records array for idempotency, so the bytes must be stable across
132/// pushes.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct SubAgentAnchor {
135    /// Which lifecycle record this anchor carries.
136    pub kind: AnchorKind,
137    /// The subject thread (`payload.agent_thread_id`): the spawned child for
138    /// `started`, the message TARGET for `interacted`.
139    pub thread_id: String,
140    /// The triggering call id (`payload.event_id`): the `spawn_agent` call for
141    /// `started`, the `send_message`/`followup_task` call for `interacted`.
142    pub call_id: String,
143    /// Slash-separated task path (`payload.agent_path`), e.g.
144    /// `/root/depth2_cli_child`.
145    pub agent_path: Option<String>,
146    /// The full rollout line, verbatim.
147    pub line: String,
148}
149
150impl SubAgentAnchor {
151    /// Label for the subagent kind slot on the upload: the last `agent_path`
152    /// segment (the task name Codex assigned).
153    #[must_use]
154    pub fn agent_type(&self) -> Option<&str> {
155        self.agent_path
156            .as_deref()
157            .and_then(|path| path.rsplit('/').next())
158            .filter(|segment| !segment.is_empty())
159    }
160
161    /// The payload/meta `kind` slot ([`TranscriptPayload::kind`]).
162    ///
163    /// Started rows omit it so their payload bytes — and the rows earlier
164    /// builds already ingested — stay unchanged; absent means spawn evidence.
165    /// The wire value is [`KIND_INTERACTED`], which matches the rollout
166    /// record's own `kind` spelling by design.
167    #[must_use]
168    pub fn payload_kind(&self) -> Option<&'static str> {
169        match self.kind {
170            AnchorKind::Started => None,
171            AnchorKind::Interacted => Some(KIND_INTERACTED),
172        }
173    }
174
175    /// Once-per-anchor identity, used both to drop duplicate parses and to
176    /// remember what a client already delivered.
177    ///
178    /// A thread starts exactly once, so started keys on the thread alone; a
179    /// thread is interacted with many times, so interacted includes the
180    /// triggering call id.
181    #[must_use]
182    pub fn dedup_key(&self) -> String {
183        match self.kind {
184            AnchorKind::Started => format!("started:{}", self.thread_id),
185            AnchorKind::Interacted => format!("interacted:{}:{}", self.call_id, self.thread_id),
186        }
187    }
188}
189
190#[derive(Deserialize)]
191struct RolloutRow {
192    #[serde(rename = "type")]
193    row_type: String,
194    payload: Option<serde_json::Value>,
195}
196
197#[derive(Deserialize)]
198struct SubAgentActivity {
199    #[serde(rename = "type")]
200    activity_type: String,
201    event_id: Option<String>,
202    agent_thread_id: Option<String>,
203    agent_path: Option<String>,
204    kind: Option<String>,
205}
206
207/// The `payload.kind` spelling of a spawn record.
208const ROLLOUT_KIND_STARTED: &str = "started";
209
210/// Extract the sub-agent anchors (`kind == "started"` spawns and
211/// `kind == "interacted"` re-entries) from raw rollout bytes.
212///
213/// Blank / malformed / non-matching lines are skipped, matching the transcript
214/// reader's tolerance for a truncated final line — a rollout is append-only and
215/// is read while the harness is still writing it.
216///
217/// Started anchors are one per child: a thread starts exactly once, so a
218/// duplicate `started` record for an already-seen `agent_thread_id` is dropped
219/// (first wins — the first start is the spawn edge). Interacted anchors are one
220/// per triggering call: the same target thread legitimately appears once per
221/// `send_message` / `followup_task`, so only an exact `(call_id, thread)` repeat
222/// drops.
223#[must_use]
224pub fn parse_subagent_anchors(raw: &[u8]) -> Vec<SubAgentAnchor> {
225    let mut out: Vec<SubAgentAnchor> = Vec::new();
226    let mut seen: HashSet<String> = HashSet::new();
227    for line in raw.split(|&byte| byte == b'\n') {
228        // Cheap reject before JSON-parsing multi-KB rollout rows.
229        if !line
230            .windows(b"sub_agent_activity".len())
231            .any(|window| window == b"sub_agent_activity")
232        {
233            continue;
234        }
235        let Ok(text) = std::str::from_utf8(line) else {
236            continue;
237        };
238        let text = text.trim();
239        let Ok(row) = serde_json::from_str::<RolloutRow>(text) else {
240            continue;
241        };
242        if row.row_type != "event_msg" {
243            continue;
244        }
245        let Some(activity) = row
246            .payload
247            .and_then(|payload| serde_json::from_value::<SubAgentActivity>(payload).ok())
248        else {
249            continue;
250        };
251        if activity.activity_type != "sub_agent_activity" {
252            continue;
253        }
254        let kind = match activity.kind.as_deref() {
255            Some(ROLLOUT_KIND_STARTED) => AnchorKind::Started,
256            Some(KIND_INTERACTED) => AnchorKind::Interacted,
257            // Unknown lifecycle kinds are not evidence we understand; skip
258            // rather than mislabel.
259            _ => continue,
260        };
261        let (Some(call_id), Some(thread)) = (activity.event_id, activity.agent_thread_id) else {
262            continue;
263        };
264        if call_id.is_empty() || thread.is_empty() {
265            continue;
266        }
267        let anchor = SubAgentAnchor {
268            kind,
269            thread_id: thread,
270            call_id,
271            agent_path: activity.agent_path,
272            line: text.to_owned(),
273        };
274        if !seen.insert(anchor.dedup_key()) {
275            if anchor.kind == AnchorKind::Started {
276                tracing::warn!(
277                    child_thread_id = %anchor.thread_id,
278                    call_id = %anchor.call_id,
279                    "codex-anchors: duplicate started record for one thread; keeping the first",
280                );
281            }
282            continue;
283        }
284        out.push(anchor);
285    }
286    out
287}
288
289/// Assemble the ingest payload for one anchor — see the module docs for the
290/// exact wire contract.
291///
292/// The body is a [`TranscriptPayload`] constructed literally rather than
293/// through [`super::payload::build_payload`] because an anchor is not a
294/// transcript file: `agent_id` carries the anchor's subject thread,
295/// `tool_use_id` the triggering call, and `kind` the lifecycle qualifier only
296/// interacted rows set.
297///
298/// `harness_id` is a parameter rather than a constant because one rollout tree
299/// serves two harnesses: a `codex` CLI session and a Codex desktop-app session
300/// write the same records, and the row must name the same harness its own wire
301/// traffic does or the deriver files the two under different sessions. Callers
302/// pass [`tapes_capture::envelope::HARNESS_ID_CODEX`] or
303/// [`tapes_capture::envelope::HARNESS_ID_CODEX_APP`].
304///
305/// `records` must be [`files::jsonl_to_records`] over `anchor.line`, wrapped in
306/// a [`RawValue`] so the bytes embed verbatim.
307#[must_use]
308pub fn build_anchor_payload<'a>(
309    rollout: &'a CodexSessionFile,
310    anchor: &'a SubAgentAnchor,
311    harness_id: &'a str,
312    records: &'a RawValue,
313) -> TranscriptPayload<'a> {
314    TranscriptPayload {
315        session: IngestEnvelope {
316            org_id: "",
317            auth_subject: "",
318            harness_id,
319            // Always the ROOT session id: a depth-1 launcher's rollout carries
320            // the root in session_meta.session_id; a root rollout falls back to
321            // its own id.
322            harness_session_id: rollout
323                .root_session_id
324                .as_deref()
325                .unwrap_or(&rollout.session_id),
326            harness_version: rollout.cli_version.as_deref(),
327            cwd: rollout.cwd.as_deref(),
328        },
329        agent_id: Some(&anchor.thread_id),
330        agent_type: anchor.agent_type(),
331        description: anchor.agent_path.as_deref(),
332        tool_use_id: Some(&anchor.call_id),
333        kind: anchor.payload_kind(),
334        records,
335    }
336}
337
338/// Convenience: the `records` array for one anchor, ready for
339/// [`build_anchor_payload`].
340///
341/// `None` only if [`files::jsonl_to_records`] produced something that is not
342/// valid JSON, which it does not — the wrapper exists so a caller does not have
343/// to decide what that impossible case means twice.
344#[must_use]
345pub fn anchor_records(anchor: &SubAgentAnchor) -> Option<Box<RawValue>> {
346    RawValue::from_string(files::jsonl_to_records(anchor.line.as_bytes())).ok()
347}
348
349/// Per-rollout scan bookkeeping.
350#[derive(Debug, Default)]
351struct RolloutScan {
352    /// Fingerprint at the last scan that left nothing undelivered; a matching
353    /// fingerprint skips the file read entirely.
354    scanned: Option<FileFingerprint>,
355    /// [`SubAgentAnchor::dedup_key`]s the client reported delivered.
356    delivered: HashSet<String>,
357}
358
359/// What a client has already taken from each rollout it watches.
360///
361/// This is the shared half of the anchor lane's state: *which anchors are still
362/// owed*, and *whether a rollout is worth re-reading*. Both answers are
363/// statements about Codex's file format — rollouts are append-only, so a stable
364/// size+mtime fingerprint means no new anchors, and each anchor's identity is
365/// its [`SubAgentAnchor::dedup_key`] — and a client that re-derived them would
366/// be re-deriving how much of a session's causal skeleton gets uploaded.
367///
368/// Everything around it stays with the client: the tick, the HTTP call, the
369/// credential, the failure backoff, and the scope rule that decides which
370/// rollouts are handed here at all.
371///
372/// A scanner is only ever as large as the live rollout set — call
373/// [`Self::retain_live`] each tick with the current snapshot.
374#[derive(Debug, Default)]
375pub struct CodexAnchorScanner {
376    states: HashMap<PathBuf, RolloutScan>,
377}
378
379impl CodexAnchorScanner {
380    /// An empty scanner.
381    #[must_use]
382    pub fn new() -> Self {
383        Self::default()
384    }
385
386    /// Drop bookkeeping for every rollout not in `live`.
387    ///
388    /// Codex's rollout directory is retention-bounded and a watcher snapshot
389    /// only reports recent files, so without this the map grows for the life of
390    /// a long-running client.
391    pub fn retain_live<'a, I>(&mut self, live: I)
392    where
393        I: IntoIterator<Item = &'a Path>,
394    {
395        let live: HashSet<&Path> = live.into_iter().collect();
396        self.states.retain(|path, _| live.contains(path.as_path()));
397    }
398
399    /// Whether `rollout` is worth reading, given its current `fingerprint`.
400    ///
401    /// `false` when the file could not be fingerprinted (vanished, or `stat`
402    /// failed — skip this tick) or when it has not moved since the last clean
403    /// scan. Rollouts are append-only, so an unchanged fingerprint cannot hide
404    /// a new anchor.
405    #[must_use]
406    pub fn needs_read(&self, rollout: &Path, fingerprint: Option<FileFingerprint>) -> bool {
407        fingerprint.is_some() && self.states.get(rollout).and_then(|s| s.scanned) != fingerprint
408    }
409
410    /// The anchors in `raw` that have not been reported delivered for
411    /// `rollout`, in file order.
412    #[must_use]
413    pub fn undelivered(&self, rollout: &Path, raw: &[u8]) -> Vec<SubAgentAnchor> {
414        let state = self.states.get(rollout);
415        parse_subagent_anchors(raw)
416            .into_iter()
417            .filter(|anchor| {
418                state.is_none_or(|state| !state.delivered.contains(&anchor.dedup_key()))
419            })
420            .collect()
421    }
422
423    /// Record that the server accepted `anchor`'s row (including a dedup — the
424    /// bytes are stored either way).
425    pub fn record_delivered(&mut self, rollout: &Path, anchor: &SubAgentAnchor) {
426        self.states
427            .entry(rollout.to_path_buf())
428            .or_default()
429            .delivered
430            .insert(anchor.dedup_key());
431    }
432
433    /// Record a scan that left nothing owed, so `rollout` is not re-read until
434    /// it grows.
435    ///
436    /// A client must NOT call this after a partial failure: leaving the
437    /// fingerprint behind is what makes the next tick re-read and retry.
438    pub fn record_clean_scan(&mut self, rollout: &Path, fingerprint: Option<FileFingerprint>) {
439        self.states
440            .entry(rollout.to_path_buf())
441            .or_default()
442            .scanned = fingerprint;
443    }
444
445    /// How many anchors have been reported delivered for `rollout`.
446    #[must_use]
447    pub fn delivered_count(&self, rollout: &Path) -> usize {
448        self.states
449            .get(rollout)
450            .map_or(0, |state| state.delivered.len())
451    }
452}
453
454/// The rollout lines and payload bytes both capture clients assert against.
455///
456/// Not feature-gated, unlike `tapes_capture::envelope::fixtures`: these are inert
457/// `&'static str`s with no I/O and no panics, and gating them would put a Cargo
458/// feature between two repositories and the one artifact that proves their
459/// anchor rows are byte-identical. The corpus is small on purpose — it pins the
460/// contract, not the implementation.
461pub mod fixtures {
462    /// The exact `sub_agent_activity` spawn line captured in the 2026-07-23
463    /// codex_skills clearing (root `019f8d46-beb1`, line 18) — the real-world
464    /// shape the parser exists for.
465    pub const STARTED_LINE: &str = r#"{"timestamp":"2026-07-23T04:41:01.858Z","type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_J7B6r7ZdtqkECtSJV8YDQaL7","occurred_at_ms":1784781661858,"agent_thread_id":"019f8d46-e663-74e1-940c-f82e34c07618","agent_path":"/root/depth2_cli_child","kind":"started"}}"#;
466
467    /// The `kind:"interacted"` line from the same clearing (grandchild rollout
468    /// `019f8d47-0473`, line 31): a `send_message` in the SENDER's rollout
469    /// targeting the sender's PARENT thread. Upward messaging is the real-world
470    /// shape these rows must survive.
471    pub const INTERACTED_LINE: &str = r#"{"timestamp":"2026-07-23T04:41:18.008Z","type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_cqusEjhomv5zKjZ7vodiY7Og","occurred_at_ms":1784781678008,"agent_thread_id":"019f8d46-e663-74e1-940c-f82e34c07618","agent_path":"/root/depth2_cli_child","kind":"interacted"}}"#;
472
473    /// Root session id of [`ROLLOUT`], as [`SESSION_ID`]'s rollout reports it.
474    pub const ROOT_SESSION_ID: &str = "019f8d46-beb1-7c40-9a1f-2e8b1c0d5a33";
475
476    /// The rollout's own thread id. Distinct from [`ROOT_SESSION_ID`] on
477    /// purpose: the parent doing the spawning is itself a subagent here, which
478    /// is the case that proves anchor rows key to the ROOT.
479    pub const SESSION_ID: &str = "019f8d46-c0de-7000-8000-000000000001";
480
481    /// `cwd` of [`ROLLOUT`]'s session.
482    pub const CWD: &str = "/w/repo";
483
484    /// Codex CLI version of [`ROLLOUT`]'s session.
485    pub const CLI_VERSION: &str = "0.145.0";
486
487    /// A rollout carrying one spawn and one re-entry, plus rows an anchor
488    /// scanner must ignore: a `session_meta` header, an unknown lifecycle kind,
489    /// and an `agent_message` that merely mentions the marker in its text.
490    pub const ROLLOUT: &str = concat!(
491        r#"{"timestamp":"2026-07-23T04:41:00.000Z","type":"session_meta","payload":{"id":"019f8d46-c0de-7000-8000-000000000001"}}"#,
492        "\n",
493        r#"{"timestamp":"2026-07-23T04:41:01.858Z","type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_J7B6r7ZdtqkECtSJV8YDQaL7","occurred_at_ms":1784781661858,"agent_thread_id":"019f8d46-e663-74e1-940c-f82e34c07618","agent_path":"/root/depth2_cli_child","kind":"started"}}"#,
494        "\n",
495        r#"{"timestamp":"2026-07-23T04:41:10.000Z","type":"event_msg","payload":{"type":"agent_message","message":"spawned via sub_agent_activity"}}"#,
496        "\n",
497        r#"{"timestamp":"2026-07-23T04:41:18.008Z","type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_cqusEjhomv5zKjZ7vodiY7Og","occurred_at_ms":1784781678008,"agent_thread_id":"019f8d46-e663-74e1-940c-f82e34c07618","agent_path":"/root/depth2_cli_child","kind":"interacted"}}"#,
498        "\n",
499        r#"{"timestamp":"2026-07-23T04:41:20.000Z","type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_J7B6r7ZdtqkECtSJV8YDQaL7","agent_thread_id":"019f8d46-e663-74e1-940c-f82e34c07618","kind":"finished"}}"#,
500        "\n",
501    );
502
503    /// The exact ingest body a `codex` capture must POST for [`ROLLOUT`]'s
504    /// spawn record.
505    ///
506    /// Two independently written capture clients agreeing on these bytes is the
507    /// whole claim: same session key, same anchor identity, same records, same
508    /// field order. Any client whose lane differs — a re-serialized records
509    /// array, an omitted `agent_type`, the rollout's own thread id in place of
510    /// the root — fails against this constant rather than at derivation time in
511    /// production.
512    pub const STARTED_BODY: &str = concat!(
513        r#"{"session":{"org_id":"","auth_subject":"","harness_id":"codex","#,
514        r#""harness_session_id":"019f8d46-beb1-7c40-9a1f-2e8b1c0d5a33","harness_version":"0.145.0","cwd":"/w/repo"},"#,
515        r#""agent_id":"019f8d46-e663-74e1-940c-f82e34c07618","agent_type":"depth2_cli_child","#,
516        r#""description":"/root/depth2_cli_child","tool_use_id":"call_J7B6r7ZdtqkECtSJV8YDQaL7","#,
517        r#""records":[{"timestamp":"2026-07-23T04:41:01.858Z","type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_J7B6r7ZdtqkECtSJV8YDQaL7","occurred_at_ms":1784781661858,"agent_thread_id":"019f8d46-e663-74e1-940c-f82e34c07618","agent_path":"/root/depth2_cli_child","kind":"started"}}]}"#,
518    );
519
520    /// The exact ingest body a `codex` capture must POST for [`ROLLOUT`]'s
521    /// re-entry record — the same shape plus the `kind` marker, which sits
522    /// after `tool_use_id` so a spawn row's bytes stay a strict prefix.
523    pub const INTERACTED_BODY: &str = concat!(
524        r#"{"session":{"org_id":"","auth_subject":"","harness_id":"codex","#,
525        r#""harness_session_id":"019f8d46-beb1-7c40-9a1f-2e8b1c0d5a33","harness_version":"0.145.0","cwd":"/w/repo"},"#,
526        r#""agent_id":"019f8d46-e663-74e1-940c-f82e34c07618","agent_type":"depth2_cli_child","#,
527        r#""description":"/root/depth2_cli_child","tool_use_id":"call_cqusEjhomv5zKjZ7vodiY7Og","kind":"interacted","#,
528        r#""records":[{"timestamp":"2026-07-23T04:41:18.008Z","type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_cqusEjhomv5zKjZ7vodiY7Og","occurred_at_ms":1784781678008,"agent_thread_id":"019f8d46-e663-74e1-940c-f82e34c07618","agent_path":"/root/depth2_cli_child","kind":"interacted"}}]}"#,
529    );
530
531    /// Both bodies in the order [`ROLLOUT`] states them, which is the order a
532    /// client's pushes must arrive in.
533    pub const BODIES: [&str; 2] = [STARTED_BODY, INTERACTED_BODY];
534
535    /// The session facts of [`ROLLOUT`], as a rollout at `path` would be
536    /// reported by the Codex watcher.
537    ///
538    /// `model_provider` is left `None`: whether a rollout is *ours* is the
539    /// consumer's scope rule, and each client stamps a provider id of its own.
540    #[must_use]
541    pub fn session_file(path: std::path::PathBuf) -> crate::attribution::CodexSessionFile {
542        crate::attribution::CodexSessionFile {
543            session_id: SESSION_ID.to_owned(),
544            root_session_id: Some(ROOT_SESSION_ID.to_owned()),
545            parent_thread_id: Some(ROOT_SESSION_ID.to_owned()),
546            subagent_kind: None,
547            timestamp: time::OffsetDateTime::UNIX_EPOCH,
548            modified_at: Some(time::OffsetDateTime::UNIX_EPOCH),
549            cwd: Some(CWD.to_owned()),
550            originator: Some("codex_exec".to_owned()),
551            cli_version: Some(CLI_VERSION.to_owned()),
552            source: Some("exec".to_owned()),
553            thread_source: Some("subagent".to_owned()),
554            model_provider: None,
555            path,
556        }
557    }
558}
559
560#[cfg(test)]
561#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
562mod tests {
563    use super::fixtures::{INTERACTED_LINE, STARTED_LINE};
564    use super::*;
565    use tapes_capture::envelope::HARNESS_ID_CODEX;
566
567    fn rollout_at(path: &Path) -> CodexSessionFile {
568        fixtures::session_file(path.to_path_buf())
569    }
570
571    fn body(harness_id: &str, rollout: &CodexSessionFile, anchor: &SubAgentAnchor) -> String {
572        let records = anchor_records(anchor).unwrap();
573        serde_json::to_string(&build_anchor_payload(rollout, anchor, harness_id, &records)).unwrap()
574    }
575
576    // --- the derivation ---------------------------------------------------
577
578    #[test]
579    fn parse_extracts_started_and_interacted_records_verbatim() {
580        let anchors = parse_subagent_anchors(fixtures::ROLLOUT.as_bytes());
581        assert_eq!(anchors.len(), 2, "the ignorable rows must stay ignored");
582
583        let started = &anchors[0];
584        assert_eq!(started.kind, AnchorKind::Started);
585        assert_eq!(started.thread_id, "019f8d46-e663-74e1-940c-f82e34c07618");
586        assert_eq!(started.call_id, "call_J7B6r7ZdtqkECtSJV8YDQaL7");
587        assert_eq!(
588            started.agent_path.as_deref(),
589            Some("/root/depth2_cli_child")
590        );
591        assert_eq!(started.agent_type(), Some("depth2_cli_child"));
592        assert_eq!(
593            started.line, STARTED_LINE,
594            "the rollout line must survive verbatim — the server dedups on its hash",
595        );
596
597        let interacted = &anchors[1];
598        assert_eq!(interacted.kind, AnchorKind::Interacted);
599        assert_eq!(
600            interacted.thread_id, "019f8d46-e663-74e1-940c-f82e34c07618",
601            "interacted rows carry the TARGET thread",
602        );
603        assert_eq!(interacted.call_id, "call_cqusEjhomv5zKjZ7vodiY7Og");
604        assert_eq!(interacted.line, INTERACTED_LINE);
605    }
606
607    #[test]
608    fn parse_tolerates_a_truncated_final_line() {
609        // The file is read while the harness is still appending to it.
610        let raw = format!("{STARTED_LINE}\n{{\"type\":\"event_msg\",\"payl");
611        assert_eq!(parse_subagent_anchors(raw.as_bytes()).len(), 1);
612    }
613
614    #[test]
615    fn parse_keeps_first_started_record_per_child() {
616        let dup = STARTED_LINE.replace("call_J7B6r7ZdtqkECtSJV8YDQaL7", "call_second");
617        let raw = format!("{STARTED_LINE}\n{dup}\n");
618        let anchors = parse_subagent_anchors(raw.as_bytes());
619        assert_eq!(anchors.len(), 1);
620        assert_eq!(anchors[0].call_id, "call_J7B6r7ZdtqkECtSJV8YDQaL7");
621    }
622
623    #[test]
624    fn parse_keeps_one_interacted_anchor_per_triggering_call() {
625        // Two sends to the SAME target are two anchors (distinct call_ids); an
626        // exact byte-repeat of one record is not.
627        let second_send = INTERACTED_LINE.replace("call_cqusEjhomv5zKjZ7vodiY7Og", "call_2nd");
628        let raw = format!("{INTERACTED_LINE}\n{second_send}\n{INTERACTED_LINE}\n");
629        let anchors = parse_subagent_anchors(raw.as_bytes());
630        assert_eq!(anchors.len(), 2);
631        assert_eq!(anchors[0].call_id, "call_cqusEjhomv5zKjZ7vodiY7Og");
632        assert_eq!(anchors[1].call_id, "call_2nd");
633
634        // A started record and an interacted record for the SAME thread never
635        // collide — kind is part of the anchor identity.
636        let raw = format!("{STARTED_LINE}\n{INTERACTED_LINE}\n");
637        assert_eq!(parse_subagent_anchors(raw.as_bytes()).len(), 2);
638    }
639
640    #[test]
641    fn parse_requires_call_and_thread_ids() {
642        let missing_thread = r#"{"type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_x","kind":"started"}}"#;
643        let missing_call = r#"{"type":"event_msg","payload":{"type":"sub_agent_activity","agent_thread_id":"child","kind":"started"}}"#;
644        let missing_thread_interacted = r#"{"type":"event_msg","payload":{"type":"sub_agent_activity","event_id":"call_x","kind":"interacted"}}"#;
645        let missing_call_interacted = r#"{"type":"event_msg","payload":{"type":"sub_agent_activity","agent_thread_id":"child","kind":"interacted"}}"#;
646        let raw = format!(
647            "{missing_thread}\n{missing_call}\n{missing_thread_interacted}\n{missing_call_interacted}\n"
648        );
649        assert!(parse_subagent_anchors(raw.as_bytes()).is_empty());
650    }
651
652    // --- the payload ------------------------------------------------------
653
654    /// The fixture bodies are the cross-repo contract: if this fails, every
655    /// capture client's parity test fails with it, which is the point.
656    #[test]
657    fn the_fixture_bodies_are_what_the_derivation_produces() {
658        let rollout = rollout_at(Path::new("/tmp/rollout.jsonl"));
659        let anchors = parse_subagent_anchors(fixtures::ROLLOUT.as_bytes());
660        let bodies: Vec<String> = anchors
661            .iter()
662            .map(|anchor| body(HARNESS_ID_CODEX, &rollout, anchor))
663            .collect();
664        assert_eq!(bodies, fixtures::BODIES.to_vec());
665    }
666
667    #[test]
668    fn anchor_rows_key_to_the_root_session_not_the_spawning_thread() {
669        // The fixture's parent is itself a subagent. Its anchor rows must still
670        // key to the ROOT session, because tapes groups reconcile inputs by
671        // session key and every re-keyed child turn lives under the root.
672        let rollout = rollout_at(Path::new("/tmp/launcher.jsonl"));
673        assert_ne!(rollout.session_id, fixtures::ROOT_SESSION_ID);
674        let anchor = &parse_subagent_anchors(fixtures::ROLLOUT.as_bytes())[0];
675        let got: serde_json::Value =
676            serde_json::from_str(&body(HARNESS_ID_CODEX, &rollout, anchor)).unwrap();
677        assert_eq!(
678            got["session"]["harness_session_id"],
679            fixtures::ROOT_SESSION_ID
680        );
681
682        // A root rollout names no root of its own and falls back to its own id.
683        let mut root = rollout;
684        root.root_session_id = None;
685        let got: serde_json::Value =
686            serde_json::from_str(&body(HARNESS_ID_CODEX, &root, anchor)).unwrap();
687        assert_eq!(got["session"]["harness_session_id"], fixtures::SESSION_ID);
688    }
689
690    #[test]
691    fn a_started_row_carries_no_kind_field_at_all() {
692        // Spawn rows were ingested by builds that predate `kind`, and the
693        // server's raw dedup keys on the payload bytes: a `"kind":null` would
694        // re-ingest every stored row as a new version.
695        let rollout = rollout_at(Path::new("/tmp/rollout.jsonl"));
696        let anchors = parse_subagent_anchors(fixtures::ROLLOUT.as_bytes());
697        // The records array quotes the rollout's own `"kind":"started"`, so the
698        // claim is about the top-level object, not the bytes.
699        let started: serde_json::Value =
700            serde_json::from_str(&body(HARNESS_ID_CODEX, &rollout, &anchors[0])).unwrap();
701        assert!(started.get("kind").is_none());
702        let interacted: serde_json::Value =
703            serde_json::from_str(&body(HARNESS_ID_CODEX, &rollout, &anchors[1])).unwrap();
704        assert_eq!(interacted["kind"], KIND_INTERACTED);
705    }
706
707    #[test]
708    fn the_row_names_the_harness_the_caller_declares() {
709        // One rollout tree, two harnesses: the CLI and the desktop app write
710        // identical records, and the row must name the harness its own wire
711        // traffic does.
712        let rollout = rollout_at(Path::new("/tmp/rollout.jsonl"));
713        let anchor = &parse_subagent_anchors(fixtures::ROLLOUT.as_bytes())[0];
714        let got = body(
715            tapes_capture::envelope::HARNESS_ID_CODEX_APP,
716            &rollout,
717            anchor,
718        );
719        assert!(got.contains(r#""harness_id":"codex-app""#), "got: {got}");
720    }
721
722    #[test]
723    fn an_anchor_with_no_agent_path_omits_the_optional_slots() {
724        let rollout = rollout_at(Path::new("/tmp/rollout.jsonl"));
725        let anchor = SubAgentAnchor {
726            kind: AnchorKind::Started,
727            thread_id: "child".to_owned(),
728            call_id: "call_x".to_owned(),
729            agent_path: None,
730            line: "{}".to_owned(),
731        };
732        let got = body(HARNESS_ID_CODEX, &rollout, &anchor);
733        assert!(!got.contains("agent_type"), "got: {got}");
734        assert!(!got.contains("description"), "got: {got}");
735        assert!(got.contains(r#""agent_id":"child""#), "got: {got}");
736    }
737
738    // --- the scanner ------------------------------------------------------
739
740    fn write_rollout(dir: &Path, body: &str) -> PathBuf {
741        let path = dir.join("rollout.jsonl");
742        std::fs::write(&path, body).unwrap();
743        path
744    }
745
746    #[test]
747    fn a_fresh_rollout_offers_every_anchor_once() {
748        let dir = tempfile::tempdir().unwrap();
749        let path = write_rollout(dir.path(), fixtures::ROLLOUT);
750        let mut scanner = CodexAnchorScanner::new();
751
752        let fingerprint = files::fingerprint(&path);
753        assert!(scanner.needs_read(&path, fingerprint));
754        let raw = std::fs::read(&path).unwrap();
755        let anchors = scanner.undelivered(&path, &raw);
756        assert_eq!(anchors.len(), 2);
757
758        for anchor in &anchors {
759            scanner.record_delivered(&path, anchor);
760        }
761        scanner.record_clean_scan(&path, fingerprint);
762
763        assert_eq!(scanner.delivered_count(&path), 2);
764        assert!(
765            !scanner.needs_read(&path, files::fingerprint(&path)),
766            "an unchanged append-only file cannot hide a new anchor",
767        );
768        assert!(scanner.undelivered(&path, &raw).is_empty());
769    }
770
771    #[test]
772    fn a_grown_rollout_offers_only_what_is_new() {
773        let dir = tempfile::tempdir().unwrap();
774        let path = write_rollout(dir.path(), fixtures::ROLLOUT);
775        let mut scanner = CodexAnchorScanner::new();
776
777        let raw = std::fs::read(&path).unwrap();
778        for anchor in scanner.undelivered(&path, &raw) {
779            scanner.record_delivered(&path, &anchor);
780        }
781        scanner.record_clean_scan(&path, files::fingerprint(&path));
782
783        // A second spawn, appended later in the same session.
784        let second = STARTED_LINE
785            .replace("call_J7B6r7ZdtqkECtSJV8YDQaL7", "call_second")
786            .replace(
787                "019f8d46-e663-74e1-940c-f82e34c07618",
788                "019f8d47-0473-7743-a1ed-9e4c0ae92ad8",
789            );
790        std::fs::write(&path, format!("{}{second}\n", fixtures::ROLLOUT)).unwrap();
791
792        assert!(scanner.needs_read(&path, files::fingerprint(&path)));
793        let raw = std::fs::read(&path).unwrap();
794        let pending = scanner.undelivered(&path, &raw);
795        assert_eq!(pending.len(), 1);
796        assert_eq!(pending[0].call_id, "call_second");
797    }
798
799    #[test]
800    fn an_undelivered_anchor_is_offered_again_next_read() {
801        // The client's failure path: nothing was recorded, so the next read
802        // must still owe the same rows.
803        let dir = tempfile::tempdir().unwrap();
804        let path = write_rollout(dir.path(), fixtures::ROLLOUT);
805        let scanner = CodexAnchorScanner::new();
806        let raw = std::fs::read(&path).unwrap();
807        assert_eq!(scanner.undelivered(&path, &raw).len(), 2);
808        assert_eq!(scanner.undelivered(&path, &raw).len(), 2);
809        assert!(
810            scanner.needs_read(&path, files::fingerprint(&path)),
811            "a scan that was never marked clean must re-run",
812        );
813    }
814
815    #[test]
816    fn a_vanished_rollout_is_skipped_rather_than_read() {
817        let scanner = CodexAnchorScanner::new();
818        assert!(!scanner.needs_read(Path::new("/nonexistent/rollout.jsonl"), None));
819    }
820
821    #[test]
822    fn retain_live_drops_rollouts_that_aged_out_of_the_snapshot() {
823        let mut scanner = CodexAnchorScanner::new();
824        let kept = PathBuf::from("/tmp/kept.jsonl");
825        let gone = PathBuf::from("/tmp/gone.jsonl");
826        let anchor = &parse_subagent_anchors(fixtures::ROLLOUT.as_bytes())[0];
827        scanner.record_delivered(&kept, anchor);
828        scanner.record_delivered(&gone, anchor);
829
830        scanner.retain_live([kept.as_path()]);
831
832        assert_eq!(scanner.delivered_count(&kept), 1);
833        assert_eq!(scanner.delivered_count(&gone), 0);
834    }
835}