Skip to main content

roder_api/
thread.rs

1use std::collections::BTreeSet;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use serde::{Deserialize, Deserializer, Serialize};
6use time::OffsetDateTime;
7
8use crate::artifacts::ContextArtifactStore;
9use crate::events::{EventEnvelope, ThreadId, TurnId};
10pub use crate::extension::{CheckpointStoreId, ThreadStoreId};
11use crate::extension_state::ExtensionStateRecord;
12use crate::inference::{TokenUsage, cache_hit_rate};
13use crate::inference_routing::ModelSelectionMode;
14use crate::remote_runner::{RunnerDestination, RunnerSessionState, ThreadRunnerBinding};
15use crate::transcript::{InputImage, TranscriptItem};
16
17mod projection;
18pub use projection::{project_thread_item_events, project_turns_from_events};
19
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ThreadListOptions {
22    pub limit: Option<usize>,
23    pub cursor: Option<String>,
24}
25
26#[derive(Debug, Clone, Default)]
27pub struct ThreadListPage {
28    pub threads: Vec<ThreadMetadata>,
29    pub next_cursor: Option<String>,
30    pub backwards_cursor: Option<String>,
31}
32
33/// Thread IDs reserved for system event streams that are not user-visible conversations.
34pub const SYNTHETIC_EVENT_THREAD_IDS: &[&str] = &["app-server", "runtime", "thread-workflow"];
35
36/// Returns true for production-emitted synthetic event streams.
37pub fn is_synthetic_event_thread_id(thread_id: &str) -> bool {
38    SYNTHETIC_EVENT_THREAD_IDS.contains(&thread_id)
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
42pub struct ThreadUsageMetadata {
43    #[serde(default)]
44    pub prompt_tokens: u64,
45    #[serde(default)]
46    pub completion_tokens: u64,
47    #[serde(default)]
48    pub total_tokens: u64,
49    #[serde(default)]
50    pub cached_prompt_tokens: u64,
51    /// Subset of `prompt_tokens` written to the provider prompt cache.
52    #[serde(default)]
53    pub cache_creation_prompt_tokens: u64,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub cache_hit_rate: Option<f64>,
56    /// Terminal turns included in the cumulative counters, persisted with those counters.
57    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
58    pub accounted_turn_ids: BTreeSet<TurnId>,
59}
60
61impl ThreadUsageMetadata {
62    /// Adds a terminal turn's usage once, including after metadata is reloaded.
63    pub fn add_turn_token_usage(&mut self, turn_id: &TurnId, usage: &TokenUsage) -> bool {
64        if !self.accounted_turn_ids.insert(turn_id.clone()) {
65            return false;
66        }
67        self.add_token_usage(usage);
68        true
69    }
70
71    pub fn add_token_usage(&mut self, usage: &TokenUsage) {
72        self.prompt_tokens = self
73            .prompt_tokens
74            .saturating_add(u64::from(usage.prompt_tokens));
75        self.completion_tokens = self
76            .completion_tokens
77            .saturating_add(u64::from(usage.completion_tokens));
78        self.total_tokens = self
79            .total_tokens
80            .saturating_add(u64::from(usage.total_tokens));
81        self.cached_prompt_tokens = self
82            .cached_prompt_tokens
83            .saturating_add(u64::from(usage.cached_prompt_tokens));
84        self.cache_creation_prompt_tokens = self
85            .cache_creation_prompt_tokens
86            .saturating_add(u64::from(usage.cache_creation_prompt_tokens));
87        self.cache_hit_rate = if self.prompt_tokens == 0 {
88            None
89        } else if self.prompt_tokens > u64::from(u32::MAX) {
90            Some(
91                (self.cached_prompt_tokens.min(self.prompt_tokens) as f64)
92                    / (self.prompt_tokens as f64),
93            )
94        } else {
95            cache_hit_rate(self.prompt_tokens as u32, self.cached_prompt_tokens as u32)
96        };
97    }
98
99    pub fn is_empty(&self) -> bool {
100        self.prompt_tokens == 0
101            && self.completion_tokens == 0
102            && self.total_tokens == 0
103            && self.cached_prompt_tokens == 0
104            && self.cache_creation_prompt_tokens == 0
105    }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct ThreadMetadata {
110    /// Default provider service tier for this thread.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub service_tier: Option<String>,
113    pub thread_id: ThreadId,
114    pub title: Option<String>,
115    #[serde(deserialize_with = "deserialize_thread_workspace")]
116    pub workspace: String,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub workspace_id: Option<String>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub root_id: Option<String>,
121    pub provider: Option<String>,
122    pub model: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub selection_mode: Option<ModelSelectionMode>,
125    /// Per-thread tool filter applied on top of the runtime allowlist. Empty = no filtering.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub tool_allowlist: Vec<String>,
128    /// Host-supplied instructions added to the developer slot of every turn's inference request.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub developer_instructions: Option<String>,
131    /**
132     * Host-executed tool specs advertised to the model on every turn of this thread. Calls to
133     * these tools pause on a `thread/toolExecutionRequested` notification until the host client
134     * answers with `tools/resolve`.
135     */
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub external_tools: Vec<crate::tools::ToolSpec>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub runner_destination: Option<RunnerDestination>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub runner_state: Option<RunnerSessionState>,
142    /**
143     * Set when the thread explicitly selected a remote runner at creation.
144     * Native coding tools for the thread route through this runner workspace;
145     * absent = local tool execution even when a runtime-level runner
146     * destination is configured.
147     */
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub runner_binding: Option<ThreadRunnerBinding>,
150    #[serde(with = "time::serde::rfc3339")]
151    pub created_at: OffsetDateTime,
152    #[serde(with = "time::serde::rfc3339")]
153    pub updated_at: OffsetDateTime,
154    pub message_count: u32,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub usage: Option<ThreadUsageMetadata>,
157    /// Parent thread this conversation was forked from. Absent for normal threads.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub parent_thread_id: Option<ThreadId>,
160    /// Parent turn the fork branched at, when the fork targeted a specific turn.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub forked_from_turn_id: Option<TurnId>,
163    /**
164     * Provider-neutral workspace-fork provenance for threads whose
165     * workspace is a fork of another workspace (roadmap phase 81; the
166     * phase-90 Git-worktree MVP shape was folded into this canonical type).
167     */
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub workspace_fork: Option<crate::forks::WorkspaceFork>,
170}
171
172pub fn validate_thread_workspace(workspace: &str) -> anyhow::Result<String> {
173    let workspace = workspace.trim();
174    anyhow::ensure!(!workspace.is_empty(), "thread workspace is required");
175    anyhow::ensure!(
176        std::path::Path::new(workspace).is_absolute(),
177        "thread workspace must be an absolute path: {workspace}"
178    );
179    Ok(workspace.to_string())
180}
181
182fn deserialize_thread_workspace<'de, D>(deserializer: D) -> Result<String, D::Error>
183where
184    D: Deserializer<'de>,
185{
186    let workspace = String::deserialize(deserializer)?;
187    validate_thread_workspace(&workspace).map_err(serde::de::Error::custom)
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
191pub struct TurnRecord {
192    pub thread_id: ThreadId,
193    pub turn_id: TurnId,
194    pub items: Vec<TranscriptItem>,
195    #[serde(with = "time::serde::rfc3339")]
196    pub created_at: OffsetDateTime,
197    #[serde(with = "time::serde::rfc3339::option")]
198    pub completed_at: Option<OffsetDateTime>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub usage: Option<TokenUsage>,
201    /// Normalized stop reason from `TurnCompleted`; `None` for failed or interrupted turns.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub finish_reason: Option<String>,
204}
205
206#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
207#[serde(rename_all = "camelCase")]
208pub enum ThreadItemStatus {
209    InProgress,
210    Completed,
211    Failed,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
215#[serde(tag = "type", rename_all = "camelCase")]
216pub enum ThreadItem {
217    UserMessage {
218        id: String,
219        text: String,
220        #[serde(default, skip_serializing_if = "Vec::is_empty")]
221        images: Vec<InputImage>,
222        #[serde(default, skip_serializing_if = "Option::is_none")]
223        status: Option<ThreadItemStatus>,
224    },
225    AgentMessage {
226        id: String,
227        text: String,
228        #[serde(default, skip_serializing_if = "Option::is_none")]
229        phase: Option<String>,
230        #[serde(default, skip_serializing_if = "Option::is_none")]
231        status: Option<ThreadItemStatus>,
232    },
233    Reasoning {
234        id: String,
235        #[serde(default, skip_serializing_if = "Vec::is_empty")]
236        summary: Vec<String>,
237        #[serde(default, skip_serializing_if = "Vec::is_empty")]
238        content: Vec<String>,
239        #[serde(default, skip_serializing_if = "Option::is_none")]
240        status: Option<ThreadItemStatus>,
241    },
242    ToolExecution {
243        id: String,
244        #[serde(rename = "toolCallId")]
245        tool_call_id: String,
246        #[serde(rename = "toolName")]
247        tool_name: String,
248        status: ThreadItemStatus,
249        #[serde(default, skip_serializing_if = "Option::is_none")]
250        input: Option<serde_json::Value>,
251        #[serde(default, skip_serializing_if = "Option::is_none")]
252        output: Option<String>,
253        #[serde(default, skip_serializing_if = "Option::is_none")]
254        error: Option<String>,
255    },
256    RoutingDecision {
257        id: String,
258        decision: crate::events::InferenceRoutingDecisionEvent,
259        #[serde(default, skip_serializing_if = "Option::is_none")]
260        status: Option<ThreadItemStatus>,
261    },
262    Compaction {
263        id: String,
264        summary: String,
265        #[serde(default, skip_serializing_if = "Option::is_none")]
266        status: Option<ThreadItemStatus>,
267    },
268    Error {
269        id: String,
270        message: String,
271        #[serde(default, skip_serializing_if = "Option::is_none")]
272        status: Option<ThreadItemStatus>,
273    },
274    Raw {
275        id: String,
276        payload: serde_json::Value,
277        #[serde(default, skip_serializing_if = "Option::is_none")]
278        status: Option<ThreadItemStatus>,
279    },
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
283pub struct ThreadItemTurnRecord {
284    pub thread_id: ThreadId,
285    pub turn_id: TurnId,
286    #[serde(with = "time::serde::rfc3339")]
287    pub created_at: OffsetDateTime,
288    pub items: Vec<ThreadItem>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
292#[serde(tag = "type", rename_all = "camelCase")]
293pub enum ThreadItemDelta {
294    AgentMessageText {
295        delta: String,
296        #[serde(default, skip_serializing_if = "Option::is_none")]
297        phase: Option<String>,
298    },
299    ReasoningText {
300        delta: String,
301        #[serde(rename = "contentIndex")]
302        content_index: usize,
303    },
304    ReasoningSummaryPartAdded {
305        #[serde(rename = "summaryIndex")]
306        summary_index: usize,
307    },
308    ReasoningSummaryText {
309        delta: String,
310        #[serde(rename = "summaryIndex")]
311        summary_index: usize,
312    },
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
316#[serde(tag = "type", rename_all = "camelCase")]
317pub enum ThreadItemEventKind {
318    ItemStarted {
319        item: ThreadItem,
320    },
321    ItemDelta {
322        #[serde(rename = "itemId")]
323        item_id: String,
324        delta: ThreadItemDelta,
325    },
326    ItemCompleted {
327        item: ThreadItem,
328    },
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
332#[serde(rename_all = "camelCase")]
333pub struct ThreadItemEvent {
334    pub seq: u64,
335    #[serde(rename = "eventId")]
336    pub event_id: String,
337    #[serde(rename = "threadId")]
338    pub thread_id: ThreadId,
339    #[serde(rename = "turnId")]
340    pub turn_id: TurnId,
341    #[serde(with = "time::serde::rfc3339")]
342    pub timestamp: OffsetDateTime,
343    pub event: ThreadItemEventKind,
344}
345
346#[derive(Debug, Clone, Default, Serialize, Deserialize)]
347pub struct ThreadSnapshot {
348    pub metadata: Option<ThreadMetadata>,
349    pub events: Vec<EventEnvelope>,
350    pub turns: Vec<TurnRecord>,
351    #[serde(default)]
352    pub item_events: Vec<ThreadItemEvent>,
353    pub extension_states: Vec<ExtensionStateRecord>,
354}
355
356impl ThreadItem {
357    pub fn id(&self) -> &str {
358        match self {
359            ThreadItem::UserMessage { id, .. }
360            | ThreadItem::AgentMessage { id, .. }
361            | ThreadItem::Reasoning { id, .. }
362            | ThreadItem::ToolExecution { id, .. }
363            | ThreadItem::RoutingDecision { id, .. }
364            | ThreadItem::Compaction { id, .. }
365            | ThreadItem::Error { id, .. }
366            | ThreadItem::Raw { id, .. } => id,
367        }
368    }
369}
370
371#[async_trait::async_trait]
372pub trait ThreadStore: Send + Sync {
373    fn id(&self) -> ThreadStoreId;
374
375    fn local_thread_root(&self) -> Option<PathBuf> {
376        None
377    }
378
379    fn context_artifact_store(&self) -> Option<ContextArtifactStore> {
380        None
381    }
382
383    async fn create_thread(&self, metadata: ThreadMetadata) -> anyhow::Result<ThreadMetadata>;
384    async fn update_thread_metadata(
385        &self,
386        metadata: ThreadMetadata,
387    ) -> anyhow::Result<ThreadMetadata> {
388        Ok(metadata)
389    }
390    async fn list_threads(&self) -> anyhow::Result<Vec<ThreadMetadata>>;
391    async fn list_threads_page(
392        &self,
393        options: ThreadListOptions,
394    ) -> anyhow::Result<ThreadListPage> {
395        let mut threads = self.list_threads().await?;
396        threads.sort_by_key(|thread| std::cmp::Reverse(thread.updated_at));
397        let offset = options
398            .cursor
399            .as_deref()
400            .and_then(|cursor| cursor.parse::<usize>().ok())
401            .unwrap_or(0)
402            .min(threads.len());
403        let limit = options
404            .limit
405            .unwrap_or(threads.len().saturating_sub(offset));
406        let next_offset = offset.saturating_add(limit).min(threads.len());
407        let total = threads.len();
408        let page_threads = threads
409            .into_iter()
410            .skip(offset)
411            .take(limit)
412            .collect::<Vec<_>>();
413        Ok(ThreadListPage {
414            threads: page_threads,
415            next_cursor: (next_offset < total).then(|| next_offset.to_string()),
416            backwards_cursor: (offset > 0).then(|| offset.saturating_sub(limit).to_string()),
417        })
418    }
419    async fn load_thread_metadata(
420        &self,
421        thread_id: &ThreadId,
422    ) -> anyhow::Result<Option<ThreadMetadata>> {
423        Ok(self
424            .load_thread(thread_id)
425            .await?
426            .and_then(|snapshot| snapshot.metadata))
427    }
428    async fn load_thread(&self, thread_id: &ThreadId) -> anyhow::Result<Option<ThreadSnapshot>>;
429    /// Loads extension state without requiring callers to project a full thread snapshot.
430    ///
431    /// Stores with dedicated extension-state backing should override this method. The default
432    /// preserves compatibility for stores that only expose extension state through `load_thread`.
433    async fn load_extension_states(
434        &self,
435        thread_id: &ThreadId,
436    ) -> anyhow::Result<Vec<ExtensionStateRecord>> {
437        Ok(self
438            .load_thread(thread_id)
439            .await?
440            .map_or_else(Vec::new, |snapshot| snapshot.extension_states))
441    }
442    async fn archive_thread(&self, thread_id: &ThreadId) -> anyhow::Result<bool> {
443        let _ = thread_id;
444        anyhow::bail!("thread store {} does not support archive", self.id())
445    }
446    async fn append_event(
447        &self,
448        thread_id: &ThreadId,
449        envelope: &EventEnvelope,
450    ) -> anyhow::Result<()>;
451    async fn append_item_event(
452        &self,
453        thread_id: &ThreadId,
454        item_event: &ThreadItemEvent,
455    ) -> anyhow::Result<()> {
456        let _ = (thread_id, item_event);
457        Ok(())
458    }
459    async fn append_extension_state(
460        &self,
461        thread_id: &ThreadId,
462        record: &ExtensionStateRecord,
463    ) -> anyhow::Result<()> {
464        let _ = (thread_id, record);
465        anyhow::bail!(
466            "thread store {} does not support extension state",
467            self.id()
468        )
469    }
470}
471
472pub trait ThreadStoreFactory: Send + Sync + 'static {
473    fn id(&self) -> ThreadStoreId;
474    fn create(&self) -> Arc<dyn ThreadStore>;
475}
476
477#[async_trait::async_trait]
478pub trait CheckpointStore: Send + Sync {
479    fn id(&self) -> CheckpointStoreId;
480    async fn save_snapshot(&self, snapshot: ThreadSnapshot) -> anyhow::Result<()>;
481    async fn load_snapshot(&self, thread_id: &ThreadId) -> anyhow::Result<Option<ThreadSnapshot>>;
482}
483
484pub trait CheckpointStoreFactory: Send + Sync + 'static {
485    fn id(&self) -> CheckpointStoreId;
486    fn create(&self) -> Arc<dyn CheckpointStore>;
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::inference::ModelSelection;
493
494    #[test]
495    fn synthetic_event_thread_ids_are_reserved_production_ids() {
496        assert!(is_synthetic_event_thread_id("app-server"));
497        assert!(is_synthetic_event_thread_id("runtime"));
498        assert!(is_synthetic_event_thread_id("thread-workflow"));
499
500        assert!(!is_synthetic_event_thread_id("thread-discovery"));
501        assert!(!is_synthetic_event_thread_id("thread-plan"));
502        assert!(!is_synthetic_event_thread_id("thread-process"));
503        assert!(!is_synthetic_event_thread_id("thread-1"));
504    }
505
506    #[test]
507    fn thread_fork_metadata_is_additive_for_legacy_records() {
508        // A legacy thread record without any fork fields still deserializes.
509        let legacy = serde_json::json!({
510            "thread_id": "thread-old",
511            "title": null,
512            "workspace": "/workspace",
513            "provider": null,
514            "model": null,
515            "created_at": "1970-01-01T00:00:00Z",
516            "updated_at": "1970-01-01T00:00:00Z",
517            "message_count": 3
518        });
519        let metadata: ThreadMetadata = serde_json::from_value(legacy).unwrap();
520        assert!(metadata.parent_thread_id.is_none());
521        assert!(metadata.forked_from_turn_id.is_none());
522        assert!(metadata.workspace_fork.is_none());
523
524        // Non-forked threads serialize without any fork keys.
525        let value = serde_json::to_value(&metadata).unwrap();
526        assert!(value.get("parentThreadId").is_none());
527        assert!(value.get("parent_thread_id").is_none());
528        assert!(value.get("workspace_fork").is_none());
529    }
530
531    #[test]
532    fn thread_fork_metadata_round_trips_workspace_fork_provenance() {
533        let fork = crate::forks::WorkspaceFork {
534            ownership: Some(crate::forks::ForkOwnership {
535                creator_thread_id: Some("parent-thread".into()),
536                owner_thread_id: Some("fork-thread".into()),
537            }),
538            id: "/repo/.roder/worktrees/parser-experiment".to_string(),
539            provider_id: "git-worktree".to_string(),
540            source_workspace: std::path::PathBuf::from("/repo"),
541            workspace: std::path::PathBuf::from("/repo/.roder/worktrees/parser-experiment"),
542            status: crate::forks::ForkStatus::Active,
543            provenance: crate::forks::ForkProvenance {
544                branch: Some("roder/fork/parser-experiment".to_string()),
545                source_branch: Some("main".to_string()),
546                source_commit: Some("abc123".to_string()),
547                snapshot_id: None,
548                session_id: None,
549                created_at: OffsetDateTime::UNIX_EPOCH,
550            },
551            cleanup: crate::forks::ForkCleanupPolicy::Explicit,
552            metadata: serde_json::json!({}),
553        };
554        let value = serde_json::to_value(&fork).unwrap();
555        assert_eq!(value["providerId"], "git-worktree");
556        assert_eq!(value["status"], "active");
557        assert_eq!(value["cleanup"], "explicit");
558        assert_eq!(value["provenance"]["sourceCommit"], "abc123");
559
560        let round_trip: crate::forks::WorkspaceFork = serde_json::from_value(value).unwrap();
561        assert_eq!(round_trip, fork);
562
563        // A detached-HEAD fork keeps clear provenance without a branch name.
564        let detached = crate::forks::WorkspaceFork {
565            provenance: crate::forks::ForkProvenance {
566                source_branch: None,
567                ..fork.provenance.clone()
568            },
569            ..fork
570        };
571        let value = serde_json::to_value(&detached).unwrap();
572        assert!(value["provenance"].get("sourceBranch").is_none());
573    }
574
575    #[test]
576    fn thread_metadata_timestamps_serialize_as_rfc3339_strings() {
577        let value = serde_json::to_value(ThreadMetadata {
578            thread_id: "thread-a".to_string(),
579            title: None,
580            workspace: "/workspace".to_string(),
581            workspace_id: None,
582            root_id: None,
583            provider: None,
584            model: None,
585            selection_mode: None,
586            tool_allowlist: Vec::new(),
587            service_tier: None,
588            developer_instructions: None,
589            external_tools: Vec::new(),
590            runner_destination: None,
591            runner_state: None,
592            runner_binding: None,
593            parent_thread_id: None,
594            forked_from_turn_id: None,
595            workspace_fork: None,
596            created_at: OffsetDateTime::UNIX_EPOCH,
597            updated_at: OffsetDateTime::UNIX_EPOCH,
598            message_count: 0,
599            usage: None,
600        })
601        .unwrap();
602
603        assert_eq!(value["created_at"], "1970-01-01T00:00:00Z");
604        assert_eq!(value["updated_at"], "1970-01-01T00:00:00Z");
605        assert_eq!(value["workspace"], "/workspace");
606    }
607
608    #[test]
609    fn thread_metadata_deserializes_without_selection_mode() {
610        let value = serde_json::json!({
611            "thread_id": "thread-a",
612            "title": null,
613            "workspace": "/workspace",
614            "provider": "codex",
615            "model": "gpt-5.5",
616            "created_at": "1970-01-01T00:00:00Z",
617            "updated_at": "1970-01-01T00:00:00Z",
618            "message_count": 0
619        });
620
621        let metadata = serde_json::from_value::<ThreadMetadata>(value).unwrap();
622
623        assert_eq!(metadata.provider.as_deref(), Some("codex"));
624        assert_eq!(metadata.model.as_deref(), Some("gpt-5.5"));
625        assert_eq!(metadata.selection_mode, None);
626    }
627
628    #[test]
629    fn thread_metadata_round_trips_auto_selection_mode() {
630        let metadata = ThreadMetadata {
631            thread_id: "thread-a".to_string(),
632            title: None,
633            workspace: "/workspace".to_string(),
634            workspace_id: None,
635            root_id: None,
636            provider: Some("codex".to_string()),
637            model: Some("gpt-5.5".to_string()),
638            selection_mode: Some(ModelSelectionMode::auto(
639                "local-router:coding",
640                "local-router",
641                "Auto: Coding",
642                ModelSelection {
643                    provider: "codex".to_string(),
644                    model: "gpt-5.5".to_string(),
645                },
646                Some("coding".to_string()),
647                Some("low".to_string()),
648            )),
649            tool_allowlist: Vec::new(),
650            service_tier: None,
651            developer_instructions: None,
652            external_tools: Vec::new(),
653            runner_destination: None,
654            runner_state: None,
655            runner_binding: None,
656            parent_thread_id: None,
657            forked_from_turn_id: None,
658            workspace_fork: None,
659            created_at: OffsetDateTime::UNIX_EPOCH,
660            updated_at: OffsetDateTime::UNIX_EPOCH,
661            message_count: 0,
662            usage: None,
663        };
664
665        let value = serde_json::to_value(&metadata).unwrap();
666        let round_trip = serde_json::from_value::<ThreadMetadata>(value).unwrap();
667
668        assert_eq!(round_trip, metadata);
669    }
670
671    #[test]
672    fn thread_metadata_requires_workspace_when_deserializing() {
673        let value = serde_json::json!({
674            "thread_id": "thread-a",
675            "title": null,
676            "provider": null,
677            "model": null,
678            "created_at": "1970-01-01T00:00:00Z",
679            "updated_at": "1970-01-01T00:00:00Z",
680            "message_count": 0
681        });
682
683        let result = serde_json::from_value::<ThreadMetadata>(value);
684
685        assert!(result.is_err());
686    }
687
688    #[test]
689    fn thread_metadata_rejects_blank_or_relative_workspace_when_deserializing() {
690        for workspace in ["", "project"] {
691            let value = serde_json::json!({
692                "thread_id": "thread-a",
693                "title": null,
694                "workspace": workspace,
695                "provider": null,
696                "model": null,
697                "created_at": "1970-01-01T00:00:00Z",
698                "updated_at": "1970-01-01T00:00:00Z",
699                "message_count": 0
700            });
701
702            let result = serde_json::from_value::<ThreadMetadata>(value);
703
704            assert!(result.is_err(), "workspace {workspace:?} should fail");
705        }
706    }
707
708    #[test]
709    fn thread_usage_metadata_accumulates_cache_hit_rate() {
710        let mut usage = ThreadUsageMetadata::default();
711
712        usage.add_token_usage(
713            &TokenUsage::new(100, 10, 110)
714                .with_cached_prompt_tokens(92)
715                .with_cache_creation_prompt_tokens(5),
716        );
717        usage.add_token_usage(
718            &TokenUsage::new(50, 5, 55)
719                .with_cached_prompt_tokens(43)
720                .with_cache_creation_prompt_tokens(3),
721        );
722
723        assert_eq!(usage.prompt_tokens, 150);
724        assert_eq!(usage.cached_prompt_tokens, 135);
725        assert_eq!(usage.cache_creation_prompt_tokens, 8);
726        assert!((usage.cache_hit_rate.unwrap() - 0.9).abs() < f64::EPSILON);
727    }
728
729    #[test]
730    fn thread_item_events_replay_reasoning_and_final_answer_into_stable_items() {
731        let timestamp = OffsetDateTime::UNIX_EPOCH;
732        let events = vec![
733            ThreadItemEvent {
734                seq: 1,
735                event_id: "event-1".to_string(),
736                thread_id: "thread-1".to_string(),
737                turn_id: "turn-1".to_string(),
738                timestamp,
739                event: ThreadItemEventKind::ItemStarted {
740                    item: ThreadItem::Reasoning {
741                        id: "turn-1-agent-reasoning".to_string(),
742                        summary: Vec::new(),
743                        content: vec![String::new()],
744                        status: Some(ThreadItemStatus::InProgress),
745                    },
746                },
747            },
748            ThreadItemEvent {
749                seq: 2,
750                event_id: "event-2".to_string(),
751                thread_id: "thread-1".to_string(),
752                turn_id: "turn-1".to_string(),
753                timestamp,
754                event: ThreadItemEventKind::ItemDelta {
755                    item_id: "turn-1-agent-reasoning".to_string(),
756                    delta: ThreadItemDelta::ReasoningText {
757                        delta: "Inspecting".to_string(),
758                        content_index: 0,
759                    },
760                },
761            },
762            ThreadItemEvent {
763                seq: 3,
764                event_id: "event-3".to_string(),
765                thread_id: "thread-1".to_string(),
766                turn_id: "turn-1".to_string(),
767                timestamp,
768                event: ThreadItemEventKind::ItemDelta {
769                    item_id: "turn-1-agent-final_answer".to_string(),
770                    delta: ThreadItemDelta::AgentMessageText {
771                        delta: "Done".to_string(),
772                        phase: Some("final_answer".to_string()),
773                    },
774                },
775            },
776            ThreadItemEvent {
777                seq: 4,
778                event_id: "event-4".to_string(),
779                thread_id: "thread-1".to_string(),
780                turn_id: "turn-1".to_string(),
781                timestamp,
782                event: ThreadItemEventKind::ItemCompleted {
783                    item: ThreadItem::AgentMessage {
784                        id: "turn-1-agent-final_answer".to_string(),
785                        text: "Done.".to_string(),
786                        phase: Some("final_answer".to_string()),
787                        status: Some(ThreadItemStatus::Completed),
788                    },
789                },
790            },
791        ];
792
793        let turns = project_thread_item_events(&events);
794
795        assert_eq!(turns.len(), 1);
796        assert_eq!(turns[0].turn_id, "turn-1");
797        assert_eq!(
798            turns[0].items,
799            vec![
800                ThreadItem::Reasoning {
801                    id: "turn-1-agent-reasoning".to_string(),
802                    summary: Vec::new(),
803                    content: vec!["Inspecting".to_string()],
804                    status: Some(ThreadItemStatus::InProgress),
805                },
806                ThreadItem::AgentMessage {
807                    id: "turn-1-agent-final_answer".to_string(),
808                    text: "Done.".to_string(),
809                    phase: Some("final_answer".to_string()),
810                    status: Some(ThreadItemStatus::Completed),
811                }
812            ]
813        );
814    }
815}