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
33pub const SYNTHETIC_EVENT_THREAD_IDS: &[&str] = &["app-server", "runtime", "thread-workflow"];
35
36pub 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 #[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 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
58 pub accounted_turn_ids: BTreeSet<TurnId>,
59}
60
61impl ThreadUsageMetadata {
62 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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub tool_allowlist: Vec<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub developer_instructions: Option<String>,
131 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub parent_thread_id: Option<ThreadId>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub forked_from_turn_id: Option<TurnId>,
163 #[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 #[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 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 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 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 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}