Skip to main content

codex_state/model/
thread_metadata.rs

1use anyhow::Result;
2use chrono::DateTime;
3use chrono::Utc;
4use codex_protocol::ThreadId;
5use codex_protocol::openai_models::ReasoningEffort;
6use codex_protocol::protocol::AskForApproval;
7use codex_protocol::protocol::SandboxPolicy;
8use codex_protocol::protocol::SessionSource;
9use codex_protocol::protocol::ThreadHistoryMode;
10use codex_protocol::protocol::ThreadSource;
11use sqlx::Row;
12use sqlx::sqlite::SqliteRow;
13use std::collections::HashMap;
14use std::path::PathBuf;
15
16/// The sort key to use when listing threads.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SortKey {
19    /// Sort by the thread's creation timestamp.
20    CreatedAt,
21    /// Sort by the thread's last update timestamp.
22    UpdatedAt,
23    /// Sort by the thread's product recency timestamp.
24    RecencyAt,
25}
26
27/// Sort direction to use when listing threads.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SortDirection {
30    Asc,
31    Desc,
32}
33
34/// Spawn-graph relationship used to filter thread listings.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ThreadRelationFilter {
37    /// Return only threads whose immediate parent is the given thread.
38    DirectChildrenOf(ThreadId),
39    /// Return every thread transitively descended from the given thread.
40    DescendantsOf(ThreadId),
41}
42
43/// A pagination anchor used for keyset pagination.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Anchor {
46    /// The timestamp component of the anchor.
47    pub ts: DateTime<Utc>,
48    /// The thread ID component used to disambiguate equal recency timestamps.
49    pub id: Option<ThreadId>,
50}
51
52/// A single page of thread metadata results.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ThreadsPage {
55    /// The thread metadata items in this page.
56    pub items: Vec<ThreadMetadata>,
57    /// Immediate parents for page items found through the persisted spawn graph.
58    pub parent_thread_ids: HashMap<ThreadId, ThreadId>,
59    /// The next anchor to use for pagination, if any.
60    pub next_anchor: Option<Anchor>,
61    /// The number of rows scanned to produce this page.
62    pub num_scanned_rows: usize,
63}
64
65/// The outcome of extracting metadata from a rollout.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ExtractionOutcome {
68    /// The extracted thread metadata.
69    pub metadata: ThreadMetadata,
70    /// The explicit thread memory mode from rollout metadata, if present.
71    pub memory_mode: Option<String>,
72    /// The number of rollout lines that failed to parse.
73    pub parse_errors: usize,
74}
75
76/// Canonical persisted thread metadata.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct ThreadMetadata {
79    /// The thread identifier.
80    pub id: ThreadId,
81    /// The absolute rollout path on disk.
82    pub rollout_path: PathBuf,
83    /// The creation timestamp.
84    pub created_at: DateTime<Utc>,
85    /// The last update timestamp.
86    pub updated_at: DateTime<Utc>,
87    /// The product recency timestamp.
88    pub recency_at: DateTime<Utc>,
89    /// The session source (stringified enum).
90    pub source: String,
91    /// Persisted thread history contract selected when this thread was created.
92    pub history_mode: ThreadHistoryMode,
93    /// Optional analytics source classification for this thread.
94    pub thread_source: Option<ThreadSource>,
95    /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
96    pub agent_nickname: Option<String>,
97    /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
98    pub agent_role: Option<String>,
99    /// Optional canonical agent path assigned to an AgentControl-spawned sub-agent.
100    pub agent_path: Option<String>,
101    /// The model provider identifier.
102    pub model_provider: String,
103    /// The latest observed model for the thread.
104    pub model: Option<String>,
105    /// The latest observed reasoning effort for the thread.
106    pub reasoning_effort: Option<ReasoningEffort>,
107    /// The working directory for the thread.
108    pub cwd: PathBuf,
109    /// Version of the CLI that created the thread.
110    pub cli_version: String,
111    /// A best-effort thread title.
112    pub title: String,
113    /// Explicit user-facing thread name, if one was set.
114    pub name: Option<String>,
115    /// Best available user-facing preview for discovery and list display.
116    pub preview: Option<String>,
117    /// The sandbox policy (stringified enum).
118    pub sandbox_policy: String,
119    /// The approval mode (stringified enum).
120    pub approval_mode: String,
121    /// The last observed token usage.
122    pub tokens_used: i64,
123    /// First user message observed for this thread, if any.
124    pub first_user_message: Option<String>,
125    /// The archive timestamp, if the thread is archived.
126    pub archived_at: Option<DateTime<Utc>>,
127    /// The git commit SHA, if known.
128    pub git_sha: Option<String>,
129    /// The git branch name, if known.
130    pub git_branch: Option<String>,
131    /// The git origin URL, if known.
132    pub git_origin_url: Option<String>,
133}
134
135/// Builder data required to construct [`ThreadMetadata`] without parsing filenames.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct ThreadMetadataBuilder {
138    /// The thread identifier.
139    pub id: ThreadId,
140    /// The absolute rollout path on disk.
141    pub rollout_path: PathBuf,
142    /// The creation timestamp.
143    pub created_at: DateTime<Utc>,
144    /// The last update timestamp, if known.
145    pub updated_at: Option<DateTime<Utc>>,
146    /// The product recency timestamp, if known.
147    pub recency_at: Option<DateTime<Utc>>,
148    /// The session source.
149    pub source: SessionSource,
150    /// Persisted thread history contract selected when this thread was created.
151    pub history_mode: ThreadHistoryMode,
152    /// Optional analytics source classification for this thread.
153    pub thread_source: Option<ThreadSource>,
154    /// Optional random unique nickname assigned to the session.
155    pub agent_nickname: Option<String>,
156    /// Optional role (agent_role) assigned to the session.
157    pub agent_role: Option<String>,
158    /// Optional canonical agent path assigned to the session.
159    pub agent_path: Option<String>,
160    /// The model provider identifier, if known.
161    pub model_provider: Option<String>,
162    /// The working directory for the thread.
163    pub cwd: PathBuf,
164    /// Version of the CLI that created the thread.
165    pub cli_version: Option<String>,
166    /// The sandbox policy.
167    pub sandbox_policy: SandboxPolicy,
168    /// The approval mode.
169    pub approval_mode: AskForApproval,
170    /// The archive timestamp, if the thread is archived.
171    pub archived_at: Option<DateTime<Utc>>,
172    /// The git commit SHA, if known.
173    pub git_sha: Option<String>,
174    /// The git branch name, if known.
175    pub git_branch: Option<String>,
176    /// The git origin URL, if known.
177    pub git_origin_url: Option<String>,
178}
179
180impl ThreadMetadataBuilder {
181    /// Create a new builder with required fields and sensible defaults.
182    pub fn new(
183        id: ThreadId,
184        rollout_path: PathBuf,
185        created_at: DateTime<Utc>,
186        source: SessionSource,
187    ) -> Self {
188        Self {
189            id,
190            rollout_path,
191            created_at,
192            updated_at: None,
193            recency_at: None,
194            source,
195            history_mode: ThreadHistoryMode::Legacy,
196            thread_source: None,
197            agent_nickname: None,
198            agent_role: None,
199            agent_path: None,
200            model_provider: None,
201            cwd: PathBuf::new(),
202            cli_version: None,
203            sandbox_policy: SandboxPolicy::new_read_only_policy(),
204            approval_mode: AskForApproval::OnRequest,
205            archived_at: None,
206            git_sha: None,
207            git_branch: None,
208            git_origin_url: None,
209        }
210    }
211
212    /// Build canonical thread metadata, filling missing values from defaults.
213    pub fn build(&self, default_provider: &str) -> ThreadMetadata {
214        let source = crate::extract::enum_to_string(&self.source);
215        let sandbox_policy = crate::extract::enum_to_string(&self.sandbox_policy);
216        let approval_mode = crate::extract::enum_to_string(&self.approval_mode);
217        let created_at = canonicalize_datetime(self.created_at);
218        let updated_at = self
219            .updated_at
220            .map(canonicalize_datetime)
221            .unwrap_or(created_at);
222        let recency_at = self
223            .recency_at
224            .map(canonicalize_datetime)
225            .unwrap_or(updated_at);
226        ThreadMetadata {
227            id: self.id,
228            rollout_path: self.rollout_path.clone(),
229            created_at,
230            updated_at,
231            recency_at,
232            source,
233            history_mode: self.history_mode,
234            thread_source: self.thread_source.clone(),
235            agent_nickname: self.agent_nickname.clone(),
236            agent_role: self.agent_role.clone(),
237            agent_path: self
238                .agent_path
239                .clone()
240                .or_else(|| self.source.get_agent_path().map(Into::into)),
241            model_provider: self
242                .model_provider
243                .clone()
244                .unwrap_or_else(|| default_provider.to_string()),
245            model: None,
246            reasoning_effort: None,
247            cwd: self.cwd.clone(),
248            cli_version: self.cli_version.clone().unwrap_or_default(),
249            title: String::new(),
250            name: None,
251            preview: None,
252            sandbox_policy,
253            approval_mode,
254            tokens_used: 0,
255            first_user_message: None,
256            archived_at: self.archived_at.map(canonicalize_datetime),
257            git_sha: self.git_sha.clone(),
258            git_branch: self.git_branch.clone(),
259            git_origin_url: self.git_origin_url.clone(),
260        }
261    }
262}
263
264impl ThreadMetadata {
265    /// Preserve SQLite-owned Git fields when rollout-derived metadata is reconciled.
266    pub fn prefer_existing_git_info(&mut self, existing: &Self) {
267        if matches!(self.history_mode, ThreadHistoryMode::Paginated)
268            && matches!(existing.history_mode, ThreadHistoryMode::Paginated)
269        {
270            // `self` was rebuilt from the rollout's initial SessionMeta. `existing` is the
271            // current SQLite row. Once that row says paginated, metadata updates are SQLite-only,
272            // so a NULL is an explicit clear, not missing data. Copy the whole tuple or the stale
273            // rollout value would be written back during reconciliation.
274            self.git_sha = existing.git_sha.clone();
275            self.git_branch = existing.git_branch.clone();
276            self.git_origin_url = existing.git_origin_url.clone();
277            return;
278        }
279        if existing.git_sha.is_some() {
280            self.git_sha = existing.git_sha.clone();
281        }
282        if existing.git_branch.is_some() {
283            self.git_branch = existing.git_branch.clone();
284        }
285        if existing.git_origin_url.is_some() {
286            self.git_origin_url = existing.git_origin_url.clone();
287        }
288    }
289
290    /// Preserve an existing user-facing title when reconciling rollout-derived metadata.
291    pub fn prefer_existing_explicit_title(&mut self, existing: &Self) {
292        let existing_title = existing.title.trim();
293        if existing_title.is_empty()
294            || existing.first_user_message.as_deref().map(str::trim) == Some(existing_title)
295        {
296            return;
297        }
298
299        let title = self.title.trim();
300        if title.is_empty() || self.first_user_message.as_deref().map(str::trim) == Some(title) {
301            self.title = existing.title.clone();
302        }
303    }
304
305    /// Return the list of field names that differ between `self` and `other`.
306    pub fn diff_fields(&self, other: &Self) -> Vec<&'static str> {
307        let mut diffs = Vec::new();
308        if self.id != other.id {
309            diffs.push("id");
310        }
311        if self.rollout_path != other.rollout_path {
312            diffs.push("rollout_path");
313        }
314        if self.created_at != other.created_at {
315            diffs.push("created_at");
316        }
317        if self.updated_at != other.updated_at {
318            diffs.push("updated_at");
319        }
320        if self.source != other.source {
321            diffs.push("source");
322        }
323        if self.agent_nickname != other.agent_nickname {
324            diffs.push("agent_nickname");
325        }
326        if self.agent_role != other.agent_role {
327            diffs.push("agent_role");
328        }
329        if self.agent_path != other.agent_path {
330            diffs.push("agent_path");
331        }
332        if self.model_provider != other.model_provider {
333            diffs.push("model_provider");
334        }
335        if self.model != other.model {
336            diffs.push("model");
337        }
338        if self.reasoning_effort != other.reasoning_effort {
339            diffs.push("reasoning_effort");
340        }
341        if self.cwd != other.cwd {
342            diffs.push("cwd");
343        }
344        if self.cli_version != other.cli_version {
345            diffs.push("cli_version");
346        }
347        if self.title != other.title {
348            diffs.push("title");
349        }
350        if self.name != other.name {
351            diffs.push("name");
352        }
353        if self.preview != other.preview {
354            diffs.push("preview");
355        }
356        if self.sandbox_policy != other.sandbox_policy {
357            diffs.push("sandbox_policy");
358        }
359        if self.approval_mode != other.approval_mode {
360            diffs.push("approval_mode");
361        }
362        if self.tokens_used != other.tokens_used {
363            diffs.push("tokens_used");
364        }
365        if self.first_user_message != other.first_user_message {
366            diffs.push("first_user_message");
367        }
368        if self.archived_at != other.archived_at {
369            diffs.push("archived_at");
370        }
371        if self.git_sha != other.git_sha {
372            diffs.push("git_sha");
373        }
374        if self.git_branch != other.git_branch {
375            diffs.push("git_branch");
376        }
377        if self.git_origin_url != other.git_origin_url {
378            diffs.push("git_origin_url");
379        }
380        diffs
381    }
382}
383
384fn canonicalize_datetime(dt: DateTime<Utc>) -> DateTime<Utc> {
385    epoch_millis_to_datetime(datetime_to_epoch_millis(dt)).unwrap_or(dt)
386}
387
388#[derive(Debug)]
389pub(crate) struct ThreadRow {
390    id: String,
391    rollout_path: String,
392    created_at: i64,
393    updated_at: i64,
394    recency_at: i64,
395    source: String,
396    history_mode: String,
397    thread_source: Option<String>,
398    agent_nickname: Option<String>,
399    agent_role: Option<String>,
400    agent_path: Option<String>,
401    model_provider: String,
402    model: Option<String>,
403    reasoning_effort: Option<String>,
404    cwd: String,
405    cli_version: String,
406    title: String,
407    name: Option<String>,
408    preview: String,
409    sandbox_policy: String,
410    approval_mode: String,
411    tokens_used: i64,
412    first_user_message: String,
413    archived_at: Option<i64>,
414    git_sha: Option<String>,
415    git_branch: Option<String>,
416    git_origin_url: Option<String>,
417}
418
419impl ThreadRow {
420    pub(crate) fn try_from_row(row: &SqliteRow) -> Result<Self> {
421        Ok(Self {
422            id: row.try_get("id")?,
423            rollout_path: row.try_get("rollout_path")?,
424            created_at: row.try_get("created_at")?,
425            updated_at: row.try_get("updated_at")?,
426            recency_at: row.try_get("recency_at")?,
427            source: row.try_get("source")?,
428            history_mode: row.try_get("history_mode")?,
429            thread_source: row.try_get("thread_source")?,
430            agent_nickname: row.try_get("agent_nickname")?,
431            agent_role: row.try_get("agent_role")?,
432            agent_path: row.try_get("agent_path")?,
433            model_provider: row.try_get("model_provider")?,
434            model: row.try_get("model")?,
435            reasoning_effort: row.try_get("reasoning_effort")?,
436            cwd: row.try_get("cwd")?,
437            cli_version: row.try_get("cli_version")?,
438            title: row.try_get("title")?,
439            name: row.try_get("name")?,
440            preview: row.try_get("preview")?,
441            sandbox_policy: row.try_get("sandbox_policy")?,
442            approval_mode: row.try_get("approval_mode")?,
443            tokens_used: row.try_get("tokens_used")?,
444            first_user_message: row.try_get("first_user_message")?,
445            archived_at: row.try_get("archived_at")?,
446            git_sha: row.try_get("git_sha")?,
447            git_branch: row.try_get("git_branch")?,
448            git_origin_url: row.try_get("git_origin_url")?,
449        })
450    }
451}
452
453impl TryFrom<ThreadRow> for ThreadMetadata {
454    type Error = anyhow::Error;
455
456    fn try_from(row: ThreadRow) -> std::result::Result<Self, Self::Error> {
457        let ThreadRow {
458            id,
459            rollout_path,
460            created_at,
461            updated_at,
462            recency_at,
463            source,
464            history_mode,
465            thread_source,
466            agent_nickname,
467            agent_role,
468            agent_path,
469            model_provider,
470            model,
471            reasoning_effort,
472            cwd,
473            cli_version,
474            title,
475            name,
476            preview,
477            sandbox_policy,
478            approval_mode,
479            tokens_used,
480            first_user_message,
481            archived_at,
482            git_sha,
483            git_branch,
484            git_origin_url,
485        } = row;
486        let thread_source = thread_source
487            .map(|thread_source| thread_source.parse())
488            .transpose()
489            .map_err(anyhow::Error::msg)?;
490        let history_mode = history_mode.parse().map_err(anyhow::Error::msg)?;
491        Ok(Self {
492            id: ThreadId::try_from(id)?,
493            rollout_path: PathBuf::from(rollout_path),
494            created_at: epoch_millis_to_datetime(created_at)?,
495            updated_at: epoch_millis_to_datetime(updated_at)?,
496            recency_at: epoch_millis_to_datetime(recency_at)?,
497            source,
498            history_mode,
499            thread_source,
500            agent_nickname,
501            agent_role,
502            agent_path,
503            model_provider,
504            model,
505            reasoning_effort: reasoning_effort
506                .and_then(|value| value.parse::<ReasoningEffort>().ok()),
507            cwd: PathBuf::from(cwd),
508            cli_version,
509            title,
510            name,
511            preview: (!preview.is_empty()).then_some(preview),
512            sandbox_policy,
513            approval_mode,
514            tokens_used,
515            first_user_message: (!first_user_message.is_empty()).then_some(first_user_message),
516            archived_at: archived_at.map(epoch_seconds_to_datetime).transpose()?,
517            git_sha,
518            git_branch,
519            git_origin_url,
520        })
521    }
522}
523
524pub(crate) fn anchor_from_item(
525    item: &ThreadMetadata,
526    sort_key: SortKey,
527    include_thread_id_tiebreaker: bool,
528) -> Option<Anchor> {
529    let ts = match sort_key {
530        SortKey::CreatedAt => item.created_at,
531        SortKey::UpdatedAt => item.updated_at,
532        SortKey::RecencyAt => item.recency_at,
533    };
534    Some(Anchor {
535        ts,
536        id: (include_thread_id_tiebreaker || sort_key == SortKey::RecencyAt).then_some(item.id),
537    })
538}
539
540pub(crate) fn datetime_to_epoch_millis(dt: DateTime<Utc>) -> i64 {
541    dt.timestamp_millis()
542}
543
544pub(crate) fn datetime_to_epoch_seconds(dt: DateTime<Utc>) -> i64 {
545    dt.timestamp()
546}
547
548pub(crate) fn epoch_millis_to_datetime(value: i64) -> Result<DateTime<Utc>> {
549    // Values older than 2020 if interpreted as milliseconds are legacy second-precision rows.
550    // Convert them in memory so old state DBs keep ordering correctly after new writes use ms.
551    const MIN_EPOCH_MILLIS: i64 = 1_577_836_800_000;
552    let millis = if value < MIN_EPOCH_MILLIS {
553        value.saturating_mul(1000)
554    } else {
555        value
556    };
557    DateTime::<Utc>::from_timestamp_millis(millis)
558        .ok_or_else(|| anyhow::anyhow!("invalid unix timestamp millis: {value}"))
559}
560
561pub(crate) fn epoch_seconds_to_datetime(value: i64) -> Result<DateTime<Utc>> {
562    DateTime::<Utc>::from_timestamp(value, 0)
563        .ok_or_else(|| anyhow::anyhow!("invalid unix timestamp seconds: {value}"))
564}
565
566/// Statistics about a backfill operation.
567#[derive(Debug, Clone)]
568pub struct BackfillStats {
569    /// The number of rollout files scanned.
570    pub scanned: usize,
571    /// The number of rows upserted successfully.
572    pub upserted: usize,
573    /// The number of rows that failed to upsert.
574    pub failed: usize,
575}
576
577#[cfg(test)]
578mod tests {
579    use super::ThreadMetadata;
580    use super::ThreadRow;
581    use chrono::DateTime;
582    use chrono::Utc;
583    use codex_protocol::ThreadId;
584    use codex_protocol::openai_models::ReasoningEffort;
585    use codex_protocol::protocol::ThreadHistoryMode;
586    use pretty_assertions::assert_eq;
587    use std::path::PathBuf;
588
589    fn thread_row(reasoning_effort: Option<&str>) -> ThreadRow {
590        ThreadRow {
591            id: "00000000-0000-0000-0000-000000000123".to_string(),
592            rollout_path: "/tmp/rollout-123.jsonl".to_string(),
593            created_at: 1_700_000_000,
594            updated_at: 1_700_000_100,
595            recency_at: 1_700_000_100,
596            source: "cli".to_string(),
597            history_mode: "legacy".to_string(),
598            thread_source: None,
599            agent_nickname: None,
600            agent_role: None,
601            agent_path: None,
602            model_provider: "openai".to_string(),
603            model: Some("gpt-5".to_string()),
604            reasoning_effort: reasoning_effort.map(str::to_string),
605            cwd: "/tmp/workspace".to_string(),
606            cli_version: "0.0.0".to_string(),
607            title: String::new(),
608            name: None,
609            preview: String::new(),
610            sandbox_policy: "read-only".to_string(),
611            approval_mode: "on-request".to_string(),
612            tokens_used: 1,
613            first_user_message: String::new(),
614            archived_at: None,
615            git_sha: None,
616            git_branch: None,
617            git_origin_url: None,
618        }
619    }
620
621    fn expected_thread_metadata(reasoning_effort: Option<ReasoningEffort>) -> ThreadMetadata {
622        ThreadMetadata {
623            id: ThreadId::from_string("00000000-0000-0000-0000-000000000123")
624                .expect("valid thread id"),
625            rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"),
626            created_at: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp"),
627            updated_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
628            recency_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
629            source: "cli".to_string(),
630            history_mode: ThreadHistoryMode::Legacy,
631            thread_source: None,
632            agent_nickname: None,
633            agent_role: None,
634            agent_path: None,
635            model_provider: "openai".to_string(),
636            model: Some("gpt-5".to_string()),
637            reasoning_effort,
638            cwd: PathBuf::from("/tmp/workspace"),
639            cli_version: "0.0.0".to_string(),
640            title: String::new(),
641            name: None,
642            preview: None,
643            sandbox_policy: "read-only".to_string(),
644            approval_mode: "on-request".to_string(),
645            tokens_used: 1,
646            first_user_message: None,
647            archived_at: None,
648            git_sha: None,
649            git_branch: None,
650            git_origin_url: None,
651        }
652    }
653
654    #[test]
655    fn thread_row_parses_reasoning_effort() {
656        let metadata = ThreadMetadata::try_from(thread_row(Some("high")))
657            .expect("thread metadata should parse");
658
659        assert_eq!(
660            metadata,
661            expected_thread_metadata(Some(ReasoningEffort::High))
662        );
663    }
664
665    #[test]
666    fn thread_row_preserves_model_defined_reasoning_effort_values() {
667        let metadata = ThreadMetadata::try_from(thread_row(Some("future")))
668            .expect("thread metadata should parse");
669
670        assert_eq!(
671            metadata,
672            expected_thread_metadata(Some(ReasoningEffort::Custom("future".to_string())))
673        );
674    }
675
676    #[test]
677    fn thread_row_rejects_unknown_history_mode() {
678        let mut row = thread_row(/*reasoning_effort*/ None);
679        row.history_mode = "future".to_string();
680
681        assert!(ThreadMetadata::try_from(row).is_err());
682    }
683
684    #[test]
685    fn paginated_rollout_git_info_keeps_rollout_values_until_sqlite_mode_is_paginated() {
686        let mut reconciled = expected_thread_metadata(/*reasoning_effort*/ None);
687        reconciled.history_mode = ThreadHistoryMode::Paginated;
688        reconciled.git_sha = Some("rollout-sha".to_string());
689        reconciled.git_branch = Some("rollout-branch".to_string());
690        reconciled.git_origin_url = Some("rollout-origin".to_string());
691        let existing = expected_thread_metadata(/*reasoning_effort*/ None);
692        let expected = reconciled.clone();
693
694        reconciled.prefer_existing_git_info(&existing);
695
696        assert_eq!(reconciled, expected);
697    }
698}