1use std::collections::{HashMap, HashSet};
14use std::fs;
15use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
16use std::path::{Path, PathBuf};
17
18use serde_json::Value;
19use unicode_normalization::UnicodeNormalization;
20use uuid::Uuid;
21
22use forge_primitives::{
23 FORGE_WORKER_TAG_PREFIX, SDKSessionInfo, SessionHistory, SessionMessage, SessionMessageKind,
24 worker_tag,
25};
26use forge_sdk::projects_dir_for;
27
28pub(crate) fn is_valid_uuid(s: &str) -> bool {
33 s.len() == 36 && Uuid::try_parse(s).is_ok()
34}
35
36const MAX_SANITIZED_LENGTH: usize = 200;
37
38fn try_read_dir(dir: &Path) -> Option<fs::ReadDir> {
44 match fs::read_dir(dir) {
45 Ok(iter) => Some(iter),
46 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
47 Err(e) => {
48 tracing::warn!(
49 target: "forge_agent::userdata::catalog",
50 path = %dir.display(),
51 error = %e,
52 "failed to read catalog directory"
53 );
54 None
55 }
56 }
57}
58
59const LITE_READ_BUF_SIZE: u64 = 65_536;
63
64pub(crate) fn sanitize_path_public(name: &str) -> String {
69 sanitize_path(name)
70}
71
72pub fn project_key_for_directory(path: Option<&str>) -> String {
77 sanitize_path(&canonicalize_path(path.unwrap_or(".")))
78}
79
80fn canonicalize_path(path: &str) -> String {
88 let resolved = match fs::canonicalize(path) {
89 Ok(p) => p.to_string_lossy().into_owned(),
90 Err(_) => path.to_string(),
91 };
92 resolved.nfc().collect()
93}
94
95fn already_kept(seen: &mut std::collections::HashSet<String>, uuid: &str) -> bool {
103 !uuid.is_empty() && !seen.insert(uuid.to_owned())
104}
105
106fn parse_session_messages<R: std::io::Read>(reader: R) -> SessionHistory {
107 let mut out = Vec::new();
108 let mut compaction_count = 0_u32;
109 let mut seen = std::collections::HashSet::new();
110 let mut queued = Vec::new();
114 for (idx, line_res) in BufReader::new(reader).lines().enumerate() {
115 let line = match line_res {
116 Ok(l) => l,
117 Err(e) => {
118 tracing::warn!(
124 line_no = idx,
125 error = %e,
126 compactions_counted_before_truncation = compaction_count,
127 "session scan: read failed; truncating both the message list and the compaction count"
128 );
129 break;
130 }
131 };
132 if line.is_empty() {
133 continue;
134 }
135 let value = match serde_json::from_str::<Value>(&line) {
136 Ok(v) => v,
137 Err(e) => {
138 tracing::debug!(
139 line_no = idx,
140 error = %e,
141 "session scan: skipping unparseable line"
142 );
143 continue;
144 }
145 };
146 if let Some(remove) = queue_remove_row(&value) {
147 let row = queued_start_row(&mut queued, &value, remove);
148 if !already_kept(&mut seen, &row.uuid) {
149 out.push(row);
150 }
151 continue;
152 }
153 let Some((message, boundary)) = transcript_row(&value) else {
154 continue;
155 };
156 if is_queued_attachment_row(&value) {
157 queued.push(message);
158 continue;
159 }
160 if already_kept(&mut seen, &message.uuid) {
161 continue;
162 }
163 if boundary {
164 compaction_count = compaction_count.saturating_add(1);
165 }
166 out.push(message);
167 }
168 SessionHistory { messages: out, compaction_count }
171}
172
173fn is_queued_attachment_row(value: &Value) -> bool {
182 if value.get("type").and_then(Value::as_str) != Some("attachment") {
183 return false;
184 }
185 let Some(attachment) = value.get("attachment") else { return false };
186 attachment.get("type").and_then(Value::as_str) == Some("queued_command")
187 && attachment.get("commandMode").and_then(Value::as_str) != Some("task-notification")
188}
189
190const START_REASONS: [&str; 3] = ["absorbed_mid_turn", "delivered_to_agent", ""];
196
197fn queue_remove_row(value: &Value) -> Option<SessionMessage> {
205 if value.get("type").and_then(Value::as_str) != Some("queue-operation")
206 || value.get("operation").and_then(Value::as_str) != Some("remove")
207 {
208 return None;
209 }
210 let reason = value.get("reason").and_then(Value::as_str).unwrap_or_default();
211 if !START_REASONS.contains(&reason) {
212 return None;
213 }
214 let content = value.get("content").and_then(Value::as_str)?;
215 if content.trim_start().starts_with("<task-notification>") {
219 return None;
220 }
221 let attachment = serde_json::json!({ "prompt": content });
222 Some(SessionMessage {
223 kind: SessionMessageKind::User,
224 uuid: queue_row_id(value),
225 session_id: value.get("sessionId").and_then(Value::as_str).unwrap_or_default().to_owned(),
226 message: synthesize_queued_command_message(&attachment),
227 parent_tool_use_id: None,
228 timestamp: value.get("timestamp").and_then(Value::as_str).map(str::to_owned),
229 tool_use_result: None,
230 synthetic: false,
231 })
232}
233
234fn queue_row_id(value: &Value) -> String {
236 use std::hash::{Hash as _, Hasher as _};
237 let timestamp = value.get("timestamp").and_then(Value::as_str).unwrap_or_default();
238 let content = value.get("content").and_then(Value::as_str).unwrap_or_default();
239 let mut hasher = std::collections::hash_map::DefaultHasher::new();
240 content.hash(&mut hasher);
241 format!("queue-remove-{timestamp}-{:x}", hasher.finish())
242}
243
244fn queued_prompt_text(row: &SessionMessage) -> Option<&str> {
246 row.message.get("content").and_then(Value::as_array)?.iter().find_map(|block| {
247 (block.get("type")?.as_str()? == "queued_command")
248 .then(|| block.get("prompt")?.as_str())
249 .flatten()
250 })
251}
252
253fn queued_start_row(
260 queued: &mut Vec<SessionMessage>,
261 remove: &Value,
262 mut row: SessionMessage,
263) -> SessionMessage {
264 let content = remove.get("content").and_then(Value::as_str).unwrap_or_default();
265 let at = queued.iter().position(|held| queued_prompt_text(held) == Some(content));
269 if let Some(at) = at {
270 let attachment = queued.remove(at);
271 row.message = attachment.message;
272 row.parent_tool_use_id = attachment.parent_tool_use_id;
273 row.synthetic = attachment.synthetic;
274 if row.session_id.is_empty() {
275 row.session_id = attachment.session_id;
276 }
277 }
278 row
279}
280
281pub fn has_a_transcript_row(message: &forge_primitives::Message) -> bool {
298 use forge_primitives::Message;
299 let parent = match message {
303 Message::User { parent_tool_use_id, .. }
304 | Message::Assistant { parent_tool_use_id, .. }
305 | Message::StopHookSummary { parent_tool_use_id, .. } => parent_tool_use_id.as_deref(),
306 Message::PermissionDenied { extras, .. } => {
309 extras.get("parent_tool_use_id").and_then(Value::as_str)
310 }
311 _ => None,
312 };
313 if forge_primitives::names_a_dispatch(parent) {
314 return false;
315 }
316 match message {
317 Message::User { .. }
318 | Message::Assistant { .. }
319 | Message::StopHookSummary { .. }
320 | Message::CompactBoundary { .. }
321 | Message::PermissionDenied { .. }
322 | Message::Notification { .. }
323 | Message::TurnDuration { .. }
327 | Message::TaskProgress { .. } => true,
328 Message::System { subtype, .. } => {
331 !SYSTEM_ROWS_DRAWN_ELSEWHERE.contains(&subtype.as_str())
332 }
333 Message::Unknown { type_str, .. } => !UNSERVED_ROWS.contains(&type_str.as_str()),
336 _ => false,
337 }
338}
339
340const SYSTEM_ROWS_DRAWN_ELSEWHERE: [&str; 11] = [
347 "task_started",
348 "task_updated",
349 "task_notification",
350 "background_tasks_changed",
351 "commands_changed",
352 "init",
353 "status",
354 "thinking_tokens",
355 "hook_started",
356 "hook_progress",
357 "hook_response",
358];
359
360const UNSERVED_ROWS: [&str; 18] = [
369 "last-prompt",
371 "atis-latch",
372 "queue-operation",
373 "worktree-state",
374 "mode",
375 "pr-link",
376 "tag",
377 "permission-mode",
378 "cost-state",
379 "ai-title",
380 "file-history-snapshot",
381 "summary",
382 "result",
385 "error",
386 "rate_limit_event",
387 "command_lifecycle",
388 "stream_event",
389 "tool_progress",
390];
391
392fn transcript_row(value: &Value) -> Option<(SessionMessage, bool)> {
400 let row_type = value.get("type").and_then(Value::as_str);
401 let mut boundary = false;
402 let (kind, message) = match row_type {
403 Some("user") => (SessionMessageKind::User, value.get("message").cloned()),
404 Some("assistant") => (SessionMessageKind::Assistant, value.get("message").cloned()),
405 Some("attachment") => match value.get("attachment") {
413 Some(att) if att.get("type").and_then(Value::as_str) == Some("queued_command") => {
414 (SessionMessageKind::User, Some(synthesize_queued_command_message(att)))
415 }
416 Some(att) if is_hook_attachment(att) => {
430 (SessionMessageKind::System, Some(synthesize_hook_frame(value, att)))
431 }
432 _ => return None,
433 },
434 Some("system")
439 if value.get("subtype").and_then(Value::as_str) == Some("compact_boundary") =>
440 {
441 boundary = true;
442 let missing = missing_boundary_facts(value);
450 if !missing.is_empty() {
451 let uuid = value.get("uuid").and_then(Value::as_str).unwrap_or_default();
452 let session = session_in_row(value);
453 tracing::warn!(
454 target: "forge_agent::userdata::catalog",
455 event_name = "compact_boundary_missing_facts",
456 uuid,
457 session,
458 missing = %missing.join(", "),
459 "compact_boundary row did not yield every fact; counted, but the kept frame arrives untyped",
460 );
461 }
462 (SessionMessageKind::System, Some(compact_boundary_frame(value)))
463 }
464 Some("system")
468 if value.get("subtype").and_then(Value::as_str) == Some("stop_hook_summary") =>
469 {
470 (SessionMessageKind::System, Some(wire_session_frame(value)))
471 }
472 Some("system")
482 if value
483 .get("subtype")
484 .and_then(Value::as_str)
485 .is_none_or(|subtype| !SYSTEM_ROWS_DRAWN_ELSEWHERE.contains(&subtype)) =>
486 {
487 (SessionMessageKind::System, Some(wire_session_frame(value)))
488 }
489 Some(other) if other != "system" && !UNSERVED_ROWS.contains(&other) => {
495 (SessionMessageKind::System, Some(value.clone()))
496 }
497 _ => return None,
498 };
499 if value
502 .get("parent_tool_use_id")
503 .and_then(Value::as_str)
504 .is_some_and(|parent| !parent.trim().is_empty())
505 {
506 return None;
507 }
508 let uuid = value.get("uuid").and_then(Value::as_str).unwrap_or_default().to_string();
509 let sess = session_in_row(value);
510 let timestamp = value.get("timestamp").and_then(Value::as_str).map(str::to_owned);
511 let synthetic = ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly", "turnCompanion"]
519 .iter()
520 .any(|flag| value.get(flag).and_then(Value::as_bool).unwrap_or(false));
521 Some((
522 SessionMessage {
523 kind,
524 uuid,
525 session_id: sess,
526 message: message.unwrap_or(Value::Null),
527 parent_tool_use_id: None,
528 timestamp,
529 tool_use_result: value.get("toolUseResult").filter(|result| !result.is_null()).cloned(),
530 synthetic,
531 },
532 boundary,
533 ))
534}
535
536fn session_in_row(value: &Value) -> String {
539 value
540 .get("session_id")
541 .or_else(|| value.get("sessionId"))
542 .and_then(Value::as_str)
543 .unwrap_or_default()
544 .to_owned()
545}
546
547fn wire_session_frame(value: &Value) -> Value {
553 let mut frame = value.clone();
554 if let Some(record) = frame.as_object_mut() {
555 record.remove("sessionId");
556 record.insert("session_id".into(), Value::String(session_in_row(value)));
557 }
558 frame
559}
560
561fn missing_boundary_facts(value: &Value) -> Vec<&'static str> {
568 let metadata = value.get("compactMetadata");
569 let read = |key: &str| metadata.and_then(|metadata| metadata.get(key));
570 [
571 ("trigger", read("trigger").and_then(Value::as_str).is_none()),
572 ("preTokens", read("preTokens").and_then(Value::as_u64).is_none()),
573 ("postTokens", read("postTokens").and_then(Value::as_u64).is_none()),
574 ]
575 .into_iter()
576 .filter_map(|(fact, absent)| absent.then_some(fact))
577 .collect()
578}
579
580fn compact_boundary_frame(value: &Value) -> Value {
589 let metadata = value.get("compactMetadata");
590 let field =
591 |key: &str| metadata.and_then(|metadata| metadata.get(key)).cloned().unwrap_or(Value::Null);
592 let mut frame = serde_json::json!({
593 "type": "system",
594 "subtype": "compact_boundary",
595 "session_id": session_in_row(value),
596 "uuid": value.get("uuid").and_then(Value::as_str).unwrap_or_default(),
597 "compact_metadata": {
598 "trigger": field("trigger"),
599 "pre_tokens": field("preTokens"),
600 "post_tokens": field("postTokens"),
601 },
602 });
603 if let Some(timestamp) = value.get("timestamp") {
604 frame["timestamp"] = timestamp.clone();
605 }
606 frame
607}
608
609fn synthesize_queued_command_message(attachment: &Value) -> Value {
614 let mut block = serde_json::Map::new();
615 block.insert("type".into(), Value::String("queued_command".into()));
616 if let Some(prompt) = attachment.get("prompt") {
617 block.insert("prompt".into(), prompt.clone());
618 }
619 if let Some(mode) = attachment.get("commandMode") {
620 block.insert("commandMode".into(), mode.clone());
621 }
622 if let Some(src) = attachment.get("source_uuid") {
623 block.insert("source_uuid".into(), src.clone());
624 }
625 serde_json::json!({
626 "role": "user",
627 "content": [Value::Object(block)],
628 })
629}
630
631fn live_hook_event(attachment: &Value) -> bool {
639 matches!(attachment.get("hookEvent").and_then(Value::as_str), Some("SessionStart" | "Setup"))
640}
641
642fn is_hook_attachment(attachment: &Value) -> bool {
648 live_hook_event(attachment)
649 && matches!(
650 attachment.get("type").and_then(Value::as_str),
651 Some("hook_success" | "hook_cancelled" | "hook_non_blocking_error")
652 )
653}
654
655fn synthesize_hook_frame(row: &Value, attachment: &Value) -> Value {
664 let mut frame = serde_json::Map::new();
665 frame.insert("type".into(), Value::String("system".into()));
666 frame.insert("subtype".into(), Value::String("hook_response".into()));
667 if let Some(id) = row.get("uuid") {
668 frame.insert("hook_id".into(), id.clone());
669 frame.insert("uuid".into(), id.clone());
670 }
671 for (field, from) in [("hook_name", "hookName"), ("hook_event", "hookEvent")] {
672 if let Some(held) = attachment.get(from) {
673 frame.insert(field.into(), held.clone());
674 }
675 }
676 frame.insert("output".into(), Value::String(hook_output(attachment).unwrap_or_default()));
682 for field in ["stdout", "stderr"] {
683 let held = attachment.get(field).and_then(Value::as_str).unwrap_or_default();
684 frame.insert(field.into(), Value::String(held.to_owned()));
685 }
686 let code = attachment.get("exitCode").and_then(Value::as_i64).unwrap_or(0);
687 frame.insert("exit_code".into(), Value::Number(code.into()));
688 frame.insert("outcome".into(), hook_outcome(attachment));
689 frame.insert("session_id".into(), Value::String(session_in_row(row)));
690 frame.insert("transcript".into(), Value::Bool(true));
691 Value::Object(frame)
692}
693
694fn hook_outcome(attachment: &Value) -> Value {
700 let outcome = match attachment.get("type").and_then(Value::as_str) {
701 Some("hook_non_blocking_error") => "error",
702 Some("hook_cancelled") => "cancelled",
703 _ => "success",
704 };
705 Value::String(outcome.to_owned())
706}
707
708fn hook_output(attachment: &Value) -> Option<String> {
715 let held = match attachment.get("content") {
716 Some(Value::String(text)) => Some(text.clone()),
717 Some(Value::Array(lines)) => {
718 Some(lines.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("\n"))
719 }
720 _ => None,
721 };
722 held.into_iter()
723 .chain(
724 ["stdout", "stderr"].iter().filter_map(|field| {
725 attachment.get(field).and_then(Value::as_str).map(str::to_owned)
726 }),
727 )
728 .find(|text| !text.trim().is_empty())
729}
730
731fn sanitize_path(name: &str) -> String {
736 let sanitized: String =
737 name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
738 if sanitized.len() <= MAX_SANITIZED_LENGTH {
739 return sanitized;
740 }
741 let hash = simple_hash(name);
742 let truncated: String = sanitized.chars().take(MAX_SANITIZED_LENGTH).collect();
743 format!("{truncated}-{hash}")
744}
745
746fn simple_hash(s: &str) -> String {
748 let mut h: i64 = 0;
749 for ch in s.chars() {
750 let c = ch as i64;
751 h = (h << 5).wrapping_sub(h).wrapping_add(c);
752 h &= 0xFFFF_FFFF;
754 if h >= 0x8000_0000 {
755 h -= 0x1_0000_0000;
756 }
757 }
758 let mut n = h.unsigned_abs();
759 if n == 0 {
760 return "0".into();
761 }
762 let digits = b"0123456789abcdefghijklmnopqrstuvwxyz";
763 let mut out = Vec::new();
764 while n > 0 {
765 out.push(digits[(n % 36) as usize]);
766 n /= 36;
767 }
768 out.reverse();
769 String::from_utf8(out).unwrap_or_default()
770}
771
772fn project_dir_for(config_dir: &Path, project_path: &str) -> PathBuf {
773 projects_dir_for(config_dir).join(sanitize_path(&canonicalize_path(project_path)))
774}
775
776const LIST_SESSIONS_MAX_CONCURRENT: usize = 16;
783
784pub fn should_exclude_worker_tag(info: &SDKSessionInfo) -> bool {
787 info.tag.as_deref().is_some_and(|t| t.starts_with(FORGE_WORKER_TAG_PREFIX))
788}
789
790#[derive(Debug, Clone, PartialEq, Eq)]
796pub enum Workers {
797 Hidden,
800 Only(String),
803}
804
805pub async fn list_sessions(
826 config_dir: &Path,
827 directory: Option<&str>,
828 limit: Option<usize>,
829 offset: usize,
830 workers: Workers,
831 tag_cache: Option<&std::sync::Arc<SessionTagCache>>,
832) -> Vec<SDKSessionInfo> {
833 let search_dirs: Vec<PathBuf> = if let Some(dir) = directory {
834 vec![project_dir_for(config_dir, dir)]
835 } else {
836 try_read_dir(&projects_dir_for(config_dir))
837 .map(|iter| {
838 iter.flatten()
839 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
840 .map(|e| e.path())
841 .collect()
842 })
843 .unwrap_or_default()
844 };
845
846 let mut candidates: Vec<PathBuf> = Vec::new();
850 for project_dir in search_dirs {
851 let Some(iter) = try_read_dir(&project_dir) else {
852 continue;
853 };
854 for entry in iter.flatten() {
855 let path = entry.path();
856 if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
857 candidates.push(path);
858 }
859 }
860 }
861
862 if let (Workers::Only(label), Some(cache)) = (&workers, tag_cache) {
869 let wanted = worker_tag(label);
870 let before = candidates.len();
871 candidates.retain(|path| cache.carries_tag(path, &wanted).unwrap_or(true));
873 if before > candidates.len() {
874 tracing::debug!(
878 event_name = "session_scan_narrowed",
879 dir = directory.unwrap_or("<every project>"),
880 label = %label,
881 skipped = before - candidates.len(),
882 to_read = candidates.len(),
883 "transcripts whose cached tag rules them out were not read",
884 );
885 }
886 }
887
888 let mut ordered: Vec<(PathBuf, u64)> = candidates
894 .into_iter()
895 .map(|path| {
896 let mtime = std::fs::metadata(&path)
897 .and_then(|meta| meta.modified())
898 .ok()
899 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
900 .map_or(0, |age| u64::try_from(age.as_millis()).unwrap_or(u64::MAX));
901 (path, mtime)
902 })
903 .collect();
904 ordered.sort_by_key(|(path, mtime)| (std::cmp::Reverse(*mtime), path.clone()));
905
906 let wanted_tag = match &workers {
907 Workers::Only(label) => Some(worker_tag(label)),
908 Workers::Hidden => None,
909 };
910 let matches = |info: &SDKSessionInfo| match &wanted_tag {
911 Some(wanted) => info.tag.as_deref() == Some(wanted.as_str()),
912 None => !should_exclude_worker_tag(info),
913 };
914 let wanted_rows = limit.map(|l| offset.saturating_add(l));
917
918 let mut entries: Vec<SDKSessionInfo> = Vec::new();
919 let mut paths = ordered.into_iter();
920 let mut set: tokio::task::JoinSet<Option<SDKSessionInfo>> = tokio::task::JoinSet::new();
921 let spawn_read = |set: &mut tokio::task::JoinSet<Option<SDKSessionInfo>>, path: PathBuf| {
922 let cache = tag_cache.cloned();
923 set.spawn_blocking(move || read_session_info(&path, cache.as_deref()));
924 };
925 let mut kept = 0usize;
931 loop {
932 let owed = wanted_rows.map_or(usize::MAX, |wanted| wanted.saturating_sub(kept));
933 if owed == 0 {
934 break;
935 }
936 let width = owed.min(LIST_SESSIONS_MAX_CONCURRENT);
942 while set.len() < width {
943 let Some((path, _)) = paths.next() else { break };
944 spawn_read(&mut set, path);
945 }
946 let Some(res) = set.join_next().await else { break };
947 match res {
948 Ok(Some(info)) => {
949 if matches(&info) {
950 kept += 1;
951 }
952 entries.push(info);
953 }
954 Ok(None) => {}
955 Err(err) => {
956 tracing::debug!(
957 target: "forge_agent::catalog::scan",
958 error = %err,
959 "list_sessions: per-file read task failed",
960 );
961 }
962 }
963 }
964 if let Some(wanted) = wanted_rows {
965 tracing::debug!(
966 event_name = "session_scan_read",
967 dir = directory.unwrap_or("<every project>"),
968 read = entries.len(),
969 wanted,
970 "the listing read the rows its cap serves, newest first",
971 );
972 }
973
974 entries.sort_by_key(|e| std::cmp::Reverse(e.last_modified));
975 let entries: Vec<SDKSessionInfo> = entries.into_iter().filter(|info| matches(info)).collect();
976 let end = limit.map_or(entries.len(), |l| offset.saturating_add(l));
977 entries.into_iter().skip(offset).take(end.saturating_sub(offset)).collect()
978}
979
980const UNREAD_TRANSCRIPT_COUNT_NOTE: &str =
985 "transcript unread, so the compaction count is unknown rather than zero";
986
987pub fn get_session_messages(
995 config_dir: &Path,
996 session_id: &str,
997 directory: Option<&str>,
998) -> SessionHistory {
999 read_session_messages(config_dir, session_id, directory, true)
1000}
1001
1002pub(crate) fn read_session_messages_main_thread(
1007 config_dir: &Path,
1008 session_id: &str,
1009 directory: Option<&str>,
1010) -> SessionHistory {
1011 read_session_messages(config_dir, session_id, directory, false)
1012}
1013
1014fn read_session_messages(
1015 config_dir: &Path,
1016 session_id: &str,
1017 directory: Option<&str>,
1018 fold_sidecars: bool,
1019) -> SessionHistory {
1020 if !is_valid_uuid(session_id) {
1021 tracing::warn!(
1022 %session_id,
1023 "get_session_messages: not a session uuid; {UNREAD_TRANSCRIPT_COUNT_NOTE}",
1024 );
1025 return SessionHistory::default();
1026 }
1027 let file_name = format!("{session_id}.jsonl");
1028 let candidate = if let Some(dir) = directory {
1029 Some(project_dir_for(config_dir, dir).join(&file_name))
1030 } else {
1031 try_read_dir(&projects_dir_for(config_dir)).and_then(|iter| {
1032 iter.flatten().map(|e| e.path().join(&file_name)).find(|p| p.is_file())
1033 })
1034 };
1035 let Some(path) = candidate else {
1036 tracing::debug!(
1037 %session_id,
1038 "get_session_messages: no on-disk file for session; {UNREAD_TRANSCRIPT_COUNT_NOTE}",
1039 );
1040 return SessionHistory::default();
1041 };
1042 let file = match fs::File::open(&path) {
1043 Ok(file) => file,
1044 Err(err) => {
1045 tracing::warn!(
1046 %session_id,
1047 path = %path.display(),
1048 %err,
1049 "get_session_messages: open failed; backfill renders empty and {UNREAD_TRANSCRIPT_COUNT_NOTE}",
1050 );
1051 return SessionHistory::default();
1052 }
1053 };
1054 let mut history = parse_session_messages(file);
1055 if fold_sidecars {
1056 let (sidecars, links) = subagent_sidecars(session_id, &path);
1057 fold_subagent_sidecars(&mut history.messages, sidecars, &links, session_id);
1058 }
1059 history
1060}
1061
1062fn fold_subagent_sidecars(
1073 messages: &mut Vec<SessionMessage>,
1074 sidecars: Vec<(String, Vec<SessionMessage>)>,
1075 links: &HashMap<String, SidecarLink>,
1076 session_id: &str,
1077) {
1078 let mut made_under: HashMap<String, String> = HashMap::new();
1083 for (dispatch, rows) in &sidecars {
1084 for row in rows {
1085 for id in tool_use_ids(row) {
1086 made_under.insert(id.to_owned(), dispatch.clone());
1087 }
1088 }
1089 }
1090 for link in links.values() {
1091 let Some(parent) = link.parent_agent.as_deref() else {
1092 continue;
1093 };
1094 let Some(parent_link) = links.get(parent) else {
1095 continue;
1096 };
1097 made_under.insert(link.dispatch.clone(), parent_link.dispatch.clone());
1098 }
1099 let answered: HashSet<String> =
1104 messages.iter().flat_map(answered_dispatches).map(str::to_owned).collect();
1105 let mut folded: HashMap<String, Vec<Block>> = HashMap::new();
1110 for (dispatch, mut rows) in sidecars {
1111 let Some(owner) = owning_dispatch(&dispatch, &answered, &made_under) else {
1112 tracing::debug!(
1115 event_name = "subagent_sidecar_unresolved",
1116 session = %session_id,
1117 dispatch = %dispatch,
1118 "subagent sidecar skipped: no dispatch in its chain is answered by the transcript",
1119 );
1120 continue;
1121 };
1122 for row in &mut rows {
1123 row.parent_tool_use_id = Some(owner.to_owned());
1124 }
1125 folded.entry(owner.to_owned()).or_default().push(Block { dispatch, rows });
1126 }
1127 for (owner, mut blocks) in folded {
1128 blocks.reverse();
1131 let Some(index) = messages.iter().position(|row| answers_dispatch(row, &owner)) else {
1135 continue;
1136 };
1137 let rows = emit_sidecar_tree(blocks);
1138 messages.splice((index + 1)..=index, rows);
1139 }
1140}
1141
1142struct Block {
1144 dispatch: String,
1145 rows: Vec<SessionMessage>,
1146}
1147
1148fn emit_sidecar_tree(mut blocks: Vec<Block>) -> Vec<SessionMessage> {
1152 let by_dispatch: HashMap<String, usize> =
1153 blocks.iter().enumerate().map(|(at, block)| (block.dispatch.clone(), at)).collect();
1154 let mut inline = vec![false; blocks.len()];
1157 for (at, block) in blocks.iter().enumerate() {
1158 for row in &block.rows {
1159 for id in tool_use_ids(row) {
1160 if let Some(&kid) = by_dispatch.get(id)
1161 && kid != at
1162 {
1163 inline[kid] = true;
1164 }
1165 }
1166 }
1167 }
1168 let mut out = Vec::new();
1169 let mut emitted = vec![false; blocks.len()];
1170 for at in (0..blocks.len()).filter(|at| !inline[*at]) {
1171 emit_block(&mut blocks, at, &by_dispatch, &mut emitted, &mut out);
1172 }
1173 for at in 0..blocks.len() {
1176 emit_block(&mut blocks, at, &by_dispatch, &mut emitted, &mut out);
1177 }
1178 out
1179}
1180
1181fn emit_block(
1184 blocks: &mut [Block],
1185 at: usize,
1186 by_dispatch: &HashMap<String, usize>,
1187 emitted: &mut [bool],
1188 out: &mut Vec<SessionMessage>,
1189) {
1190 if emitted[at] {
1191 return;
1192 }
1193 emitted[at] = true;
1194 for row in std::mem::take(&mut blocks[at].rows) {
1195 let mut kids = Vec::new();
1196 for id in tool_use_ids(&row) {
1197 if let Some(&kid) = by_dispatch.get(id)
1198 && kid != at
1199 {
1200 kids.push(kid);
1201 }
1202 }
1203 out.push(row);
1204 for kid in kids {
1205 emit_block(blocks, kid, by_dispatch, emitted, out);
1206 }
1207 }
1208}
1209
1210fn owning_dispatch<'a>(
1213 dispatch: &'a str,
1214 answered: &HashSet<String>,
1215 made_under: &'a HashMap<String, String>,
1216) -> Option<&'a str> {
1217 let mut cursor = dispatch;
1218 for _ in 0..=made_under.len() {
1221 if answered.contains(cursor) {
1222 return Some(cursor);
1223 }
1224 cursor = made_under.get(cursor)?;
1225 }
1226 None
1227}
1228
1229fn tool_use_ids(row: &SessionMessage) -> impl Iterator<Item = &str> {
1231 block_field(row, "tool_use", "id")
1232}
1233
1234fn answered_dispatches(row: &SessionMessage) -> impl Iterator<Item = &str> {
1236 block_field(row, "tool_result", "tool_use_id")
1237}
1238
1239fn block_field<'a>(
1241 row: &'a SessionMessage,
1242 kind: &'static str,
1243 field: &'static str,
1244) -> impl Iterator<Item = &'a str> {
1245 row.message.get("content").and_then(Value::as_array).into_iter().flatten().filter_map(
1246 move |block| {
1247 (block.get("type").and_then(Value::as_str) == Some(kind))
1248 .then(|| block.get(field).and_then(Value::as_str))
1249 .flatten()
1250 },
1251 )
1252}
1253
1254fn answers_dispatch(row: &SessionMessage, dispatch: &str) -> bool {
1256 answered_dispatches(row).any(|answered| answered == dispatch)
1257}
1258
1259const SIDECAR_BUDGET_BYTES: u64 = 8 * 1024 * 1024;
1264
1265struct SidecarLink {
1268 dispatch: String,
1269 parent_agent: Option<String>,
1270}
1271
1272type SidecarRead = (Vec<(String, Vec<SessionMessage>)>, HashMap<String, SidecarLink>);
1274
1275fn subagent_sidecars(session_id: &str, transcript: &Path) -> SidecarRead {
1289 subagent_sidecars_with_budget(session_id, transcript, SIDECAR_BUDGET_BYTES)
1290}
1291
1292fn subagent_sidecars_with_budget(session_id: &str, transcript: &Path, budget: u64) -> SidecarRead {
1295 let Some(dir) = transcript.parent() else {
1296 return (Vec::new(), HashMap::new());
1297 };
1298 let sidecar_dir = dir.join(session_id).join("subagents");
1299 let entries = match fs::read_dir(&sidecar_dir) {
1300 Ok(entries) => entries,
1301 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1303 return (Vec::new(), HashMap::new());
1304 }
1305 Err(error) => {
1306 tracing::debug!(
1307 event_name = "subagent_sidecar_dir_unreadable",
1308 session = %session_id,
1309 dir = %sidecar_dir.display(),
1310 %error,
1311 "subagent sidecars skipped: the directory could not be read",
1312 );
1313 return (Vec::new(), HashMap::new());
1314 }
1315 };
1316 let mut metas: Vec<(PathBuf, std::time::SystemTime)> = entries
1317 .filter_map(|entry| match entry {
1318 Ok(entry) => Some(entry),
1319 Err(error) => {
1320 tracing::debug!(
1321 event_name = "subagent_sidecar_dir_entry_unreadable",
1322 session = %session_id,
1323 dir = %sidecar_dir.display(),
1324 %error,
1325 "a subagent sidecar entry was skipped: it could not be read",
1326 );
1327 None
1328 }
1329 })
1330 .filter(|entry| entry.file_name().to_str().is_some_and(|name| name.ends_with(".meta.json")))
1331 .filter_map(|entry| {
1332 let modified = match entry.metadata().and_then(|meta| meta.modified()) {
1333 Ok(modified) => modified,
1334 Err(error) => {
1335 tracing::debug!(
1336 event_name = "subagent_sidecar_meta_unreadable",
1337 session = %session_id,
1338 meta = %entry.path().display(),
1339 %error,
1340 "subagent sidecar meta skipped: its timestamp could not be read",
1341 );
1342 return None;
1343 }
1344 };
1345 Some((entry.path(), modified))
1346 })
1347 .collect();
1348 metas.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1349 let mut budget = budget;
1350 let mut sidecars = Vec::new();
1351 let mut links = HashMap::new();
1352 for (meta, _) in metas {
1353 let Ok(body) = fs::read(&meta) else {
1354 tracing::debug!(
1355 event_name = "subagent_sidecar_meta_unreadable",
1356 session = %session_id,
1357 meta = %meta.display(),
1358 "subagent sidecar meta skipped: it could not be read",
1359 );
1360 continue;
1361 };
1362 let Ok(value) = serde_json::from_slice::<Value>(&body) else {
1363 tracing::debug!(
1364 event_name = "subagent_sidecar_meta_unreadable",
1365 session = %session_id,
1366 meta = %meta.display(),
1367 "subagent sidecar meta skipped: it is not JSON",
1368 );
1369 continue;
1370 };
1371 let Some(dispatch) =
1372 value.get("toolUseId").and_then(Value::as_str).filter(|id| !id.trim().is_empty())
1373 else {
1374 tracing::debug!(
1375 event_name = "subagent_sidecar_meta_unreadable",
1376 session = %session_id,
1377 meta = %meta.display(),
1378 "subagent sidecar meta skipped: it names no dispatch",
1379 );
1380 continue;
1381 };
1382 let Some(stem) = meta
1383 .file_name()
1384 .and_then(|name| name.to_str())
1385 .and_then(|name| name.strip_suffix(".meta.json"))
1386 else {
1387 tracing::debug!(
1388 event_name = "subagent_sidecar_meta_unreadable",
1389 session = %session_id,
1390 meta = %meta.display(),
1391 "subagent sidecar meta skipped: no sidecar name derives from it",
1392 );
1393 continue;
1394 };
1395 let agent = stem.strip_prefix("agent-").unwrap_or(stem);
1396 links.insert(
1397 agent.to_owned(),
1398 SidecarLink {
1399 dispatch: dispatch.to_owned(),
1400 parent_agent: value.get("parentAgentId").and_then(Value::as_str).map(str::to_owned),
1401 },
1402 );
1403 let sidecar = meta.with_file_name(format!("{stem}.jsonl"));
1404 let Ok(size) = fs::metadata(&sidecar).map(|info| info.len()) else {
1405 tracing::debug!(
1406 event_name = "subagent_sidecar_body_missing",
1407 session = %session_id,
1408 sidecar = %sidecar.display(),
1409 "subagent sidecar skipped: its body is missing",
1410 );
1411 continue;
1412 };
1413 if size > budget {
1414 tracing::debug!(
1415 event_name = "subagent_sidecar_budget_skipped",
1416 session = %session_id,
1417 sidecar = %sidecar.display(),
1418 "subagent sidecar skipped: the resume read's budget is spent",
1419 );
1420 continue;
1421 }
1422 budget -= size;
1423 let Ok(file) = fs::File::open(&sidecar) else {
1424 tracing::debug!(
1425 event_name = "subagent_sidecar_body_unreadable",
1426 session = %session_id,
1427 sidecar = %sidecar.display(),
1428 "subagent sidecar skipped: its body could not be opened",
1429 );
1430 continue;
1431 };
1432 let rows = parse_session_messages(file).messages;
1435 sidecars.push((dispatch.to_owned(), rows));
1436 }
1437 (sidecars, links)
1438}
1439
1440pub(crate) fn messages_from_rows(
1448 rows: Vec<SessionMessage>,
1449 session_id: &str,
1450) -> Vec<forge_primitives::Message> {
1451 let raw: Vec<Value> = rows
1452 .into_iter()
1453 .map(|m| {
1454 let kind = match m.kind {
1455 forge_primitives::SessionMessageKind::User => "user",
1456 forge_primitives::SessionMessageKind::Assistant => "assistant",
1457 forge_primitives::SessionMessageKind::System => "system",
1458 };
1459 serde_json::json!({
1460 "type": kind,
1461 "uuid": m.uuid,
1462 "message": m.message,
1463 "parent_tool_use_id": m.parent_tool_use_id,
1464 "timestamp": m.timestamp,
1465 "tool_use_result": m.tool_use_result,
1466 "synthetic": m.synthetic,
1467 })
1468 })
1469 .collect();
1470 let mut messages = crate::replay::synthesize_replay_messages(&raw);
1471 for message in &mut messages {
1474 match message {
1475 forge_primitives::Message::Assistant { session_id: s, .. }
1476 | forge_primitives::Message::User { session_id: s, .. }
1477 | forge_primitives::Message::StopHookSummary { session_id: s, .. }
1478 | forge_primitives::Message::CompactBoundary { session_id: s, .. }
1479 | forge_primitives::Message::PermissionDenied { session_id: s, .. }
1480 | forge_primitives::Message::Notification { session_id: s, .. } => {
1481 session_id.clone_into(s);
1482 }
1483 forge_primitives::Message::System { session_id: s, .. } => {
1486 *s = Some(session_id.to_owned());
1487 }
1488 _ => {}
1489 }
1490 }
1491 messages
1492}
1493
1494fn session_transcript(
1497 config_dir: &Path,
1498 session_id: &str,
1499 directory: Option<&str>,
1500) -> Option<PathBuf> {
1501 let file_name = format!("{session_id}.jsonl");
1502 if let Some(dir) = directory {
1503 let candidate = project_dir_for(config_dir, dir).join(&file_name);
1504 return candidate.is_file().then_some(candidate);
1505 }
1506 try_read_dir(&projects_dir_for(config_dir))?
1507 .flatten()
1508 .map(|entry| entry.path().join(&file_name))
1509 .find(|path| path.is_file())
1510}
1511
1512pub const SPAN_STEP_BYTES: u64 = 1024 * 1024;
1528pub const SPAN_CAP_BYTES: u64 = 64 * 1024 * 1024;
1529
1530pub const SPAN_ROWS_PER_TURN: usize = 150;
1545
1546const ROWS_PAST_CUT: usize = 8;
1554
1555fn carries_a_result(row: &SessionMessage) -> bool {
1558 row.kind == forge_primitives::SessionMessageKind::User
1559 && row.message.get("content").and_then(Value::as_array).is_some_and(|blocks| {
1560 blocks
1561 .iter()
1562 .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
1563 })
1564}
1565
1566pub fn read_rows(
1587 config_dir: &Path,
1588 session_id: &str,
1589 directory: Option<&str>,
1590 below: Option<&forge_primitives::TranscriptAnchor>,
1591 rows_wanted: usize,
1592) -> Option<forge_primitives::TranscriptSpan> {
1593 read_rows_with(
1594 config_dir,
1595 session_id,
1596 directory,
1597 below,
1598 rows_wanted,
1599 SPAN_STEP_BYTES,
1600 SPAN_CAP_BYTES,
1601 )
1602}
1603
1604fn read_rows_with(
1608 config_dir: &Path,
1609 session_id: &str,
1610 directory: Option<&str>,
1611 below: Option<&forge_primitives::TranscriptAnchor>,
1612 rows_wanted: usize,
1613 step: u64,
1614 cap: u64,
1615) -> Option<forge_primitives::TranscriptSpan> {
1616 if !is_valid_uuid(session_id) {
1619 return None;
1620 }
1621 let path = session_transcript(config_dir, session_id, directory)?;
1622 let mut file = match fs::File::open(&path) {
1623 Ok(file) => file,
1624 Err(err) => {
1625 tracing::warn!(
1626 event_name = "span_read_open_failed",
1627 %session_id,
1628 path = %path.display(),
1629 %err,
1630 "the transcript could not be opened; the page is answered empty",
1631 );
1632 return None;
1633 }
1634 };
1635 let len = file.metadata().ok()?.len();
1636 if len == 0 {
1637 return None;
1638 }
1639 let end = match below {
1644 None => len,
1645 Some(anchor) => resolve_anchor(&mut file, session_id, len, anchor, step, cap)?,
1646 };
1647
1648 let mut kept: Vec<(SessionMessage, u64)> = Vec::new();
1653 let mut take = step.min(len).max(1);
1654 let mut at = end;
1655 let mut reached_start = false;
1656 let mut file_first: Option<u64> = None;
1657 loop {
1658 let window = match window_ending(&mut file, at, take) {
1659 Ok(window) => window,
1660 Err(err) => {
1661 tracing::warn!(
1662 event_name = "span_read_failed",
1663 %session_id,
1664 from = at.saturating_sub(take),
1665 %err,
1666 "the transcript window could not be read; the page is answered empty",
1667 );
1668 return None;
1669 }
1670 };
1671 let rows: Vec<(SessionMessage, u64)> =
1672 window_rows(&window).into_iter().filter(|(_, offset)| *offset < at).collect();
1673 let next_at = rows.first().map(|(_, offset)| *offset);
1674 if window.from == 0 {
1675 reached_start = true;
1676 file_first = next_at;
1677 }
1678 let wanted = rows_wanted.saturating_add(ROWS_PAST_CUT);
1682 let room = wanted.saturating_sub(kept.len());
1683 let mut fresh = rows;
1684 if fresh.len() > room {
1685 fresh = fresh.split_off(fresh.len() - room);
1688 }
1689 let mut merged = fresh;
1690 merged.append(&mut kept);
1691 kept = merged;
1692 if kept.len() >= rows_wanted || window.from == 0 || take >= cap {
1693 break;
1694 }
1695 take = (take * 2).min(cap);
1696 at = next_at.unwrap_or(window.from);
1697 if at == 0 {
1698 break;
1699 }
1700 }
1701
1702 let over = kept.len().saturating_sub(rows_wanted);
1705 kept.drain(..over);
1706 let deferred = kept
1715 .iter()
1716 .position(|(row, _)| !carries_a_result(row))
1717 .map_or(0, |at| at.min(ROWS_PAST_CUT));
1718 kept.drain(..deferred);
1719 let mut seen = std::collections::HashSet::new();
1723 kept.retain(|(row, _)| !already_kept(&mut seen, &row.uuid));
1724 let exhausted = reached_start
1729 && file_first.is_none_or(|first| kept.first().is_none_or(|(_, offset)| *offset == first));
1730
1731 let mut messages = Vec::with_capacity(kept.len());
1732 let mut offsets = Vec::with_capacity(kept.len());
1733 for (row, offset) in kept {
1734 if let Some(message) = messages_from_rows(vec![row], session_id).into_iter().next() {
1737 messages.push(message);
1738 offsets.push(offset);
1739 }
1740 }
1741 Some(forge_primitives::TranscriptSpan { messages, offsets, exhausted })
1742}
1743
1744fn resolve_anchor(
1752 file: &mut fs::File,
1753 session_id: &str,
1754 len: u64,
1755 anchor: &forge_primitives::TranscriptAnchor,
1756 step: u64,
1757 cap: u64,
1758) -> Option<u64> {
1759 if let Some(offset) = anchor.offset.filter(|offset| *offset <= len) {
1760 if anchor.row.is_empty() || row_at(file, offset).is_some_and(|row| row.uuid == anchor.row) {
1763 return Some(offset);
1764 }
1765 }
1766 if anchor.row.is_empty() {
1767 return None;
1768 }
1769 let mut take = step.min(len).max(1);
1770 loop {
1771 let window = window_ending(file, len, take).ok()?;
1772 if let Some((_, offset)) =
1773 window_rows(&window).into_iter().find(|(row, _)| row.uuid == anchor.row)
1774 {
1775 return Some(offset);
1776 }
1777 if take >= cap || take >= len {
1778 tracing::debug!(
1784 event_name = "span_read_anchor_unreached",
1785 %session_id,
1786 row = %anchor.row,
1787 len,
1788 cap,
1789 "no row in the file names this anchor; the read answers the caller empty",
1790 );
1791 return None;
1792 }
1793 take = (take * 2).min(cap);
1794 }
1795}
1796
1797struct Window {
1800 from: u64,
1801 bytes: Vec<u8>,
1802}
1803
1804fn window_ending(file: &mut fs::File, end: u64, take: u64) -> std::io::Result<Window> {
1812 let from = end.saturating_sub(take).saturating_sub(1);
1813 let mut bytes = vec![0_u8; usize::try_from(end - from).unwrap_or(0)];
1814 file.seek(SeekFrom::Start(from))?;
1815 file.read_exact(&mut bytes)?;
1816 Ok(Window { from, bytes })
1817}
1818
1819fn window_rows(window: &Window) -> Vec<(SessionMessage, u64)> {
1825 let fragment = window.from > 0 && window.bytes.first() != Some(&b'\n');
1828 let mut rows = Vec::new();
1829 let mut queued = Vec::new();
1834 let mut at = window.from;
1835 for line in window.bytes.split(|byte| *byte == b'\n') {
1836 let start = at;
1837 at = at.saturating_add(line.len() as u64 + 1);
1838 if fragment && start == window.from {
1839 continue;
1840 }
1841 let Ok(text) = std::str::from_utf8(line) else { continue };
1842 let Ok(value) = serde_json::from_str::<Value>(text) else { continue };
1843 if let Some(remove) = queue_remove_row(&value) {
1844 rows.push((queued_start_row(&mut queued, &value, remove), start));
1845 continue;
1846 }
1847 let Some((row, _)) = transcript_row(&value) else { continue };
1848 if is_queued_attachment_row(&value) {
1849 queued.push(row);
1850 continue;
1851 }
1852 rows.push((row, start));
1853 }
1854 rows
1855}
1856
1857fn row_at(file: &mut fs::File, at: u64) -> Option<SessionMessage> {
1859 const LINE_CAP: u64 = 1024 * 1024;
1861 file.seek(SeekFrom::Start(at)).ok()?;
1862 let mut bytes = Vec::new();
1863 std::io::Read::take(&mut *file, LINE_CAP).read_to_end(&mut bytes).ok()?;
1864 let text = String::from_utf8_lossy(&bytes);
1865 session_row(text.lines().next().unwrap_or_default())
1866}
1867
1868fn session_row(line: &str) -> Option<SessionMessage> {
1870 let line = line.trim_end_matches('\r');
1871 if line.is_empty() {
1872 return None;
1873 }
1874 let value = serde_json::from_str::<Value>(line).ok()?;
1875 if let Some(row) = queue_remove_row(&value) {
1878 return Some(row);
1879 }
1880 transcript_row(&value).map(|(row, _)| row)
1881}
1882
1883struct LiteSessionFile {
1894 mtime: u64,
1895 size: u64,
1896 head: String,
1897 tail: String,
1898 tag: Option<String>,
1899}
1900
1901fn read_session_lite(
1916 path: &Path,
1917 cache: Option<&SessionTagCache>,
1918 scan_tag: bool,
1919) -> Option<LiteSessionFile> {
1920 let mut file = match fs::File::open(path) {
1921 Ok(f) => f,
1922 Err(e) => {
1923 tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "open", "lite-read failed");
1924 return None;
1925 }
1926 };
1927 let meta = match file.metadata() {
1928 Ok(m) => m,
1929 Err(e) => {
1930 tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "metadata", "lite-read failed");
1931 return None;
1932 }
1933 };
1934 let size = meta.len();
1935 if size == 0 {
1936 return None;
1937 }
1938 let mtime = meta
1939 .modified()
1940 .ok()
1941 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1942 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
1943
1944 let head_len = usize::try_from(LITE_READ_BUF_SIZE.min(size)).unwrap_or(usize::MAX);
1945 let mut head_bytes = vec![0u8; head_len];
1946 let read = match file.read(&mut head_bytes) {
1947 Ok(n) => n,
1948 Err(e) => {
1949 tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "read_head", "lite-read failed");
1950 return None;
1951 }
1952 };
1953 head_bytes.truncate(read);
1954 if head_bytes.is_empty() {
1955 return None;
1956 }
1957 let head = String::from_utf8_lossy(&head_bytes).into_owned();
1958
1959 let tail = if size <= LITE_READ_BUF_SIZE {
1960 head.clone()
1961 } else {
1962 let tail_offset = size - LITE_READ_BUF_SIZE;
1963 if let Err(e) = file.seek(SeekFrom::Start(tail_offset)) {
1964 tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "seek_tail", "lite-read failed");
1965 return None;
1966 }
1967 let mut tail_bytes = vec![0u8; usize::try_from(LITE_READ_BUF_SIZE).unwrap_or(usize::MAX)];
1968 let read = match file.read(&mut tail_bytes) {
1969 Ok(n) => n,
1970 Err(e) => {
1971 tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "read_tail", "lite-read failed");
1972 return None;
1973 }
1974 };
1975 tail_bytes.truncate(read);
1976 String::from_utf8_lossy(&tail_bytes).into_owned()
1977 };
1978
1979 let tag = if scan_tag {
1982 match tag_of(path, &mut file, size, cache) {
1983 Ok(tag) => tag,
1984 Err(e) => {
1985 tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "seek_tag_scan", "lite-read tag-scan failed; resume will treat as untagged");
1986 None
1987 }
1988 }
1989 } else {
1990 None
1991 };
1992
1993 Some(LiteSessionFile { mtime, size, head, tail, tag })
1994}
1995
1996pub(crate) fn tag_of(
2005 path: &Path,
2006 file: &mut fs::File,
2007 size: u64,
2008 cache: Option<&SessionTagCache>,
2009) -> std::io::Result<Option<String>> {
2010 Ok(tag_and_project_of(path, file, size, cache)?.0)
2011}
2012
2013pub(crate) fn tag_and_project_of(
2018 path: &Path,
2019 file: &mut fs::File,
2020 size: u64,
2021 cache: Option<&SessionTagCache>,
2022) -> std::io::Result<(Option<String>, Option<String>)> {
2023 let carried = cache
2024 .and_then(|c| c.get(path))
2025 .filter(|prior| prior.scanned_len <= size)
2026 .unwrap_or_default();
2027 file.seek(SeekFrom::Start(carried.scanned_len))?;
2028 let (tag, project, state) = scan_tag_from(BufReader::new(&mut *file), Some(carried));
2029 if let Some(cache) = cache {
2030 cache.put(path, state);
2031 }
2032 Ok((tag, project))
2033}
2034
2035#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2045pub struct SessionTagScan {
2046 pub tag: Option<String>,
2048 #[serde(default)]
2052 pub project: Option<String>,
2053 pub scanned_len: u64,
2054}
2055
2056#[derive(Debug, Default)]
2064pub struct SessionTagCache {
2065 prior: std::collections::HashMap<String, SessionTagScan>,
2066 updated: std::sync::Mutex<Vec<(String, SessionTagScan)>>,
2067}
2068
2069impl SessionTagCache {
2070 pub fn new(prior: std::collections::HashMap<String, SessionTagScan>) -> Self {
2071 Self { prior, updated: std::sync::Mutex::new(Vec::new()) }
2072 }
2073
2074 fn get(&self, path: &Path) -> Option<SessionTagScan> {
2075 self.prior.get(path.to_str()?).cloned()
2076 }
2077
2078 fn carries_tag(&self, path: &Path, wanted: &str) -> Option<bool> {
2083 let held = self.get(path)?;
2086 let size = std::fs::metadata(path).ok()?.len();
2087 (held.scanned_len == size).then(|| held.tag.as_deref() == Some(wanted))
2088 }
2089
2090 fn put(&self, path: &Path, scan: SessionTagScan) {
2091 let Some(key) = path.to_str() else { return };
2092 if self.prior.get(key) == Some(&scan) {
2095 return;
2096 }
2097 if let Ok(mut updated) = self.updated.lock() {
2098 updated.push((key.to_owned(), scan));
2099 }
2100 }
2101
2102 pub fn updates(&self) -> Vec<(String, SessionTagScan)> {
2104 self.updated.lock().map(|u| u.clone()).unwrap_or_default()
2105 }
2106}
2107
2108fn scan_tag_from<R: BufRead>(
2129 mut reader: R,
2130 carried: Option<SessionTagScan>,
2131) -> (Option<String>, Option<String>, SessionTagScan) {
2132 let mut state = carried.unwrap_or_default();
2133 let mut last_tag = state.tag.clone();
2136 let mut last_project = state.project.clone();
2137 let mut line = Vec::new();
2138 loop {
2139 line.clear();
2140 match reader.read_until(b'\n', &mut line) {
2141 Ok(0) => break,
2142 Ok(_) => {}
2143 Err(e) => {
2144 tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, error = %e, step = "tag_scan_line", "lite-read tag-scan line read failed; ending scan with last seen tag");
2145 break;
2146 }
2147 }
2148 let complete = line.last() == Some(&b'\n');
2149 let raw_len = line.len() as u64;
2150 if complete {
2154 line.pop();
2155 }
2156 if line.last() == Some(&b'\r') {
2157 line.pop();
2158 }
2159 let text = match std::str::from_utf8(&line) {
2164 Ok(text) => text,
2165 Err(e) => {
2166 tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, error = %e, step = "tag_scan_line", "lite-read tag-scan line read failed; ending scan with last seen tag");
2167 break;
2168 }
2169 };
2170 if text.starts_with("{\"type\":\"tag\"")
2171 && let Some(tag) = extract_last_json_string_field(text, "tag")
2172 && !tag.is_empty()
2173 {
2174 last_tag = Some(tag);
2175 last_project = extract_json_string_field(text, "project");
2176 if complete {
2177 state.tag.clone_from(&last_tag);
2178 state.project.clone_from(&last_project);
2179 }
2180 }
2181 if complete {
2182 state.scanned_len += raw_len;
2183 }
2184 }
2185 (last_tag, last_project, state)
2186}
2187
2188fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2190 if needle.is_empty() || needle.len() > haystack.len() {
2191 return None;
2192 }
2193 haystack.windows(needle.len()).position(|w| w == needle)
2194}
2195
2196fn extract_json_string_field(text: &str, key: &str) -> Option<String> {
2201 let bytes = text.as_bytes();
2202 let compact = format!("\"{key}\":\"");
2203 let spaced = format!("\"{key}\": \"");
2204 for pattern in [compact.as_bytes(), spaced.as_bytes()] {
2205 if let Some(idx) = find_bytes(bytes, pattern) {
2206 let value_start = idx + pattern.len();
2207 let mut i = value_start;
2208 while i < bytes.len() {
2209 if bytes[i] == b'\\' {
2210 i += 2;
2211 continue;
2212 }
2213 if bytes[i] == b'"' {
2214 let raw = std::str::from_utf8(&bytes[value_start..i]).ok()?;
2215 return Some(unescape_json_string(raw));
2216 }
2217 i += 1;
2218 }
2219 }
2220 }
2221 None
2222}
2223
2224fn extract_last_json_string_field(text: &str, key: &str) -> Option<String> {
2226 let bytes = text.as_bytes();
2227 let compact = format!("\"{key}\":\"");
2228 let spaced = format!("\"{key}\": \"");
2229 let mut last: Option<String> = None;
2230 let mut last_pos: Option<usize> = None;
2234 for pattern in [compact.as_bytes(), spaced.as_bytes()] {
2235 let mut search_from = 0usize;
2236 while search_from < bytes.len() {
2237 let remaining = &bytes[search_from..];
2238 let Some(rel_idx) = find_bytes(remaining, pattern) else {
2239 break;
2240 };
2241 let idx = search_from + rel_idx;
2242 let value_start = idx + pattern.len();
2243 let mut i = value_start;
2244 while i < bytes.len() {
2245 if bytes[i] == b'\\' {
2246 i += 2;
2247 continue;
2248 }
2249 if bytes[i] == b'"' {
2250 if last_pos.is_none_or(|p| idx >= p)
2251 && let Ok(raw) = std::str::from_utf8(&bytes[value_start..i])
2252 {
2253 last = Some(unescape_json_string(raw));
2254 last_pos = Some(idx);
2255 }
2256 break;
2257 }
2258 i += 1;
2259 }
2260 search_from = i + 1;
2261 }
2262 }
2263 last
2264}
2265
2266fn unescape_json_string(raw: &str) -> String {
2268 if !raw.contains('\\') {
2269 return raw.to_string();
2270 }
2271 let wrapped = format!("\"{raw}\"");
2272 serde_json::from_str::<String>(&wrapped).unwrap_or_else(|_| raw.to_string())
2273}
2274
2275fn extract_first_prompt_from_head(head: &str) -> Option<String> {
2281 let mut command_fallback: Option<String> = None;
2282 for line in head.split('\n') {
2283 if !line.contains("\"type\":\"user\"") && !line.contains("\"type\": \"user\"") {
2284 continue;
2285 }
2286 if line.contains("\"tool_result\"") {
2287 continue;
2288 }
2289 if line.contains("\"isMeta\":true") || line.contains("\"isMeta\": true") {
2290 continue;
2291 }
2292 if line.contains("\"isCompactSummary\":true") || line.contains("\"isCompactSummary\": true")
2293 {
2294 continue;
2295 }
2296 let Ok(entry) = serde_json::from_str::<Value>(line) else {
2297 continue;
2298 };
2299 if entry.get("type").and_then(Value::as_str) != Some("user") {
2300 continue;
2301 }
2302 let Some(message) = entry.get("message") else {
2303 continue;
2304 };
2305 let Some(content) = message.get("content") else {
2306 continue;
2307 };
2308 let texts: Vec<String> = if let Some(s) = content.as_str() {
2309 vec![s.to_string()]
2310 } else if let Some(arr) = content.as_array() {
2311 arr.iter()
2312 .filter_map(|b| {
2313 (b.get("type").and_then(Value::as_str) == Some("text"))
2314 .then(|| b.get("text").and_then(Value::as_str).map(str::to_string))
2315 .flatten()
2316 })
2317 .collect()
2318 } else {
2319 Vec::new()
2320 };
2321 for raw in texts {
2322 let result = raw.replace('\n', " ");
2323 let result = result.trim();
2324 if result.is_empty() {
2325 continue;
2326 }
2327 if let Some(cmd) = extract_command_name(result) {
2328 if command_fallback.is_none() {
2329 command_fallback = Some(cmd);
2330 }
2331 continue;
2332 }
2333 if should_skip_first_prompt(result) {
2334 continue;
2335 }
2336 let truncated = if result.chars().count() > 200 {
2337 let mut buf: String = result.chars().take(200).collect();
2338 while buf.ends_with(char::is_whitespace) {
2339 buf.pop();
2340 }
2341 buf.push('\u{2026}');
2342 buf
2343 } else {
2344 result.to_string()
2345 };
2346 return Some(truncated);
2347 }
2348 }
2349 command_fallback
2350}
2351
2352pub(crate) fn extract_command_name(s: &str) -> Option<String> {
2354 const OPEN: &str = "<command-name>";
2355 const CLOSE: &str = "</command-name>";
2356 let open = s.find(OPEN)?;
2357 let after = &s[open + OPEN.len()..];
2358 let close = after.find(CLOSE)?;
2359 Some(after[..close].to_string())
2360}
2361
2362pub(crate) fn should_skip_first_prompt(s: &str) -> bool {
2364 const PREFIXES: [&str; 4] =
2365 ["<local-command-stdout>", "<session-start-hook>", "<tick>", "<goal>"];
2366 if PREFIXES.iter().any(|p| s.starts_with(p)) {
2367 return true;
2368 }
2369 if s.starts_with("[Request interrupted by user") && s.contains(']') {
2370 return true;
2371 }
2372 let trimmed = s.trim();
2373 for (open, close) in
2374 [("<ide_opened_file>", "</ide_opened_file>"), ("<ide_selection>", "</ide_selection>")]
2375 {
2376 if trimmed.starts_with(open) && trimmed.ends_with(close) {
2377 return true;
2378 }
2379 }
2380 false
2381}
2382
2383pub fn read_session_info_at(path: &Path) -> Option<SDKSessionInfo> {
2394 read_session_info_with(path, None, false)
2395}
2396
2397fn read_session_info(path: &Path, cache: Option<&SessionTagCache>) -> Option<SDKSessionInfo> {
2398 read_session_info_with(path, cache, true)
2399}
2400
2401fn read_session_info_with(
2402 path: &Path,
2403 cache: Option<&SessionTagCache>,
2404 scan_tag: bool,
2405) -> Option<SDKSessionInfo> {
2406 let session_id = path.file_stem().and_then(|s| s.to_str())?.to_string();
2407 let lite = read_session_lite(path, cache, scan_tag)?;
2408 let storage_key = path
2409 .parent()
2410 .and_then(Path::file_name)
2411 .and_then(|name| name.to_str())
2412 .unwrap_or_default()
2413 .to_owned();
2414 let mut info = parse_session_info_from_lite(&session_id, &lite, None)?;
2415 info.storage_key = storage_key;
2416 Some(info)
2417}
2418
2419fn parse_session_info_from_lite(
2423 session_id: &str,
2424 lite: &LiteSessionFile,
2425 project_path: Option<&str>,
2426) -> Option<SDKSessionInfo> {
2427 let head = lite.head.as_str();
2428 let tail = lite.tail.as_str();
2429
2430 let first_line = head.find('\n').map_or(head, |idx| &head[..idx]);
2431 if first_line.contains("\"isSidechain\":true") || first_line.contains("\"isSidechain\": true") {
2432 return None;
2433 }
2434
2435 let custom_title = extract_last_json_string_field(tail, "customTitle")
2436 .or_else(|| extract_last_json_string_field(head, "customTitle"))
2437 .or_else(|| extract_last_json_string_field(tail, "aiTitle"))
2438 .or_else(|| extract_last_json_string_field(head, "aiTitle"));
2439 let first_prompt = extract_first_prompt_from_head(head);
2440 let summary = custom_title
2447 .clone()
2448 .or_else(|| first_prompt.clone())
2449 .or_else(|| extract_last_json_string_field(tail, "lastPrompt"))
2450 .or_else(|| extract_last_json_string_field(tail, "summary"))?;
2451
2452 let git_branch = extract_last_json_string_field(tail, "gitBranch")
2453 .or_else(|| extract_json_string_field(head, "gitBranch"));
2454 let cwd = extract_json_string_field(head, "cwd").or_else(|| project_path.map(str::to_string));
2455 let tag = lite.tag.clone();
2459 let created_at = extract_json_string_field(first_line, "timestamp")
2460 .and_then(|ts| parse_rfc3339_ms(&ts).ok());
2461
2462 Some(SDKSessionInfo {
2463 session_id: session_id.to_string(),
2464 summary,
2465 last_modified: lite.mtime,
2466 file_size: Some(lite.size),
2467 custom_title,
2468 first_prompt,
2469 git_branch,
2470 cwd,
2471 storage_key: String::new(),
2472 tag,
2473 created_at,
2474 })
2475}
2476
2477pub(crate) fn parse_rfc3339_ms(ts: &str) -> Result<u64, time::error::Parse> {
2480 let dt = time::OffsetDateTime::parse(ts, &time::format_description::well_known::Rfc3339)?;
2481 let nanos = dt.unix_timestamp_nanos();
2482 Ok(u64::try_from(nanos / 1_000_000).unwrap_or(0))
2483}
2484
2485#[cfg(test)]
2486mod tests {
2487
2488 use super::*;
2489
2490 #[derive(Clone, Default)]
2492 struct LogCapture(std::sync::Arc<parking_lot::Mutex<Vec<u8>>>);
2493
2494 impl std::io::Write for LogCapture {
2495 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2496 self.0.lock().extend_from_slice(buf);
2497 Ok(buf.len())
2498 }
2499 fn flush(&mut self) -> std::io::Result<()> {
2500 Ok(())
2501 }
2502 }
2503
2504 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
2505 type Writer = Self;
2506 fn make_writer(&'a self) -> Self::Writer {
2507 self.clone()
2508 }
2509 }
2510
2511 fn capture_logs(f: impl FnOnce()) -> String {
2512 let capture = LogCapture::default();
2513 let subscriber = tracing_subscriber::fmt().with_writer(capture.clone()).finish();
2514 tracing::subscriber::with_default(subscriber, f);
2515 let bytes = capture.0.lock().clone();
2516 String::from_utf8_lossy(&bytes).into_owned()
2517 }
2518
2519 #[test]
2531 fn a_boundary_row_whose_facts_did_not_survive_is_logged() {
2532 for (shape, row, missing) in [
2533 (
2534 "outer key renamed",
2535 r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadataMoved":{"trigger":"auto","preTokens":41207}}"#,
2536 "trigger, preTokens, postTokens",
2537 ),
2538 (
2539 "count renamed inside it",
2540 r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","pre_tokens":41207}}"#,
2541 "preTokens, postTokens",
2542 ),
2543 (
2544 "trigger renamed inside it",
2545 r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"triggers":"auto","preTokens":41207}}"#,
2546 "trigger, postTokens",
2547 ),
2548 (
2549 "post count renamed inside it",
2550 r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":41207,"post_tokens":1265}}"#,
2551 "postTokens",
2552 ),
2553 (
2554 "no metadata at all",
2555 r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1"}"#,
2556 "trigger, preTokens, postTokens",
2557 ),
2558 ] {
2559 let log = capture_logs(|| {
2560 parse_session_messages(row.as_bytes());
2561 });
2562
2563 assert!(log.contains("cb1"), "the record for a {shape} names its row: {log}");
2564 assert!(log.contains("s1"), "and the session it was read for: {log}");
2565 assert!(log.contains(missing), "and the fact {shape} left out: {log}");
2566 }
2567 }
2568
2569 #[test]
2572 fn a_boundary_row_with_its_metadata_logs_nothing() {
2573 let log = capture_logs(|| {
2574 parse_session_messages(
2575 br#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":41207,"postTokens":1265}}"#
2576 .as_slice(),
2577 );
2578 });
2579
2580 assert!(log.is_empty(), "a row the scan can read warns about nothing: {log}");
2581 }
2582
2583 #[test]
2584 fn sanitize_ascii_only_passthrough() {
2585 assert_eq!(sanitize_path("alphanum123"), "alphanum123");
2586 }
2587
2588 #[test]
2589 fn sanitize_replaces_non_alphanum_with_hyphens() {
2590 assert_eq!(
2591 sanitize_path("/Users/developer/projects/forge"),
2592 "-Users-developer-projects-forge"
2593 );
2594 }
2595
2596 #[test]
2597 fn extract_last_json_string_field_picks_globally_last_across_forms() {
2598 let text = r#"{"tag": "early-spaced"} {"tag":"late-compact"}"#;
2603 assert_eq!(extract_last_json_string_field(text, "tag"), Some("late-compact".to_owned()));
2604 let text2 = r#"{"tag":"early-compact"} {"tag": "late-spaced"}"#;
2606 assert_eq!(extract_last_json_string_field(text2, "tag"), Some("late-spaced".to_owned()));
2607 }
2608
2609 #[test]
2610 fn simple_hash_matches_known_value() {
2611 assert_eq!(simple_hash("foo"), "26di");
2614 }
2615
2616 #[test]
2617 fn long_path_gets_hash_suffix() {
2618 let long = "a".repeat(300);
2619 let got = sanitize_path(&long);
2620 assert_eq!(got.len(), MAX_SANITIZED_LENGTH + 1 + simple_hash(&long).len());
2621 }
2622
2623 #[test]
2624 fn uuid_validator_accepts_canonical() {
2625 assert!(is_valid_uuid("550e8400-e29b-41d4-a716-446655440000"));
2626 }
2627
2628 #[test]
2629 fn uuid_validator_rejects_garbage() {
2630 assert!(!is_valid_uuid("not-a-uuid"));
2631 assert!(!is_valid_uuid("550e8400e29b41d4a716446655440000"));
2632 assert!(!is_valid_uuid(""));
2633 }
2634
2635 #[test]
2636 fn iso_parser_handles_millis() {
2637 let ms = parse_rfc3339_ms("2026-04-22T00:00:00.500Z").unwrap();
2638 assert_eq!(ms % 1000, 500);
2639 }
2640
2641 #[test]
2642 fn extract_json_string_field_finds_compact_form() {
2643 let t = r#"noise {"type":"user","message":{"content":"hi"}} noise"#;
2644 assert_eq!(extract_json_string_field(t, "content"), Some("hi".to_string()));
2645 }
2646
2647 #[test]
2648 fn extract_json_string_field_finds_spaced_form() {
2649 let t = r#"{"gitBranch": "main"}"#;
2650 assert_eq!(extract_json_string_field(t, "gitBranch"), Some("main".to_string()));
2651 }
2652
2653 #[test]
2654 fn extract_json_string_field_handles_escaped_quotes() {
2655 let t = r#"{"customTitle":"he said \"hi\""}"#;
2656 assert_eq!(
2657 extract_json_string_field(t, "customTitle"),
2658 Some(r#"he said "hi""#.to_string())
2659 );
2660 }
2661
2662 #[test]
2663 fn extract_last_json_string_field_picks_last() {
2664 let t = r#"{"tag":"old"} {"tag":"new"}"#;
2665 assert_eq!(extract_last_json_string_field(t, "tag"), Some("new".to_string()));
2666 }
2667
2668 #[test]
2669 fn first_prompt_skips_local_command_stdout() {
2670 let head = r#"{"type":"user","message":{"content":"<local-command-stdout>out</local-command-stdout>"}}
2671{"type":"user","message":{"content":"actual prompt"}}"#;
2672 assert_eq!(extract_first_prompt_from_head(head), Some("actual prompt".to_string()));
2673 }
2674
2675 #[test]
2676 fn first_prompt_falls_back_to_command_name() {
2677 let head = r#"{"type":"user","message":{"content":"<command-name>foo</command-name>"}}"#;
2678 assert_eq!(extract_first_prompt_from_head(head), Some("foo".to_string()));
2679 }
2680
2681 #[test]
2682 fn first_prompt_skips_tool_result_line() {
2683 let head = r#"{"type":"user","message":{"content":[{"type":"tool_result","content":"x"}]}}
2684{"type":"user","message":{"content":"real prompt"}}"#;
2685 assert_eq!(extract_first_prompt_from_head(head), Some("real prompt".to_string()));
2686 }
2687
2688 #[test]
2689 fn parse_session_info_skips_sidechain() {
2690 let head = "{\"isSidechain\":true,\"type\":\"user\"}\n".to_string();
2691 let lite = LiteSessionFile { mtime: 0, size: 1, head: head.clone(), tail: head, tag: None };
2692 assert!(parse_session_info_from_lite("abc", &lite, None).is_none());
2693 }
2694
2695 #[test]
2696 fn parse_session_info_skips_metadata_only() {
2697 let content = "{\"type\":\"tag\",\"tag\":\"meta\"}\n".to_string();
2698 let lite = LiteSessionFile {
2699 mtime: 10,
2700 size: content.len() as u64,
2701 head: content.clone(),
2702 tail: content,
2703 tag: Some("meta".to_string()),
2704 };
2705 assert!(parse_session_info_from_lite("abc", &lite, None).is_none());
2708 }
2709
2710 #[test]
2711 fn parse_session_info_extracts_prompt_and_tag() {
2712 let content = r#"{"type":"user","timestamp":"2026-04-22T00:00:00.000Z","gitBranch":"main","cwd":"/p","message":{"content":"hello"}}
2713{"type":"tag","tag":"mytag"}
2714"#
2715 .to_string();
2716 let lite = LiteSessionFile {
2717 mtime: 99,
2718 size: content.len() as u64,
2719 head: content.clone(),
2720 tail: content,
2721 tag: Some("mytag".to_string()),
2722 };
2723 let info = parse_session_info_from_lite("abc", &lite, None).expect("some");
2724 assert_eq!(info.first_prompt.as_deref(), Some("hello"));
2725 assert_eq!(info.summary, "hello");
2726 assert_eq!(info.tag.as_deref(), Some("mytag"));
2727 assert_eq!(info.git_branch.as_deref(), Some("main"));
2728 assert_eq!(info.cwd.as_deref(), Some("/p"));
2729 assert!(info.created_at.is_some());
2730 }
2731
2732 #[test]
2733 fn find_session_tag_ignores_tag_on_tool_use_lines() {
2734 let content = r#"{"type":"user","message":{"content":"hi"}}
2737{"type":"assistant","message":{"content":[{"type":"tool_use","input":{"command":"git tag","tag":"v1.0"}}]}}
2738"#;
2739 assert_eq!(scan_tag_from(std::io::Cursor::new(content), None).0, None);
2740 }
2741
2742 #[test]
2743 fn parse_session_info_prefers_custom_title_over_last_prompt() {
2744 let content = r#"{"type":"user","message":{"content":"initial"}}
2745{"customTitle":"Curated","lastPrompt":"last"}
2746"#
2747 .to_string();
2748 let lite = LiteSessionFile {
2749 mtime: 0,
2750 size: content.len() as u64,
2751 head: content.clone(),
2752 tail: content,
2753 tag: None,
2754 };
2755 let info = parse_session_info_from_lite("abc", &lite, None).expect("some");
2756 assert_eq!(info.summary, "Curated");
2757 assert_eq!(info.custom_title.as_deref(), Some("Curated"));
2758 }
2759
2760 #[test]
2768 fn parse_session_messages_places_a_queued_prompt_at_its_start() {
2769 let jsonl = r#"{"type":"user","message":{"role":"user","content":"plain prompt"},"uuid":"u1","session_id":"s1","timestamp":"2026-11-03T12:00:50.000Z"}
2774{"type":"attachment","attachment":{"type":"queued_command","prompt":"queued prompt","commandMode":"prompt","source_uuid":"p1"},"uuid":"a1","session_id":"s1","timestamp":"2026-11-03T12:00:58.000Z"}
2775{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"after the press"}]},"uuid":"as1","session_id":"s1","timestamp":"2026-11-03T12:00:59.000Z"}
2776{"type":"queue-operation","operation":"remove","timestamp":"2026-11-03T12:01:10.000Z","sessionId":"s1","content":"queued prompt"}
2777{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]},"uuid":"as2","session_id":"s1","timestamp":"2026-11-03T12:01:11.000Z"}
2778{"type":"attachment","attachment":{"type":"queued_command","prompt":"never started","commandMode":"prompt"},"uuid":"a2","session_id":"s1","timestamp":"2026-11-03T12:02:00.000Z"}
2779"#;
2780 let msgs = parse_session_messages(jsonl.as_bytes()).messages;
2781 assert_eq!(msgs.len(), 4, "the prompt that never started draws no row");
2782 assert_eq!(msgs[0].uuid, "u1");
2783 assert_eq!(msgs[1].uuid, "as1");
2784 assert_eq!(msgs[3].uuid, "as2");
2785 assert!(
2786 msgs[2].uuid.starts_with("queue-remove-2026-11-03T12:01:10.000Z-"),
2787 "the row is named by the start it was served at: {:?}",
2788 msgs[2].uuid,
2789 );
2790 let content = msgs[2]
2791 .message
2792 .get("content")
2793 .and_then(Value::as_array)
2794 .expect("synthesised message has content array");
2795 assert_eq!(content.len(), 1);
2796 assert_eq!(content[0].get("type").and_then(Value::as_str), Some("queued_command"));
2797 assert_eq!(content[0].get("prompt").and_then(Value::as_str), Some("queued prompt"));
2798 assert_eq!(content[0].get("commandMode").and_then(Value::as_str), Some("prompt"));
2799 assert_eq!(
2800 content[0].get("source_uuid").and_then(Value::as_str),
2801 Some("p1"),
2802 "the prompt id the live row pairs with",
2803 );
2804 assert_eq!(
2805 msgs[2].timestamp.as_deref(),
2806 Some("2026-11-03T12:01:10.000Z"),
2807 "the start's clock, which is where the row is placed",
2808 );
2809 }
2810
2811 #[test]
2823 fn parse_session_messages_serves_the_hook_runs_the_live_wire_forwards() {
2824 let jsonl = r###"{"type":"attachment","attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","content":"## The PR review gate","stdout":"## The PR review gate","exitCode":0,"toolUseID":"tool-1"},"uuid":"h1","sessionId":"s1"}
2825{"type":"attachment","attachment":{"type":"hook_success","hookName":"PreToolUse:Skill","hookEvent":"PreToolUse","content":"not served live","stdout":"not served live","exitCode":0,"toolUseID":"tool-1"},"uuid":"h-skip","sessionId":"s1"}
2826{"type":"attachment","attachment":{"type":"hook_additional_context","content":["aggregate context"],"hookName":"SessionStart:startup","hookEvent":"SessionStart","toolUseID":"tool-1"},"uuid":"h-context","sessionId":"s1"}
2827{"type":"assistant","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"after the first run"}]},"uuid":"a1","sessionId":"s1"}
2828{"type":"attachment","attachment":{"type":"hook_success","hookName":"Setup:init","hookEvent":"Setup","content":["line one","line two"],"stdout":"line one\nline two","exitCode":0,"toolUseID":"tool-1"},"uuid":"h2","sessionId":"s1"}
2829{"type":"attachment","attachment":{"type":"hook_non_blocking_error","hookName":"SessionStart:startup","hookEvent":"SessionStart","stdout":"","stderr":"Failed with non-blocking status code","exitCode":141,"command":"bash til.sh","durationMs":45,"toolUseID":"tool-1"},"uuid":"h3","sessionId":"s1"}
2830{"type":"attachment","attachment":{"type":"hook_cancelled","hookName":"SessionStart:startup","hookEvent":"SessionStart","command":"check.sh","durationMs":3018,"timedOut":true,"timeoutMs":3000,"toolUseID":"tool-1"},"uuid":"h4","sessionId":"s1"}
2831{"type":"attachment","attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","content":"","stdout":"plugin lines","stderr":"","exitCode":0,"toolUseID":"tool-1"},"uuid":"h5","sessionId":"s1"}
2832"###;
2833 let msgs = parse_session_messages(jsonl.as_bytes()).messages;
2834 assert_eq!(
2835 msgs.iter().map(|m| m.uuid.clone()).collect::<Vec<_>>().join(","),
2836 "h1,a1,h2,h3,h4,h5",
2837 "the served runs, in file order, with the assistant row between them"
2838 );
2839 assert!(
2840 msgs.iter().filter(|m| !matches!(m.kind, SessionMessageKind::System)).count() == 1,
2841 "a hook row is a system frame, the shape the live path draws"
2842 );
2843
2844 let h1 = &msgs[0].message;
2845 assert_eq!(h1.get("subtype").and_then(Value::as_str), Some("hook_response"));
2846 assert_eq!(h1.get("hook_id").and_then(Value::as_str), Some("h1"));
2847 assert_eq!(h1.get("uuid").and_then(Value::as_str), Some("h1"));
2848 assert_eq!(h1.get("hook_name").and_then(Value::as_str), Some("SessionStart:startup"));
2849 assert_eq!(h1.get("hook_event").and_then(Value::as_str), Some("SessionStart"));
2850 assert_eq!(h1.get("output").and_then(Value::as_str), Some("## The PR review gate"));
2851 assert_eq!(h1.get("stdout").and_then(Value::as_str), Some("## The PR review gate"));
2852 assert_eq!(h1.get("exit_code").and_then(Value::as_i64), Some(0));
2853 assert_eq!(h1.get("outcome").and_then(Value::as_str), Some("success"));
2854 assert_eq!(h1.get("session_id").and_then(Value::as_str), Some("s1"));
2855 assert_eq!(h1.get("transcript").and_then(Value::as_bool), Some(true));
2856
2857 let h2 = &msgs[2].message;
2858 assert_eq!(
2859 h2.get("output").and_then(Value::as_str),
2860 Some("line one\nline two"),
2861 "the content array joins its lines"
2862 );
2863 assert_eq!(h2.get("hook_event").and_then(Value::as_str), Some("Setup"));
2864
2865 let h3 = &msgs[3].message;
2866 assert_eq!(h3.get("outcome").and_then(Value::as_str), Some("error"));
2867 assert_eq!(
2868 h3.get("output").and_then(Value::as_str),
2869 Some("Failed with non-blocking status code"),
2870 "the stderr fallback when the combined output and stdout are empty"
2871 );
2872 assert_eq!(h3.get("exit_code").and_then(Value::as_i64), Some(141));
2873
2874 let h4 = &msgs[4].message;
2875 assert_eq!(h4.get("outcome").and_then(Value::as_str), Some("cancelled"));
2876 assert_eq!(h4.get("output").and_then(Value::as_str), Some(""));
2877
2878 let h5 = &msgs[5].message;
2879 assert_eq!(
2880 h5.get("output").and_then(Value::as_str),
2881 Some("plugin lines"),
2882 "the stdout fallback for a row with no combined content"
2883 );
2884
2885 let frames = messages_from_rows(parse_session_messages(jsonl.as_bytes()).messages, "s1");
2888 assert!(
2889 matches!(&frames[0], forge_primitives::Message::HookResponse { output, exit_code, .. }
2890 if output == "## The PR review gate" && *exit_code == 0),
2891 "the row decodes to the typed live response"
2892 );
2893 assert!(
2894 matches!(&frames[1], forge_primitives::Message::Assistant { .. }),
2895 "and the assistant row keeps its place between the hook rows"
2896 );
2897 assert!(
2898 frames.iter().any(|frame| matches!(frame,
2899 forge_primitives::Message::HookResponse { hook_id, outcome, .. }
2900 if hook_id == "h4" && outcome == "cancelled")),
2901 "and a cancelled run decodes as the same typed response, not the generic system shape"
2902 );
2903 let crossed = serde_json::to_value(&frames[0]).expect("the frame crosses");
2904 for (field, want) in [
2905 ("hook_id", "h1"),
2906 ("output", "## The PR review gate"),
2907 ("outcome", "success"),
2908 ("transcript", "true"),
2909 ] {
2910 assert_eq!(
2911 crossed.get(field).map(|held| held.to_string().trim_matches('"').to_owned()),
2912 Some(want.to_owned()),
2913 "{field} survives the decode"
2914 );
2915 }
2916 }
2917
2918 #[test]
2929 fn parse_session_messages_counts_compaction_boundaries() {
2930 let jsonl = r#"{"type":"user","message":{"role":"user","content":"one"},"uuid":"u1","session_id":"s1"}
2931{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":1002459,"postTokens":31253}}
2932{"type":"assistant","message":{"role":"assistant","content":[]},"uuid":"as1","session_id":"s1"}
2933{"type":"system","subtype":"compact_boundary","uuid":"cb2","session_id":"s1","compactMetadata":{"trigger":"manual","preTokens":53903,"postTokens":9149}}
2934{"type":"system","subtype":"init","uuid":"x1","session_id":"s1"}
2935"#;
2936 let history = parse_session_messages(jsonl.as_bytes());
2937 assert_eq!(
2938 history.compaction_count, 2,
2939 "both boundaries counted, the other system row not"
2940 );
2941 assert_eq!(
2942 history.messages.len(),
2943 4,
2944 "both boundary rows ride the message list, and the unread system row does not"
2945 );
2946
2947 let boundary = history
2952 .messages
2953 .iter()
2954 .find(|message| message.uuid == "cb1")
2955 .expect("the first boundary is carried");
2956 assert_eq!(boundary.session_id, "s1");
2957 assert_eq!(
2958 boundary.message.get("compact_metadata"),
2959 Some(&serde_json::json!({
2960 "trigger": "auto",
2961 "pre_tokens": 1_002_459,
2962 "post_tokens": 31_253,
2963 })),
2964 "normalised to the shape the wire sends",
2965 );
2966 assert_eq!(
2967 serde_json::from_value::<forge_primitives::Message>(boundary.message.clone())
2968 .expect("the kept row decodes"),
2969 forge_primitives::Message::CompactBoundary {
2970 trigger: "auto".to_owned(),
2971 pre_tokens: 1_002_459,
2972 post_tokens: 31_253,
2973 uuid: "cb1".to_owned(),
2974 session_id: "s1".to_owned(),
2975 metadata_extras: forge_primitives::messages::Extras::new(),
2976 extras: forge_primitives::messages::Extras::new(),
2977 },
2978 "and decodes typed, which is what the fold reads",
2979 );
2980 }
2981
2982 #[test]
2983 fn parse_session_messages_counts_no_boundaries_in_a_never_compacted_session() {
2984 let jsonl = r#"{"type":"user","message":{"role":"user","content":"one"},"uuid":"u1","session_id":"s1"}
2985"#;
2986 assert_eq!(parse_session_messages(jsonl.as_bytes()).compaction_count, 0);
2987 }
2988
2989 #[test]
2995 fn parse_session_messages_keeps_a_stop_hook_summary() {
2996 let jsonl = r#"{"type":"user","message":{"role":"user","content":"one"},"uuid":"u1","session_id":"s1"}
2997{"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"just fmt","durationMs":1400},{"command":"just check","durationMs":62000}],"hookErrors":[],"hasOutput":true,"level":"suggestion","preventedContinuation":false,"stopReason":"","toolUseID":"toolu_hook","timestamp":"2026-09-04T00:57:38.264Z","uuid":"h1","sessionId":"s1","cwd":"/proj"}
2998{"type":"system","subtype":"init","uuid":"x1","session_id":"s1"}
2999"#;
3000 let history = parse_session_messages(jsonl.as_bytes());
3001
3002 assert_eq!(history.messages.len(), 2, "the hook row joins the turn, and nothing else does");
3003 assert!(
3004 matches!(history.messages[1].kind, SessionMessageKind::System),
3005 "kept as the system row it is",
3006 );
3007 assert_eq!(
3008 history.messages[1].uuid, "h1",
3009 "under its own id, which is what a view keys on"
3010 );
3011 assert_eq!(history.messages[1].timestamp.as_deref(), Some("2026-09-04T00:57:38.264Z"));
3012
3013 let frame: forge_primitives::Message =
3014 serde_json::from_value(history.messages[1].message.clone())
3015 .expect("the kept row decodes as the frame the wire sends");
3016 let forge_primitives::Message::StopHookSummary { actions, hook_infos, .. } = frame else {
3017 panic!("a hook summary");
3018 };
3019 assert_eq!(actions, 2, "carrying the count the row reports");
3020 assert_eq!(hook_infos.len(), 2, "and one entry per hook behind it");
3021 assert_eq!(hook_infos[0].command, "just fmt", "each naming what it ran");
3022 assert_eq!(hook_infos[1].duration_ms, Some(62000), "and how long it took");
3023 }
3024
3025 #[test]
3033 fn parse_session_messages_keeps_the_system_rows_the_live_view_draws() {
3034 let jsonl = r#"{"type":"system","subtype":"permission_denied","tool_name":"Bash","tool_use_id":"toolu_1","decision_reason_type":"other","decision_reason":"Contains simple_expansion","message":"Contains simple_expansion","uuid":"p1","sessionId":"s1","timestamp":"2026-10-26T10:00:00.000Z"}
3035{"type":"system","subtype":"api_error","level":"error","error":{"message":"502 upstream request failed","status":502},"retryInMs":610,"uuid":"e1","sessionId":"s1","timestamp":"2026-10-26T10:00:01.000Z"}
3036{"type":"system","subtype":"informational","content":"Goal cleared","level":"warning","uuid":"i1","sessionId":"s1","timestamp":"2026-10-26T10:00:02.000Z"}
3037{"type":"system","subtype":"notification","text":"a stop hook errored","priority":"immediate","uuid":"n1","sessionId":"s1","timestamp":"2026-10-26T10:00:03.000Z"}
3038{"type":"widget_state","widget":{"name":"counter"},"uuid":"w1","sessionId":"s1","timestamp":"2026-10-26T10:00:04.000Z"}
3039{"type":"worktree-state","worktree":{},"uuid":"ws1"}
3040{"type":"mode","mode":"default","uuid":"m1"}
3041{"type":"queue-operation","operation":"enqueue","timestamp":"2026-11-03T12:00:58.593Z","sessionId":"s1","content":"a prompt the reader cancelled"}
3042{"type":"system","subtype":"turn_duration","durationMs":10,"uuid":"t1","sessionId":"s1"}
3043"#;
3044 let history = parse_session_messages(jsonl.as_bytes());
3045 assert_eq!(
3046 history.messages.iter().map(|row| row.uuid.as_str()).collect::<Vec<_>>(),
3047 ["p1", "e1", "i1", "n1", "w1", "t1"],
3050 "the live rows are kept, and no bookkeeping row is",
3051 );
3052 assert!(
3053 !history.messages.iter().any(|row| {
3054 row.message.get("type").and_then(serde_json::Value::as_str)
3055 == Some("queue-operation")
3056 }),
3057 "an enqueue-only queue-operation row is bookkeeping, so a cold read never serves it",
3058 );
3059
3060 let messages = messages_from_rows(history.messages, "s1");
3061 assert!(
3062 matches!(
3063 &messages[0],
3064 forge_primitives::Message::PermissionDenied { tool_use_id, session_id, .. }
3065 if tool_use_id == "toolu_1" && session_id == "s1"
3066 ),
3067 "the refusal decodes typed, under the session the caller names: {:?}",
3068 messages[0],
3069 );
3070 assert!(
3071 matches!(
3072 &messages[1],
3073 forge_primitives::Message::System { subtype, .. } if subtype == "api_error"
3074 ),
3075 "the api error decodes as the generic system frame: {:?}",
3076 messages[1],
3077 );
3078 assert!(
3079 matches!(
3080 &messages[2],
3081 forge_primitives::Message::System { subtype, .. } if subtype == "informational"
3082 ),
3083 "and so does the informational line: {:?}",
3084 messages[2],
3085 );
3086 assert!(
3087 matches!(
3088 &messages[3],
3089 forge_primitives::Message::Notification { text, .. }
3090 if text == "a stop hook errored"
3091 ),
3092 "the notification decodes typed: {:?}",
3093 messages[3],
3094 );
3095 assert!(
3096 matches!(
3097 &messages[4],
3098 forge_primitives::Message::Unknown { type_str, raw }
3099 if type_str == "widget_state" && raw["widget"]["name"] == "counter"
3100 ),
3101 "an unknown top-level row arrives as itself, payload and all: {:?}",
3102 messages[4],
3103 );
3104 assert!(
3105 matches!(&messages[5], forge_primitives::Message::TurnDuration { ms: 10, .. }),
3106 "a system subtype with no face of its own still round-trips: {:?}",
3107 messages[5],
3108 );
3109
3110 let frame = |line: &str| -> forge_primitives::Message {
3111 serde_json::from_value(serde_json::from_str(line).expect("json"))
3112 .expect("a decodable frame")
3113 };
3114 for served in [
3115 r#"{"type":"system","subtype":"permission_denied","tool_name":"Bash","tool_use_id":"t","decision_reason_type":"other","decision_reason":"r","message":"r","uuid":"p","session_id":"s"}"#,
3116 r#"{"type":"system","subtype":"api_error","error":{"message":"down"},"uuid":"e","session_id":"s"}"#,
3117 r#"{"type":"system","subtype":"informational","content":"c","uuid":"i","session_id":"s"}"#,
3118 r#"{"type":"system","subtype":"notification","text":"t","uuid":"n","session_id":"s"}"#,
3119 r#"{"type":"system","subtype":"task_progress","task_id":"t","description":"mapping","usage":{"total_tokens":1,"tool_uses":1,"duration_ms":1},"uuid":"tp","session_id":"s"}"#,
3122 ] {
3123 assert!(
3124 has_a_transcript_row(&frame(served)),
3125 "a frame the read serves counts as having a row: {served}",
3126 );
3127 }
3128 assert!(
3129 !has_a_transcript_row(&frame(
3130 r#"{"type":"system","subtype":"init","uuid":"t","session_id":"s"}"#,
3131 )),
3132 "a system subtype the read drops still counts as no row",
3133 );
3134 assert!(
3135 !has_a_transcript_row(&frame(
3136 r#"{"type":"system","subtype":"thinking_tokens","estimated_tokens":100,"estimated_tokens_delta":10,"uuid":"k","session_id":"s"}"#,
3137 )),
3138 "the thinking counter is a report figure, not a row",
3139 );
3140 assert!(
3141 has_a_transcript_row(&frame(
3142 r#"{"type":"system","subtype":"turn_duration","durationMs":1,"uuid":"t","session_id":"s"}"#,
3143 )),
3144 "and a subtype the client draws provisionally has its row",
3145 );
3146 }
3147
3148 #[test]
3154 fn a_kept_system_row_carries_one_session_spelling() {
3155 let jsonl = r#"{"type":"system","subtype":"api_error","error":{"message":"down"},"uuid":"e1","sessionId":"s1","timestamp":"2026-10-28T10:00:00.000Z"}
3156{"type":"system","subtype":"stop_hook_summary","hookCount":0,"hookInfos":[],"hookErrors":[],"hasOutput":false,"level":"suggestion","preventedContinuation":false,"stopReason":"","toolUseID":"toolu_h","uuid":"h1","sessionId":"s1","timestamp":"2026-10-28T10:00:01.000Z"}
3157"#;
3158 let frames = messages_from_rows(parse_session_messages(jsonl.as_bytes()).messages, "s1");
3159 assert_eq!(frames.len(), 2, "both kept system rows reach the frame");
3160 for (at, frame) in frames.iter().enumerate() {
3161 let wire = serde_json::to_value(frame).expect("a frame serialises").to_string();
3162 assert!(
3163 !wire.contains("\"sessionId\""),
3164 "frame {at} carries the CLI's spelling as well as the wire's: {wire}",
3165 );
3166 assert_eq!(
3167 wire.matches("\"session_id\"").count(),
3168 1,
3169 "frame {at} names the session exactly once: {wire}",
3170 );
3171 }
3172 }
3173
3174 #[test]
3180 fn the_system_skip_list_matches_the_clients() {
3181 const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../client/src/chat/units.ts");
3182 const MARKER: &str = "const SYSTEM_DRAWN_ELSEWHERE: ReadonlySet<string> = new Set([";
3183 let source = std::fs::read_to_string(CLIENT)
3184 .unwrap_or_else(|err| panic!("the client's fold is readable at {CLIENT}: {err}"));
3185 let start =
3186 source.find(MARKER).unwrap_or_else(|| panic!("the client still declares {MARKER:?}"));
3187 let block = &source[start + MARKER.len()..];
3188 let end = block
3189 .find("]);")
3190 .unwrap_or_else(|| panic!("the client's skip list closes after its entries"));
3191 let mut theirs: Vec<String> = block[..end]
3192 .split(',')
3193 .filter_map(|entry| {
3194 let entry = entry.trim();
3195 let quoted = entry.strip_prefix('\'')?.strip_suffix('\'')?;
3196 (!quoted.is_empty()).then(|| quoted.to_owned())
3197 })
3198 .collect();
3199 assert!(
3200 !theirs.is_empty(),
3201 "the client's skip list parsed to nothing, so this test compared nothing",
3202 );
3203 theirs.sort();
3204 let mut ours: Vec<String> =
3205 SYSTEM_ROWS_DRAWN_ELSEWHERE.iter().map(|name| (*name).to_owned()).collect();
3206 ours.sort();
3207 assert_eq!(
3208 ours, theirs,
3209 "the scan's SYSTEM_ROWS_DRAWN_ELSEWHERE and the client's SYSTEM_DRAWN_ELSEWHERE \
3210 have diverged; a subtype belongs in both or neither",
3211 );
3212 }
3213
3214 #[test]
3220 fn every_row_a_transcript_read_keeps_reaches_the_wire_stamped() {
3221 let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"uuid":"u1","sessionId":"s1","timestamp":"2026-10-15T05:18:05.217Z"}
3222{"type":"attachment","attachment":{"type":"queued_command","prompt":"queued words","commandMode":"prompt"},"uuid":"a1","session_id":"s1","timestamp":"2026-10-15T05:19:00.100Z"}
3223{"type":"queue-operation","operation":"remove","timestamp":"2026-10-15T05:19:03.000Z","sessionId":"s1","content":"queued words"}
3224{"type":"assistant","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"working"}]},"uuid":"a1","sessionId":"s1","timestamp":"2026-10-15T05:19:05.544Z"}
3225{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[],"hookErrors":[],"hasOutput":false,"level":"suggestion","preventedContinuation":false,"stopReason":"","toolUseID":"tu1","uuid":"h1","sessionId":"s1","timestamp":"2026-10-15T05:19:40.632Z"}
3226{"type":"system","subtype":"compact_boundary","uuid":"b1","sessionId":"s1","compactMetadata":{"trigger":"auto","preTokens":1018466,"postTokens":19526},"timestamp":"2026-10-15T05:20:11.004Z"}
3227"#;
3228 let history = parse_session_messages(jsonl.as_bytes());
3229 let messages = messages_from_rows(history.messages, "s1");
3230
3231 let stamps: Vec<(String, Option<String>)> = messages
3232 .iter()
3233 .map(|message| {
3234 let value = serde_json::to_value(message).expect("encode");
3235 let kind = value["type"].as_str().unwrap_or("?").to_owned();
3236 (kind, value["timestamp"].as_str().map(str::to_owned))
3237 })
3238 .collect();
3239 assert_eq!(
3240 stamps,
3241 vec![
3242 ("user".to_owned(), Some("2026-10-15T05:18:05.217Z".to_owned())),
3243 ("user".to_owned(), Some("2026-10-15T05:19:03.000Z".to_owned())),
3244 ("assistant".to_owned(), Some("2026-10-15T05:19:05.544Z".to_owned())),
3245 ("system".to_owned(), Some("2026-10-15T05:19:40.632Z".to_owned())),
3246 ("system".to_owned(), Some("2026-10-15T05:20:11.004Z".to_owned())),
3247 ],
3248 "each kept kind must carry the row's own stamp, in the row's own order",
3249 );
3250 }
3251
3252 #[test]
3253 fn parse_session_messages_skips_attachment_without_queued_command() {
3254 let jsonl = r#"{"type":"attachment","attachment":{"type":"image","source":{}},"uuid":"a1","session_id":"s1"}
3257{"type":"assistant","message":{"role":"assistant","content":[]},"uuid":"as1","session_id":"s1"}
3258"#;
3259 let msgs = parse_session_messages(jsonl.as_bytes()).messages;
3260 assert_eq!(msgs.len(), 1, "non-queued_command attachment must be skipped");
3261 assert!(matches!(msgs[0].kind, SessionMessageKind::Assistant));
3262 }
3263
3264 #[test]
3272 fn parse_session_messages_reads_a_duplicated_block_once() {
3273 let jsonl = r#"{"type":"user","message":{"role":"user","content":"first"},"uuid":"u1","session_id":"s1","timestamp":"2026-09-04T02:23:48.850Z"}
3274{"type":"assistant","message":{"id":"msg_01","role":"assistant","content":[{"type":"text","text":"answer"}]},"uuid":"a1","session_id":"s1","timestamp":"2026-09-04T02:23:50.062Z"}
3275{"type":"last-prompt","uuid":"lp1"}
3276{"type":"tag","tag":"t"}
3277{"type":"user","message":{"role":"user","content":"first"},"uuid":"u1","slug":"structured-pondering-nygaard","session_id":"s1","timestamp":"2026-09-04T02:23:48.850Z"}
3278{"type":"assistant","message":{"id":"msg_01","role":"assistant","content":[{"type":"text","text":"answer"}]},"uuid":"a1","slug":"structured-pondering-nygaard","session_id":"s1","timestamp":"2026-09-04T02:23:50.062Z"}
3279"#;
3280 let history = parse_session_messages(jsonl.as_bytes());
3281 assert_eq!(
3282 history.messages.iter().map(|row| row.uuid.as_str()).collect::<Vec<_>>(),
3283 ["u1", "a1"],
3284 "the duplicated block reads once, in its original order, and the session-state rows \
3285 stay out",
3286 );
3287 }
3288
3289 #[test]
3292 fn parse_session_messages_counts_a_duplicated_boundary_once() {
3293 let jsonl = r#"{"type":"user","message":{"role":"user","content":"one"},"uuid":"u1","session_id":"s1"}
3294{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":1002459,"postTokens":31253}}
3295{"type":"last-prompt","uuid":"lp1"}
3296{"type":"system","subtype":"compact_boundary","uuid":"cb1","slug":"s","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":1002459,"postTokens":31253}}
3297"#;
3298 let history = parse_session_messages(jsonl.as_bytes());
3299 assert_eq!(history.compaction_count, 1, "one boundary, written twice");
3300 assert_eq!(history.messages.len(), 2, "and drawn once");
3301 }
3302
3303 fn session_info_with_tag(session_id: &str, tag: Option<&str>) -> SDKSessionInfo {
3304 SDKSessionInfo {
3305 session_id: session_id.to_string(),
3306 summary: "test".to_string(),
3307 last_modified: 0,
3308 file_size: None,
3309 custom_title: None,
3310 first_prompt: None,
3311 git_branch: None,
3312 cwd: None,
3313 storage_key: String::new(),
3314 tag: tag.map(str::to_string),
3315 created_at: None,
3316 }
3317 }
3318
3319 #[test]
3320 fn parse_session_info_filter_excludes_worker_tag_by_default() {
3321 let content = "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hi\"}}\n{\"type\":\"tag\",\"tag\":\"forge:worker:reviewer\"}\n".to_string();
3322 let lite = LiteSessionFile {
3323 mtime: 0,
3324 size: content.len() as u64,
3325 head: content.clone(),
3326 tail: content,
3327 tag: Some("forge:worker:reviewer".to_string()),
3328 };
3329 let info = parse_session_info_from_lite("abc", &lite, None).expect("some");
3330 assert_eq!(info.tag.as_deref(), Some("forge:worker:reviewer"));
3331 assert!(should_exclude_worker_tag(&info));
3334 }
3335
3336 #[test]
3337 fn should_exclude_worker_tag_recognises_prefix() {
3338 let info = session_info_with_tag("s1", Some("forge:worker:reviewer"));
3339 assert!(should_exclude_worker_tag(&info));
3340 }
3341
3342 #[test]
3343 fn should_exclude_worker_tag_passes_lead_and_untagged() {
3344 let lead = session_info_with_tag("s1", Some("forge:lead"));
3345 let untagged = session_info_with_tag("s2", None);
3346 assert!(!should_exclude_worker_tag(&lead));
3347 assert!(!should_exclude_worker_tag(&untagged));
3348 }
3349
3350 fn transcript(tag: Option<&str>) -> String {
3352 use std::fmt::Write as _;
3353 let mut body = "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\
3354 \"message\":{\"content\":\"hi\"}}\n"
3355 .to_owned();
3356 if let Some(tag) = tag {
3357 let _ = writeln!(body, "{{\"type\":\"tag\",\"tag\":\"{tag}\"}}");
3358 }
3359 body
3360 }
3361
3362 fn session_ids(entries: &[SDKSessionInfo]) -> Vec<String> {
3364 entries.iter().map(|info| info.session_id.clone()).collect()
3365 }
3366
3367 fn dir_for(config: &Path, cwd: &str) -> PathBuf {
3369 projects_dir_for(config).join(project_key_for_directory(Some(cwd)))
3370 }
3371
3372 fn set_age(path: &Path, seconds: u64) {
3375 let when = std::time::SystemTime::now() - std::time::Duration::from_secs(seconds.max(1));
3376 fs::File::options()
3377 .write(true)
3378 .open(path)
3379 .expect("open for its mtime")
3380 .set_modified(when)
3381 .expect("set its mtime");
3382 }
3383
3384 #[tokio::test]
3392 async fn a_listing_keeps_the_seats_own_sessions_and_no_others() {
3393 let tmp = tempfile::tempdir().unwrap();
3394 let cwd = tmp.path().join("project");
3395 fs::create_dir_all(&cwd).unwrap();
3396 let cwd = cwd.to_string_lossy().into_owned();
3397 let dir = dir_for(tmp.path(), &cwd);
3398 fs::create_dir_all(&dir).unwrap();
3399 write_session_jsonl(&dir, "lead-own", &transcript(None));
3400 write_session_jsonl(&dir, "alpha-own", &transcript(Some("forge:worker:alpha")));
3401 write_session_jsonl(&dir, "alpha-2-own", &transcript(Some("forge:worker:alpha-2")));
3402 write_session_jsonl(&dir, "beta-own", &transcript(Some("forge:worker:beta")));
3403
3404 let lead = list_sessions(tmp.path(), Some(&cwd), None, 0, Workers::Hidden, None).await;
3405 assert_eq!(
3406 session_ids(&lead),
3407 vec!["lead-own"],
3408 "a lead's listing is the project's own sessions, never its workers'"
3409 );
3410
3411 let worker =
3412 list_sessions(tmp.path(), Some(&cwd), None, 0, Workers::Only("alpha".to_owned()), None)
3413 .await;
3414 assert_eq!(
3415 session_ids(&worker),
3416 vec!["alpha-own"],
3417 "a worker's listing is the sessions its own label ran, not its prefix kin's"
3418 );
3419 }
3420
3421 #[tokio::test]
3427 async fn a_workers_listing_skips_what_its_cached_tags_rule_out() {
3428 let logs = LogCapture::default();
3429 let subscriber = tracing_subscriber::fmt()
3430 .with_max_level(tracing::Level::DEBUG)
3431 .with_ansi(false)
3432 .with_writer(logs.clone())
3433 .finish();
3434 let _guard = tracing::subscriber::set_default(subscriber);
3435
3436 let tmp = tempfile::tempdir().unwrap();
3437 let cwd = tmp.path().join("project");
3438 fs::create_dir_all(&cwd).unwrap();
3439 let cwd = cwd.to_string_lossy().into_owned();
3440 let dir = dir_for(tmp.path(), &cwd);
3441 fs::create_dir_all(&dir).unwrap();
3442 write_session_jsonl(&dir, "lead-own", &transcript(None));
3443 write_session_jsonl(&dir, "beta-own", &transcript(Some("forge:worker:beta")));
3444 write_session_jsonl(&dir, "alpha-own", &transcript(Some("forge:worker:alpha")));
3445
3446 let first = std::sync::Arc::new(SessionTagCache::new(std::collections::HashMap::new()));
3448 let read =
3449 list_sessions(tmp.path(), Some(&cwd), None, 0, Workers::Hidden, Some(&first)).await;
3450 assert_eq!(read.len(), 1, "precondition: a lead's listing hides its workers");
3451 let prior: std::collections::HashMap<String, SessionTagScan> =
3452 first.updates().into_iter().collect();
3453 assert_eq!(prior.len(), 3, "and the pass still read every transcript's tag");
3454 let warm = std::sync::Arc::new(SessionTagCache::new(prior));
3455
3456 let worker = list_sessions(
3457 tmp.path(),
3458 Some(&cwd),
3459 None,
3460 0,
3461 Workers::Only("alpha".to_owned()),
3462 Some(&warm),
3463 )
3464 .await;
3465 assert_eq!(
3466 session_ids(&worker),
3467 vec!["alpha-own"],
3468 "the listing is still the label's own sessions",
3469 );
3470 let logged = String::from_utf8(logs.0.lock().clone()).unwrap();
3471 assert!(
3472 logged.contains("session_scan_narrowed") && logged.contains("skipped=2"),
3473 "the two transcripts the cache ruled out are named as skipped: {logged}",
3474 );
3475
3476 let mut retagged = transcript(Some("forge:worker:beta"));
3481 retagged.push_str("{\"type\":\"tag\",\"tag\":\"forge:worker:alpha\"}\n");
3482 write_session_jsonl(&dir, "beta-own", &retagged);
3483 let again = list_sessions(
3484 tmp.path(),
3485 Some(&cwd),
3486 None,
3487 0,
3488 Workers::Only("alpha".to_owned()),
3489 Some(&warm),
3490 )
3491 .await;
3492 let mut again_ids = session_ids(&again);
3495 again_ids.sort();
3496 assert_eq!(
3497 again_ids,
3498 vec!["alpha-own", "beta-own"],
3499 "a transcript that changed after its scan must be read, not skipped",
3500 );
3501
3502 write_session_jsonl(&dir, "gamma-own", &transcript(Some("forge:worker:alpha")));
3505 let fresh = list_sessions(
3506 tmp.path(),
3507 Some(&cwd),
3508 None,
3509 0,
3510 Workers::Only("alpha".to_owned()),
3511 Some(&warm),
3512 )
3513 .await;
3514 let fresh_ids = session_ids(&fresh);
3515 assert!(
3516 fresh_ids.iter().any(|id| id.as_str() == "gamma-own"),
3517 "a transcript the cache has not scanned must be read: {fresh_ids:?}",
3518 );
3519 }
3520
3521 #[tokio::test]
3526 async fn a_capped_listing_reads_only_the_rows_it_serves() {
3527 let logs = LogCapture::default();
3528 let subscriber = tracing_subscriber::fmt()
3529 .with_max_level(tracing::Level::DEBUG)
3530 .with_ansi(false)
3531 .with_writer(logs.clone())
3532 .finish();
3533 let _guard = tracing::subscriber::set_default(subscriber);
3534
3535 let tmp = tempfile::tempdir().unwrap();
3536 let cwd = tmp.path().join("project");
3537 fs::create_dir_all(&cwd).unwrap();
3538 let cwd = cwd.to_string_lossy().into_owned();
3539 let dir = dir_for(tmp.path(), &cwd);
3540 fs::create_dir_all(&dir).unwrap();
3541 for at in (0..8).rev() {
3546 let path = write_session_jsonl(
3547 &dir,
3548 &format!("run-{at}"),
3549 &transcript(Some("forge:worker:alpha")),
3550 );
3551 set_age(&path, at + 1);
3553 }
3554
3555 let listed = list_sessions(
3556 tmp.path(),
3557 Some(&cwd),
3558 Some(2),
3559 0,
3560 Workers::Only("alpha".to_owned()),
3561 None,
3562 )
3563 .await;
3564 assert_eq!(session_ids(&listed), vec!["run-0", "run-1"], "the two newest rows");
3565
3566 let logged = String::from_utf8(logs.0.lock().clone()).unwrap();
3567 assert!(
3568 logged.contains("session_scan_read") && logged.contains("read=2"),
3569 "the scan read every transcript for a two-row page: {logged}",
3570 );
3571
3572 let all =
3574 list_sessions(tmp.path(), Some(&cwd), None, 0, Workers::Only("alpha".to_owned()), None)
3575 .await;
3576 assert_eq!(all.len(), 8, "an uncapped listing reads every transcript");
3577 }
3578
3579 #[tokio::test]
3584 async fn the_cap_is_applied_to_the_rows_the_listing_carries() {
3585 let tmp = tempfile::tempdir().unwrap();
3586 let cwd = tmp.path().join("project");
3587 fs::create_dir_all(&cwd).unwrap();
3588 let cwd = cwd.to_string_lossy().into_owned();
3589 let dir = dir_for(tmp.path(), &cwd);
3590 fs::create_dir_all(&dir).unwrap();
3591 let held = write_session_jsonl(&dir, "lead-own", &transcript(None));
3592 let newest =
3595 write_session_jsonl(&dir, "alpha-own", &transcript(Some("forge:worker:alpha")));
3596 set_age(&held, 600);
3597 set_age(&newest, 60);
3598
3599 let capped = list_sessions(tmp.path(), Some(&cwd), Some(1), 0, Workers::Hidden, None).await;
3600 assert_eq!(
3601 session_ids(&capped),
3602 vec!["lead-own"],
3603 "the cap must be spent on the rows the listing carries"
3604 );
3605 }
3606
3607 fn write_session_jsonl(dir: &Path, name: &str, body: &str) -> PathBuf {
3618 let path = dir.join(format!("{name}.jsonl"));
3619 fs::write(&path, body).unwrap();
3620 path
3621 }
3622
3623 #[test]
3624 fn read_session_info_finds_tag_at_start_of_large_file() {
3625 let window = usize::try_from(LITE_READ_BUF_SIZE).unwrap();
3632 let tmp = tempfile::tempdir().unwrap();
3633 let mut body = String::new();
3634 body.push_str(
3635 "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hello\"}}\n",
3636 );
3637 body.push_str("{\"type\":\"tag\",\"tag\":\"forge:worker:reviewer\"}\n");
3638 let filler = "{\"type\":\"assistant\",\"message\":{\"content\":\"x\"}}\n";
3639 while body.len() < window * 3 {
3640 body.push_str(filler);
3641 }
3642 let path = write_session_jsonl(tmp.path(), "abc", &body);
3643
3644 let info = read_session_info(&path, None).expect("session info parsed");
3645 assert_eq!(info.tag.as_deref(), Some("forge:worker:reviewer"));
3646 }
3647
3648 #[test]
3649 fn read_session_info_finds_tag_mid_file() {
3650 let window = usize::try_from(LITE_READ_BUF_SIZE).unwrap();
3655 let tmp = tempfile::tempdir().unwrap();
3656 let mut body = String::new();
3657 body.push_str(
3658 "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hello\"}}\n",
3659 );
3660 let filler = "{\"type\":\"assistant\",\"message\":{\"content\":\"x\"}}\n";
3661 while body.len() < window + 4_096 {
3663 body.push_str(filler);
3664 }
3665 body.push_str("{\"type\":\"tag\",\"tag\":\"forge:worker:tester\"}\n");
3666 while body.len() < window * 3 {
3669 body.push_str(filler);
3670 }
3671 let path = write_session_jsonl(tmp.path(), "abc", &body);
3672
3673 let info = read_session_info(&path, None).expect("session info parsed");
3674 assert_eq!(info.tag.as_deref(), Some("forge:worker:tester"));
3675 }
3676
3677 #[test]
3683 fn a_title_read_carries_the_title_and_not_the_tag() {
3684 let tmp = tempfile::tempdir().unwrap();
3685 let tagged = "{\"type\":\"user\",\"message\":{\"content\":\"the work I left\"}}\n\
3686 {\"type\":\"tag\",\"tag\":\"forge:worker:gone\"}\n";
3687 let path = write_session_jsonl(tmp.path(), "abc", tagged);
3688
3689 let info = read_session_info_at(&path).expect("a readable transcript");
3690
3691 assert_eq!(info.summary, "the work I left", "the title is read");
3692 assert_eq!(info.tag, None, "and the tag is not, because that scan is the whole file");
3693 }
3694
3695 #[test]
3700 fn a_shrunk_transcript_is_scanned_again_rather_than_resumed() {
3701 let tmp = tempfile::tempdir().unwrap();
3702 let tagged = "{\"type\":\"user\",\"message\":{\"content\":\"hi\"}}\n\
3703 {\"type\":\"tag\",\"tag\":\"forge:worker:gone\"}\n";
3704 let path = write_session_jsonl(tmp.path(), "abc", tagged);
3705
3706 let cache = SessionTagCache::default();
3707 assert_eq!(
3708 read_session_lite(&path, Some(&cache), true).unwrap().tag.as_deref(),
3709 Some("forge:worker:gone"),
3710 "the first scan must find the tag it will later be asked to forget"
3711 );
3712
3713 let primed = SessionTagCache::new(cache.updates().into_iter().collect());
3716 std::fs::write(&path, "{\"type\":\"user\",\"message\":{\"content\":\"x\"}}\n").unwrap();
3717
3718 assert_eq!(
3719 read_session_lite(&path, Some(&primed), true).unwrap().tag,
3720 None,
3721 "a replaced transcript must be re-scanned, not answered from the old offset"
3722 );
3723 }
3724
3725 #[test]
3732 fn a_resumed_scan_sees_a_tag_that_was_half_written_when_it_stopped() {
3733 let settled = "{\"type\":\"user\",\"message\":{\"content\":\"hi\"}}\n";
3734 let tag_row = "{\"type\":\"tag\",\"tag\":\"forge-worker-x\"}\n";
3735 let torn = &tag_row[..20];
3737 let (found, _project, state) =
3738 scan_tag_from(std::io::Cursor::new(format!("{settled}{torn}")), None);
3739 assert_eq!(found, None, "half a tag row carries no closing quote to parse");
3740 assert_eq!(
3741 state.scanned_len,
3742 settled.len() as u64,
3743 "an unterminated line must not be credited, or it is skipped on resume"
3744 );
3745
3746 let whole = format!("{settled}{tag_row}");
3749 let resume_at = usize::try_from(state.scanned_len).unwrap();
3750 let (resumed, _project, _) =
3751 scan_tag_from(std::io::Cursor::new(&whole[resume_at..]), Some(state));
3752 assert_eq!(
3753 resumed.as_deref(),
3754 Some("forge-worker-x"),
3755 "a resumed scan must find the completed row a whole-file scan would"
3756 );
3757 assert_eq!(
3758 resumed,
3759 scan_tag_from(std::io::Cursor::new(whole.as_str()), None).0,
3760 "resuming must agree with scanning the whole file"
3761 );
3762 }
3763
3764 #[test]
3765 fn read_session_info_picks_last_tag_when_multiple() {
3766 let window = usize::try_from(LITE_READ_BUF_SIZE).unwrap();
3773 let tmp = tempfile::tempdir().unwrap();
3774 let mut body = String::new();
3775 body.push_str(
3776 "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hi\"}}\n",
3777 );
3778 body.push_str("{\"type\":\"tag\",\"tag\":\"first\"}\n");
3779 let filler = "{\"type\":\"assistant\",\"message\":{\"content\":\"x\"}}\n";
3780 while body.len() < window + 4_096 {
3781 body.push_str(filler);
3782 }
3783 body.push_str("{\"type\":\"tag\",\"tag\":\"second\"}\n");
3784 while body.len() < window * 3 {
3785 body.push_str(filler);
3786 }
3787 let path = write_session_jsonl(tmp.path(), "abc", &body);
3788
3789 let info = read_session_info(&path, None).expect("session info parsed");
3790 assert_eq!(info.tag.as_deref(), Some("second"));
3791 }
3792
3793 #[test]
3794 fn read_session_info_reports_storage_dir_key() {
3795 let tmp = tempfile::tempdir().unwrap();
3798 let storage_key = "-Users-me-Projects-playground--claude-worktrees-gpt-tutor";
3799 let project_dir = tmp.path().join(storage_key);
3800 fs::create_dir_all(&project_dir).unwrap();
3801 let path = write_session_jsonl(
3802 &project_dir,
3803 "abc",
3804 "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hi\"}}\n",
3805 );
3806
3807 let info = read_session_info(&path, None).expect("session info parsed");
3808 assert_eq!(
3809 info.storage_key, storage_key,
3810 "the scanned session reports the projects/<KEY>/ dir it lives in",
3811 );
3812 }
3813
3814 #[test]
3821 fn get_session_messages_folds_subagent_sidecars_under_their_dispatch() {
3822 let config_dir = tempfile::tempdir().unwrap();
3823 let session_id = "550e8400-e29b-41d4-a716-446655440000";
3824 let directory = "/Users/me/Projects/playground";
3825 let dir = project_dir_for(config_dir.path(), directory);
3826 fs::create_dir_all(&dir).unwrap();
3827 write_session_jsonl(
3828 &dir,
3829 session_id,
3830 "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n\
3831 {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_dispatch\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n\
3832 {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]},\"uuid\":\"a2\"}\n",
3833 );
3834 let sidecars = dir.join(session_id).join("subagents");
3837 fs::create_dir_all(&sidecars).unwrap();
3838 fs::write(
3839 sidecars.join("agent-abc.meta.json"),
3840 "{\"agentType\":\"Explore\",\"description\":\"map it\",\"toolUseId\":\"toolu_dispatch\"}",
3841 )
3842 .unwrap();
3843 fs::write(
3844 sidecars.join("agent-abc.jsonl"),
3845 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_child\",\"name\":\"Bash\",\"input\":{\"command\":\"ls\"}}]},\"uuid\":\"c1\"}\n\
3846 {\"type\":\"user\",\"isSidechain\":true,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_child\",\"content\":\"out\"}]},\"uuid\":\"c2\"}\n",
3847 )
3848 .unwrap();
3849 fs::write(
3851 sidecars.join("agent-def.meta.json"),
3852 "{\"agentType\":\"Explore\",\"description\":\"more\",\"toolUseId\":\"toolu_dispatch\"}",
3853 )
3854 .unwrap();
3855 fs::write(
3856 sidecars.join("agent-def.jsonl"),
3857 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]},\"uuid\":\"d1\"}\n",
3858 )
3859 .unwrap();
3860 fs::write(
3863 sidecars.join("agent-zzz.meta.json"),
3864 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_absent\"}",
3865 )
3866 .unwrap();
3867 fs::write(
3868 sidecars.join("agent-zzz.jsonl"),
3869 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"orphan\"}]},\"uuid\":\"x1\"}\n",
3870 )
3871 .unwrap();
3872 fs::write(
3873 sidecars.join("agent-nofile.meta.json"),
3874 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
3875 )
3876 .unwrap();
3877 for (stem, frame) in [("agent-lone", "y1"), ("agent-bad", "y2"), ("agent-empty", "y3")] {
3881 if stem != "agent-lone" {
3882 let meta = if stem == "agent-bad" {
3883 "not json".to_owned()
3884 } else {
3885 "{\"agentType\":\"Explore\",\"toolUseId\":\"\"}".to_owned()
3886 };
3887 fs::write(sidecars.join(format!("{stem}.meta.json")), meta).unwrap();
3888 }
3889 fs::write(
3890 sidecars.join(format!("{stem}.jsonl")),
3891 format!(
3892 "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"{frame}\"}}]}},\"uuid\":\"{frame}\"}}\n"
3893 ),
3894 )
3895 .unwrap();
3896 }
3897
3898 let messages =
3899 get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
3900
3901 let uuids: Vec<_> = messages.iter().map(|message| message.uuid.as_str()).collect();
3902 assert_eq!(uuids.len(), 6, "the transcript's three rows plus three sidecar frames");
3903 assert_eq!(&uuids[..2], &["a1", "u1"], "the dispatch and its result come first");
3904 assert_eq!(uuids[5], "a2", "and the conversation's later row stays last");
3905 let mut folded = uuids[2..5].to_vec();
3906 folded.sort_unstable();
3907 assert_eq!(
3908 folded,
3909 vec!["c1", "c2", "d1"],
3910 "every sidecar sharing the dispatch sits where it ran, not at the end",
3911 );
3912 for absent in ["x1", "y1", "y2", "y3"] {
3913 assert!(
3914 !uuids.contains(&absent),
3915 "'{absent}' has no joinable dispatch and must not be folded",
3916 );
3917 }
3918 assert_eq!(
3919 messages.iter().filter(|message| message.parent_tool_use_id.is_none()).count(),
3920 3,
3921 "the transcript's own rows stay the main thread's",
3922 );
3923 for message in messages.iter().filter(|message| message.parent_tool_use_id.is_some()) {
3924 assert_eq!(
3925 message.parent_tool_use_id.as_deref(),
3926 Some("toolu_dispatch"),
3927 "each folded frame names the dispatch id itself, not the stem or the agent id",
3928 );
3929 }
3930 let main_thread =
3933 read_session_messages_main_thread(config_dir.path(), session_id, Some(directory))
3934 .messages;
3935 let main_uuids: Vec<_> = main_thread.iter().map(|message| message.uuid.as_str()).collect();
3936 assert_eq!(
3937 main_uuids,
3938 vec!["a1", "u1", "a2"],
3939 "the main-thread read leaves every sub-agent frame out",
3940 );
3941 }
3942
3943 #[test]
3958 fn get_session_messages_folds_a_nested_sidecar_under_its_owner() {
3959 let config_dir = tempfile::tempdir().unwrap();
3960 let session_id = "550e8400-e29b-41d4-a716-446655440002";
3961 let directory = "/Users/me/Projects/playground";
3962 let dir = project_dir_for(config_dir.path(), directory);
3963 fs::create_dir_all(&dir).unwrap();
3964 write_session_jsonl(
3965 &dir,
3966 session_id,
3967 "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n\
3968 {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_dispatch\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n\
3969 {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]},\"uuid\":\"a2\"}\n",
3970 );
3971 let sidecars = dir.join(session_id).join("subagents");
3972 fs::create_dir_all(&sidecars).unwrap();
3973 fs::write(
3977 sidecars.join("agent-outer.meta.json"),
3978 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
3979 )
3980 .unwrap();
3981 fs::write(
3982 sidecars.join("agent-outer.jsonl"),
3983 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_inner\",\"name\":\"Agent\",\"input\":{\"prompt\":\"inner\"}}]},\"uuid\":\"o1\"}\n\
3984 {\"type\":\"user\",\"isSidechain\":true,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_inner\",\"content\":\"inner done\"}]},\"uuid\":\"o2\"}\n",
3985 )
3986 .unwrap();
3987 fs::write(
3989 sidecars.join("agent-nested.meta.json"),
3990 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_inner\",\"parentAgentId\":\"outer\",\"spawnDepth\":2}",
3991 )
3992 .unwrap();
3993 fs::write(
3994 sidecars.join("agent-nested.jsonl"),
3995 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_deep\",\"name\":\"Bash\",\"input\":{\"command\":\"ls\"}}]},\"uuid\":\"n1\"}\n\
3996 {\"type\":\"user\",\"isSidechain\":true,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_deep\",\"content\":\"out\"}]},\"uuid\":\"n2\"}\n",
3997 )
3998 .unwrap();
3999 fs::write(
4002 sidecars.join("agent-deep.meta.json"),
4003 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_deep\",\"parentAgentId\":\"nested\",\"spawnDepth\":3}",
4004 )
4005 .unwrap();
4006 fs::write(
4007 sidecars.join("agent-deep.jsonl"),
4008 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"deep\"}]},\"uuid\":\"d1\"}\n",
4009 )
4010 .unwrap();
4011
4012 let read = || get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
4013 let assert_folded = |messages: &[SessionMessage]| {
4014 let uuids: Vec<_> = messages.iter().map(|message| message.uuid.as_str()).collect();
4015 assert_eq!(
4016 uuids,
4017 vec!["a1", "u1", "o1", "n1", "d1", "n2", "o2", "a2"],
4018 "the transcript's rows with each block emitted inside the row that fired it",
4019 );
4020 for message in messages.iter().filter(|message| message.parent_tool_use_id.is_some()) {
4021 assert_eq!(
4022 message.parent_tool_use_id.as_deref(),
4023 Some("toolu_dispatch"),
4024 "every nested frame names the card that owns the dispatch, not the cardless one",
4025 );
4026 }
4027 };
4028
4029 set_age(&sidecars.join("agent-outer.meta.json"), 600);
4032 set_age(&sidecars.join("agent-nested.meta.json"), 60);
4033 set_age(&sidecars.join("agent-deep.meta.json"), 30);
4034 assert_folded(&read());
4035
4036 set_age(&sidecars.join("agent-outer.meta.json"), 30);
4040 set_age(&sidecars.join("agent-nested.meta.json"), 60);
4041 set_age(&sidecars.join("agent-deep.meta.json"), 600);
4042 assert_folded(&read());
4043 }
4044
4045 #[test]
4050 fn a_nested_sidecar_folds_when_its_parent_body_was_skipped() {
4051 let config_dir = tempfile::tempdir().unwrap();
4052 let session_id = "550e8400-e29b-41d4-a716-446655440004";
4053 let directory = "/Users/me/Projects/playground";
4054 let dir = project_dir_for(config_dir.path(), directory);
4055 fs::create_dir_all(&dir).unwrap();
4056 write_session_jsonl(
4057 &dir,
4058 session_id,
4059 "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n\
4060 {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_dispatch\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n",
4061 );
4062 let sidecars = dir.join(session_id).join("subagents");
4063 fs::create_dir_all(&sidecars).unwrap();
4064 fs::write(
4065 sidecars.join("agent-outer.meta.json"),
4066 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
4067 )
4068 .unwrap();
4069 fs::write(
4070 sidecars.join("agent-outer.jsonl"),
4071 format!(
4072 "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"tool_use\",\"id\":\"toolu_inner\",\"name\":\"Agent\",\"input\":{{\"prompt\":\"inner\"}}}}]}},\"uuid\":\"o1\"}}\n{}",
4073 " ".repeat(400)
4074 ),
4075 )
4076 .unwrap();
4077 fs::write(
4078 sidecars.join("agent-nested.meta.json"),
4079 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_inner\",\"parentAgentId\":\"outer\",\"spawnDepth\":2}",
4080 )
4081 .unwrap();
4082 fs::write(
4083 sidecars.join("agent-nested.jsonl"),
4084 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"kept\"}]},\"uuid\":\"n1\"}\n",
4085 )
4086 .unwrap();
4087 fs::write(
4090 sidecars.join("agent-second.meta.json"),
4091 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_other\",\"parentAgentId\":\"outer\",\"spawnDepth\":2}",
4092 )
4093 .unwrap();
4094 fs::write(
4095 sidecars.join("agent-second.jsonl"),
4096 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"older\"}]},\"uuid\":\"m1\"}\n",
4097 )
4098 .unwrap();
4099 set_age(&sidecars.join("agent-outer.meta.json"), 600);
4100 set_age(&sidecars.join("agent-nested.meta.json"), 60);
4101 set_age(&sidecars.join("agent-second.meta.json"), 120);
4102
4103 let transcript = dir.join(format!("{session_id}.jsonl"));
4105 let (sidecars, links) = subagent_sidecars_with_budget(session_id, &transcript, 300);
4106 assert!(
4107 sidecars.iter().all(|(dispatch, _)| dispatch != "toolu_dispatch"),
4108 "the parent's body is past the budget",
4109 );
4110 assert_eq!(links.len(), 3, "but every meta is read for the chain");
4111
4112 let mut messages = parse_session_messages(fs::File::open(&transcript).unwrap()).messages;
4113 fold_subagent_sidecars(&mut messages, sidecars, &links, session_id);
4114
4115 assert_eq!(
4116 messages.iter().map(|message| message.uuid.as_str()).collect::<Vec<_>>(),
4117 vec!["a1", "u1", "m1", "n1"],
4118 "both children fold, the older root first, although the parent's body was skipped",
4119 );
4120 for message in messages.iter().filter(|message| message.parent_tool_use_id.is_some()) {
4121 assert_eq!(
4122 message.parent_tool_use_id.as_deref(),
4123 Some("toolu_dispatch"),
4124 "every child is stamped with the dispatch the transcript answers",
4125 );
4126 }
4127 }
4128
4129 #[test]
4133 fn a_child_meta_without_a_parent_agent_id_resolves_through_the_body() {
4134 let config_dir = tempfile::tempdir().unwrap();
4135 let session_id = "550e8400-e29b-41d4-a716-446655440006";
4136 let directory = "/Users/me/Projects/playground";
4137 let dir = project_dir_for(config_dir.path(), directory);
4138 fs::create_dir_all(&dir).unwrap();
4139 write_session_jsonl(
4140 &dir,
4141 session_id,
4142 "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n\
4143 {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_dispatch\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n",
4144 );
4145 let sidecars = dir.join(session_id).join("subagents");
4146 fs::create_dir_all(&sidecars).unwrap();
4147 fs::write(
4148 sidecars.join("agent-outer.meta.json"),
4149 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
4150 )
4151 .unwrap();
4152 fs::write(
4153 sidecars.join("agent-outer.jsonl"),
4154 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_child\",\"name\":\"Agent\",\"input\":{\"prompt\":\"inner\"}}]},\"uuid\":\"o1\"}\n\
4155 {\"type\":\"user\",\"isSidechain\":true,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_child\",\"content\":\"ok\"}]},\"uuid\":\"o2\"}\n",
4156 )
4157 .unwrap();
4158 fs::write(
4160 sidecars.join("agent-child.meta.json"),
4161 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_child\"}",
4162 )
4163 .unwrap();
4164 fs::write(
4165 sidecars.join("agent-child.jsonl"),
4166 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"child\"}]},\"uuid\":\"c1\"}\n",
4167 )
4168 .unwrap();
4169 set_age(&sidecars.join("agent-outer.meta.json"), 600);
4170 set_age(&sidecars.join("agent-child.meta.json"), 60);
4171
4172 let messages =
4173 get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
4174
4175 assert_eq!(
4176 messages.iter().map(|message| message.uuid.as_str()).collect::<Vec<_>>(),
4177 vec!["a1", "u1", "o1", "c1", "o2"],
4178 "the child folds inside the row that fired it",
4179 );
4180 for message in messages.iter().filter(|message| message.parent_tool_use_id.is_some()) {
4181 assert_eq!(message.parent_tool_use_id.as_deref(), Some("toolu_dispatch"));
4182 }
4183 }
4184
4185 #[test]
4189 fn a_sidecar_chain_with_a_cycle_or_a_missing_parent_joins_nothing() {
4190 let config_dir = tempfile::tempdir().unwrap();
4191 let session_id = "550e8400-e29b-41d4-a716-446655440005";
4192 let directory = "/Users/me/Projects/playground";
4193 let dir = project_dir_for(config_dir.path(), directory);
4194 fs::create_dir_all(&dir).unwrap();
4195 write_session_jsonl(
4196 &dir,
4197 session_id,
4198 "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"before\"}]},\"uuid\":\"a1\"}\n",
4199 );
4200 let sidecars = dir.join(session_id).join("subagents");
4201 fs::create_dir_all(&sidecars).unwrap();
4202 let write = |stem: &str, meta: &str| {
4203 fs::write(sidecars.join(format!("agent-{stem}.meta.json")), meta).unwrap();
4204 fs::write(
4205 sidecars.join(format!("agent-{stem}.jsonl")),
4206 format!(
4207 "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"{stem}\"}}]}},\"uuid\":\"{stem}\"}}\n"
4208 ),
4209 )
4210 .unwrap();
4211 };
4212 write("a", "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_a\",\"parentAgentId\":\"b\"}");
4214 write("b", "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_b\",\"parentAgentId\":\"a\"}");
4215 write(
4217 "c",
4218 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_c\",\"parentAgentId\":\"ghost\"}",
4219 );
4220
4221 let messages =
4222 get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
4223
4224 assert_eq!(
4225 messages.iter().map(|message| message.uuid.as_str()).collect::<Vec<_>>(),
4226 vec!["a1"],
4227 "a cycle and a missing parent both resolve to nothing, and nothing loops",
4228 );
4229 let mut made_under = HashMap::new();
4232 made_under.insert("toolu_a".to_owned(), "toolu_b".to_owned());
4233 made_under.insert("toolu_b".to_owned(), "toolu_a".to_owned());
4234 made_under.insert("toolu_c".to_owned(), "toolu_ghost".to_owned());
4235 let mut answered = HashSet::new();
4236 answered.insert("toolu_top".to_owned());
4237 assert_eq!(
4238 owning_dispatch("toolu_a", &answered, &made_under),
4239 None,
4240 "a cycle resolves to nothing"
4241 );
4242 assert_eq!(owning_dispatch("toolu_b", &answered, &made_under), None);
4243 assert_eq!(
4244 owning_dispatch("toolu_c", &answered, &made_under),
4245 None,
4246 "a missing parent resolves to nothing",
4247 );
4248 made_under.insert("toolu_top_child".to_owned(), "toolu_top".to_owned());
4249 assert_eq!(
4250 owning_dispatch("toolu_top_child", &answered, &made_under),
4251 Some("toolu_top"),
4252 "a chain that reaches an answered dispatch resolves to it",
4253 );
4254 }
4255
4256 #[test]
4260 fn a_sidecar_nested_under_an_unanswered_dispatch_joins_nothing() {
4261 let config_dir = tempfile::tempdir().unwrap();
4262 let session_id = "550e8400-e29b-41d4-a716-446655440003";
4263 let directory = "/Users/me/Projects/playground";
4264 let dir = project_dir_for(config_dir.path(), directory);
4265 fs::create_dir_all(&dir).unwrap();
4266 write_session_jsonl(
4268 &dir,
4269 session_id,
4270 "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n",
4271 );
4272 let sidecars = dir.join(session_id).join("subagents");
4273 fs::create_dir_all(&sidecars).unwrap();
4274 fs::write(
4275 sidecars.join("agent-outer.meta.json"),
4276 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
4277 )
4278 .unwrap();
4279 fs::write(
4280 sidecars.join("agent-outer.jsonl"),
4281 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_inner\",\"name\":\"Agent\",\"input\":{\"prompt\":\"inner\"}}]},\"uuid\":\"o1\"}\n",
4282 )
4283 .unwrap();
4284 fs::write(
4285 sidecars.join("agent-nested.meta.json"),
4286 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_inner\"}",
4287 )
4288 .unwrap();
4289 fs::write(
4290 sidecars.join("agent-nested.jsonl"),
4291 "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"nested\"}]},\"uuid\":\"n1\"}\n",
4292 )
4293 .unwrap();
4294
4295 let messages =
4296 get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
4297
4298 assert_eq!(
4299 messages.iter().map(|message| message.uuid.as_str()).collect::<Vec<_>>(),
4300 vec!["a1"],
4301 "neither sidecar has a row the transcript answers",
4302 );
4303 }
4304
4305 #[test]
4310 fn subagent_sidecars_stop_at_the_read_budget() {
4311 let config_dir = tempfile::tempdir().unwrap();
4312 let session_id = "550e8400-e29b-41d4-a716-446655440001";
4313 let directory = "/Users/me/Projects/playground";
4314 let dir = project_dir_for(config_dir.path(), directory);
4315 fs::create_dir_all(&dir).unwrap();
4316 write_session_jsonl(&dir, session_id, "");
4317 let transcript = dir.join(format!("{session_id}.jsonl"));
4318 let sidecars = dir.join(session_id).join("subagents");
4319 fs::create_dir_all(&sidecars).unwrap();
4320 let write_sidecar = |stem: &str, uuid: &str, pad: usize| {
4321 fs::write(
4322 sidecars.join(format!("{stem}.meta.json")),
4323 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_a\"}",
4324 )
4325 .unwrap();
4326 fs::write(
4327 sidecars.join(format!("{stem}.jsonl")),
4328 format!(
4329 "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"x\"}}]}},\"uuid\":\"{uuid}\"}}\n{}",
4330 " ".repeat(pad)
4331 ),
4332 )
4333 .unwrap();
4334 };
4335 write_sidecar("agent-small", "s1", 0);
4336 write_sidecar("agent-large", "l1", 400);
4337
4338 let (sidecars, _) = subagent_sidecars_with_budget(session_id, &transcript, 200);
4339 let uuids: Vec<_> =
4340 sidecars.iter().flat_map(|(_, rows)| rows.iter().map(|row| row.uuid.clone())).collect();
4341
4342 assert!(uuids.iter().any(|uuid| uuid == "s1"), "a sidecar inside the budget is read");
4343 assert!(
4344 !uuids.iter().any(|uuid| uuid == "l1"),
4345 "a sidecar past the budget is skipped rather than parsed",
4346 );
4347 }
4348
4349 #[test]
4354 fn subagent_sidecars_spend_the_budget_on_the_newest_first() {
4355 let config_dir = tempfile::tempdir().unwrap();
4356 let session_id = "550e8400-e29b-41d4-a716-446655440002";
4357 let directory = "/Users/me/Projects/playground";
4358 let dir = project_dir_for(config_dir.path(), directory);
4359 fs::create_dir_all(&dir).unwrap();
4360 write_session_jsonl(&dir, session_id, "");
4361 let transcript = dir.join(format!("{session_id}.jsonl"));
4362 let sidecars = dir.join(session_id).join("subagents");
4363 fs::create_dir_all(&sidecars).unwrap();
4364 let write_sidecar = |stem: &str, uuid: &str| {
4365 fs::write(
4366 sidecars.join(format!("{stem}.meta.json")),
4367 "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_a\"}",
4368 )
4369 .unwrap();
4370 fs::write(
4371 sidecars.join(format!("{stem}.jsonl")),
4372 format!(
4373 "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"x\"}}]}},\"uuid\":\"{uuid}\"}}\n"
4374 ),
4375 )
4376 .unwrap();
4377 };
4378 write_sidecar("agent-older", "old");
4379 write_sidecar("agent-newer", "new");
4380 set_age(&sidecars.join("agent-older.meta.json"), 600);
4381 set_age(&sidecars.join("agent-newer.meta.json"), 60);
4382 let one = fs::metadata(sidecars.join("agent-newer.jsonl")).unwrap().len();
4383
4384 let (sidecars, _) = subagent_sidecars_with_budget(session_id, &transcript, one);
4385 let uuids: Vec<_> =
4386 sidecars.iter().flat_map(|(_, rows)| rows.iter().map(|row| row.uuid.clone())).collect();
4387
4388 assert!(uuids.iter().any(|uuid| uuid == "new"), "the newest sidecar is read");
4389 assert!(
4390 !uuids.iter().any(|uuid| uuid == "old"),
4391 "an older sidecar past the budget is skipped, not kept in place of the newest",
4392 );
4393 }
4394
4395 #[test]
4401 fn get_session_messages_reads_git_worker_from_worktree_key() {
4402 let config_dir = tempfile::tempdir().unwrap();
4403 let session_id = "550e8400-e29b-41d4-a716-446655440000";
4404 let repo_root = "/Users/me/Projects/playground";
4405 let worktree = "/Users/me/Projects/playground/.claude/worktrees/gpt-tutor";
4406
4407 let worktree_dir = project_dir_for(config_dir.path(), worktree);
4408 fs::create_dir_all(&worktree_dir).unwrap();
4409 write_session_jsonl(
4410 &worktree_dir,
4411 session_id,
4412 "{\"type\":\"user\",\"message\":{\"content\":\"hello from the worktree\"}}\n",
4413 );
4414
4415 let from_worktree =
4416 get_session_messages(config_dir.path(), session_id, Some(worktree)).messages;
4417 assert_eq!(from_worktree.len(), 1, "resume reads the JSONL under the worktree key");
4418
4419 let from_repo_root =
4420 get_session_messages(config_dir.path(), session_id, Some(repo_root)).messages;
4421 assert!(
4422 from_repo_root.is_empty(),
4423 "the same session id under the repo-root key is not where a git worker reads",
4424 );
4425 }
4426
4427 fn a_transcript(rows: usize) -> String {
4430 use std::fmt::Write as _;
4431 let mut out = String::new();
4432 for at in 0..rows {
4433 let _ = writeln!(
4434 out,
4435 "{{\"type\":\"user\",\"uuid\":\"u{at}\",\"session_id\":\"s1\",\
4436 \"message\":{{\"role\":\"user\",\"content\":\"turn {at}\"}}}}"
4437 );
4438 }
4439 out
4440 }
4441
4442 fn anchored(row: &str) -> forge_primitives::TranscriptAnchor {
4445 forge_primitives::TranscriptAnchor { row: row.to_owned(), offset: None }
4446 }
4447
4448 fn said(message: &forge_primitives::Message) -> String {
4450 let forge_primitives::Message::User { message, .. } = message else {
4451 return String::new();
4452 };
4453 message
4454 .content
4455 .iter()
4456 .find_map(|block| match block {
4457 forge_primitives::ContentBlock::Text { text, .. } => Some(text.clone()),
4458 _ => None,
4459 })
4460 .unwrap_or_default()
4461 }
4462
4463 fn words(span: &forge_primitives::TranscriptSpan) -> Vec<String> {
4465 span.messages.iter().map(said).collect()
4466 }
4467
4468 #[test]
4473 fn a_row_read_serves_the_rows_below_its_anchor() {
4474 let config = tempfile::tempdir().unwrap();
4475 let cwd = "/Users/me/Projects/playground";
4476 let dir = project_dir_for(config.path(), cwd);
4477 fs::create_dir_all(&dir).unwrap();
4478 let session = "550e8400-e29b-41d4-a716-446655440000";
4479 write_session_jsonl(&dir, session, &a_transcript(40));
4480
4481 let span = read_rows(config.path(), session, Some(cwd), Some(&anchored("u30")), 100)
4482 .expect("the transcript has rows below the anchor");
4483
4484 assert!(span.exhausted, "the read reached the file's own first row");
4485 assert_eq!(span.messages.len(), 30, "every row below the anchor, and none above");
4486 assert_eq!(span.messages.len(), span.offsets.len(), "each row with the byte it starts at");
4487 let expected: Vec<String> = (0..30).map(|at| format!("turn {at}")).collect();
4488 assert_eq!(words(&span), expected, "in conversation order");
4489
4490 let trimmed = read_rows(config.path(), session, Some(cwd), Some(&anchored("u30")), 5)
4493 .expect("a span the budget trims");
4494 assert_eq!(
4495 words(&trimmed),
4496 (25..30).map(|at| format!("turn {at}")).collect::<Vec<_>>(),
4497 "the page's own rows and no more, taken nearest the anchor",
4498 );
4499 assert!(!trimmed.exhausted, "which is not the file's start");
4500
4501 let newest = read_rows(config.path(), session, Some(cwd), None, 5)
4504 .expect("the file answers with its newest rows");
4505 assert_eq!(
4506 words(&newest),
4507 (35..40).map(|at| format!("turn {at}")).collect::<Vec<_>>(),
4508 "the newest rows the session wrote",
4509 );
4510 assert!(!newest.exhausted, "with more above them");
4511 }
4512
4513 #[test]
4519 fn a_row_read_reads_a_duplicated_block_once() {
4520 use std::fmt::Write as _;
4521 let config = tempfile::tempdir().unwrap();
4522 let cwd = "/Users/me/Projects/playground";
4523 let dir = project_dir_for(config.path(), cwd);
4524 fs::create_dir_all(&dir).unwrap();
4525 let session = "550e8400-e29b-41d4-a716-446655440000";
4526 let mut body = a_transcript(6);
4527 let _ = writeln!(body, "{{\"type\":\"last-prompt\",\"uuid\":\"lp1\"}}");
4530 for at in 3..6 {
4531 let _ = writeln!(
4532 body,
4533 "{{\"type\":\"user\",\"uuid\":\"u{at}\",\"slug\":\"s\",\"session_id\":\"s1\",\
4534 \"message\":{{\"role\":\"user\",\"content\":\"turn {at}\"}}}}"
4535 );
4536 }
4537 write_session_jsonl(&dir, session, &body);
4538
4539 let span = read_rows(config.path(), session, Some(cwd), None, 100)
4540 .expect("the transcript answers with its rows");
4541 assert_eq!(
4542 ids(&span),
4543 ["u0", "u1", "u2", "u3", "u4", "u5"],
4544 "the newest rows, the duplicated tail block among them only once",
4545 );
4546 }
4547
4548 #[test]
4554 fn a_row_read_seeks_when_its_anchor_carries_a_byte() {
4555 const SMALL: u64 = 200;
4558 let config = tempfile::tempdir().unwrap();
4559 let cwd = "/Users/me/Projects/playground";
4560 let dir = project_dir_for(config.path(), cwd);
4561 fs::create_dir_all(&dir).unwrap();
4562 let session = "550e8400-e29b-41d4-a716-446655440000";
4563 write_session_jsonl(&dir, session, &a_transcript(40));
4564
4565 let located = read_rows(config.path(), session, Some(cwd), Some(&anchored("u30")), 100)
4566 .expect("the first read locates the anchor");
4567 let oldest = located.offsets.first().copied().expect("the span's oldest row byte");
4568 let oldest_row = said(located.messages.first().expect("a first"));
4569 assert_eq!(oldest_row, "turn 0", "precondition: the span ran to the file's first row");
4570
4571 let resume =
4575 forge_primitives::TranscriptAnchor { row: "u0".to_owned(), offset: Some(oldest) };
4576 let chained =
4577 read_rows_with(config.path(), session, Some(cwd), Some(&resume), 100, SMALL, SMALL)
4578 .expect("the resumed read seeks to the row's byte");
4579 assert!(chained.messages.is_empty(), "nothing sits below the file's first row");
4580 assert!(chained.exhausted, "and the read says so");
4581
4582 assert!(
4587 read_rows_with(
4588 config.path(),
4589 session,
4590 Some(cwd),
4591 Some(&anchored("u0")),
4592 100,
4593 SMALL,
4594 SMALL,
4595 )
4596 .is_none(),
4597 "a locating read whose window cannot reach its anchor answers nothing",
4598 );
4599
4600 let chained = read_rows_with(
4605 config.path(),
4606 session,
4607 Some(cwd),
4608 Some(&forge_primitives::TranscriptAnchor {
4609 row: "u20".to_owned(),
4610 offset: located.offsets.get(20).copied(),
4611 }),
4612 100,
4613 SMALL,
4614 SMALL,
4615 )
4616 .expect("the chained read seeks to the row's byte");
4617 assert!(!chained.messages.is_empty(), "and answers the rows the byte reaches");
4618 assert_eq!(
4619 said(chained.messages.last().expect("a last")),
4620 "turn 19",
4621 "the newest row below the anchor",
4622 );
4623 assert!(!chained.exhausted, "with the file's start still above it");
4624 }
4625
4626 fn a_called_transcript(pairs: usize) -> String {
4629 use std::fmt::Write as _;
4630 let mut out = String::new();
4631 for at in 0..pairs {
4632 let _ = writeln!(
4633 out,
4634 "{{\"type\":\"assistant\",\"uuid\":\"call{at}\",\"session_id\":\"s1\",\
4635 \"message\":{{\"id\":\"m{at}\",\"role\":\"assistant\",\"model\":\"claude-opus-5\",\
4636 \"content\":[{{\"type\":\"tool_use\",\
4637 \"id\":\"tu{at}\",\"name\":\"Bash\",\"input\":{{\"command\":\"ls\"}}}}]}}}}"
4638 );
4639 let _ = writeln!(
4640 out,
4641 "{{\"type\":\"user\",\"uuid\":\"result{at}\",\"session_id\":\"s1\",\
4642 \"message\":{{\"role\":\"user\",\"content\":[{{\"type\":\"tool_result\",\
4643 \"tool_use_id\":\"tu{at}\",\"content\":\"ok\"}}]}}}}"
4644 );
4645 }
4646 out
4647 }
4648
4649 fn ids(span: &forge_primitives::TranscriptSpan) -> Vec<String> {
4651 span.messages
4652 .iter()
4653 .map(|message| match message {
4654 forge_primitives::Message::Assistant { uuid: Some(uuid), .. }
4655 | forge_primitives::Message::User { uuid: Some(uuid), .. }
4656 | forge_primitives::Message::StopHookSummary { uuid, .. }
4657 | forge_primitives::Message::CompactBoundary { uuid, .. } => uuid.clone(),
4658 other => panic!("every row of this transcript carries an id: {other:?}"),
4659 })
4660 .collect()
4661 }
4662
4663 #[test]
4669 fn a_row_read_defers_leading_results_to_the_page_below() {
4670 let config = tempfile::tempdir().unwrap();
4671 let cwd = "/Users/me/Projects/playground";
4672 let dir = project_dir_for(config.path(), cwd);
4673 fs::create_dir_all(&dir).unwrap();
4674 let session = "550e8400-e29b-41d4-a716-446655440000";
4675 write_session_jsonl(&dir, session, &a_called_transcript(20));
4676
4677 let span = read_rows(config.path(), session, Some(cwd), Some(&anchored("call10")), 3)
4681 .expect("a span below the anchor");
4682 assert_eq!(
4683 ids(&span),
4684 vec!["call9", "result9"],
4685 "the page opens on the call the cut stepped back to, rather than on a result without \
4686 its call",
4687 );
4688
4689 let mut seen: Vec<String> = Vec::new();
4693 let mut anchor = Some(anchored("call10"));
4694 let mut pages = 0_usize;
4695 while let Some(at) = anchor {
4696 let span = read_rows(config.path(), session, Some(cwd), Some(&at), 3).expect("a page");
4697 let page = ids(&span);
4698 assert!(
4699 page.first().is_some_and(|id| id.starts_with("call")),
4700 "every page below the floor opens on a call, never on a bare result: {page:?}",
4701 );
4702 assert!(page.len() <= 3, "and carries the rows it was asked for: {page:?}");
4703 seen.extend(page.clone());
4704 pages += 1;
4705 assert!(pages < 40, "the walk reaches the file's first row");
4706 anchor = (!span.exhausted).then(|| forge_primitives::TranscriptAnchor {
4707 row: page[0].clone(),
4708 offset: span.offsets.first().copied(),
4709 });
4710 }
4711 let expected: Vec<String> =
4712 (0..10).rev().flat_map(|at| [format!("call{at}"), format!("result{at}")]).collect();
4713 assert_eq!(
4714 seen, expected,
4715 "the whole span below the anchor, once each, newest page first, with every result \
4716 served beside the call that produced it",
4717 );
4718 }
4719
4720 #[test]
4725 fn a_row_read_falls_back_when_the_anchors_byte_moved() {
4726 let config = tempfile::tempdir().unwrap();
4727 let cwd = "/Users/me/Projects/playground";
4728 let dir = project_dir_for(config.path(), cwd);
4729 fs::create_dir_all(&dir).unwrap();
4730 let session = "550e8400-e29b-41d4-a716-446655440000";
4731 write_session_jsonl(&dir, session, &a_transcript(40));
4732
4733 let located = read_rows(config.path(), session, Some(cwd), Some(&anchored("u30")), 100)
4734 .expect("the first read locates the anchor");
4735 let last = located.messages.len() - 1;
4736 let resume = forge_primitives::TranscriptAnchor {
4737 row: "u29".to_owned(),
4738 offset: located.offsets.get(last).copied(),
4739 };
4740 assert!(resume.offset.is_some(), "precondition: the span gave the row its byte");
4741
4742 let prepended = format!(
4745 "{{\"type\":\"user\",\"uuid\":\"u_pre\",\"session_id\":\"s1\",\
4746 \"message\":{{\"role\":\"user\",\"content\":\"prepended\"}}}}\n{}",
4747 a_transcript(40),
4748 );
4749 write_session_jsonl(&dir, session, &prepended);
4750
4751 let stale = read_rows(config.path(), session, Some(cwd), Some(&resume), 100);
4752 let located_again =
4753 read_rows(config.path(), session, Some(cwd), Some(&anchored("u29")), 100);
4754 assert_eq!(
4755 stale.map(|span| words(&span)),
4756 located_again.map(|span| words(&span)),
4757 "a stale byte answers exactly as the read that never had one: the anchor's row is \
4758 found again by its id, so the page below it is the page below it",
4759 );
4760 }
4761
4762 #[test]
4766 fn a_sub_agents_frame_has_no_row() {
4767 let row = serde_json::json!({
4768 "type": "user",
4769 "uuid": "u1",
4770 "parent_tool_use_id": "toolu_dispatch",
4771 "session_id": "s1",
4772 "message": {"role": "user", "content": "under a sub-agent"},
4773 });
4774 let frame: forge_primitives::Message =
4775 serde_json::from_value(row.clone()).expect("a frame");
4776 assert!(!has_a_transcript_row(&frame), "a sub-agent's frame carries no row");
4777 assert!(transcript_row(&row).is_none(), "and the row rule drops it, as this says it does");
4778 }
4779
4780 #[test]
4785 fn a_stamped_refusal_has_no_row() {
4786 let row = serde_json::json!({
4787 "type": "system",
4788 "subtype": "permission_denied",
4789 "tool_name": "Bash",
4790 "tool_use_id": "call_refused",
4791 "decision_reason_type": "subcommandResults",
4792 "message": "Permission to use Bash has been denied.",
4793 "uuid": "p1",
4794 "session_id": "s1",
4795 "parent_tool_use_id": "toolu_dispatch",
4796 });
4797 let frame: forge_primitives::Message =
4798 serde_json::from_value(row).expect("a stamped refusal");
4799 assert!(!has_a_transcript_row(&frame), "a stamped refusal carries no row");
4800 let unstamped: forge_primitives::Message = serde_json::from_value(serde_json::json!({
4801 "type": "system",
4802 "subtype": "permission_denied",
4803 "tool_name": "Bash",
4804 "tool_use_id": "call_refused",
4805 "decision_reason_type": "other",
4806 "decision_reason": "Contains simple_expansion",
4807 "message": "Contains simple_expansion",
4808 "uuid": "p2",
4809 "session_id": "s1",
4810 }))
4811 .expect("a session-level refusal");
4812 assert!(has_a_transcript_row(&unstamped), "the session's own refusal keeps its row");
4813 }
4814
4815 #[test]
4819 fn a_row_read_types_the_rows_the_conversion_has_forks_for() {
4820 let config = tempfile::tempdir().unwrap();
4821 let cwd = "/Users/me/Projects/playground";
4822 let dir = project_dir_for(config.path(), cwd);
4823 fs::create_dir_all(&dir).unwrap();
4824 let session = "550e8400-e29b-41d4-a716-446655440000";
4825 let body = format!(
4826 "{}\n{}\n{}\n{}\n{}",
4827 "{\"type\":\"user\",\"uuid\":\"u0\",\"session_id\":\"s1\",\
4828 \"message\":{\"role\":\"user\",\"content\":\"turn 0\"}}",
4829 "{\"type\":\"attachment\",\"uuid\":\"a1\",\"session_id\":\"s1\",\
4830 \"attachment\":{\"type\":\"queued_command\",\"prompt\":\"queued\",\"commandMode\":\"prompt\"}}",
4831 "{\"type\":\"queue-operation\",\"operation\":\"remove\",\"timestamp\":\"2026-11-03T12:00:00Z\",\
4832 \"sessionId\":\"s1\",\"content\":\"queued\"}",
4833 "{\"type\":\"system\",\"subtype\":\"compact_boundary\",\"uuid\":\"h1\",\
4834 \"sessionId\":\"s1\",\"compactMetadata\":{\"trigger\":\"auto\",\"preTokens\":1,\"postTokens\":1}}",
4835 "{\"type\":\"user\",\"uuid\":\"u2\",\"session_id\":\"s1\",\
4836 \"message\":{\"role\":\"user\",\"content\":\"turn 1\"}}",
4837 );
4838 write_session_jsonl(&dir, session, &body);
4839
4840 let span = read_rows(config.path(), session, Some(cwd), Some(&anchored("u2")), 100)
4841 .expect("a span over the forks");
4842
4843 assert!(span.exhausted, "every row the read kept is below the anchor");
4844 assert_eq!(span.messages.len(), 3, "the attachment's row is a frame, not a skip");
4845 assert!(
4846 matches!(
4847 &span.messages[1],
4848 forge_primitives::Message::User { message, .. }
4849 if matches!(message.content[0], forge_primitives::ContentBlock::QueuedCommand { .. })
4850 ),
4851 "the queued command arrives as the block a view reads it as",
4852 );
4853 assert!(
4854 matches!(&span.messages[2], forge_primitives::Message::CompactBoundary { uuid, .. }
4855 if uuid == "h1"),
4856 "and the boundary row as the frame it is, not a skip: {:?}",
4857 span.messages[2],
4858 );
4859 }
4860
4861 #[test]
4865 fn a_remove_reason_that_is_not_a_start_draws_no_prompt_row() {
4866 let jsonl = r#"{"type":"user","message":{"role":"user","content":"plain"},"uuid":"u1","session_id":"s1"}
4867{"type":"attachment","attachment":{"type":"queued_command","prompt":"cancelled words","commandMode":"prompt"},"uuid":"a1","session_id":"s1","timestamp":"2026-11-03T12:00:00Z"}
4868{"type":"queue-operation","operation":"remove","reason":"cancelled","timestamp":"2026-11-03T12:00:05Z","sessionId":"s1","content":"cancelled words"}
4869"#;
4870 let msgs = parse_session_messages(jsonl.as_bytes()).messages;
4871 assert_eq!(
4872 msgs.iter().map(|row| row.uuid.as_str()).collect::<Vec<_>>(),
4873 ["u1"],
4874 "the prompt a non-start removal names draws nothing",
4875 );
4876 }
4877
4878 #[test]
4884 fn a_paged_read_serves_a_queued_prompt_at_its_remove() {
4885 let config = tempfile::tempdir().unwrap();
4886 let cwd = "/Users/me/Projects/playground";
4887 let dir = project_dir_for(config.path(), cwd);
4888 fs::create_dir_all(&dir).unwrap();
4889 let session = "550e8400-e29b-41d4-a716-446655440000";
4890 let body = format!(
4891 "{}\n{}\n{}\n{}\n{}",
4892 "{\"type\":\"user\",\"uuid\":\"u1\",\"session_id\":\"s1\",\
4893 \"message\":{\"role\":\"user\",\"content\":\"before\"},\
4894 \"timestamp\":\"2026-11-03T11:59:00Z\"}",
4895 "{\"type\":\"attachment\",\"uuid\":\"a1\",\"session_id\":\"s1\",\
4896 \"attachment\":{\"type\":\"queued_command\",\"prompt\":\"queued words\",\
4897 \"commandMode\":\"prompt\",\"source_uuid\":\"p1\"},\
4898 \"timestamp\":\"2026-11-03T12:00:00Z\"}",
4899 "{\"type\":\"assistant\",\"uuid\":\"as1\",\"session_id\":\"s1\",\
4900 \"message\":{\"id\":\"m1\",\"role\":\"assistant\",\"model\":\"claude-opus-5\",\
4901 \"content\":[{\"type\":\"text\",\"text\":\"working\"}]},\
4902 \"timestamp\":\"2026-11-03T12:00:01Z\"}",
4903 "{\"type\":\"queue-operation\",\"operation\":\"remove\",\
4904 \"reason\":\"absorbed_mid_turn\",\"timestamp\":\"2026-11-03T12:00:05Z\",\
4905 \"sessionId\":\"s1\",\"content\":\"queued words\"}",
4906 "{\"type\":\"user\",\"uuid\":\"u2\",\"session_id\":\"s1\",\
4907 \"message\":{\"role\":\"user\",\"content\":\"after\"},\
4908 \"timestamp\":\"2026-11-03T12:00:06Z\"}",
4909 );
4910 write_session_jsonl(&dir, session, &body);
4911
4912 let span = read_rows(config.path(), session, Some(cwd), Some(&anchored("u2")), 100)
4913 .expect("a span over the queued prompt");
4914
4915 assert_eq!(span.messages.len(), 3, "the page carries the rows below the anchor");
4916 assert!(
4917 matches!(&span.messages[1], forge_primitives::Message::Assistant { .. }),
4918 "the assistant row written between the press and the start is served first",
4919 );
4920 let forge_primitives::Message::User { uuid, timestamp, message, .. } = &span.messages[2]
4921 else {
4922 panic!("the queued prompt is a user row: {:?}", span.messages[2]);
4923 };
4924 assert!(
4925 uuid.as_deref().is_some_and(|id| id.starts_with("queue-remove-2026-11-03T12:00:05Z-")),
4926 "named by the start it was served at: {uuid:?}",
4927 );
4928 assert_eq!(
4929 timestamp.as_deref(),
4930 Some("2026-11-03T12:00:05Z"),
4931 "stamped at the start, not the press",
4932 );
4933 assert!(
4934 message.content.iter().any(|block| matches!(
4935 block,
4936 forge_primitives::ContentBlock::QueuedCommand { prompt, source_uuid, .. }
4937 if prompt.as_str() == Some("queued words")
4938 && source_uuid.as_deref() == Some("p1")
4939 )),
4940 "carrying the prompt's words and the id the live row pairs with: {message:?}",
4941 );
4942 }
4943
4944 #[test]
4949 fn a_row_read_grows_its_window_only_while_the_rows_are_outside_it() {
4950 let config = tempfile::tempdir().unwrap();
4951 let cwd = "/Users/me/Projects/playground";
4952 let dir = project_dir_for(config.path(), cwd);
4953 fs::create_dir_all(&dir).unwrap();
4954 let session = "550e8400-e29b-41d4-a716-446655440000";
4955 let body = a_transcript(40);
4956 write_session_jsonl(&dir, session, &body);
4957 let len = body.len() as u64;
4958
4959 let grown = read_rows_with(config.path(), session, Some(cwd), None, 30, len / 10, len)
4963 .expect("the read grows to hold the page");
4964 assert_eq!(grown.messages.len(), 30, "the page's own rows and no more");
4965 assert_eq!(
4966 said(grown.messages.first().expect("a first")),
4967 "turn 10",
4968 "taken nearest the boundary",
4969 );
4970 assert!(!grown.exhausted, "which is not the file's start");
4971
4972 assert!(
4975 read_rows_with(
4976 config.path(),
4977 session,
4978 Some(cwd),
4979 Some(&anchored("u5")),
4980 100,
4981 len / 5,
4982 len / 5,
4983 )
4984 .is_none(),
4985 "an anchor the cap cannot reach is not answered by reading further",
4986 );
4987 }
4988
4989 #[test]
4993 fn a_row_read_refuses_an_anchor_the_file_does_not_carry() {
4994 let config = tempfile::tempdir().unwrap();
4995 let cwd = "/Users/me/Projects/playground";
4996 let dir = project_dir_for(config.path(), cwd);
4997 fs::create_dir_all(&dir).unwrap();
4998 let session = "550e8400-e29b-41d4-a716-446655440000";
4999 write_session_jsonl(&dir, session, &a_transcript(40));
5000
5001 assert!(
5002 read_rows(config.path(), session, Some(cwd), Some(&anchored("nowhere")), 100).is_none(),
5003 "an anchor the file does not carry answers nothing",
5004 );
5005 }
5006
5007 #[test]
5010 fn a_row_read_answers_nothing_without_a_transcript() {
5011 let config = tempfile::tempdir().unwrap();
5012 let cwd = "/Users/me/Projects/playground";
5013 let dir = project_dir_for(config.path(), cwd);
5014 fs::create_dir_all(&dir).unwrap();
5015 let session = "550e8400-e29b-41d4-a716-446655440000";
5016
5017 assert!(
5018 read_rows(config.path(), session, Some(cwd), Some(&anchored("u1")), 100).is_none(),
5019 "no file for the session is no span",
5020 );
5021 write_session_jsonl(&dir, session, "");
5022 assert!(
5023 read_rows(config.path(), session, Some(cwd), None, 100).is_none(),
5024 "and an empty file is none either",
5025 );
5026 assert!(
5027 read_rows(config.path(), "../escape", Some(cwd), None, 100).is_none(),
5028 "and an id that is not a uuid names a path this refuses rather than resolves",
5029 );
5030 }
5031}