From d0f139230314c82a1b633faae7c55841f85b5344 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 7 Oct 2026 07:30:58 -0400 Subject: [PATCH 1/4] feat(adapters): add Qoder session adapter Signed-off-by: samzong --- README.md | 3 + src/adapters/mod.rs | 3 + src/adapters/qoder.rs | 829 +++++++++++++++++++++++++++++++++ src/usage.rs | 85 +++- tests/fixtures/qoder/README.md | 28 ++ tests/fixtures/qoder/cli.jsonl | 8 + 6 files changed, 936 insertions(+), 20 deletions(-) create mode 100644 src/adapters/qoder.rs create mode 100644 tests/fixtures/qoder/README.md create mode 100644 tests/fixtures/qoder/cli.jsonl diff --git a/README.md b/README.md index d2c3bb7f..802668d8 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ One index across every AI coding CLI. Sync once, search everywhere, resume right | Grok | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kimi Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | MiniMax Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Qoder CLI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Qwen Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kilo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Crush | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -77,6 +78,8 @@ One index across every AI coding CLI. Sync once, search everywhere, resume right | CodeBuddy | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | TRAE CLI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +Qoder CLI usage requires exposed, nonzero token counts. Cache-inclusive input is split into fresh input, cache read, and cache write. Hidden token counts are not estimated. + ## Acknowledgements - Thanks to [tokscale](https://github.com/junhoyeo/tokscale) for the usage dashboard reference and token accounting behavior. diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index b14bf37a..03b8497f 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -30,6 +30,7 @@ pub(crate) mod openhands; pub(crate) mod paths; pub(crate) mod pi; mod pi_session; +pub(crate) mod qoder; pub(crate) mod qwen; pub(crate) mod roo; pub(crate) mod sync_state; @@ -434,6 +435,7 @@ pub(crate) fn all_adapters() -> Vec> { Box::new(deepseek_harness::DeepSeekHarnessAdapter), Box::new(kimi_code::KimiCodeAdapter), Box::new(qwen::QwenAdapter), + Box::new(qoder::QoderAdapter), Box::new(kilo::KiloCodeAdapter), Box::new(crush::CrushAdapter), Box::new(mimo_code::MimoCodeAdapter), @@ -488,6 +490,7 @@ pub(crate) fn source_supports_event_backfill(source_id: &str) -> bool { | "cline" | "roo" | "qwen-code" + | "qoder" | "gemini-cli" | "pi" | "omp" diff --git a/src/adapters/qoder.rs b/src/adapters/qoder.rs new file mode 100644 index 00000000..5f4a850b --- /dev/null +++ b/src/adapters/qoder.rs @@ -0,0 +1,829 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use tracing::warn; + +use crate::adapters::events::{self, EventContext}; +use crate::adapters::file_scan::{self, FileScanEntry, FileScanOptions, FileScanSnapshot}; +use crate::adapters::json_util::{jsonl_indexed, rfc3339_ms}; +use crate::adapters::paths; +use crate::adapters::{ + AdapterSyncContext, RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, + first_timestamp, +}; +use crate::types::{ + CommandEvidenceStatus, EvidenceVisibility, FileEvidence, FileOperation, ParentLink, + ParentRelation, RawUsageEvent, Role, ThreadRole, +}; + +const USAGE_PARSER_VERSION: u32 = 2; +const EVENT_PARSER_VERSION: u32 = 2; +const METADATA_PARSER_VERSION: u32 = 3; + +pub(crate) struct QoderAdapter; + +struct QoderEntries { + entries: Vec, + parent_ids: HashMap, +} + +impl SourceAdapter for QoderAdapter { + fn id(&self) -> &str { + "qoder" + } + + fn label(&self) -> &str { + "QD" + } + + fn usage_parser_version(&self) -> Option { + Some(USAGE_PARSER_VERSION) + } + + fn resume_command(&self, source_id: &str) -> Option { + let (program, id) = native_command(source_id); + (!id.contains(":agent-")).then(|| ResumeCommand::new(program, &["--resume", id])) + } + + fn start_command(&self, prompt: String) -> Option { + Some(crate::adapters::prompt_start("qodercli", prompt)) + } + + fn scan(&self) -> anyhow::Result> { + let mut sessions = Vec::new(); + let QoderEntries { entries, parent_ids } = collect_entries(&config_dirs()?); + for entry in entries { + let Some(before) = snapshot(&entry) else { continue }; + let raw = parse_entry( + entry.clone(), + before.effective_mtime_ms(), + true, + parent_ids.get(&entry.session_id).map(String::as_str), + )?; + if snapshot(&entry).as_ref() == Some(&before) { + sessions.extend(raw); + } + } + Ok(sessions) + } + + fn scan_for_sync( + &self, + context: &AdapterSyncContext, + since_ts: Option, + include_events: bool, + ) -> anyhow::Result> { + let QoderEntries { entries, parent_ids } = collect_entries(&config_dirs()?); + Ok(Some(file_scan::run_file_scan_with_options_and_snapshot( + context, + since_ts, + FileScanOptions { + stream_sessions: true, + usage_parser_version: Some(USAGE_PARSER_VERSION), + event_parser_version: include_events.then_some(EVENT_PARSER_VERSION), + metadata_parser_version: Some(METADATA_PARSER_VERSION), + }, + entries, + snapshot, + |entry, mtime| { + let parent = parent_ids.get(&entry.session_id).map(String::as_str); + parse_entry(entry, mtime, include_events, parent) + }, + )?)) + } +} + +fn native_command(id: &str) -> (&str, &str) { + id.strip_prefix("cn:").map_or(("qodercli", id), |id| ("qoderclicn", id)) +} + +fn config_dirs() -> anyhow::Result> { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?; + Ok([ + (paths::env_path_dir("QODER_CONFIG_DIR").unwrap_or_else(|| home.join(".qoder")), ""), + ( + paths::env_path_dir("QODERCN_CONFIG_DIR").unwrap_or_else(|| home.join(".qoder-cn")), + "cn:", + ), + ] + .into_iter() + .filter(|(dir, _)| dir.is_dir()) + .collect()) +} + +fn collect_entries(dirs: &[(PathBuf, &str)]) -> QoderEntries { + let mut entries = Vec::new(); + let mut parent_ids = HashMap::new(); + for (dir, prefix) in dirs { + let Ok(projects) = fs::read_dir(dir.join("projects")) else { continue }; + for project in projects.flatten() { + let Ok(files) = fs::read_dir(project.path()) else { continue }; + for file in files.flatten() { + let path = file.path(); + if let Some(id) = transcript_id(&path, false).filter(|id| !id.starts_with("agent-")) + { + entries.push(FileScanEntry { + session_id: format!("{prefix}{id}"), + stat_target: path, + directory: None, + }); + } else if path.is_dir() { + let Some(parent) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !valid_id(parent) { + continue; + } + let Ok(subagents) = fs::read_dir(path.join("subagents")) else { continue }; + for file in subagents.flatten() { + let path = file.path(); + if let Some(id) = transcript_id(&path, true) { + let session_id = format!("{prefix}{parent}:{id}"); + parent_ids.insert(session_id.clone(), format!("{prefix}{parent}")); + entries.push(FileScanEntry { + session_id, + stat_target: path, + directory: None, + }); + } + } + } + } + } + } + entries.sort_by(|a, b| a.stat_target.cmp(&b.stat_target)); + let mut claimed = HashSet::new(); + entries.retain(|entry| claimed.insert(entry.session_id.clone())); + QoderEntries { entries, parent_ids } +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +fn transcript_id(path: &Path, subagent: bool) -> Option { + if !path.is_file() || path.extension()? != "jsonl" { + return None; + } + let id = path.file_stem()?.to_str()?; + let valid = + if subagent { id.starts_with("agent-") && !id.contains(['/', '\\']) } else { valid_id(id) }; + valid.then(|| id.to_string()) +} + +fn snapshot(entry: &FileScanEntry) -> Option> { + let transcript = file_scan::file_metadata_snapshot(&entry.stat_target)?; + Some(FileScanSnapshot::new(transcript.mtime_ms()?, transcript)) +} + +fn parse_entry( + entry: FileScanEntry, + mtime: i64, + include_events: bool, + parent_id: Option<&str>, +) -> anyhow::Result> { + match parse_transcript(&entry, mtime, include_events, parent_id) { + Ok(session) => Ok(session), + Err(error) => { + warn!("failed to parse {}: {error}", entry.stat_target.display()); + Ok(None) + } + } +} + +fn parse_transcript( + entry: &FileScanEntry, + mtime: i64, + include_events: bool, + parent_id: Option<&str>, +) -> anyhow::Result> { + let path = &entry.stat_target; + let subagent = parent_id.is_some(); + let source_path = path.to_string_lossy().to_string(); + let mut directory = None; + let mut relocated_cwd = None; + let mut worktree_cwd = None; + let mut original_cwd = None; + let mut custom_title = None; + let mut generated_title = None; + let mut fork_parent = None; + let mut messages = Vec::new(); + let mut events = Vec::new(); + let mut usage = Vec::::new(); + let mut usage_index = HashMap::::new(); + let mut seen = HashSet::new(); + let mut calls = HashMap::new(); + let mut parents = HashMap::new(); + let mut event_uuids = Vec::new(); + let mut active_leaf = None; + let mut rewound = false; + for item in jsonl_indexed(BufReader::new(fs::File::open(path)?).lines()) { + let (line, record) = item?; + let kind = record.get("type").and_then(Value::as_str).unwrap_or(""); + if matches!(kind, "user" | "assistant" | "system") + && (subagent || record.get("isSidechain").and_then(Value::as_bool) != Some(true)) + && let Some(cwd) = nonempty(record.get("cwd")) + { + directory = Some(cwd); + } + if fork_parent.is_none() { + fork_parent = nonempty(record.pointer("/forkedFrom/sessionId")); + } + match kind { + "relocated" => { + relocated_cwd = nonempty(record.get("relocatedCwd")); + continue; + } + "worktree-state" => { + if record.get("worktreeSession").is_some_and(Value::is_null) { + worktree_cwd = original_cwd.clone(); + } else { + original_cwd = nonempty(record.pointer("/worktreeSession/originalCwd")); + worktree_cwd = nonempty(record.pointer("/worktreeSession/worktreeCwd")) + .or_else(|| nonempty(record.pointer("/worktreeSession/worktreePath"))); + } + continue; + } + "custom-title" => { + custom_title = nonempty(record.get("customTitle")); + continue; + } + "ai-title" => { + generated_title = nonempty(record.get("aiTitle")); + continue; + } + "active-leaf" => { + active_leaf = nonempty(record.get("leafUuid")); + rewound |= record.get("rewound").and_then(Value::as_bool) == Some(true); + continue; + } + _ => {} + } + let uuid = nonempty(record.get("uuid")); + if let Some(id) = &uuid { + if !seen.insert(id.clone()) { + continue; + } + parents.insert( + id.clone(), + nonempty(record.get("parentUuid")) + .or_else(|| nonempty(record.get("logicalParentUuid"))), + ); + } + let role = match kind { + "user" => Role::User, + "assistant" => Role::Assistant, + _ => continue, + }; + if (!subagent && record.get("isSidechain").and_then(Value::as_bool) == Some(true)) + || record.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) + { + continue; + } + let timestamp = rfc3339_ms(record.get("timestamp")); + let hidden = ["isMeta", "isCompactSummary", "isVirtual", "isVisibleInTranscriptOnly"] + .iter() + .any(|key| record.get(*key).and_then(Value::as_bool) == Some(true)); + let content = record.pointer("/message/content"); + let text = visible_text(content); + let message_seq = (!hidden && !text.is_empty()).then_some(messages.len() as u32); + if role == Role::Assistant + && let Some(mut event) = usage_event( + &record, + line as u32, + timestamp.unwrap_or(mtime), + &source_path, + message_seq, + ) + { + if let Some(index) = usage_index.get(&event.event_key) { + let existing = &mut usage[*index]; + existing.input_tokens = existing.input_tokens.max(event.input_tokens); + existing.output_tokens = existing.output_tokens.max(event.output_tokens); + existing.cache_read_tokens = + existing.cache_read_tokens.max(event.cache_read_tokens); + existing.cache_write_tokens = + existing.cache_write_tokens.max(event.cache_write_tokens); + existing.message_seq = existing.message_seq.or(event.message_seq); + } else { + event.event_seq = usage.len() as u32; + usage_index.insert(event.event_key.clone(), usage.len()); + usage.push(event); + } + } + let event_cwd = nonempty(record.get("cwd")).or_else(|| directory.clone()); + if include_events + && !hidden + && let Some(items) = content.and_then(Value::as_array) + { + for (index, item) in items.iter().enumerate() { + let context = EventContext { + event_seq: events.len() as u32, + timestamp, + source_path: Some(source_path.clone()), + source_event_id: Some(format!( + "{}:{index}", + uuid.as_deref() + .map(str::to_string) + .unwrap_or_else(|| format!("line:{line}")) + )), + message_seq: message_seq + .or_else(|| messages.len().checked_sub(1).map(|s| s as u32)), + parser_version: EVENT_PARSER_VERSION, + }; + let event = match item.get("type").and_then(Value::as_str) { + Some("tool_use") if role == Role::Assistant => { + let Some(name) = nonempty(item.get("name")) else { continue }; + let mut event = + events::tool_call_event(context, name.clone(), item.get("input")); + event.tool_call_id = nonempty(item.get("id")); + if let Some(id) = &event.tool_call_id { + calls.insert(id.clone(), name.clone()); + } + let operation = match name.as_str() { + "Read" => Some(FileOperation::Read), + "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => { + Some(FileOperation::Write) + } + _ => None, + }; + if let Some(operation) = operation + && let Some(file) = nonempty(item.pointer("/input/file_path")) + .or_else(|| nonempty(item.pointer("/input/notebook_path"))) + { + event.target = Some(file.clone()); + event.kind = if operation == FileOperation::Read { + "file_read" + } else { + "file_write" + } + .to_string(); + event.files.push(FileEvidence::call( + file, + operation, + event_cwd.clone(), + )); + } + if matches!(name.as_str(), "Bash" | "PowerShell") + && let Some(command) = + item.pointer("/input/command").and_then(Value::as_str) + { + let (files, status) = if name == "Bash" { + events::shell_file_evidence(command, event_cwd.as_deref()) + } else { + (Vec::new(), CommandEvidenceStatus::Unsupported) + }; + event.files = files; + event.command_evidence_status = Some(status); + } + event + } + Some("tool_result") => { + let id = nonempty(item.get("tool_use_id")); + let mut event = events::tool_result_event( + context, + id.as_ref().and_then(|id| calls.get(id)).cloned(), + Some(visible_text(item.get("content"))), + ); + event.tool_call_id = id; + event.status = item + .get("is_error") + .and_then(Value::as_bool) + .map(|error| if error { "error" } else { "success" }.to_string()); + event.attrs_json = Some(item.to_string()); + event + } + _ => continue, + }; + event_uuids.push(uuid.clone()); + events.push(event); + } + } + if message_seq.is_some() { + messages.push(RawMessage { role, content: text, timestamp }); + } + } + if rewound { + let mut active = HashSet::new(); + let mut complete = true; + while let Some(id) = active_leaf { + if !active.insert(id.clone()) { + complete = false; + break; + } + let Some(parent) = parents.get(&id) else { + complete = false; + break; + }; + active_leaf = parent.clone(); + } + if complete && !active.is_empty() { + for (event, uuid) in events.iter_mut().zip(event_uuids) { + if uuid.is_some_and(|id| !active.contains(&id)) { + event.visibility = Some(EvidenceVisibility::Inactive); + } + } + } + } + if messages.is_empty() && usage.is_empty() && events.is_empty() { + return Ok(None); + } + let mut session = RawSession::search_only( + &entry.session_id, + worktree_cwd.or(relocated_cwd).or(directory), + first_timestamp(None, &messages, &usage, &events).unwrap_or(mtime), + Some(mtime), + Some("cli".to_string()), + messages, + ) + .with_usage(usage, USAGE_PARSER_VERSION); + session.source_file_path = Some(source_path); + session.custom_title = custom_title.or(generated_title); + session.thread_role = Some(if subagent { ThreadRole::Subagent } else { ThreadRole::Primary }); + if let Some(parent) = parent_id { + session.parent_links.push(ParentLink { + relation: ParentRelation::Spawn, + source: "qoder".to_string(), + source_id: parent.to_string(), + }); + } else if let Some(parent) = fork_parent { + let prefix = if entry.session_id.starts_with("cn:") { "cn:" } else { "" }; + session.parent_links.push(ParentLink { + relation: ParentRelation::Fork, + source: "qoder".to_string(), + source_id: format!("{prefix}{parent}"), + }); + } + session.metadata_parser_version = Some(METADATA_PARSER_VERSION); + session.refresh_session_on_metadata_backfill = true; + if include_events { + session = session.with_events(events, EVENT_PARSER_VERSION); + } + Ok(Some(session)) +} + +fn nonempty(value: Option<&Value>) -> Option { + value.and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()).map(str::to_string) +} + +fn visible_text(content: Option<&Value>) -> String { + match content { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(items)) => items + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +fn usage_event( + record: &Value, + seq: u32, + timestamp: i64, + path: &str, + message_seq: Option, +) -> Option { + let usage = record.pointer("/message/usage")?; + let count = |key: &str| usage.get(key).and_then(Value::as_i64).filter(|n| *n >= 0).unwrap_or(0); + let input_tokens = count("input_tokens"); + let output_tokens = count("output_tokens"); + let cache_read_tokens = count("cache_read_input_tokens"); + let cache_write_tokens = count("cache_creation_input_tokens"); + if input_tokens == 0 && output_tokens == 0 && cache_read_tokens == 0 && cache_write_tokens == 0 + { + return None; + } + let input_tokens = + input_tokens.saturating_sub(cache_read_tokens).saturating_sub(cache_write_tokens).max(0); + let id = nonempty(record.pointer("/message/id")).unwrap_or_else(|| format!("line:{seq}")); + Some(RawUsageEvent { + message_seq, + model: nonempty(record.pointer("/message/model")).unwrap_or_else(|| "unknown".to_string()), + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + source_path: Some(path.to_string()), + raw_usage_json: Some(usage.to_string()), + ..RawUsageEvent::observed(format!("assistant:{id}"), seq, timestamp, USAGE_PARSER_VERSION) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + const SESSION: &str = "11111111-1111-4111-8111-111111111111"; + const FIXTURE: &str = include_str!("../../tests/fixtures/qoder/cli.jsonl"); + + fn fixture(root: &Path) -> FileScanEntry { + let path = + root.join("projects/-tmp-qoder-fixture-project").join(format!("{SESSION}.jsonl")); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, FIXTURE).unwrap(); + FileScanEntry { session_id: SESSION.to_string(), stat_target: path, directory: None } + } + + fn write_records(entry: &FileScanEntry, records: &[Value]) { + fs::write(&entry.stat_target, records.iter().map(|r| format!("{r}\n")).collect::()) + .unwrap(); + } + + #[test] + fn parses_cli_envelope_messages_timestamps_and_tools() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + let raw = parse_transcript(&entry, 1791370810000, true, None).unwrap().unwrap(); + assert_eq!(raw.directory.as_deref(), Some("/tmp/qoder-fixture-project")); + assert_eq!(raw.started_at, 1791370800000); + assert_eq!(raw.updated_at, Some(1791370810000)); + assert_eq!(raw.messages.len(), 3); + assert_eq!(raw.messages[0].role, Role::User); + assert_eq!(raw.messages[1].content, "I will inspect the file."); + assert_eq!(raw.messages[2].timestamp, Some(1791370805000)); + assert_eq!(raw.custom_title.as_deref(), Some("Verify sample file")); + assert_eq!(raw.events.len(), 6); + let read = &raw.events[0]; + assert_eq!(read.kind, "file_read"); + assert_eq!(read.files[0].operation, FileOperation::Read); + assert_eq!(read.files[0].cwd.as_deref(), Some("/tmp/qoder-fixture-project")); + assert_eq!(raw.events[1].kind, "command"); + assert_eq!(raw.events[1].target.as_deref(), Some("wc -l sample.txt")); + assert_eq!(raw.events[3].name.as_deref(), Some("Bash")); + assert_eq!(raw.events[3].status.as_deref(), Some("success")); + assert_eq!(raw.events[3].tool_call_id.as_deref(), Some("call-shell")); + assert_eq!(raw.events[4].kind, "file_write"); + assert_eq!(raw.usage_events.len(), 3); + assert_eq!(raw.usage_events.iter().map(|u| u.input_tokens).sum::(), 480); + assert!(raw.usage_events.iter().all(|u| u.provider == "unknown")); + let without_events = parse_transcript(&entry, 1791370810000, false, None).unwrap().unwrap(); + assert!(without_events.events.is_empty()); + assert_eq!(without_events.event_parser_version, None); + assert_eq!(without_events.messages.len(), raw.messages.len()); + assert_eq!(without_events.usage_events.len(), raw.usage_events.len()); + } + + #[test] + fn filters_runtime_text_images_and_auth_errors_without_estimated_usage() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + write_records( + &entry, + &[ + json!({"type":"user", "isMeta":true, "message":{"content":"injected context"}}), + json!({"type":"user", "isCompactSummary":true, "message":{"content":"summary"}}), + json!({"type":"user", "isSidechain":true, "message":{"content":"subagent prompt"}}), + json!({"type":"assistant", "isApiErrorMessage":true, "message":{"content":[{"type":"text","text":"Please login"}]}}), + json!({"type":"user", "message":{"content":[{"type":"image","source":{"data":"synthetic-image"}},{"type":"text","text":"describe file"}]}}), + json!({"type":"assistant", "message":{"content":[{"type":"thinking","thinking":"internal"},{"type":"text","text":"done"}], "usage":{"input_tokens":0,"output_tokens":0,"credits":0.5}}}), + json!({"type":"token-stats", "promptTokenCount":4000,"timestamp":1791370800000i64}), + ], + ); + let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + assert_eq!( + raw.messages.iter().map(|m| m.content.as_str()).collect::>(), + ["describe file", "done"] + ); + assert!(raw.usage_events.is_empty()); + assert!(raw.events.is_empty()); + } + + #[test] + fn deduplicates_response_usage_and_duplicate_records() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + let first = json!({"type":"assistant","uuid":"one", "message":{"id":"response", "model":"fixture", "content":[{"type":"tool_use","name":"Read","id":"call","input":{"file_path":"sample.txt"}}]}}); + let last = json!({"type":"assistant","uuid":"two", "message":{"id":"response", "model":"fixture", "content":[{"type":"text","text":"done"}], "usage":{"input_tokens":80,"output_tokens":12}}}); + write_records(&entry, &[first.clone(), first, last.clone(), last]); + let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + assert_eq!(raw.messages.len(), 1); + assert_eq!(raw.events.len(), 1); + assert_eq!(raw.usage_events.len(), 1); + assert_eq!(raw.usage_events[0].input_tokens, 80); + } + + #[test] + fn discovers_subagents_and_namespaces_china_edition() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + let child = entry + .stat_target + .parent() + .unwrap() + .join(SESSION) + .join("subagents/agent-a1b2c3d4.jsonl"); + fs::create_dir_all(child.parent().unwrap()).unwrap(); + fs::write(&child, r#"{"type":"user","isSidechain":true,"sessionId":"11111111-1111-4111-8111-111111111111","message":{"content":"inspect file"}}"#).unwrap(); + fs::write(entry.stat_target.parent().unwrap().join("agent-orphan.jsonl"), FIXTURE).unwrap(); + let QoderEntries { entries, parent_ids } = + collect_entries(&[(root.path().to_path_buf(), "cn:")]); + assert_eq!(entries.len(), 2); + let child = entries.iter().find(|e| e.session_id.contains(":agent-")).unwrap(); + let raw = parse_transcript( + child, + 10, + true, + parent_ids.get(&child.session_id).map(String::as_str), + ) + .unwrap() + .unwrap(); + assert_eq!(raw.messages[0].content, "inspect file"); + assert_eq!(raw.thread_role, Some(ThreadRole::Subagent)); + assert_eq!(raw.parent_links[0].source_id, format!("cn:{SESSION}")); + assert!(QoderAdapter.resume_command(&child.session_id).is_none()); + let cmd = QoderAdapter.resume_command(&format!("cn:{SESSION}")).unwrap(); + assert_eq!(cmd.program, "qoderclicn"); + assert_eq!(cmd.args, ["--resume", SESSION]); + } + + #[test] + fn discovers_plugin_and_custom_subagent_names_without_losing_parent() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + let children = entry.stat_target.parent().unwrap().join(SESSION).join("subagents"); + fs::create_dir_all(&children).unwrap(); + for name in [ + "agent-aplugin:namespace:name-abcd", + "agent-aCustom agent.v2-abcd", + "agent-aplugin:agent-helper-abcd", + ] { + fs::write(children.join(format!("{name}.jsonl")), r#"{"type":"user","isSidechain":true,"message":{"content":"plugin subagent result"}}"#).unwrap(); + } + let QoderEntries { entries, parent_ids } = + collect_entries(&[(root.path().to_path_buf(), "cn:")]); + assert_eq!(entries.len(), 4); + for child in entries.iter().filter(|entry| entry.session_id.contains(":agent-")) { + let raw = parse_transcript( + child, + 10, + true, + parent_ids.get(&child.session_id).map(String::as_str), + ) + .unwrap() + .unwrap(); + assert_eq!(raw.messages[0].content, "plugin subagent result"); + assert_eq!(raw.parent_links[0].source_id, format!("cn:{SESSION}")); + assert_eq!(raw.parent_links[0].relation, ParentRelation::Spawn); + assert!(QoderAdapter.resume_command(&child.session_id).is_none()); + } + } + + #[test] + fn session_directory_prefers_worktree_then_relocation_then_latest_cwd() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + let mut records = vec![ + json!({"type":"user","cwd":"/tmp/original","message":{"content":"inspect file"}}), + json!({"type":"assistant","cwd":"/tmp/latest","message":{"content":"done"}}), + ]; + write_records(&entry, &records); + assert_eq!( + parse_transcript(&entry, 10, true, None).unwrap().unwrap().directory.as_deref(), + Some("/tmp/latest") + ); + records + .push(json!({"type":"relocated","sessionId":SESSION,"relocatedCwd":"/tmp/relocated"})); + write_records(&entry, &records); + assert_eq!( + parse_transcript(&entry, 10, true, None).unwrap().unwrap().directory.as_deref(), + Some("/tmp/relocated") + ); + records.push(json!({"type":"worktree-state","sessionId":SESSION,"worktreeSession":{"worktreeCwd":"/tmp/worktree/subdir","worktreePath":"/tmp/worktree","originalCwd":"/tmp/restored"}})); + write_records(&entry, &records); + assert_eq!( + parse_transcript(&entry, 10, true, None).unwrap().unwrap().directory.as_deref(), + Some("/tmp/worktree/subdir") + ); + records.push(json!({"type":"worktree-state","sessionId":SESSION,"worktreeSession":null})); + write_records(&entry, &records); + assert_eq!( + parse_transcript(&entry, 10, true, None).unwrap().unwrap().directory.as_deref(), + Some("/tmp/restored") + ); + } + + #[test] + fn reads_first_fork_origin_and_namespaces_china_edition() { + let root = tempfile::tempdir().unwrap(); + let mut entry = fixture(root.path()); + let mut records: Vec = + FIXTURE.lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + records[0]["forkedFrom"] = json!({"sessionId":"parent", "messageUuid":"original"}); + records[1]["forkedFrom"] = json!({"sessionId":"other", "messageUuid":"later"}); + write_records(&entry, &records); + for prefix in ["", "cn:"] { + entry.session_id = format!("{prefix}{SESSION}"); + let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + assert_eq!(raw.parent_links.len(), 1); + assert_eq!(raw.parent_links[0].source_id, format!("{prefix}parent")); + assert_eq!(raw.parent_links[0].relation, ParentRelation::Fork); + assert_eq!(raw.custom_title.as_deref(), Some("Verify sample file")); + } + } + + #[test] + fn malformed_jsonl_records_do_not_drop_valid_conversation() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + fs::write(&entry.stat_target, format!("invalid\n{FIXTURE}\n{{partial")).unwrap(); + let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + assert_eq!(raw.messages.len(), 3); + assert!(collect_entries(&[(root.path().join("missing"), "")]).entries.is_empty()); + } + + #[test] + fn rewound_tool_events_are_inactive_without_erasing_history() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + let mut records: Vec = + FIXTURE.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + records.push(json!({"type":"active-leaf","sessionId":SESSION,"leafUuid":"u1","explicit":true,"rewound":true,"timestamp":1791370806000i64})); + write_records(&entry, &records); + let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + assert_eq!(raw.messages.len(), 3); + assert!(raw.events.iter().all(|e| e.visibility == Some(EvidenceVisibility::Inactive))); + } + #[test] + fn splits_cache_inclusive_usage_and_ignores_invalid_counts() { + let cached = json!({"type":"assistant","modelSource":"custom","message":{"id":"cached","model":"fixture","usage":{"input_tokens":3250,"output_tokens":40,"cache_read_input_tokens":3000,"cache_creation_input_tokens":200}}}); + let usage = usage_event(&cached, 0, 1, "fixture", None).unwrap(); + assert_eq!(usage.input_tokens, 50); + assert_eq!(usage.output_tokens, 40); + assert_eq!(usage.cache_read_tokens, 3000); + assert_eq!(usage.cache_write_tokens, 200); + let cache_only = json!({"type":"assistant","message":{"usage":{"input_tokens":5,"cache_read_input_tokens":8,"cache_creation_input_tokens":2}}}); + let usage = usage_event(&cache_only, 0, 1, "fixture", None).unwrap(); + assert_eq!(usage.input_tokens, 0); + assert_eq!(usage.cache_read_tokens, 8); + assert_eq!(usage.cache_write_tokens, 2); + let invalid = json!({"type":"assistant","message":{"usage":{"input_tokens":"120","output_tokens":-5,"credits":0.5}}}); + assert!(usage_event(&invalid, 0, 1, "fixture", None).is_none()); + } + + #[test] + fn rewind_keeps_tools_before_compaction_active() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + write_records( + &entry, + &[ + json!({"type":"user","uuid":"u1","parentUuid":null,"message":{"content":"Read then edit"}}), + json!({"type":"assistant","uuid":"read","parentUuid":"u1","message":{"content":[{"type":"tool_use","name":"Read","id":"read","input":{"file_path":"sample.txt"}}]}}), + json!({"type":"system","subtype":"compact_boundary","uuid":"boundary","parentUuid":null,"logicalParentUuid":"read"}), + json!({"type":"user","uuid":"summary","parentUuid":"boundary","isCompactSummary":true,"message":{"content":"summary"}}), + json!({"type":"user","uuid":"u2","parentUuid":"summary","message":{"content":"Edit file"}}), + json!({"type":"assistant","uuid":"edit","parentUuid":"u2","message":{"content":[{"type":"tool_use","name":"Edit","id":"edit","input":{"file_path":"sample.txt"}}]}}), + json!({"type":"active-leaf","leafUuid":"u2","rewound":true}), + ], + ); + let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + assert_eq!(raw.events.len(), 2); + assert_eq!(raw.events[0].visibility, None); + assert_eq!(raw.events[1].visibility, Some(EvidenceVisibility::Inactive)); + } + + #[test] + fn records_powershell_command_and_notebook_file_evidence() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + write_records( + &entry, + &[json!({"type":"assistant","cwd":"/tmp/project","message":{"content":[ + {"type":"tool_use","name":"PowerShell","id":"shell","input":{"command":"Get-Content sample.txt"}}, + {"type":"tool_use","name":"NotebookEdit","id":"notebook","input":{"notebook_path":"sample.ipynb","new_source":"print(1)"}} + ]}})], + ); + let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + assert_eq!(raw.events[0].kind, "command"); + assert_eq!(raw.events[0].target.as_deref(), Some("Get-Content sample.txt")); + assert_eq!(raw.events[0].command_evidence_status, Some(CommandEvidenceStatus::Unsupported)); + assert_eq!(raw.events[1].kind, "file_write"); + assert_eq!(raw.events[1].target.as_deref(), Some("sample.ipynb")); + assert_eq!(raw.events[1].files[0].operation, FileOperation::Write); + assert_eq!(raw.events[1].files[0].cwd.as_deref(), Some("/tmp/project")); + } + + #[test] + fn file_evidence_uses_record_cwd_after_workspace_change() { + let root = tempfile::tempdir().unwrap(); + let entry = fixture(root.path()); + write_records( + &entry, + &[ + json!({"type":"user","cwd":"/tmp/old","message":{"content":"inspect file"}}), + json!({"type":"assistant","cwd":"/tmp/new","message":{"content":[{"type":"tool_use","name":"Read","id":"read","input":{"file_path":"sample.txt"}}]}}), + ], + ); + let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + assert_eq!(raw.directory.as_deref(), Some("/tmp/new")); + assert_eq!(raw.events[0].files[0].cwd.as_deref(), Some("/tmp/new")); + } +} diff --git a/src/usage.rs b/src/usage.rs index c993ddd1..dddb8dbe 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -85,31 +85,40 @@ impl TokenTotals { pub(crate) struct UsageDedup { codex_seen: HashSet, claude_seen: HashSet, + qoder_seen: HashSet, } impl UsageDedup { pub(crate) fn accept(&mut self, event: &UsageEventRecord) -> bool { - if event.source == "codex" { - let key = format!( - "codex:token_count:{}:{}:{}:{}:{}:{}:{}:{}", - event.timestamp, - event.provider, - event.model, - event.input_tokens, - event.output_tokens, - event.cache_read_tokens, - event.cache_write_tokens, - event.reasoning_tokens - ); - return self.codex_seen.insert(key); + match event.source.as_str() { + "codex" => { + let key = format!( + "codex:token_count:{}:{}:{}:{}:{}:{}:{}:{}", + event.timestamp, + event.provider, + event.model, + event.input_tokens, + event.output_tokens, + event.cache_read_tokens, + event.cache_write_tokens, + event.reasoning_tokens + ); + self.codex_seen.insert(key) + } + "claude-code" + if event.event_key.starts_with("assistant:") + && !event.event_key.contains(":line:") => + { + self.claude_seen.insert(event.event_key.clone()) + } + "qoder" + if event.event_key.starts_with("assistant:") + && !event.event_key.starts_with("assistant:line:") => + { + self.qoder_seen.insert(event.event_key.clone()) + } + _ => true, } - if event.source == "claude-code" - && event.event_key.starts_with("assistant:") - && !event.event_key.contains(":line:") - { - return self.claude_seen.insert(event.event_key.clone()); - } - true } } @@ -361,4 +370,40 @@ mod tests { assert_eq!(report.summary.events, 1); assert_eq!(report.summary.tokens.total_tokens, 14); } + + #[test] + fn aggregate_usage_dedupes_qoder_forked_messages() { + let mut events = Vec::new(); + for (index, (input, output)) in [(120, 20), (170, 15), (190, 12)].into_iter().enumerate() { + let mut original = + event("qoder", "parent", &format!("assistant:msg-{index}"), index as i64); + original.input_tokens = input; + original.output_tokens = output; + original.cache_read_tokens = 0; + original.reasoning_tokens = 0; + original.token_source = "observed".to_string(); + let mut forked = original.clone(); + forked.session_id = "fork".to_string(); + forked.source_id = "fork".to_string(); + events.extend([original, forked]); + } + let report = aggregate_usage_events(&events); + assert_eq!(report.summary.events, 3); + assert_eq!(report.summary.tokens.input_tokens, 480); + assert_eq!(report.summary.tokens.output_tokens, 47); + assert_eq!(report.summary.tokens.total_tokens, 527); + } + + #[test] + fn aggregate_usage_keeps_qoder_line_keys_and_other_sources_separate() { + let events = [ + event("qoder", "parent", "assistant:line:2", 1), + event("qoder", "fork", "assistant:line:2", 1), + event("claude-code", "claude", "assistant:msg-1", 1), + event("qoder", "parent", "assistant:msg-1", 1), + ]; + let report = aggregate_usage_events(&events); + assert_eq!(report.summary.events, 4); + assert_eq!(report.summary.tokens.total_tokens, 56); + } } diff --git a/tests/fixtures/qoder/README.md b/tests/fixtures/qoder/README.md new file mode 100644 index 00000000..4fed9eaf --- /dev/null +++ b/tests/fixtures/qoder/README.md @@ -0,0 +1,28 @@ +# Qoder CLI parser fixture + +`cli.jsonl` is synthetic, not captured from an authenticated Qoder session. +It contains no credentials or private conversation data. + +The envelope, message blocks, inline titles, and active-leaf records follow the +published `@qoder-ai/qodercli` 1.1.65 npm bundle (`bundle/qodercli.js`): +`keo` writes cwd, sessionId, parentUuid, isSidechain and RFC3339 timestamps; +`jW` / `Xo` create assistant / user records; `KTl` persists assistant usage for +custom models and strips requestId; `_sl` puts usage on the final response block; +`av` hides native-provider token counts. Numbers and message contents here are +invented for assertions, not measured token consumption. + +Regression inputs in the adapter tests follow `Ogi` / `nVA` / `OM` / `UQc` for +cache-inclusive usage, `keo` for `logicalParentUuid` at compaction boundaries, +and `buildForkEntries` for per-record `forkedFrom` ancestry. Original functions +extracted from the bundle confirm that Anthropic, Chat, and Responses input +counts include cache read and cache write before persistence. +`Ob` permits plugin-qualified and user-defined agent names in subagent filenames. +`cco` selects worktree cwd (restoring original cwd after exit), relocation cwd, +then the latest message cwd; directory-priority tests follow these records. +No `-session.json` or `fork_from` writer exists in this bundle; only transcript +metadata is supported. + +Installation on macOS succeeded, but print mode, model discovery, and the +interactive startup all required vendor authentication. Qoder itself recognized this synthetic transcript through `--list-sessions`. +No vendor account was registered or used. Replace or supplement this fixture with a sanitized real +session once authorized credentials are available. diff --git a/tests/fixtures/qoder/cli.jsonl b/tests/fixtures/qoder/cli.jsonl new file mode 100644 index 00000000..8b0015ae --- /dev/null +++ b/tests/fixtures/qoder/cli.jsonl @@ -0,0 +1,8 @@ +{"cwd":"/tmp/qoder-fixture-project","sessionId":"11111111-1111-4111-8111-111111111111","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","type":"user","uuid":"u1","parentUuid":null,"timestamp":"2026-10-07T11:00:00.000Z","message":{"role":"user","content":"Read sample.txt, run wc -l sample.txt, and replace initial with verified."}} +{"cwd":"/tmp/qoder-fixture-project","sessionId":"11111111-1111-4111-8111-111111111111","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2026-10-07T11:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"I will inspect the file."},{"type":"tool_use","id":"call-read","name":"Read","input":{"file_path":"/tmp/qoder-fixture-project/sample.txt"}},{"type":"tool_use","id":"call-shell","name":"Bash","input":{"command":"wc -l sample.txt"}}],"id":"msg-1","model":"fixture-model","stop_reason":"tool_use","usage":{"input_tokens":120,"output_tokens":20,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"modelSource":"custom","customProviderId":"fixture-model"} +{"cwd":"/tmp/qoder-fixture-project","sessionId":"11111111-1111-4111-8111-111111111111","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2026-10-07T11:00:02.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-read","content":[{"type":"text","text":"initial\n"}],"is_error":false},{"type":"tool_result","tool_use_id":"call-shell","content":"1 sample.txt","is_error":false}]}} +{"cwd":"/tmp/qoder-fixture-project","sessionId":"11111111-1111-4111-8111-111111111111","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2026-10-07T11:00:03.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"call-edit","name":"Edit","input":{"file_path":"/tmp/qoder-fixture-project/sample.txt","old_string":"initial","new_string":"verified"}}],"id":"msg-2","model":"fixture-model","stop_reason":"tool_use","usage":{"input_tokens":170,"output_tokens":15}},"modelSource":"custom"} +{"cwd":"/tmp/qoder-fixture-project","sessionId":"11111111-1111-4111-8111-111111111111","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","type":"user","uuid":"u3","parentUuid":"a2","timestamp":"2026-10-07T11:00:04.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-edit","content":"The file was updated.","is_error":false}]}} +{"cwd":"/tmp/qoder-fixture-project","sessionId":"11111111-1111-4111-8111-111111111111","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","type":"assistant","uuid":"a3","parentUuid":"u3","timestamp":"2026-10-07T11:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Updated sample.txt; wc reported one line."}],"id":"msg-3","model":"fixture-model","stop_reason":"end_turn","usage":{"input_tokens":190,"output_tokens":12}},"modelSource":"custom"} +{"type":"ai-title","sessionId":"11111111-1111-4111-8111-111111111111","aiTitle":"Verify sample file"} +{"type":"active-leaf","sessionId":"11111111-1111-4111-8111-111111111111","leafUuid":"a3","explicit":false,"timestamp":1791370805000} From f6221fb0eb596618056db1c70b25cabc63c26660 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 7 Oct 2026 10:33:41 -0400 Subject: [PATCH 2/4] fix(adapters): honor Qoder config dirs without home Signed-off-by: samzong --- src/adapters/qoder.rs | 51 +++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/adapters/qoder.rs b/src/adapters/qoder.rs index 5f4a850b..97267822 100644 --- a/src/adapters/qoder.rs +++ b/src/adapters/qoder.rs @@ -54,7 +54,7 @@ impl SourceAdapter for QoderAdapter { fn scan(&self) -> anyhow::Result> { let mut sessions = Vec::new(); - let QoderEntries { entries, parent_ids } = collect_entries(&config_dirs()?); + let QoderEntries { entries, parent_ids } = collect_entries(&config_dirs()); for entry in entries { let Some(before) = snapshot(&entry) else { continue }; let raw = parse_entry( @@ -76,7 +76,7 @@ impl SourceAdapter for QoderAdapter { since_ts: Option, include_events: bool, ) -> anyhow::Result> { - let QoderEntries { entries, parent_ids } = collect_entries(&config_dirs()?); + let QoderEntries { entries, parent_ids } = collect_entries(&config_dirs()); Ok(Some(file_scan::run_file_scan_with_options_and_snapshot( context, since_ts, @@ -100,18 +100,26 @@ fn native_command(id: &str) -> (&str, &str) { id.strip_prefix("cn:").map_or(("qodercli", id), |id| ("qoderclicn", id)) } -fn config_dirs() -> anyhow::Result> { - let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?; - Ok([ - (paths::env_path_dir("QODER_CONFIG_DIR").unwrap_or_else(|| home.join(".qoder")), ""), - ( - paths::env_path_dir("QODERCN_CONFIG_DIR").unwrap_or_else(|| home.join(".qoder-cn")), - "cn:", - ), +fn config_dirs() -> Vec<(PathBuf, &'static str)> { + resolve_config_dirs( + paths::env_path_dir("QODER_CONFIG_DIR"), + paths::env_path_dir("QODERCN_CONFIG_DIR"), + dirs::home_dir().as_deref(), + ) +} + +fn resolve_config_dirs( + qoder: Option, + qoder_cn: Option, + home: Option<&Path>, +) -> Vec<(PathBuf, &'static str)> { + [ + (qoder.or_else(|| home.map(|home| home.join(".qoder"))), ""), + (qoder_cn.or_else(|| home.map(|home| home.join(".qoder-cn"))), "cn:"), ] .into_iter() - .filter(|(dir, _)| dir.is_dir()) - .collect()) + .filter_map(|(dir, prefix)| dir.filter(|dir| dir.is_dir()).map(|dir| (dir, prefix))) + .collect() } fn collect_entries(dirs: &[(PathBuf, &str)]) -> QoderEntries { @@ -537,6 +545,25 @@ mod tests { .unwrap(); } + #[test] + fn resolves_explicit_config_dirs_without_home() { + let root = tempfile::tempdir().unwrap(); + let qoder = root.path().join("custom-qoder"); + let qoder_cn = root.path().join("custom-qoder-cn"); + fs::create_dir_all(&qoder).unwrap(); + fs::create_dir_all(&qoder_cn).unwrap(); + assert_eq!( + resolve_config_dirs(Some(qoder.clone()), Some(qoder_cn.clone()), None), + [(qoder.clone(), ""), (qoder_cn.clone(), "cn:")] + ); + assert_eq!(resolve_config_dirs(Some(qoder), None, None).len(), 1); + assert_eq!(resolve_config_dirs(None, Some(qoder_cn), None).len(), 1); + assert!(resolve_config_dirs(None, None, None).is_empty()); + let default = root.path().join(".qoder"); + fs::create_dir_all(&default).unwrap(); + assert_eq!(resolve_config_dirs(None, None, Some(root.path())), [(default, "")]); + } + #[test] fn parses_cli_envelope_messages_timestamps_and_tools() { let root = tempfile::tempdir().unwrap(); From 8d5672e1876100bfb288a48c9d1173cc43e31a15 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 7 Oct 2026 11:51:31 -0400 Subject: [PATCH 3/4] test(adapters): verify authenticated Qoder CLI sessions Signed-off-by: samzong --- src/adapters/qoder.rs | 89 +++++++++++++++++++++++++++- tests/fixtures/qoder/README.md | 35 +++++++++-- tests/fixtures/qoder/real-fork.jsonl | 33 +++++++++++ tests/fixtures/qoder/real-main.jsonl | 42 +++++++++++++ 4 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/qoder/real-fork.jsonl create mode 100644 tests/fixtures/qoder/real-main.jsonl diff --git a/src/adapters/qoder.rs b/src/adapters/qoder.rs index 97267822..c7ae1a63 100644 --- a/src/adapters/qoder.rs +++ b/src/adapters/qoder.rs @@ -533,11 +533,15 @@ mod tests { const FIXTURE: &str = include_str!("../../tests/fixtures/qoder/cli.jsonl"); fn fixture(root: &Path) -> FileScanEntry { + transcript_fixture(root, SESSION, FIXTURE) + } + + fn transcript_fixture(root: &Path, session_id: &str, transcript: &str) -> FileScanEntry { let path = - root.join("projects/-tmp-qoder-fixture-project").join(format!("{SESSION}.jsonl")); + root.join("projects/-tmp-qoder-fixture-project").join(format!("{session_id}.jsonl")); fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, FIXTURE).unwrap(); - FileScanEntry { session_id: SESSION.to_string(), stat_target: path, directory: None } + fs::write(&path, transcript).unwrap(); + FileScanEntry { session_id: session_id.to_string(), stat_target: path, directory: None } } fn write_records(entry: &FileScanEntry, records: &[Value]) { @@ -545,6 +549,85 @@ mod tests { .unwrap(); } + #[test] + fn parses_authenticated_main_and_resumed_session_without_hidden_usage() { + let root = tempfile::tempdir().unwrap(); + let entry = transcript_fixture( + root.path(), + "22222222-2222-4222-8222-222222222222", + include_str!("../../tests/fixtures/qoder/real-main.jsonl"), + ); + let raw = parse_transcript(&entry, 1791387600000, true, None).unwrap().unwrap(); + assert_eq!(raw.directory.as_deref(), Some("/tmp/qoder-real-fixture-project")); + assert_eq!(raw.custom_title.as_deref(), Some("Recall probe main")); + assert_eq!(raw.started_at, 1791387384288); + assert_eq!(raw.messages.len(), 6); + assert_eq!(raw.messages[0].role, Role::User); + assert_eq!(raw.messages[3].content, "The final line count is 3."); + assert_eq!(raw.messages[4].role, Role::User); + assert_eq!(raw.messages[5].role, Role::Assistant); + assert_eq!(raw.messages[5].content, "gamma"); + assert_eq!(raw.messages[5].timestamp, Some(1791387524399)); + assert!(raw.parent_links.is_empty()); + assert!(raw.usage_events.is_empty()); + assert_eq!(raw.events.len(), 10); + let calls: Vec<_> = raw.events.iter().filter(|event| event.kind != "tool_result").collect(); + let results: Vec<_> = + raw.events.iter().filter(|event| event.kind == "tool_result").collect(); + assert_eq!( + calls.iter().map(|event| event.name.as_deref()).collect::>(), + [Some("Read"), Some("Bash"), Some("Edit"), Some("Bash"), Some("Bash")] + ); + assert_eq!(calls[0].files[0].operation, FileOperation::Read); + assert_eq!(calls[2].files[0].operation, FileOperation::Write); + for call in [calls[0], calls[2]] { + assert_eq!(call.target.as_deref(), Some("/tmp/qoder-real-fixture-project/sample.txt")); + assert_eq!(call.files[0].cwd.as_deref(), Some("/tmp/qoder-real-fixture-project")); + } + assert_eq!(results.len(), calls.len()); + for (call, result) in calls.iter().zip(results) { + assert!(call.timestamp.is_some()); + assert_eq!(result.tool_call_id, call.tool_call_id); + assert_eq!(result.name, call.name); + assert_eq!(result.status, None); + assert_eq!(result.visibility, None); + } + } + + #[test] + fn parses_authenticated_fork_with_copied_history_and_parent_link() { + let root = tempfile::tempdir().unwrap(); + let main = transcript_fixture( + root.path(), + "22222222-2222-4222-8222-222222222222", + include_str!("../../tests/fixtures/qoder/real-main.jsonl"), + ); + let fork = transcript_fixture( + root.path(), + "33333333-3333-4333-8333-333333333333", + include_str!("../../tests/fixtures/qoder/real-fork.jsonl"), + ); + let main = parse_transcript(&main, 1791387600000, true, None).unwrap().unwrap(); + let fork = parse_transcript(&fork, 1791387600000, true, None).unwrap().unwrap(); + assert_eq!(fork.messages.len(), 8); + for (original, copied) in main.messages.iter().zip(&fork.messages) { + assert_eq!(copied.role, original.role); + assert_eq!(copied.content, original.content); + assert_eq!(copied.timestamp, original.timestamp); + } + assert_eq!(fork.custom_title.as_deref(), Some("Recall probe fork")); + assert_eq!(fork.parent_links.len(), 1); + assert_eq!(fork.parent_links[0].source_id, main.source_id); + assert_eq!(fork.parent_links[0].relation, ParentRelation::Fork); + assert_eq!(fork.messages[6].content, "Without using tools, answer FORK_OK only."); + assert_eq!(fork.messages[7].content, "The final line count in sample.txt is 3."); + assert_eq!(fork.messages[7].timestamp, Some(1791387585678)); + assert_eq!(fork.directory, main.directory); + assert_eq!(fork.started_at, main.started_at); + assert_eq!(fork.events.len(), main.events.len()); + assert!(fork.usage_events.is_empty()); + } + #[test] fn resolves_explicit_config_dirs_without_home() { let root = tempfile::tempdir().unwrap(); diff --git a/tests/fixtures/qoder/README.md b/tests/fixtures/qoder/README.md index 4fed9eaf..4b7835ba 100644 --- a/tests/fixtures/qoder/README.md +++ b/tests/fixtures/qoder/README.md @@ -1,4 +1,29 @@ -# Qoder CLI parser fixture +# Qoder CLI parser fixtures + +`real-main.jsonl` and `real-fork.jsonl` were captured from authenticated Qoder +CLI 1.1.65 on macOS 27.0.1 arm64 on 2026-10-07, using Qwen3.8-Flash in an +isolated HOME and a temporary project containing only `sample.txt`. +Three short tasks read the file, ran `wc -l sample.txt`, appended `gamma`, +resumed the main session to ask which line was appended, and forked the session. +The file changed from `alpha` / `beta` to `alpha` / `beta` / `gamma`. + +The main transcript contains six visible messages and five tool calls with five +results. Resume returned `gamma`. The fork copies those messages and tools, +adds two visible messages, and records `forkedFrom` pointing to the main session. +The fork request asked for `FORK_OK`; the actual assistant instead answered +`The final line count in sample.txt is 3.`. Both the request and actual answer +are preserved. Native-provider input, output, cache-read, and cache-write counts +were all zero; these real fixtures must not generate usage events. + +Sanitization keeps probe messages, tool payloads/results, timestamps, inline +titles, ancestry, and zero token counters. Session IDs, record UUIDs, model +response IDs, and tool-call IDs are replaced consistently; project paths become +`/tmp/qoder-real-fixture-project`. Reasoning text is redacted. Runtime +configuration, file-history snapshots, account and billing metadata, request +identifiers, and other unrelated records are omitted. No credentials or original +login conversation are included. The parser tests exercise the sanitized real +envelopes, copied fork history, resume messages, tool result linkage, and hidden +usage behavior. `cli.jsonl` is synthetic, not captured from an authenticated Qoder session. It contains no credentials or private conversation data. @@ -22,7 +47,7 @@ then the latest message cwd; directory-priority tests follow these records. No `-session.json` or `fork_from` writer exists in this bundle; only transcript metadata is supported. -Installation on macOS succeeded, but print mode, model discovery, and the -interactive startup all required vendor authentication. Qoder itself recognized this synthetic transcript through `--list-sessions`. -No vendor account was registered or used. Replace or supplement this fixture with a sanitized real -session once authorized credentials are available. +Positive token values, cache splitting, compaction/rewind behavior, subagent +names, and worktree/relocation priority remain synthetic or bundle-derived +regression coverage. The authenticated real captures do not establish those +numeric or runtime behaviors, China-edition behavior, or IDE storage formats. diff --git a/tests/fixtures/qoder/real-fork.jsonl b/tests/fixtures/qoder/real-fork.jsonl new file mode 100644 index 00000000..8e3ca2d1 --- /dev/null +++ b/tests/fixtures/qoder/real-fork.jsonl @@ -0,0 +1,33 @@ +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:24.288Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-1","parentUuid":null,"cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-1"},"message":{"role":"user","content":"Use Read to read sample.txt, use Bash to run wc -l sample.txt, then use Edit to append exactly one line gamma to sample.txt. Only work on this file. Do not delegate. Report the final line count in one short sentence."}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.058Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-2","parentUuid":"record-1","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-2"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-1"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.058Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-3","parentUuid":"record-2","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-3"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"text","text":"I'll read sample.txt and get its line count.\n\n"}],"id":"response-1"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.058Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-4","parentUuid":"record-3","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-4"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"tool_use","id":"call-1","name":"Read","input":{"file_path":"/tmp/qoder-real-fixture-project/sample.txt"}}],"id":"response-1"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.058Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-5","parentUuid":"record-4","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-5"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"call-2","name":"Bash","input":{"command":"wc -l /tmp/qoder-real-fixture-project/sample.txt","description":"Count lines in sample.txt"}}],"id":"response-1","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.161Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-6","parentUuid":"record-4","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-6"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-1","content":"1\talpha\n2\tbeta\n3\t"}]}} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.261Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-7","parentUuid":"record-5","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-7"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-2","content":"2 /tmp/qoder-real-fixture-project/sample.txt"}]}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:32.381Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-8","parentUuid":"record-7","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-8"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-2"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:32.381Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-9","parentUuid":"record-8","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-9"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"text","text":"The file currently has 2 lines (alpha, beta). Appending \"gamma\" now.\n\n"}],"id":"response-2"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:32.381Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-10","parentUuid":"record-9","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-10"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"call-3","name":"Edit","input":{"file_path":"/tmp/qoder-real-fixture-project/sample.txt","new_string":"beta\ngamma","old_string":"beta"}}],"id":"response-2","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:32.495Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-11","parentUuid":"record-10","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-11"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-3","content":"Successfully modified file: /tmp/qoder-real-fixture-project/sample.txt (1 replacements). The file state is current in your context; no need to read it back."}]}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:35.226Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-12","parentUuid":"record-11","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-12"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-3"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:35.226Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-13","parentUuid":"record-12","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-13"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"call-4","name":"Bash","input":{"command":"wc -l sample.txt","description":"Count lines in sample.txt"}}],"id":"response-3","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:35.330Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-14","parentUuid":"record-13","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-14"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-4","content":"3 sample.txt"}]}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:41.722Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-15","parentUuid":"record-14","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-15"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-4"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:41.722Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-16","parentUuid":"record-15","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-16"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"call-5","name":"Bash","input":{"command":"wc -l sample.txt","description":"Verify final line count","dir_path":"/tmp/qoder-real-fixture-project"}}],"id":"response-4","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:41.827Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-17","parentUuid":"record-16","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-17"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-5","content":"3 sample.txt"}]}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:45.875Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-18","parentUuid":"record-17","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-18"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-5"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:45.875Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-19","parentUuid":"record-18","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-19"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"end_turn","stop_sequence":null,"content":[{"type":"text","text":"The final line count is 3."}],"id":"response-5","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:38:41.316Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-20","parentUuid":"record-19","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-20"},"message":{"role":"user","content":"Without using tools, what line did you append to sample.txt in this session? Answer with that word only."}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:38:44.399Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-21","parentUuid":"record-20","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-21"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-6"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:38:44.399Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-22","parentUuid":"record-21","cwd":"/tmp/qoder-real-fixture-project","forkedFrom":{"sessionId":"22222222-2222-4222-8222-222222222222","messageUuid":"record-22"},"message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"end_turn","stop_sequence":null,"content":[{"type":"text","text":"gamma"}],"id":"response-6","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"relocated","sessionId":"33333333-3333-4333-8333-333333333333","relocatedCwd":"/tmp/qoder-real-fixture-project"} +{"type":"worktree-state","sessionId":"33333333-3333-4333-8333-333333333333","worktreeSession":null} +{"type":"worktree-state","sessionId":"33333333-3333-4333-8333-333333333333","worktreeSession":null} +{"type":"custom-title","sessionId":"33333333-3333-4333-8333-333333333333","customTitle":"Recall probe fork"} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:39:41.767Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-23","parentUuid":"record-22","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"user","content":"Without using tools, answer FORK_OK only."}} +{"type":"active-leaf","timestamp":1791387581767,"explicit":false,"sessionId":"33333333-3333-4333-8333-333333333333","leafUuid":"record-23"} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:39:45.678Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-24","parentUuid":"record-23","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-7"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:39:45.678Z","sessionId":"33333333-3333-4333-8333-333333333333","uuid":"record-25","parentUuid":"record-24","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"end_turn","stop_sequence":null,"content":[{"type":"text","text":"The final line count in sample.txt is 3."}],"id":"response-7","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"active-leaf","timestamp":1791387585678,"explicit":false,"sessionId":"33333333-3333-4333-8333-333333333333","leafUuid":"record-25"} +{"type":"custom-title","sessionId":"33333333-3333-4333-8333-333333333333","customTitle":"Recall probe fork"} +{"type":"active-leaf","timestamp":1791387585678,"explicit":false,"sessionId":"33333333-3333-4333-8333-333333333333","leafUuid":"record-25"} diff --git a/tests/fixtures/qoder/real-main.jsonl b/tests/fixtures/qoder/real-main.jsonl new file mode 100644 index 00000000..a093f3cf --- /dev/null +++ b/tests/fixtures/qoder/real-main.jsonl @@ -0,0 +1,42 @@ +{"type":"custom-title","sessionId":"22222222-2222-4222-8222-222222222222","customTitle":"Recall probe main"} +{"type":"worktree-state","sessionId":"22222222-2222-4222-8222-222222222222","worktreeSession":null} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:24.288Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-1","parentUuid":null,"cwd":"/tmp/qoder-real-fixture-project","message":{"role":"user","content":"Use Read to read sample.txt, use Bash to run wc -l sample.txt, then use Edit to append exactly one line gamma to sample.txt. Only work on this file. Do not delegate. Report the final line count in one short sentence."}} +{"type":"active-leaf","timestamp":1791387384391,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-1"} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.058Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-2","parentUuid":"record-1","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-1"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.058Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-3","parentUuid":"record-2","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"text","text":"I'll read sample.txt and get its line count.\n\n"}],"id":"response-1"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.058Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-4","parentUuid":"record-3","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"tool_use","id":"call-1","name":"Read","input":{"file_path":"/tmp/qoder-real-fixture-project/sample.txt"}}],"id":"response-1"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.058Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-5","parentUuid":"record-4","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"call-2","name":"Bash","input":{"command":"wc -l /tmp/qoder-real-fixture-project/sample.txt","description":"Count lines in sample.txt"}}],"id":"response-1","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"active-leaf","timestamp":1791387389058,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-5"} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.161Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-6","parentUuid":"record-4","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-1","content":"1\talpha\n2\tbeta\n3\t"}]}} +{"type":"active-leaf","timestamp":1791387389161,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-6"} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:29.261Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-7","parentUuid":"record-5","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-2","content":"2 /tmp/qoder-real-fixture-project/sample.txt"}]}} +{"type":"active-leaf","timestamp":1791387389261,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-7"} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:32.381Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-8","parentUuid":"record-7","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-2"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:32.381Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-9","parentUuid":"record-8","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"text","text":"The file currently has 2 lines (alpha, beta). Appending \"gamma\" now.\n\n"}],"id":"response-2"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:32.381Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-10","parentUuid":"record-9","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"call-3","name":"Edit","input":{"file_path":"/tmp/qoder-real-fixture-project/sample.txt","new_string":"beta\ngamma","old_string":"beta"}}],"id":"response-2","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"active-leaf","timestamp":1791387392381,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-10"} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:32.495Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-11","parentUuid":"record-10","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-3","content":"Successfully modified file: /tmp/qoder-real-fixture-project/sample.txt (1 replacements). The file state is current in your context; no need to read it back."}]}} +{"type":"active-leaf","timestamp":1791387392495,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-11"} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:35.226Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-12","parentUuid":"record-11","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-3"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:35.226Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-13","parentUuid":"record-12","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"call-4","name":"Bash","input":{"command":"wc -l sample.txt","description":"Count lines in sample.txt"}}],"id":"response-3","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"active-leaf","timestamp":1791387395226,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-13"} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:35.330Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-14","parentUuid":"record-13","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-4","content":"3 sample.txt"}]}} +{"type":"active-leaf","timestamp":1791387395330,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-14"} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:41.722Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-15","parentUuid":"record-14","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-4"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:41.722Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-16","parentUuid":"record-15","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"call-5","name":"Bash","input":{"command":"wc -l sample.txt","description":"Verify final line count","dir_path":"/tmp/qoder-real-fixture-project"}}],"id":"response-4","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"active-leaf","timestamp":1791387401722,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-16"} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:41.827Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-17","parentUuid":"record-16","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-5","content":"3 sample.txt"}]}} +{"type":"active-leaf","timestamp":1791387401827,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-17"} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:45.875Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-18","parentUuid":"record-17","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-5"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:36:45.875Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-19","parentUuid":"record-18","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"end_turn","stop_sequence":null,"content":[{"type":"text","text":"The final line count is 3."}],"id":"response-5","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"active-leaf","timestamp":1791387405875,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-19"} +{"type":"custom-title","sessionId":"22222222-2222-4222-8222-222222222222","customTitle":"Recall probe main"} +{"type":"active-leaf","timestamp":1791387405875,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-19"} +{"type":"worktree-state","sessionId":"22222222-2222-4222-8222-222222222222","worktreeSession":null} +{"type":"user","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:38:41.316Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-20","parentUuid":"record-19","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"user","content":"Without using tools, what line did you append to sample.txt in this session? Answer with that word only."}} +{"type":"active-leaf","timestamp":1791387521316,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-20"} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:38:44.399Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-21","parentUuid":"record-20","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":null,"stop_sequence":null,"content":[{"type":"thinking","thinking":"redacted"}],"id":"response-6"}} +{"type":"assistant","isSidechain":false,"userType":"external","entrypoint":"cli","version":"1.1.65","timestamp":"2026-10-07T15:38:44.399Z","sessionId":"22222222-2222-4222-8222-222222222222","uuid":"record-22","parentUuid":"record-21","cwd":"/tmp/qoder-real-fixture-project","message":{"role":"assistant","type":"message","model":"qfmodel","stop_reason":"end_turn","stop_sequence":null,"content":[{"type":"text","text":"gamma"}],"id":"response-6","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}} +{"type":"active-leaf","timestamp":1791387524399,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-22"} +{"type":"custom-title","sessionId":"22222222-2222-4222-8222-222222222222","customTitle":"Recall probe main"} +{"type":"active-leaf","timestamp":1791387524399,"explicit":false,"sessionId":"22222222-2222-4222-8222-222222222222","leafUuid":"record-22"} From 53ecde86d1a1de0d860a2db72435a5f6306c88ed Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 7 Oct 2026 12:19:52 -0400 Subject: [PATCH 4/4] refactor(adapters): simplify Qoder session parsing Signed-off-by: samzong --- src/adapters/qoder.rs | 143 +++++++++++++++--------------------------- src/usage.rs | 54 ++++++++-------- 2 files changed, 76 insertions(+), 121 deletions(-) diff --git a/src/adapters/qoder.rs b/src/adapters/qoder.rs index c7ae1a63..7d5bfbe6 100644 --- a/src/adapters/qoder.rs +++ b/src/adapters/qoder.rs @@ -25,11 +25,6 @@ const METADATA_PARSER_VERSION: u32 = 3; pub(crate) struct QoderAdapter; -struct QoderEntries { - entries: Vec, - parent_ids: HashMap, -} - impl SourceAdapter for QoderAdapter { fn id(&self) -> &str { "qoder" @@ -54,15 +49,9 @@ impl SourceAdapter for QoderAdapter { fn scan(&self) -> anyhow::Result> { let mut sessions = Vec::new(); - let QoderEntries { entries, parent_ids } = collect_entries(&config_dirs()); - for entry in entries { + for entry in collect_entries(&config_dirs()) { let Some(before) = snapshot(&entry) else { continue }; - let raw = parse_entry( - entry.clone(), - before.effective_mtime_ms(), - true, - parent_ids.get(&entry.session_id).map(String::as_str), - )?; + let raw = parse_entry(entry.clone(), before.effective_mtime_ms(), true)?; if snapshot(&entry).as_ref() == Some(&before) { sessions.extend(raw); } @@ -76,7 +65,6 @@ impl SourceAdapter for QoderAdapter { since_ts: Option, include_events: bool, ) -> anyhow::Result> { - let QoderEntries { entries, parent_ids } = collect_entries(&config_dirs()); Ok(Some(file_scan::run_file_scan_with_options_and_snapshot( context, since_ts, @@ -86,12 +74,9 @@ impl SourceAdapter for QoderAdapter { event_parser_version: include_events.then_some(EVENT_PARSER_VERSION), metadata_parser_version: Some(METADATA_PARSER_VERSION), }, - entries, + collect_entries(&config_dirs()), snapshot, - |entry, mtime| { - let parent = parent_ids.get(&entry.session_id).map(String::as_str); - parse_entry(entry, mtime, include_events, parent) - }, + |entry, mtime| parse_entry(entry, mtime, include_events), )?)) } } @@ -122,9 +107,8 @@ fn resolve_config_dirs( .collect() } -fn collect_entries(dirs: &[(PathBuf, &str)]) -> QoderEntries { +fn collect_entries(dirs: &[(PathBuf, &str)]) -> Vec { let mut entries = Vec::new(); - let mut parent_ids = HashMap::new(); for (dir, prefix) in dirs { let Ok(projects) = fs::read_dir(dir.join("projects")) else { continue }; for project in projects.flatten() { @@ -149,10 +133,8 @@ fn collect_entries(dirs: &[(PathBuf, &str)]) -> QoderEntries { for file in subagents.flatten() { let path = file.path(); if let Some(id) = transcript_id(&path, true) { - let session_id = format!("{prefix}{parent}:{id}"); - parent_ids.insert(session_id.clone(), format!("{prefix}{parent}")); entries.push(FileScanEntry { - session_id, + session_id: format!("{prefix}{parent}:{id}"), stat_target: path, directory: None, }); @@ -165,7 +147,7 @@ fn collect_entries(dirs: &[(PathBuf, &str)]) -> QoderEntries { entries.sort_by(|a, b| a.stat_target.cmp(&b.stat_target)); let mut claimed = HashSet::new(); entries.retain(|entry| claimed.insert(entry.session_id.clone())); - QoderEntries { entries, parent_ids } + entries } fn valid_id(id: &str) -> bool { @@ -191,9 +173,8 @@ fn parse_entry( entry: FileScanEntry, mtime: i64, include_events: bool, - parent_id: Option<&str>, ) -> anyhow::Result> { - match parse_transcript(&entry, mtime, include_events, parent_id) { + match parse_transcript(&entry, mtime, include_events) { Ok(session) => Ok(session), Err(error) => { warn!("failed to parse {}: {error}", entry.stat_target.display()); @@ -206,9 +187,13 @@ fn parse_transcript( entry: &FileScanEntry, mtime: i64, include_events: bool, - parent_id: Option<&str>, ) -> anyhow::Result> { let path = &entry.stat_target; + let (prefix, local_id) = entry + .session_id + .strip_prefix("cn:") + .map_or(("", entry.session_id.as_str()), |id| ("cn:", id)); + let parent_id = local_id.split_once(':').map(|(parent, _)| format!("{prefix}{parent}")); let subagent = parent_id.is_some(); let source_path = path.to_string_lossy().to_string(); let mut directory = None; @@ -222,7 +207,6 @@ fn parse_transcript( let mut events = Vec::new(); let mut usage = Vec::::new(); let mut usage_index = HashMap::::new(); - let mut seen = HashSet::new(); let mut calls = HashMap::new(); let mut parents = HashMap::new(); let mut event_uuids = Vec::new(); @@ -272,7 +256,7 @@ fn parse_transcript( } let uuid = nonempty(record.get("uuid")); if let Some(id) = &uuid { - if !seen.insert(id.clone()) { + if parents.contains_key(id) { continue; } parents.insert( @@ -322,7 +306,6 @@ fn parse_transcript( usage.push(event); } } - let event_cwd = nonempty(record.get("cwd")).or_else(|| directory.clone()); if include_events && !hidden && let Some(items) = content.and_then(Value::as_array) @@ -352,27 +335,22 @@ fn parse_transcript( calls.insert(id.clone(), name.clone()); } let operation = match name.as_str() { - "Read" => Some(FileOperation::Read), + "Read" => Some((FileOperation::Read, "file_read")), "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => { - Some(FileOperation::Write) + Some((FileOperation::Write, "file_write")) } _ => None, }; - if let Some(operation) = operation + if let Some((operation, kind)) = operation && let Some(file) = nonempty(item.pointer("/input/file_path")) .or_else(|| nonempty(item.pointer("/input/notebook_path"))) { event.target = Some(file.clone()); - event.kind = if operation == FileOperation::Read { - "file_read" - } else { - "file_write" - } - .to_string(); + event.kind = kind.to_string(); event.files.push(FileEvidence::call( file, operation, - event_cwd.clone(), + directory.clone(), )); } if matches!(name.as_str(), "Bash" | "PowerShell") @@ -380,7 +358,7 @@ fn parse_transcript( item.pointer("/input/command").and_then(Value::as_str) { let (files, status) = if name == "Bash" { - events::shell_file_evidence(command, event_cwd.as_deref()) + events::shell_file_evidence(command, directory.as_deref()) } else { (Vec::new(), CommandEvidenceStatus::Unsupported) }; @@ -416,19 +394,15 @@ fn parse_transcript( } if rewound { let mut active = HashSet::new(); - let mut complete = true; - while let Some(id) = active_leaf { - if !active.insert(id.clone()) { - complete = false; - break; + let complete = loop { + let Some(id) = active_leaf else { break !active.is_empty() }; + let Some(parent) = parents.get(&id) else { break false }; + if !active.insert(id) { + break false; } - let Some(parent) = parents.get(&id) else { - complete = false; - break; - }; active_leaf = parent.clone(); - } - if complete && !active.is_empty() { + }; + if complete { for (event, uuid) in events.iter_mut().zip(event_uuids) { if uuid.is_some_and(|id| !active.contains(&id)) { event.visibility = Some(EvidenceVisibility::Inactive); @@ -455,10 +429,9 @@ fn parse_transcript( session.parent_links.push(ParentLink { relation: ParentRelation::Spawn, source: "qoder".to_string(), - source_id: parent.to_string(), + source_id: parent, }); } else if let Some(parent) = fork_parent { - let prefix = if entry.session_id.starts_with("cn:") { "cn:" } else { "" }; session.parent_links.push(ParentLink { relation: ParentRelation::Fork, source: "qoder".to_string(), @@ -557,7 +530,7 @@ mod tests { "22222222-2222-4222-8222-222222222222", include_str!("../../tests/fixtures/qoder/real-main.jsonl"), ); - let raw = parse_transcript(&entry, 1791387600000, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 1791387600000, true).unwrap().unwrap(); assert_eq!(raw.directory.as_deref(), Some("/tmp/qoder-real-fixture-project")); assert_eq!(raw.custom_title.as_deref(), Some("Recall probe main")); assert_eq!(raw.started_at, 1791387384288); @@ -607,8 +580,8 @@ mod tests { "33333333-3333-4333-8333-333333333333", include_str!("../../tests/fixtures/qoder/real-fork.jsonl"), ); - let main = parse_transcript(&main, 1791387600000, true, None).unwrap().unwrap(); - let fork = parse_transcript(&fork, 1791387600000, true, None).unwrap().unwrap(); + let main = parse_transcript(&main, 1791387600000, true).unwrap().unwrap(); + let fork = parse_transcript(&fork, 1791387600000, true).unwrap().unwrap(); assert_eq!(fork.messages.len(), 8); for (original, copied) in main.messages.iter().zip(&fork.messages) { assert_eq!(copied.role, original.role); @@ -651,7 +624,7 @@ mod tests { fn parses_cli_envelope_messages_timestamps_and_tools() { let root = tempfile::tempdir().unwrap(); let entry = fixture(root.path()); - let raw = parse_transcript(&entry, 1791370810000, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 1791370810000, true).unwrap().unwrap(); assert_eq!(raw.directory.as_deref(), Some("/tmp/qoder-fixture-project")); assert_eq!(raw.started_at, 1791370800000); assert_eq!(raw.updated_at, Some(1791370810000)); @@ -674,7 +647,7 @@ mod tests { assert_eq!(raw.usage_events.len(), 3); assert_eq!(raw.usage_events.iter().map(|u| u.input_tokens).sum::(), 480); assert!(raw.usage_events.iter().all(|u| u.provider == "unknown")); - let without_events = parse_transcript(&entry, 1791370810000, false, None).unwrap().unwrap(); + let without_events = parse_transcript(&entry, 1791370810000, false).unwrap().unwrap(); assert!(without_events.events.is_empty()); assert_eq!(without_events.event_parser_version, None); assert_eq!(without_events.messages.len(), raw.messages.len()); @@ -697,7 +670,7 @@ mod tests { json!({"type":"token-stats", "promptTokenCount":4000,"timestamp":1791370800000i64}), ], ); - let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 10, true).unwrap().unwrap(); assert_eq!( raw.messages.iter().map(|m| m.content.as_str()).collect::>(), ["describe file", "done"] @@ -713,7 +686,7 @@ mod tests { let first = json!({"type":"assistant","uuid":"one", "message":{"id":"response", "model":"fixture", "content":[{"type":"tool_use","name":"Read","id":"call","input":{"file_path":"sample.txt"}}]}}); let last = json!({"type":"assistant","uuid":"two", "message":{"id":"response", "model":"fixture", "content":[{"type":"text","text":"done"}], "usage":{"input_tokens":80,"output_tokens":12}}}); write_records(&entry, &[first.clone(), first, last.clone(), last]); - let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 10, true).unwrap().unwrap(); assert_eq!(raw.messages.len(), 1); assert_eq!(raw.events.len(), 1); assert_eq!(raw.usage_events.len(), 1); @@ -733,18 +706,10 @@ mod tests { fs::create_dir_all(child.parent().unwrap()).unwrap(); fs::write(&child, r#"{"type":"user","isSidechain":true,"sessionId":"11111111-1111-4111-8111-111111111111","message":{"content":"inspect file"}}"#).unwrap(); fs::write(entry.stat_target.parent().unwrap().join("agent-orphan.jsonl"), FIXTURE).unwrap(); - let QoderEntries { entries, parent_ids } = - collect_entries(&[(root.path().to_path_buf(), "cn:")]); + let entries = collect_entries(&[(root.path().to_path_buf(), "cn:")]); assert_eq!(entries.len(), 2); let child = entries.iter().find(|e| e.session_id.contains(":agent-")).unwrap(); - let raw = parse_transcript( - child, - 10, - true, - parent_ids.get(&child.session_id).map(String::as_str), - ) - .unwrap() - .unwrap(); + let raw = parse_transcript(child, 10, true).unwrap().unwrap(); assert_eq!(raw.messages[0].content, "inspect file"); assert_eq!(raw.thread_role, Some(ThreadRole::Subagent)); assert_eq!(raw.parent_links[0].source_id, format!("cn:{SESSION}")); @@ -767,18 +732,10 @@ mod tests { ] { fs::write(children.join(format!("{name}.jsonl")), r#"{"type":"user","isSidechain":true,"message":{"content":"plugin subagent result"}}"#).unwrap(); } - let QoderEntries { entries, parent_ids } = - collect_entries(&[(root.path().to_path_buf(), "cn:")]); + let entries = collect_entries(&[(root.path().to_path_buf(), "cn:")]); assert_eq!(entries.len(), 4); for child in entries.iter().filter(|entry| entry.session_id.contains(":agent-")) { - let raw = parse_transcript( - child, - 10, - true, - parent_ids.get(&child.session_id).map(String::as_str), - ) - .unwrap() - .unwrap(); + let raw = parse_transcript(child, 10, true).unwrap().unwrap(); assert_eq!(raw.messages[0].content, "plugin subagent result"); assert_eq!(raw.parent_links[0].source_id, format!("cn:{SESSION}")); assert_eq!(raw.parent_links[0].relation, ParentRelation::Spawn); @@ -796,26 +753,26 @@ mod tests { ]; write_records(&entry, &records); assert_eq!( - parse_transcript(&entry, 10, true, None).unwrap().unwrap().directory.as_deref(), + parse_transcript(&entry, 10, true).unwrap().unwrap().directory.as_deref(), Some("/tmp/latest") ); records .push(json!({"type":"relocated","sessionId":SESSION,"relocatedCwd":"/tmp/relocated"})); write_records(&entry, &records); assert_eq!( - parse_transcript(&entry, 10, true, None).unwrap().unwrap().directory.as_deref(), + parse_transcript(&entry, 10, true).unwrap().unwrap().directory.as_deref(), Some("/tmp/relocated") ); records.push(json!({"type":"worktree-state","sessionId":SESSION,"worktreeSession":{"worktreeCwd":"/tmp/worktree/subdir","worktreePath":"/tmp/worktree","originalCwd":"/tmp/restored"}})); write_records(&entry, &records); assert_eq!( - parse_transcript(&entry, 10, true, None).unwrap().unwrap().directory.as_deref(), + parse_transcript(&entry, 10, true).unwrap().unwrap().directory.as_deref(), Some("/tmp/worktree/subdir") ); records.push(json!({"type":"worktree-state","sessionId":SESSION,"worktreeSession":null})); write_records(&entry, &records); assert_eq!( - parse_transcript(&entry, 10, true, None).unwrap().unwrap().directory.as_deref(), + parse_transcript(&entry, 10, true).unwrap().unwrap().directory.as_deref(), Some("/tmp/restored") ); } @@ -831,7 +788,7 @@ mod tests { write_records(&entry, &records); for prefix in ["", "cn:"] { entry.session_id = format!("{prefix}{SESSION}"); - let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 10, true).unwrap().unwrap(); assert_eq!(raw.parent_links.len(), 1); assert_eq!(raw.parent_links[0].source_id, format!("{prefix}parent")); assert_eq!(raw.parent_links[0].relation, ParentRelation::Fork); @@ -844,9 +801,9 @@ mod tests { let root = tempfile::tempdir().unwrap(); let entry = fixture(root.path()); fs::write(&entry.stat_target, format!("invalid\n{FIXTURE}\n{{partial")).unwrap(); - let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 10, true).unwrap().unwrap(); assert_eq!(raw.messages.len(), 3); - assert!(collect_entries(&[(root.path().join("missing"), "")]).entries.is_empty()); + assert!(collect_entries(&[(root.path().join("missing"), "")]).is_empty()); } #[test] @@ -857,7 +814,7 @@ mod tests { FIXTURE.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); records.push(json!({"type":"active-leaf","sessionId":SESSION,"leafUuid":"u1","explicit":true,"rewound":true,"timestamp":1791370806000i64})); write_records(&entry, &records); - let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 10, true).unwrap().unwrap(); assert_eq!(raw.messages.len(), 3); assert!(raw.events.iter().all(|e| e.visibility == Some(EvidenceVisibility::Inactive))); } @@ -894,7 +851,7 @@ mod tests { json!({"type":"active-leaf","leafUuid":"u2","rewound":true}), ], ); - let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 10, true).unwrap().unwrap(); assert_eq!(raw.events.len(), 2); assert_eq!(raw.events[0].visibility, None); assert_eq!(raw.events[1].visibility, Some(EvidenceVisibility::Inactive)); @@ -911,7 +868,7 @@ mod tests { {"type":"tool_use","name":"NotebookEdit","id":"notebook","input":{"notebook_path":"sample.ipynb","new_source":"print(1)"}} ]}})], ); - let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 10, true).unwrap().unwrap(); assert_eq!(raw.events[0].kind, "command"); assert_eq!(raw.events[0].target.as_deref(), Some("Get-Content sample.txt")); assert_eq!(raw.events[0].command_evidence_status, Some(CommandEvidenceStatus::Unsupported)); @@ -932,7 +889,7 @@ mod tests { json!({"type":"assistant","cwd":"/tmp/new","message":{"content":[{"type":"tool_use","name":"Read","id":"read","input":{"file_path":"sample.txt"}}]}}), ], ); - let raw = parse_transcript(&entry, 10, true, None).unwrap().unwrap(); + let raw = parse_transcript(&entry, 10, true).unwrap().unwrap(); assert_eq!(raw.directory.as_deref(), Some("/tmp/new")); assert_eq!(raw.events[0].files[0].cwd.as_deref(), Some("/tmp/new")); } diff --git a/src/usage.rs b/src/usage.rs index dddb8dbe..1d34de1c 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -90,35 +90,33 @@ pub(crate) struct UsageDedup { impl UsageDedup { pub(crate) fn accept(&mut self, event: &UsageEventRecord) -> bool { - match event.source.as_str() { - "codex" => { - let key = format!( - "codex:token_count:{}:{}:{}:{}:{}:{}:{}:{}", - event.timestamp, - event.provider, - event.model, - event.input_tokens, - event.output_tokens, - event.cache_read_tokens, - event.cache_write_tokens, - event.reasoning_tokens - ); - self.codex_seen.insert(key) - } - "claude-code" - if event.event_key.starts_with("assistant:") - && !event.event_key.contains(":line:") => - { - self.claude_seen.insert(event.event_key.clone()) - } - "qoder" - if event.event_key.starts_with("assistant:") - && !event.event_key.starts_with("assistant:line:") => - { - self.qoder_seen.insert(event.event_key.clone()) - } - _ => true, + if event.source == "codex" { + let key = format!( + "codex:token_count:{}:{}:{}:{}:{}:{}:{}:{}", + event.timestamp, + event.provider, + event.model, + event.input_tokens, + event.output_tokens, + event.cache_read_tokens, + event.cache_write_tokens, + event.reasoning_tokens + ); + return self.codex_seen.insert(key); } + if event.source == "claude-code" + && event.event_key.starts_with("assistant:") + && !event.event_key.contains(":line:") + { + return self.claude_seen.insert(event.event_key.clone()); + } + if event.source == "qoder" + && event.event_key.starts_with("assistant:") + && !event.event_key.starts_with("assistant:line:") + { + return self.qoder_seen.insert(event.event_key.clone()); + } + true } }