diff --git a/README.md b/README.md index 1f8b6b0..530ba1b 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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -79,6 +80,8 @@ One index across every AI coding CLI. Sync once, search everywhere, resume right | JetBrains Junie | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Zed | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | +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 3ebf4c1..2b4fd17 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -31,6 +31,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; @@ -438,6 +439,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), @@ -494,6 +496,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 0000000..7d5bfbe --- /dev/null +++ b/src/adapters/qoder.rs @@ -0,0 +1,896 @@ +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; + +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(); + 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)?; + 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> { + 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), + }, + collect_entries(&config_dirs()), + snapshot, + |entry, mtime| parse_entry(entry, mtime, include_events), + )?)) + } +} + +fn native_command(id: &str) -> (&str, &str) { + id.strip_prefix("cn:").map_or(("qodercli", id), |id| ("qoderclicn", id)) +} + +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_map(|(dir, prefix)| dir.filter(|dir| dir.is_dir()).map(|dir| (dir, prefix))) + .collect() +} + +fn collect_entries(dirs: &[(PathBuf, &str)]) -> Vec { + let mut entries = Vec::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) { + entries.push(FileScanEntry { + session_id: format!("{prefix}{parent}:{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())); + entries +} + +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, +) -> anyhow::Result> { + match parse_transcript(&entry, mtime, include_events) { + 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, +) -> 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; + 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 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 parents.contains_key(id) { + 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); + } + } + 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, "file_read")), + "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => { + Some((FileOperation::Write, "file_write")) + } + _ => None, + }; + 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 = kind.to_string(); + event.files.push(FileEvidence::call( + file, + operation, + directory.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, directory.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 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; + } + active_leaf = parent.clone(); + }; + 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); + } + } + } + } + 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, + }); + } else if let Some(parent) = fork_parent { + 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 { + 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_id}.jsonl")); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, transcript).unwrap(); + FileScanEntry { session_id: session_id.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_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).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).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); + 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(); + 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(); + let entry = fixture(root.path()); + 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)); + 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).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).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).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 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).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 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).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).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).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).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).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).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).unwrap().unwrap(); + assert_eq!(raw.messages.len(), 3); + assert!(collect_entries(&[(root.path().join("missing"), "")]).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).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).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).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).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 c993ddd..1d34de1 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -85,6 +85,7 @@ impl TokenTotals { pub(crate) struct UsageDedup { codex_seen: HashSet, claude_seen: HashSet, + qoder_seen: HashSet, } impl UsageDedup { @@ -109,6 +110,12 @@ impl UsageDedup { { 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 } } @@ -361,4 +368,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 0000000..4b7835b --- /dev/null +++ b/tests/fixtures/qoder/README.md @@ -0,0 +1,53 @@ +# 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. + +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. + +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/cli.jsonl b/tests/fixtures/qoder/cli.jsonl new file mode 100644 index 0000000..8b0015a --- /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} diff --git a/tests/fixtures/qoder/real-fork.jsonl b/tests/fixtures/qoder/real-fork.jsonl new file mode 100644 index 0000000..8e3ca2d --- /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 0000000..a093f3c --- /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"}