Skip to content

Commit bc999aa

Browse files
committed
feat: implement incremental token scanner for session file updates
1 parent 6577c21 commit bc999aa

2 files changed

Lines changed: 359 additions & 12 deletions

File tree

src-tauri/src/parser/session.rs

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,258 @@ pub(crate) fn scan_session_metadata(path: &str) -> SessionMetadata {
644644
meta
645645
}
646646

647+
/// Incremental token scanner for the watcher — avoids re-reading the entire file.
648+
///
649+
/// Keeps a running `request_tokens` map and byte offset so that each call to
650+
/// `scan_new_bytes` only reads the newly appended portion of the main session
651+
/// file. Subagent files are rescanned only when their size changes.
652+
pub struct IncrementalTokenScanner {
653+
/// Byte offset into the main session file (how far we've read).
654+
offset: u64,
655+
/// Per-requestId best token snapshot (global dedup across main + subagents).
656+
request_tokens: HashMap<String, super::subagent::TokenSnapshot>,
657+
/// Accumulated tokens from entries without a requestId.
658+
fallback: super::subagent::TokenSnapshot,
659+
/// Model string (first real non-sidechain assistant model).
660+
model: String,
661+
/// Cached subagent file sizes — only rescan files that grew.
662+
subagent_sizes: HashMap<String, u64>,
663+
/// Per-subagent byte offsets for incremental reading.
664+
subagent_offsets: HashMap<String, u64>,
665+
}
666+
667+
impl IncrementalTokenScanner {
668+
pub fn new() -> Self {
669+
Self {
670+
offset: 0,
671+
request_tokens: HashMap::new(),
672+
fallback: super::subagent::TokenSnapshot {
673+
input: 0,
674+
output: 0,
675+
cache_read: 0,
676+
cache_create: 0,
677+
model: String::new(),
678+
has_stop_reason: false,
679+
},
680+
model: String::new(),
681+
subagent_sizes: HashMap::new(),
682+
subagent_offsets: HashMap::new(),
683+
}
684+
}
685+
686+
/// Scan only new bytes from the main session file and any changed subagent files.
687+
/// Returns the current totals.
688+
pub fn scan_new_bytes(&mut self, path: &str) -> crate::convert::SessionTotals {
689+
// 1. Read new bytes from main session file.
690+
self.scan_main_file(path);
691+
692+
// 2. Incrementally scan subagent files (only changed ones).
693+
self.scan_subagents_incremental(path);
694+
695+
// 3. Compute totals from accumulated state.
696+
self.compute_totals()
697+
}
698+
699+
fn scan_main_file(&mut self, path: &str) {
700+
let f = match fs::File::open(path) {
701+
Ok(f) => f,
702+
Err(_) => return,
703+
};
704+
let mut reader = BufReader::new(f);
705+
if reader.seek(SeekFrom::Start(self.offset)).is_err() {
706+
return;
707+
}
708+
709+
let mut line = String::new();
710+
loop {
711+
line.clear();
712+
let n = match reader.read_line(&mut line) {
713+
Ok(n) => n,
714+
Err(_) => break,
715+
};
716+
if n == 0 {
717+
break;
718+
}
719+
self.offset += n as u64;
720+
self.process_line(line.trim(), false);
721+
}
722+
}
723+
724+
fn scan_subagents_incremental(&mut self, session_path: &str) {
725+
let dir = super::subagent::subagents_dir(session_path);
726+
let entries = match fs::read_dir(&dir) {
727+
Ok(e) => e,
728+
Err(_) => return,
729+
};
730+
731+
for entry in entries.flatten() {
732+
let name = entry.file_name().to_string_lossy().to_string();
733+
if !name.starts_with("agent-") || !name.ends_with(".jsonl") {
734+
continue;
735+
}
736+
let file_path = dir.join(&name);
737+
let key = file_path.to_string_lossy().to_string();
738+
739+
// Check if file has grown since last scan.
740+
let current_size = entry.metadata().map(|m| m.len()).unwrap_or(0);
741+
let prev_size = self.subagent_sizes.get(&key).copied().unwrap_or(0);
742+
if current_size <= prev_size {
743+
continue;
744+
}
745+
self.subagent_sizes.insert(key.clone(), current_size);
746+
747+
// Read from where we left off.
748+
let sub_offset = self.subagent_offsets.get(&key).copied().unwrap_or(0);
749+
let f = match fs::File::open(&file_path) {
750+
Ok(f) => f,
751+
Err(_) => continue,
752+
};
753+
let mut reader = BufReader::new(f);
754+
if reader.seek(SeekFrom::Start(sub_offset)).is_err() {
755+
continue;
756+
}
757+
758+
let mut new_offset = sub_offset;
759+
let mut line = String::new();
760+
loop {
761+
line.clear();
762+
let n = match reader.read_line(&mut line) {
763+
Ok(n) => n,
764+
Err(_) => break,
765+
};
766+
if n == 0 {
767+
break;
768+
}
769+
new_offset += n as u64;
770+
self.process_line(line.trim(), true);
771+
}
772+
self.subagent_offsets.insert(key, new_offset);
773+
}
774+
}
775+
776+
fn process_line(&mut self, line: &str, _is_subagent: bool) {
777+
let raw: Value = match serde_json::from_str(line) {
778+
Ok(v) => v,
779+
Err(_) => return,
780+
};
781+
782+
let entry_type = raw.get("type").and_then(|v| v.as_str()).unwrap_or("");
783+
if entry_type != "assistant" {
784+
return;
785+
}
786+
787+
let model_str = raw
788+
.get("message")
789+
.and_then(|m| m.get("model"))
790+
.and_then(|v| v.as_str())
791+
.unwrap_or("");
792+
if model_str == "<synthetic>" {
793+
return;
794+
}
795+
796+
let usage = match raw.get("message").and_then(|m| m.get("usage")) {
797+
Some(u) => u,
798+
None => return,
799+
};
800+
801+
let has_stop = !raw
802+
.get("message")
803+
.and_then(|m| m.get("stop_reason"))
804+
.and_then(|v| v.as_str())
805+
.unwrap_or("")
806+
.is_empty();
807+
808+
let reported_output = usage
809+
.get("output_tokens")
810+
.and_then(|v| v.as_i64())
811+
.unwrap_or(0);
812+
813+
let output = if has_stop {
814+
reported_output
815+
} else {
816+
let estimated = super::subagent::estimate_output_from_content(&raw);
817+
reported_output.max(estimated)
818+
};
819+
820+
let inp = usage
821+
.get("input_tokens")
822+
.and_then(|v| v.as_i64())
823+
.unwrap_or(0);
824+
let cr = usage
825+
.get("cache_read_input_tokens")
826+
.and_then(|v| v.as_i64())
827+
.unwrap_or(0);
828+
let cc = usage
829+
.get("cache_creation_input_tokens")
830+
.and_then(|v| v.as_i64())
831+
.unwrap_or(0);
832+
833+
if inp + output + cr + cc == 0 {
834+
return;
835+
}
836+
837+
let snap = super::subagent::TokenSnapshot {
838+
input: inp,
839+
output,
840+
cache_read: cr,
841+
cache_create: cc,
842+
model: model_str.to_string(),
843+
has_stop_reason: has_stop,
844+
};
845+
846+
let request_id = raw.get("requestId").and_then(|v| v.as_str()).unwrap_or("");
847+
if !request_id.is_empty() {
848+
super::subagent::insert_best_snapshot(
849+
&mut self.request_tokens,
850+
request_id.to_string(),
851+
snap,
852+
);
853+
} else {
854+
self.fallback.input += inp;
855+
self.fallback.output += output;
856+
self.fallback.cache_read += cr;
857+
self.fallback.cache_create += cc;
858+
}
859+
860+
// Capture model from first real entry.
861+
if self.model.is_empty() && !model_str.is_empty() {
862+
self.model = model_str.to_string();
863+
}
864+
}
865+
866+
fn compute_totals(&self) -> crate::convert::SessionTotals {
867+
let mut total_tokens = self.fallback.input
868+
+ self.fallback.output
869+
+ self.fallback.cache_read
870+
+ self.fallback.cache_create;
871+
let mut input_tokens = self.fallback.input;
872+
let mut output_tokens = self.fallback.output;
873+
let mut cache_read_tokens = self.fallback.cache_read;
874+
let mut cache_creation_tokens = self.fallback.cache_create;
875+
876+
for snap in self.request_tokens.values() {
877+
total_tokens += snap.input + snap.output + snap.cache_read + snap.cache_create;
878+
input_tokens += snap.input;
879+
output_tokens += snap.output;
880+
cache_read_tokens += snap.cache_read;
881+
cache_creation_tokens += snap.cache_create;
882+
}
883+
884+
let cost_usd =
885+
super::subagent::estimate_cost_from_snapshots(&self.request_tokens, &self.fallback);
886+
887+
crate::convert::SessionTotals {
888+
total_tokens,
889+
input_tokens,
890+
output_tokens,
891+
cache_read_tokens,
892+
cache_creation_tokens,
893+
cost_usd,
894+
model: self.model.clone(),
895+
}
896+
}
897+
}
898+
647899
/// Mirrors claude-devtools' isParsedUserChunkMessage.
648900
fn is_user_chunk_for_turn_count(
649901
raw: &Value,
@@ -899,6 +1151,95 @@ mod tests {
8991151
assert_eq!(dir, home.join(".claude").join("projects"));
9001152
env::remove_var("CLAUDE_PROJECTS_DIR");
9011153
}
1154+
1155+
#[test]
1156+
fn incremental_scanner_empty_file() {
1157+
let tmp = env::temp_dir().join("tail-test-scanner-empty");
1158+
std::fs::create_dir_all(&tmp).unwrap();
1159+
let path = tmp.join("session.jsonl");
1160+
std::fs::write(&path, "").unwrap();
1161+
1162+
let mut scanner = IncrementalTokenScanner::new();
1163+
let totals = scanner.scan_new_bytes(path.to_str().unwrap());
1164+
assert_eq!(totals.total_tokens, 0);
1165+
assert_eq!(totals.cost_usd, 0.0);
1166+
assert!(totals.model.is_empty());
1167+
1168+
std::fs::remove_dir_all(&tmp).ok();
1169+
}
1170+
1171+
#[test]
1172+
fn incremental_scanner_accumulates_tokens() {
1173+
let tmp = env::temp_dir().join("tail-test-scanner-accum");
1174+
std::fs::create_dir_all(&tmp).unwrap();
1175+
let path = tmp.join("session.jsonl");
1176+
1177+
// Write first entry.
1178+
let entry1 = r#"{"type":"assistant","uuid":"a1","requestId":"r1","message":{"model":"claude-sonnet-4-20250514","role":"assistant","content":[],"usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"stop_reason":"end_turn"}}"#;
1179+
std::fs::write(&path, format!("{entry1}\n")).unwrap();
1180+
1181+
let mut scanner = IncrementalTokenScanner::new();
1182+
let totals1 = scanner.scan_new_bytes(path.to_str().unwrap());
1183+
assert_eq!(totals1.input_tokens, 100);
1184+
assert_eq!(totals1.output_tokens, 50);
1185+
assert_eq!(totals1.total_tokens, 150);
1186+
assert_eq!(totals1.model, "claude-sonnet-4-20250514");
1187+
1188+
// Append second entry with different requestId.
1189+
let entry2 = r#"{"type":"assistant","uuid":"a2","requestId":"r2","message":{"model":"claude-sonnet-4-20250514","role":"assistant","content":[],"usage":{"input_tokens":200,"output_tokens":80,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"stop_reason":"end_turn"}}"#;
1190+
use std::io::Write;
1191+
let mut f = std::fs::OpenOptions::new()
1192+
.append(true)
1193+
.open(&path)
1194+
.unwrap();
1195+
writeln!(f, "{entry2}").unwrap();
1196+
1197+
// Second scan should only read the new bytes.
1198+
let totals2 = scanner.scan_new_bytes(path.to_str().unwrap());
1199+
assert_eq!(totals2.input_tokens, 300);
1200+
assert_eq!(totals2.output_tokens, 130);
1201+
assert_eq!(totals2.total_tokens, 430);
1202+
1203+
std::fs::remove_dir_all(&tmp).ok();
1204+
}
1205+
1206+
#[test]
1207+
fn incremental_scanner_deduplicates_request_ids() {
1208+
let tmp = env::temp_dir().join("tail-test-scanner-dedup");
1209+
std::fs::create_dir_all(&tmp).unwrap();
1210+
let path = tmp.join("session.jsonl");
1211+
1212+
// Two entries with same requestId — scanner should keep the one with stop_reason.
1213+
let streaming = r#"{"type":"assistant","uuid":"a1","requestId":"r1","message":{"model":"claude-sonnet-4-20250514","role":"assistant","content":[],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1214+
let complete = r#"{"type":"assistant","uuid":"a2","requestId":"r1","message":{"model":"claude-sonnet-4-20250514","role":"assistant","content":[],"usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"stop_reason":"end_turn"}}"#;
1215+
std::fs::write(&path, format!("{streaming}\n{complete}\n")).unwrap();
1216+
1217+
let mut scanner = IncrementalTokenScanner::new();
1218+
let totals = scanner.scan_new_bytes(path.to_str().unwrap());
1219+
// Should use the complete entry (50 output), not streaming (20).
1220+
assert_eq!(totals.input_tokens, 100);
1221+
assert_eq!(totals.output_tokens, 50);
1222+
assert_eq!(totals.total_tokens, 150);
1223+
1224+
std::fs::remove_dir_all(&tmp).ok();
1225+
}
1226+
1227+
#[test]
1228+
fn incremental_scanner_skips_non_assistant_lines() {
1229+
let tmp = env::temp_dir().join("tail-test-scanner-skip");
1230+
std::fs::create_dir_all(&tmp).unwrap();
1231+
let path = tmp.join("session.jsonl");
1232+
1233+
let user_entry = r#"{"type":"user","uuid":"u1","message":{"content":"hello"}}"#;
1234+
let assistant_entry = r#"{"type":"assistant","uuid":"a1","requestId":"r1","message":{"model":"claude-sonnet-4-20250514","role":"assistant","content":[],"usage":{"input_tokens":50,"output_tokens":25,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"stop_reason":"end_turn"}}"#;
1235+
std::fs::write(&path, format!("{user_entry}\n{assistant_entry}\n")).unwrap();
1236+
1237+
let mut scanner = IncrementalTokenScanner::new();
1238+
let totals = scanner.scan_new_bytes(path.to_str().unwrap());
1239+
assert_eq!(totals.total_tokens, 75);
1240+
1241+
std::fs::remove_dir_all(&tmp).ok();
1242+
}
9021243
}
9031244

9041245
const TOOL_USE_REJECTED_MSG: &str = "User rejected tool use";

0 commit comments

Comments
 (0)