1use std::io::Write;
18use std::path::{Path, PathBuf};
19
20use locode_protocol::{Event, Message};
21use serde::{Deserialize, Serialize};
22
23use crate::session_dirs::encode_cwd_dirname;
24
25pub const TRACE_SCHEMA_VERSION: u32 = 1;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SessionMeta {
34 pub schema_version: u32,
36 pub session_id: String,
38 #[serde(default = "default_kind")]
41 pub kind: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub parent_id: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub group: Option<String>,
48 pub cwd: PathBuf,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub git: Option<GitMeta>,
53 pub cli_version: String,
55 pub harness: String,
58 pub api_schema: String,
60 pub model: String,
62}
63
64fn default_kind() -> String {
65 "main".to_string()
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct GitMeta {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub root: Option<PathBuf>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub branch: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub head: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub remote: Option<String>,
83}
84
85#[derive(Debug, Clone, Default)]
87pub struct TraceExtras {
88 pub cli_version: String,
90 pub git: Option<GitMeta>,
92 pub kind: Option<String>,
94 pub parent_id: Option<String>,
96 pub group: Option<String>,
98}
99
100#[derive(Debug)]
105pub struct TraceWriter {
106 sessions_root: PathBuf,
107 extras: TraceExtras,
108 file: Option<std::fs::File>,
109 path: Option<PathBuf>,
111 error: Option<String>,
113 resumed: bool,
115}
116
117impl TraceWriter {
118 #[must_use]
121 pub fn new(sessions_root: PathBuf, extras: TraceExtras) -> Self {
122 Self {
123 sessions_root,
124 extras,
125 file: None,
126 path: None,
127 error: None,
128 resumed: false,
129 }
130 }
131
132 pub fn resume(path: PathBuf, sessions_root: PathBuf) -> Result<Self, String> {
140 let file = open_append_private(&path)?;
141 Ok(Self {
142 sessions_root,
143 extras: TraceExtras::default(),
144 file: Some(file),
145 path: Some(path),
146 error: None,
147 resumed: true,
148 })
149 }
150
151 pub fn on_event(&mut self, event: &Event) {
154 if self.error.is_some() {
155 return;
156 }
157 let result = match event {
158 Event::Init {
159 session_id,
160 harness,
161 api_schema,
162 model,
163 cwd,
164 preamble,
165 ..
166 } if !self.resumed => {
167 self.on_init(session_id, harness, api_schema, model, cwd, preamble)
168 }
169 Event::Message { message } => self.append_message(message),
170 Event::Result { report } => {
179 if self.file.is_some() {
180 self.append_record("usage", &report.context_usage)
181 } else {
182 Ok(())
183 }
184 }
185 _ => Ok(()),
186 };
187 if let Err(e) = result {
188 self.error = Some(e);
189 self.file = None;
190 }
191 }
192
193 #[must_use]
195 pub fn path(&self) -> Option<&Path> {
196 self.path.as_deref()
197 }
198
199 pub fn take_error(&mut self) -> Option<String> {
201 self.error.take()
202 }
203
204 fn on_init(
205 &mut self,
206 session_id: &str,
207 harness: &str,
208 api_schema: &str,
209 model: &str,
210 cwd: &str,
211 preamble: &[Message],
212 ) -> Result<(), String> {
213 let cwd_path = PathBuf::from(cwd);
214 let dirname = encode_cwd_dirname(&cwd_path);
215 let dir = self.sessions_root.join(&dirname);
216 create_dir_private(&dir)?;
217 if !dirname.starts_with('+') {
220 let sidecar = dir.join(".cwd");
221 if !sidecar.exists() {
222 std::fs::write(&sidecar, cwd).map_err(|e| format!("write .cwd sidecar: {e}"))?;
223 }
224 }
225 let filename = format!("rollout-{}-{session_id}.jsonl", filename_timestamp());
226 let path = dir.join(&filename);
227 let file = open_append_private(&path)?;
228 self.file = Some(file);
229 self.path = Some(path);
230
231 let meta = SessionMeta {
232 schema_version: TRACE_SCHEMA_VERSION,
233 session_id: session_id.to_string(),
234 kind: self
235 .extras
236 .kind
237 .clone()
238 .unwrap_or_else(|| "main".to_string()),
239 parent_id: self.extras.parent_id.clone(),
240 group: self.extras.group.clone(),
241 cwd: cwd_path,
242 git: self.extras.git.clone(),
243 cli_version: self.extras.cli_version.clone(),
244 harness: harness.to_string(),
245 api_schema: api_schema.to_string(),
246 model: model.to_string(),
247 };
248 self.append_record("session_meta", &meta)?;
249 for message in preamble {
251 self.append_record("message", message)?;
252 }
253 Ok(())
254 }
255
256 fn append_message(&mut self, message: &Message) -> Result<(), String> {
257 if self.file.is_none() {
258 return Ok(()); }
260 self.append_record("message", message)
261 }
262
263 fn append_record(&mut self, record_type: &str, payload: &impl Serialize) -> Result<(), String> {
264 let Some(file) = self.file.as_mut() else {
265 return Ok(());
266 };
267 let line = serde_json::json!({
268 "timestamp": now_rfc3339_millis(),
269 "type": record_type,
270 "payload": payload,
271 });
272 let mut buf = serde_json::to_string(&line).map_err(|e| format!("serialize: {e}"))?;
273 buf.push('\n');
274 file.write_all(buf.as_bytes())
275 .and_then(|()| file.flush())
276 .map_err(|e| format!("append trace record: {e}"))
277 }
278}
279
280#[derive(Debug)]
282pub struct RolloutContents {
283 pub meta: SessionMeta,
285 pub history: Vec<Message>,
288 pub last_usage: Option<locode_protocol::Usage>,
292}
293
294pub fn read_rollout(path: &Path) -> Result<RolloutContents, String> {
303 let text =
304 std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
305 let mut lines = text.lines();
306 let first = lines
307 .next()
308 .ok_or_else(|| format!("{}: empty rollout", path.display()))?;
309 let first: serde_json::Value = serde_json::from_str(first)
310 .map_err(|e| format!("{}: line 1 unparsable: {e}", path.display()))?;
311 if first.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") {
312 return Err(format!("{}: line 1 is not session_meta", path.display()));
313 }
314 let meta: SessionMeta = serde_json::from_value(first["payload"].clone())
315 .map_err(|e| format!("{}: session_meta invalid: {e}", path.display()))?;
316
317 let mut history: Vec<Message> = Vec::new();
318 let mut last_usage: Option<locode_protocol::Usage> = None;
319 for line in lines {
320 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
322 continue;
323 };
324 match value.get("type").and_then(serde_json::Value::as_str) {
325 Some("message") => {
326 if let Ok(message) = serde_json::from_value::<Message>(value["payload"].clone()) {
327 history.push(message);
328 }
329 }
330 Some("compacted") => {
331 if let Some(replacement) = value["payload"].get("replacement_history")
333 && let Ok(messages) =
334 serde_json::from_value::<Vec<Message>>(replacement.clone())
335 {
336 history = messages;
337 }
338 }
339 Some("usage") => {
340 if let Ok(usage) =
341 serde_json::from_value::<locode_protocol::Usage>(value["payload"].clone())
342 {
343 last_usage = Some(usage);
344 }
345 }
346 _ => {}
348 }
349 }
350 Ok(RolloutContents {
351 meta,
352 history,
353 last_usage,
354 })
355}
356
357#[must_use]
363pub fn find_latest_rollout(sessions_root: &Path, cwd: &Path) -> Option<PathBuf> {
364 let dir = sessions_root.join(encode_cwd_dirname(cwd));
365 let mut names: Vec<String> = rollout_names(&dir);
366 names.sort_unstable_by(|a, b| b.cmp(a)); names
368 .into_iter()
369 .map(|n| dir.join(n))
370 .find(|path| read_rollout(path).is_ok_and(|c| c.meta.kind == "main"))
371}
372
373#[must_use]
377pub fn find_rollout_by_id(sessions_root: &Path, cwd: &Path, id: &str) -> Option<PathBuf> {
378 let suffix = format!("-{id}.jsonl");
379 let scoped = sessions_root.join(encode_cwd_dirname(cwd));
380 if let Some(hit) = dir_hit(&scoped, &suffix) {
381 return Some(hit);
382 }
383 let entries = std::fs::read_dir(sessions_root).ok()?;
384 for entry in entries.flatten() {
385 let dir = entry.path();
386 if dir == scoped || !dir.is_dir() {
387 continue;
388 }
389 if let Some(hit) = dir_hit(&dir, &suffix) {
390 return Some(hit);
391 }
392 }
393 None
394}
395
396fn rollout_names(dir: &Path) -> Vec<String> {
398 let Ok(entries) = std::fs::read_dir(dir) else {
399 return Vec::new();
400 };
401 entries
402 .flatten()
403 .filter_map(|e| e.file_name().into_string().ok())
404 .filter(|n| {
405 n.starts_with("rollout-")
406 && std::path::Path::new(n)
407 .extension()
408 .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
409 })
410 .collect()
411}
412
413fn dir_hit(dir: &Path, suffix: &str) -> Option<PathBuf> {
414 rollout_names(dir)
415 .into_iter()
416 .find(|n| n.ends_with(suffix))
417 .map(|n| dir.join(n))
418}
419
420pub(crate) fn open_append_private(path: &Path) -> Result<std::fs::File, String> {
424 let mut options = std::fs::OpenOptions::new();
425 options.create(true).append(true);
426 #[cfg(unix)]
427 {
428 use std::os::unix::fs::OpenOptionsExt;
429 options.mode(0o600);
430 }
431 let mut file = options
432 .open(path)
433 .map_err(|e| format!("open {}: {e}", path.display()))?;
434 heal_torn_tail(path, &mut file)?;
435 Ok(file)
436}
437
438fn heal_torn_tail(path: &Path, file: &mut std::fs::File) -> Result<(), String> {
439 use std::io::{Read, Seek, SeekFrom};
440 let len = file
441 .metadata()
442 .map_err(|e| format!("stat {}: {e}", path.display()))?
443 .len();
444 if len == 0 {
445 return Ok(());
446 }
447 let mut reader =
449 std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
450 reader
451 .seek(SeekFrom::End(-1))
452 .map_err(|e| format!("seek {}: {e}", path.display()))?;
453 let mut last = [0_u8; 1];
454 reader
455 .read_exact(&mut last)
456 .map_err(|e| format!("read {}: {e}", path.display()))?;
457 if last[0] != b'\n' {
458 file.write_all(b"\n")
459 .map_err(|e| format!("heal {}: {e}", path.display()))?;
460 }
461 Ok(())
462}
463
464pub(crate) fn create_dir_private(dir: &Path) -> Result<(), String> {
467 #[cfg(unix)]
468 {
469 use std::os::unix::fs::DirBuilderExt;
470 std::fs::DirBuilder::new()
471 .recursive(true)
472 .mode(0o700)
473 .create(dir)
474 .map_err(|e| format!("create {}: {e}", dir.display()))
475 }
476 #[cfg(not(unix))]
477 {
478 std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))
479 }
480}
481
482fn now_rfc3339_millis() -> String {
484 chrono::Utc::now()
485 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
486 .to_string()
487}
488
489fn filename_timestamp() -> String {
492 chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string()
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use locode_protocol::{ContentBlock, Role};
499 use serde_json::Value;
500
501 fn init_event(session_id: &str, cwd: &Path) -> Event {
502 Event::Init {
503 session_id: session_id.to_string(),
504 harness: "codex".to_string(),
505 api_schema: "mock".to_string(),
506 model: "mock-1".to_string(),
507 cwd: cwd.to_string_lossy().into_owned(),
508 max_turns: None,
509 preamble: vec![Message {
510 role: Role::System,
511 content: vec![ContentBlock::Text {
512 text: "base prompt".to_string(),
513 }],
514 }],
515 tools: vec![],
516 }
517 }
518
519 fn message_event(role: Role, text: &str) -> Event {
520 Event::Message {
521 message: Message {
522 role,
523 content: vec![ContentBlock::Text {
524 text: text.to_string(),
525 }],
526 },
527 }
528 }
529
530 fn read_lines(path: &Path) -> Vec<Value> {
531 std::fs::read_to_string(path)
532 .unwrap()
533 .lines()
534 .map(|l| serde_json::from_str(l).unwrap())
535 .collect()
536 }
537
538 #[test]
539 fn writes_header_preamble_and_messages() {
540 let root = tempfile::tempdir().unwrap();
541 let cwd = tempfile::tempdir().unwrap();
542 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
543 let mut writer = TraceWriter::new(
544 root.path().join("sessions"),
545 TraceExtras {
546 cli_version: "0.1.9".to_string(),
547 git: Some(GitMeta {
548 branch: Some("main".to_string()),
549 ..Default::default()
550 }),
551 ..Default::default()
552 },
553 );
554 writer.on_event(&init_event("sess-1", &cwd));
555 writer.on_event(&message_event(Role::User, "hi"));
556 writer.on_event(&Event::MessageDelta {
557 text: "ignored".to_string(),
558 });
559 writer.on_event(&message_event(Role::Assistant, "hello"));
560 assert!(writer.take_error().is_none());
561
562 let path = writer.path().unwrap().to_path_buf();
564 assert_eq!(
565 path.parent()
566 .unwrap()
567 .file_name()
568 .unwrap()
569 .to_str()
570 .unwrap(),
571 encode_cwd_dirname(&cwd)
572 );
573 assert!(
574 path.file_name()
575 .unwrap()
576 .to_str()
577 .unwrap()
578 .starts_with("rollout-")
579 );
580 assert!(
581 path.file_name()
582 .unwrap()
583 .to_str()
584 .unwrap()
585 .ends_with("-sess-1.jsonl")
586 );
587
588 let lines = read_lines(&path);
589 assert_eq!(lines.len(), 4, "meta + preamble + 2 messages");
590 assert_eq!(lines[0]["type"], "session_meta");
591 let meta = &lines[0]["payload"];
592 assert_eq!(meta["schema_version"], 1);
593 assert_eq!(meta["session_id"], "sess-1");
594 assert_eq!(meta["kind"], "main");
595 assert_eq!(meta["harness"], "codex");
596 assert_eq!(meta["git"]["branch"], "main");
597 assert!(meta.get("parent_id").is_none(), "absent when None");
598 assert_eq!(lines[1]["type"], "message");
600 assert_eq!(lines[1]["payload"]["role"], "system");
601 assert_eq!(lines[2]["payload"]["role"], "user");
602 assert_eq!(lines[3]["payload"]["role"], "assistant");
603 for line in &lines {
605 assert!(line["timestamp"].as_str().unwrap().ends_with('Z'));
606 }
607 }
608
609 #[cfg(unix)]
610 #[test]
611 fn modes_are_private() {
612 use std::os::unix::fs::PermissionsExt;
613 let root = tempfile::tempdir().unwrap();
614 let cwd = tempfile::tempdir().unwrap();
615 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
616 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
617 writer.on_event(&init_event("sess-2", &cwd));
618 let path = writer.path().unwrap();
619 let file_mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
620 let dir_mode = std::fs::metadata(path.parent().unwrap())
621 .unwrap()
622 .permissions()
623 .mode()
624 & 0o777;
625 assert_eq!(file_mode, 0o600, "rollout is 0600");
626 assert_eq!(dir_mode, 0o700, "session dir is 0700");
627 }
628
629 #[test]
630 fn torn_tail_is_healed_on_reopen() {
631 let dir = tempfile::tempdir().unwrap();
632 let path = dir.path().join("rollout-x.jsonl");
633 std::fs::write(&path, "{\"ok\":1}\n{\"torn\":").unwrap();
634 let mut file = open_append_private(&path).unwrap();
635 file.write_all(b"{\"next\":2}\n").unwrap();
636 drop(file);
637 let text = std::fs::read_to_string(&path).unwrap();
638 assert_eq!(text, "{\"ok\":1}\n{\"torn\":\n{\"next\":2}\n");
639 let parsed: Vec<_> = text
641 .lines()
642 .filter(|l| serde_json::from_str::<Value>(l).is_ok())
643 .collect();
644 assert_eq!(parsed.len(), 2);
645 }
646
647 #[test]
648 fn no_init_means_no_file_and_errors_disable() {
649 let root = tempfile::tempdir().unwrap();
650 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
651 writer.on_event(&message_event(Role::User, "before init"));
652 assert!(writer.path().is_none(), "nothing written before Init");
653
654 let mut writer = TraceWriter::new(PathBuf::from("/dev/null/nope"), TraceExtras::default());
656 let cwd = std::env::temp_dir();
657 writer.on_event(&init_event("sess-3", &cwd));
658 assert!(writer.take_error().is_some());
659 writer.on_event(&message_event(Role::User, "x"));
661 }
662
663 #[test]
664 fn read_rollout_replays_and_tolerates() {
665 let root = tempfile::tempdir().unwrap();
666 let cwd = tempfile::tempdir().unwrap();
667 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
668 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
669 writer.on_event(&init_event("sess-r", &cwd));
670 writer.on_event(&message_event(Role::User, "hi"));
671 writer.on_event(&message_event(Role::Assistant, "yo"));
672 let path = writer.path().unwrap().to_path_buf();
673
674 {
676 let mut file = open_append_private(&path).unwrap();
677 file.write_all(
678 b"{\"timestamp\":\"x\",\"type\":\"future_thing\",\"payload\":{\"n\":1}}\n",
679 )
680 .unwrap();
681 file.write_all(b"{\"torn").unwrap();
682 }
683 let contents = read_rollout(&path).unwrap();
684 assert_eq!(contents.meta.session_id, "sess-r");
685 assert_eq!(contents.history.len(), 3);
687 assert_eq!(contents.history[0].role, Role::System);
688 assert_eq!(contents.history[2].role, Role::Assistant);
689 }
690
691 #[test]
692 fn read_rollout_folds_compacted() {
693 let dir = tempfile::tempdir().unwrap();
694 let path = dir.path().join("rollout-t-sess-c.jsonl");
695 let meta = serde_json::json!({"timestamp":"t","type":"session_meta","payload":{
696 "schema_version":1,"session_id":"sess-c","kind":"main","cwd":"/x",
697 "cli_version":"0","harness":"grok","api_schema":"mock","model":"m"}});
698 let msg = |role: &str, text: &str| {
699 serde_json::json!({"timestamp":"t","type":"message","payload":
700 {"role":role,"content":[{"type":"text","text":text}]}})
701 };
702 let compacted = serde_json::json!({"timestamp":"t","type":"compacted","payload":{
703 "summary":"s","replacement_history":[
704 {"role":"user","content":[{"type":"text","text":"summary of the past"}]}]}});
705 let lines = [
706 meta.to_string(),
707 msg("user", "old-1").to_string(),
708 msg("assistant", "old-2").to_string(),
709 compacted.to_string(),
710 msg("user", "after").to_string(),
711 ];
712 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
713 let contents = read_rollout(&path).unwrap();
714 assert_eq!(contents.history.len(), 2);
716 assert_eq!(contents.history[1].role, Role::User);
717 }
718
719 #[test]
720 fn resumed_writer_appends_without_a_new_header() {
721 let root = tempfile::tempdir().unwrap();
722 let cwd = tempfile::tempdir().unwrap();
723 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
724 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
725 writer.on_event(&init_event("sess-a", &cwd));
726 writer.on_event(&message_event(Role::User, "first run"));
727 let path = writer.path().unwrap().to_path_buf();
728 let before = read_lines(&path).len();
729 drop(writer);
730
731 let mut resumed = TraceWriter::resume(path.clone(), root.path().join("sessions")).unwrap();
732 resumed.on_event(&init_event("sess-a", &cwd)); resumed.on_event(&message_event(Role::User, "second run"));
734 assert!(resumed.take_error().is_none());
735 let lines = read_lines(&path);
736 assert_eq!(lines.len(), before + 1, "exactly one new message line");
737 assert_eq!(
738 lines.iter().filter(|l| l["type"] == "session_meta").count(),
739 1,
740 "one header, ever"
741 );
742 }
743
744 #[test]
745 fn find_latest_scopes_by_cwd_and_kind() {
746 let root = tempfile::tempdir().unwrap();
747 let sessions = root.path().join("sessions");
748 let cwd_a = tempfile::tempdir().unwrap();
749 let cwd_a = std::fs::canonicalize(cwd_a.path()).unwrap();
750 let cwd_b = tempfile::tempdir().unwrap();
751 let cwd_b = std::fs::canonicalize(cwd_b.path()).unwrap();
752
753 let mut w1 = TraceWriter::new(sessions.clone(), TraceExtras::default());
755 w1.on_event(&init_event("sess-old", &cwd_a));
756 let mut w2 = TraceWriter::new(
757 sessions.clone(),
758 TraceExtras {
759 kind: Some("subagent".to_string()),
760 ..Default::default()
761 },
762 );
763 w2.on_event(&init_event("sess-sub", &cwd_a));
764 let mut w3 = TraceWriter::new(sessions.clone(), TraceExtras::default());
765 w3.on_event(&init_event("sess-b", &cwd_b));
766
767 let latest = find_latest_rollout(&sessions, &cwd_a).unwrap();
769 assert!(latest.to_string_lossy().contains("sess-old"), "{latest:?}");
770 let latest_b = find_latest_rollout(&sessions, &cwd_b).unwrap();
772 assert!(latest_b.to_string_lossy().contains("sess-b"));
773 let empty = tempfile::tempdir().unwrap();
775 assert!(find_latest_rollout(&sessions, empty.path()).is_none());
776
777 let by_id = find_rollout_by_id(&sessions, &cwd_b, "sess-sub").unwrap();
780 assert!(by_id.to_string_lossy().contains("sess-sub"));
781 assert!(find_rollout_by_id(&sessions, &cwd_b, "sess-nope").is_none());
782 }
783
784 #[test]
785 fn usage_records_round_trip_for_exact_resume() {
786 use locode_protocol::{Report, Status, Usage};
787 let root = tempfile::tempdir().unwrap();
788 let cwd = tempfile::tempdir().unwrap();
789 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
790 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
791 writer.on_event(&init_event("sess-u", &cwd));
792 writer.on_event(&message_event(Role::User, "hi"));
793 let report = |input: u64, output: u64| Report {
795 schema_version: 1,
796 status: Status::Completed,
797 harness: "grok".into(),
798 api_schema: "mock".into(),
799 final_message: None,
800 structured_output: None,
801 turns: 1,
802 tool_calls: vec![],
803 usage: Usage {
806 input_tokens: input * 3,
807 output_tokens: output * 3,
808 cache_read_tokens: Some(99),
809 ..Default::default()
810 },
811 context_usage: Usage {
812 input_tokens: input,
813 output_tokens: output,
814 cache_read_tokens: Some(7),
815 cache_creation_tokens: Some(5),
816 ..Default::default()
817 },
818 session_id: "sess-u".into(),
819 stop_reason: None,
820 error: None,
821 };
822 writer.on_event(&locode_protocol::Event::Result {
823 report: report(100, 10),
824 });
825 writer.on_event(&locode_protocol::Event::Result {
826 report: report(200, 20),
827 });
828 assert!(writer.take_error().is_none());
829
830 let contents = read_rollout(writer.path().unwrap()).unwrap();
831 let usage = contents.last_usage.expect("usage recovered");
832 assert_eq!(usage.input_tokens, 200, "the last run's context usage");
833 assert_eq!(usage.output_tokens, 20);
834 assert_eq!(usage.cache_read_tokens, Some(7));
835 assert_eq!(
838 usage.context_tokens(),
839 200 + 7 + 5 + 20,
840 "both cache counters are part of the prompt"
841 );
842 let bare = root.path().join("bare.jsonl");
844 let meta_line = std::fs::read_to_string(writer.path().unwrap())
845 .unwrap()
846 .lines()
847 .next()
848 .unwrap()
849 .to_string();
850 std::fs::write(&bare, meta_line + "\n").unwrap();
851 assert!(read_rollout(&bare).unwrap().last_usage.is_none());
852 }
853
854 #[test]
855 fn reserved_kinds_and_parents_serialize() {
856 let root = tempfile::tempdir().unwrap();
857 let cwd = tempfile::tempdir().unwrap();
858 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
859 let mut writer = TraceWriter::new(
860 root.path().join("sessions"),
861 TraceExtras {
862 cli_version: "x".to_string(),
863 kind: Some("subagent".to_string()),
864 parent_id: Some("sess-parent".to_string()),
865 group: Some("wf-1".to_string()),
866 ..Default::default()
867 },
868 );
869 writer.on_event(&init_event("sess-4", &cwd));
870 let lines = read_lines(writer.path().unwrap());
871 let meta = &lines[0]["payload"];
872 assert_eq!(meta["kind"], "subagent");
873 assert_eq!(meta["parent_id"], "sess-parent");
874 assert_eq!(meta["group"], "wf-1");
875 let parsed: SessionMeta = serde_json::from_value(meta.clone()).unwrap();
878 assert_eq!(parsed.kind, "subagent");
879 }
880}