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
35pub const SYNTHETIC_EVENT_THREAD_IDS: &[&str] = &["app-server", "runtime", "thread-workflow"];
37
38pub 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 #[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 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
60 pub accounted_turn_ids: BTreeSet<TurnId>,
61}
62
63impl ThreadUsageMetadata {
64 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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub tool_allowlist: Vec<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub developer_instructions: Option<String>,
133 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub parent_thread_id: Option<ThreadId>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub forked_from_turn_id: Option<TurnId>,
165 #[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 #[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 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 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 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 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 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}