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