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 } => {
173 if self.file.is_some() {
174 self.append_record("usage", &report.usage)
175 } else {
176 Ok(())
177 }
178 }
179 _ => Ok(()),
180 };
181 if let Err(e) = result {
182 self.error = Some(e);
183 self.file = None;
184 }
185 }
186
187 #[must_use]
189 pub fn path(&self) -> Option<&Path> {
190 self.path.as_deref()
191 }
192
193 pub fn take_error(&mut self) -> Option<String> {
195 self.error.take()
196 }
197
198 fn on_init(
199 &mut self,
200 session_id: &str,
201 harness: &str,
202 api_schema: &str,
203 model: &str,
204 cwd: &str,
205 preamble: &[Message],
206 ) -> Result<(), String> {
207 let cwd_path = PathBuf::from(cwd);
208 let dirname = encode_cwd_dirname(&cwd_path);
209 let dir = self.sessions_root.join(&dirname);
210 create_dir_private(&dir)?;
211 if !dirname.starts_with('+') {
214 let sidecar = dir.join(".cwd");
215 if !sidecar.exists() {
216 std::fs::write(&sidecar, cwd).map_err(|e| format!("write .cwd sidecar: {e}"))?;
217 }
218 }
219 let filename = format!("rollout-{}-{session_id}.jsonl", filename_timestamp());
220 let path = dir.join(&filename);
221 let file = open_append_private(&path)?;
222 self.file = Some(file);
223 self.path = Some(path);
224
225 let meta = SessionMeta {
226 schema_version: TRACE_SCHEMA_VERSION,
227 session_id: session_id.to_string(),
228 kind: self
229 .extras
230 .kind
231 .clone()
232 .unwrap_or_else(|| "main".to_string()),
233 parent_id: self.extras.parent_id.clone(),
234 group: self.extras.group.clone(),
235 cwd: cwd_path,
236 git: self.extras.git.clone(),
237 cli_version: self.extras.cli_version.clone(),
238 harness: harness.to_string(),
239 api_schema: api_schema.to_string(),
240 model: model.to_string(),
241 };
242 self.append_record("session_meta", &meta)?;
243 for message in preamble {
245 self.append_record("message", message)?;
246 }
247 Ok(())
248 }
249
250 fn append_message(&mut self, message: &Message) -> Result<(), String> {
251 if self.file.is_none() {
252 return Ok(()); }
254 self.append_record("message", message)
255 }
256
257 fn append_record(&mut self, record_type: &str, payload: &impl Serialize) -> Result<(), String> {
258 let Some(file) = self.file.as_mut() else {
259 return Ok(());
260 };
261 let line = serde_json::json!({
262 "timestamp": now_rfc3339_millis(),
263 "type": record_type,
264 "payload": payload,
265 });
266 let mut buf = serde_json::to_string(&line).map_err(|e| format!("serialize: {e}"))?;
267 buf.push('\n');
268 file.write_all(buf.as_bytes())
269 .and_then(|()| file.flush())
270 .map_err(|e| format!("append trace record: {e}"))
271 }
272}
273
274#[derive(Debug)]
276pub struct RolloutContents {
277 pub meta: SessionMeta,
279 pub history: Vec<Message>,
282 pub last_usage: Option<locode_protocol::Usage>,
286}
287
288pub fn read_rollout(path: &Path) -> Result<RolloutContents, String> {
297 let text =
298 std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
299 let mut lines = text.lines();
300 let first = lines
301 .next()
302 .ok_or_else(|| format!("{}: empty rollout", path.display()))?;
303 let first: serde_json::Value = serde_json::from_str(first)
304 .map_err(|e| format!("{}: line 1 unparsable: {e}", path.display()))?;
305 if first.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") {
306 return Err(format!("{}: line 1 is not session_meta", path.display()));
307 }
308 let meta: SessionMeta = serde_json::from_value(first["payload"].clone())
309 .map_err(|e| format!("{}: session_meta invalid: {e}", path.display()))?;
310
311 let mut history: Vec<Message> = Vec::new();
312 let mut last_usage: Option<locode_protocol::Usage> = None;
313 for line in lines {
314 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
316 continue;
317 };
318 match value.get("type").and_then(serde_json::Value::as_str) {
319 Some("message") => {
320 if let Ok(message) = serde_json::from_value::<Message>(value["payload"].clone()) {
321 history.push(message);
322 }
323 }
324 Some("compacted") => {
325 if let Some(replacement) = value["payload"].get("replacement_history")
327 && let Ok(messages) =
328 serde_json::from_value::<Vec<Message>>(replacement.clone())
329 {
330 history = messages;
331 }
332 }
333 Some("usage") => {
334 if let Ok(usage) =
335 serde_json::from_value::<locode_protocol::Usage>(value["payload"].clone())
336 {
337 last_usage = Some(usage);
338 }
339 }
340 _ => {}
342 }
343 }
344 Ok(RolloutContents {
345 meta,
346 history,
347 last_usage,
348 })
349}
350
351#[must_use]
357pub fn find_latest_rollout(sessions_root: &Path, cwd: &Path) -> Option<PathBuf> {
358 let dir = sessions_root.join(encode_cwd_dirname(cwd));
359 let mut names: Vec<String> = rollout_names(&dir);
360 names.sort_unstable_by(|a, b| b.cmp(a)); names
362 .into_iter()
363 .map(|n| dir.join(n))
364 .find(|path| read_rollout(path).is_ok_and(|c| c.meta.kind == "main"))
365}
366
367#[must_use]
371pub fn find_rollout_by_id(sessions_root: &Path, cwd: &Path, id: &str) -> Option<PathBuf> {
372 let suffix = format!("-{id}.jsonl");
373 let scoped = sessions_root.join(encode_cwd_dirname(cwd));
374 if let Some(hit) = dir_hit(&scoped, &suffix) {
375 return Some(hit);
376 }
377 let entries = std::fs::read_dir(sessions_root).ok()?;
378 for entry in entries.flatten() {
379 let dir = entry.path();
380 if dir == scoped || !dir.is_dir() {
381 continue;
382 }
383 if let Some(hit) = dir_hit(&dir, &suffix) {
384 return Some(hit);
385 }
386 }
387 None
388}
389
390fn rollout_names(dir: &Path) -> Vec<String> {
392 let Ok(entries) = std::fs::read_dir(dir) else {
393 return Vec::new();
394 };
395 entries
396 .flatten()
397 .filter_map(|e| e.file_name().into_string().ok())
398 .filter(|n| {
399 n.starts_with("rollout-")
400 && std::path::Path::new(n)
401 .extension()
402 .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
403 })
404 .collect()
405}
406
407fn dir_hit(dir: &Path, suffix: &str) -> Option<PathBuf> {
408 rollout_names(dir)
409 .into_iter()
410 .find(|n| n.ends_with(suffix))
411 .map(|n| dir.join(n))
412}
413
414pub(crate) fn open_append_private(path: &Path) -> Result<std::fs::File, String> {
418 let mut options = std::fs::OpenOptions::new();
419 options.create(true).append(true);
420 #[cfg(unix)]
421 {
422 use std::os::unix::fs::OpenOptionsExt;
423 options.mode(0o600);
424 }
425 let mut file = options
426 .open(path)
427 .map_err(|e| format!("open {}: {e}", path.display()))?;
428 heal_torn_tail(path, &mut file)?;
429 Ok(file)
430}
431
432fn heal_torn_tail(path: &Path, file: &mut std::fs::File) -> Result<(), String> {
433 use std::io::{Read, Seek, SeekFrom};
434 let len = file
435 .metadata()
436 .map_err(|e| format!("stat {}: {e}", path.display()))?
437 .len();
438 if len == 0 {
439 return Ok(());
440 }
441 let mut reader =
443 std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
444 reader
445 .seek(SeekFrom::End(-1))
446 .map_err(|e| format!("seek {}: {e}", path.display()))?;
447 let mut last = [0_u8; 1];
448 reader
449 .read_exact(&mut last)
450 .map_err(|e| format!("read {}: {e}", path.display()))?;
451 if last[0] != b'\n' {
452 file.write_all(b"\n")
453 .map_err(|e| format!("heal {}: {e}", path.display()))?;
454 }
455 Ok(())
456}
457
458pub(crate) fn create_dir_private(dir: &Path) -> Result<(), String> {
461 #[cfg(unix)]
462 {
463 use std::os::unix::fs::DirBuilderExt;
464 std::fs::DirBuilder::new()
465 .recursive(true)
466 .mode(0o700)
467 .create(dir)
468 .map_err(|e| format!("create {}: {e}", dir.display()))
469 }
470 #[cfg(not(unix))]
471 {
472 std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))
473 }
474}
475
476fn now_rfc3339_millis() -> String {
478 chrono::Utc::now()
479 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
480 .to_string()
481}
482
483fn filename_timestamp() -> String {
486 chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string()
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use locode_protocol::{ContentBlock, Role};
493 use serde_json::Value;
494
495 fn init_event(session_id: &str, cwd: &Path) -> Event {
496 Event::Init {
497 session_id: session_id.to_string(),
498 harness: "codex".to_string(),
499 api_schema: "mock".to_string(),
500 model: "mock-1".to_string(),
501 cwd: cwd.to_string_lossy().into_owned(),
502 max_turns: None,
503 preamble: vec![Message {
504 role: Role::System,
505 content: vec![ContentBlock::Text {
506 text: "base prompt".to_string(),
507 }],
508 }],
509 tools: vec![],
510 }
511 }
512
513 fn message_event(role: Role, text: &str) -> Event {
514 Event::Message {
515 message: Message {
516 role,
517 content: vec![ContentBlock::Text {
518 text: text.to_string(),
519 }],
520 },
521 }
522 }
523
524 fn read_lines(path: &Path) -> Vec<Value> {
525 std::fs::read_to_string(path)
526 .unwrap()
527 .lines()
528 .map(|l| serde_json::from_str(l).unwrap())
529 .collect()
530 }
531
532 #[test]
533 fn writes_header_preamble_and_messages() {
534 let root = tempfile::tempdir().unwrap();
535 let cwd = tempfile::tempdir().unwrap();
536 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
537 let mut writer = TraceWriter::new(
538 root.path().join("sessions"),
539 TraceExtras {
540 cli_version: "0.1.9".to_string(),
541 git: Some(GitMeta {
542 branch: Some("main".to_string()),
543 ..Default::default()
544 }),
545 ..Default::default()
546 },
547 );
548 writer.on_event(&init_event("sess-1", &cwd));
549 writer.on_event(&message_event(Role::User, "hi"));
550 writer.on_event(&Event::MessageDelta {
551 text: "ignored".to_string(),
552 });
553 writer.on_event(&message_event(Role::Assistant, "hello"));
554 assert!(writer.take_error().is_none());
555
556 let path = writer.path().unwrap().to_path_buf();
558 assert_eq!(
559 path.parent()
560 .unwrap()
561 .file_name()
562 .unwrap()
563 .to_str()
564 .unwrap(),
565 encode_cwd_dirname(&cwd)
566 );
567 assert!(
568 path.file_name()
569 .unwrap()
570 .to_str()
571 .unwrap()
572 .starts_with("rollout-")
573 );
574 assert!(
575 path.file_name()
576 .unwrap()
577 .to_str()
578 .unwrap()
579 .ends_with("-sess-1.jsonl")
580 );
581
582 let lines = read_lines(&path);
583 assert_eq!(lines.len(), 4, "meta + preamble + 2 messages");
584 assert_eq!(lines[0]["type"], "session_meta");
585 let meta = &lines[0]["payload"];
586 assert_eq!(meta["schema_version"], 1);
587 assert_eq!(meta["session_id"], "sess-1");
588 assert_eq!(meta["kind"], "main");
589 assert_eq!(meta["harness"], "codex");
590 assert_eq!(meta["git"]["branch"], "main");
591 assert!(meta.get("parent_id").is_none(), "absent when None");
592 assert_eq!(lines[1]["type"], "message");
594 assert_eq!(lines[1]["payload"]["role"], "system");
595 assert_eq!(lines[2]["payload"]["role"], "user");
596 assert_eq!(lines[3]["payload"]["role"], "assistant");
597 for line in &lines {
599 assert!(line["timestamp"].as_str().unwrap().ends_with('Z'));
600 }
601 }
602
603 #[cfg(unix)]
604 #[test]
605 fn modes_are_private() {
606 use std::os::unix::fs::PermissionsExt;
607 let root = tempfile::tempdir().unwrap();
608 let cwd = tempfile::tempdir().unwrap();
609 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
610 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
611 writer.on_event(&init_event("sess-2", &cwd));
612 let path = writer.path().unwrap();
613 let file_mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
614 let dir_mode = std::fs::metadata(path.parent().unwrap())
615 .unwrap()
616 .permissions()
617 .mode()
618 & 0o777;
619 assert_eq!(file_mode, 0o600, "rollout is 0600");
620 assert_eq!(dir_mode, 0o700, "session dir is 0700");
621 }
622
623 #[test]
624 fn torn_tail_is_healed_on_reopen() {
625 let dir = tempfile::tempdir().unwrap();
626 let path = dir.path().join("rollout-x.jsonl");
627 std::fs::write(&path, "{\"ok\":1}\n{\"torn\":").unwrap();
628 let mut file = open_append_private(&path).unwrap();
629 file.write_all(b"{\"next\":2}\n").unwrap();
630 drop(file);
631 let text = std::fs::read_to_string(&path).unwrap();
632 assert_eq!(text, "{\"ok\":1}\n{\"torn\":\n{\"next\":2}\n");
633 let parsed: Vec<_> = text
635 .lines()
636 .filter(|l| serde_json::from_str::<Value>(l).is_ok())
637 .collect();
638 assert_eq!(parsed.len(), 2);
639 }
640
641 #[test]
642 fn no_init_means_no_file_and_errors_disable() {
643 let root = tempfile::tempdir().unwrap();
644 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
645 writer.on_event(&message_event(Role::User, "before init"));
646 assert!(writer.path().is_none(), "nothing written before Init");
647
648 let mut writer = TraceWriter::new(PathBuf::from("/dev/null/nope"), TraceExtras::default());
650 let cwd = std::env::temp_dir();
651 writer.on_event(&init_event("sess-3", &cwd));
652 assert!(writer.take_error().is_some());
653 writer.on_event(&message_event(Role::User, "x"));
655 }
656
657 #[test]
658 fn read_rollout_replays_and_tolerates() {
659 let root = tempfile::tempdir().unwrap();
660 let cwd = tempfile::tempdir().unwrap();
661 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
662 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
663 writer.on_event(&init_event("sess-r", &cwd));
664 writer.on_event(&message_event(Role::User, "hi"));
665 writer.on_event(&message_event(Role::Assistant, "yo"));
666 let path = writer.path().unwrap().to_path_buf();
667
668 {
670 let mut file = open_append_private(&path).unwrap();
671 file.write_all(
672 b"{\"timestamp\":\"x\",\"type\":\"future_thing\",\"payload\":{\"n\":1}}\n",
673 )
674 .unwrap();
675 file.write_all(b"{\"torn").unwrap();
676 }
677 let contents = read_rollout(&path).unwrap();
678 assert_eq!(contents.meta.session_id, "sess-r");
679 assert_eq!(contents.history.len(), 3);
681 assert_eq!(contents.history[0].role, Role::System);
682 assert_eq!(contents.history[2].role, Role::Assistant);
683 }
684
685 #[test]
686 fn read_rollout_folds_compacted() {
687 let dir = tempfile::tempdir().unwrap();
688 let path = dir.path().join("rollout-t-sess-c.jsonl");
689 let meta = serde_json::json!({"timestamp":"t","type":"session_meta","payload":{
690 "schema_version":1,"session_id":"sess-c","kind":"main","cwd":"/x",
691 "cli_version":"0","harness":"grok","api_schema":"mock","model":"m"}});
692 let msg = |role: &str, text: &str| {
693 serde_json::json!({"timestamp":"t","type":"message","payload":
694 {"role":role,"content":[{"type":"text","text":text}]}})
695 };
696 let compacted = serde_json::json!({"timestamp":"t","type":"compacted","payload":{
697 "summary":"s","replacement_history":[
698 {"role":"user","content":[{"type":"text","text":"summary of the past"}]}]}});
699 let lines = [
700 meta.to_string(),
701 msg("user", "old-1").to_string(),
702 msg("assistant", "old-2").to_string(),
703 compacted.to_string(),
704 msg("user", "after").to_string(),
705 ];
706 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
707 let contents = read_rollout(&path).unwrap();
708 assert_eq!(contents.history.len(), 2);
710 assert_eq!(contents.history[1].role, Role::User);
711 }
712
713 #[test]
714 fn resumed_writer_appends_without_a_new_header() {
715 let root = tempfile::tempdir().unwrap();
716 let cwd = tempfile::tempdir().unwrap();
717 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
718 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
719 writer.on_event(&init_event("sess-a", &cwd));
720 writer.on_event(&message_event(Role::User, "first run"));
721 let path = writer.path().unwrap().to_path_buf();
722 let before = read_lines(&path).len();
723 drop(writer);
724
725 let mut resumed = TraceWriter::resume(path.clone(), root.path().join("sessions")).unwrap();
726 resumed.on_event(&init_event("sess-a", &cwd)); resumed.on_event(&message_event(Role::User, "second run"));
728 assert!(resumed.take_error().is_none());
729 let lines = read_lines(&path);
730 assert_eq!(lines.len(), before + 1, "exactly one new message line");
731 assert_eq!(
732 lines.iter().filter(|l| l["type"] == "session_meta").count(),
733 1,
734 "one header, ever"
735 );
736 }
737
738 #[test]
739 fn find_latest_scopes_by_cwd_and_kind() {
740 let root = tempfile::tempdir().unwrap();
741 let sessions = root.path().join("sessions");
742 let cwd_a = tempfile::tempdir().unwrap();
743 let cwd_a = std::fs::canonicalize(cwd_a.path()).unwrap();
744 let cwd_b = tempfile::tempdir().unwrap();
745 let cwd_b = std::fs::canonicalize(cwd_b.path()).unwrap();
746
747 let mut w1 = TraceWriter::new(sessions.clone(), TraceExtras::default());
749 w1.on_event(&init_event("sess-old", &cwd_a));
750 let mut w2 = TraceWriter::new(
751 sessions.clone(),
752 TraceExtras {
753 kind: Some("subagent".to_string()),
754 ..Default::default()
755 },
756 );
757 w2.on_event(&init_event("sess-sub", &cwd_a));
758 let mut w3 = TraceWriter::new(sessions.clone(), TraceExtras::default());
759 w3.on_event(&init_event("sess-b", &cwd_b));
760
761 let latest = find_latest_rollout(&sessions, &cwd_a).unwrap();
763 assert!(latest.to_string_lossy().contains("sess-old"), "{latest:?}");
764 let latest_b = find_latest_rollout(&sessions, &cwd_b).unwrap();
766 assert!(latest_b.to_string_lossy().contains("sess-b"));
767 let empty = tempfile::tempdir().unwrap();
769 assert!(find_latest_rollout(&sessions, empty.path()).is_none());
770
771 let by_id = find_rollout_by_id(&sessions, &cwd_b, "sess-sub").unwrap();
774 assert!(by_id.to_string_lossy().contains("sess-sub"));
775 assert!(find_rollout_by_id(&sessions, &cwd_b, "sess-nope").is_none());
776 }
777
778 #[test]
779 fn usage_records_round_trip_for_exact_resume() {
780 use locode_protocol::{Report, Status, Usage};
781 let root = tempfile::tempdir().unwrap();
782 let cwd = tempfile::tempdir().unwrap();
783 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
784 let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
785 writer.on_event(&init_event("sess-u", &cwd));
786 writer.on_event(&message_event(Role::User, "hi"));
787 let report = |input: u64, output: u64| Report {
789 schema_version: 1,
790 status: Status::Completed,
791 harness: "grok".into(),
792 api_schema: "mock".into(),
793 final_message: None,
794 structured_output: None,
795 turns: 1,
796 tool_calls: vec![],
797 usage: Usage {
798 input_tokens: input,
799 output_tokens: output,
800 cache_read_tokens: Some(7),
801 ..Default::default()
802 },
803 session_id: "sess-u".into(),
804 stop_reason: None,
805 error: None,
806 };
807 writer.on_event(&locode_protocol::Event::Result {
808 report: report(100, 10),
809 });
810 writer.on_event(&locode_protocol::Event::Result {
811 report: report(200, 20),
812 });
813 assert!(writer.take_error().is_none());
814
815 let contents = read_rollout(writer.path().unwrap()).unwrap();
816 let usage = contents.last_usage.expect("usage recovered");
817 assert_eq!(usage.input_tokens, 200, "the last run's usage");
818 assert_eq!(usage.output_tokens, 20);
819 assert_eq!(usage.cache_read_tokens, Some(7));
820 let bare = root.path().join("bare.jsonl");
822 let meta_line = std::fs::read_to_string(writer.path().unwrap())
823 .unwrap()
824 .lines()
825 .next()
826 .unwrap()
827 .to_string();
828 std::fs::write(&bare, meta_line + "\n").unwrap();
829 assert!(read_rollout(&bare).unwrap().last_usage.is_none());
830 }
831
832 #[test]
833 fn reserved_kinds_and_parents_serialize() {
834 let root = tempfile::tempdir().unwrap();
835 let cwd = tempfile::tempdir().unwrap();
836 let cwd = std::fs::canonicalize(cwd.path()).unwrap();
837 let mut writer = TraceWriter::new(
838 root.path().join("sessions"),
839 TraceExtras {
840 cli_version: "x".to_string(),
841 kind: Some("subagent".to_string()),
842 parent_id: Some("sess-parent".to_string()),
843 group: Some("wf-1".to_string()),
844 ..Default::default()
845 },
846 );
847 writer.on_event(&init_event("sess-4", &cwd));
848 let lines = read_lines(writer.path().unwrap());
849 let meta = &lines[0]["payload"];
850 assert_eq!(meta["kind"], "subagent");
851 assert_eq!(meta["parent_id"], "sess-parent");
852 assert_eq!(meta["group"], "wf-1");
853 let parsed: SessionMeta = serde_json::from_value(meta.clone()).unwrap();
856 assert_eq!(parsed.kind, "subagent");
857 }
858}