From 4587f7bb5be998425ab115adfd8f6e5d06e61ddb Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:30:55 +0800 Subject: [PATCH] feat(sessions): import Qoder IDE session history Add Qoder (Alibaba's agentic IDE) as an external-history source using the existing imported-history pipeline: - reader discovers per-project transcripts at ~/.qoder/cache/projects//conversation-history//.jsonl (Anthropic-style content blocks, text-only), keyed by the composite / id since task dir names are truncated prefixes unique only per project - session metadata (title, timestamps, workspace) joined from the aicoding.questTaskListSnapshot key in Qoder's global state.vscdb, degrading gracefully when the app holds the db - best-effort trajectory recovery from per-launch logs: ACP tool_call events, SubAgentService registrations, ToolInvokeHandler/exthost invocations (terminal commands with cwd), FileChangeTracking edit events; attribution is content-first (workspace/cache paths) with an unambiguous-window fallback, and agent-tools spill files attach as read_file outputs - real edit diffs and per-session impact stats (files changed, +/- via line diff, touched files) from the chatEditingSessions snapshot store; store mtime folded into the discovery fingerprint (parser v2) - canonical mappings: run_in_terminal -> run_command_line, get_problems -> query_lsp, FileChangeTracking ops -> edit_file_by_replace - frontend registration: source descriptor, replay adapter, recent paths, source stats, kanban filter, model icon - turn duration label reads "<1min" instead of "0s" for imported transcripts that carry no timestamps --- .../src/sources/imported_history/metadata.rs | 2 + .../src/sources/imported_history/mod.rs | 7 + .../crates/orgtrack-core/src/sources/mod.rs | 1 + .../src/sources/qoder/history.rs | 779 +++++++++++++++ .../src/sources/qoder/history_tests.rs | 226 +++++ .../src/sources/qoder/log_enrichment.rs | 891 ++++++++++++++++++ .../src/sources/qoder/log_enrichment_tests.rs | 417 ++++++++ .../orgtrack-core/src/sources/qoder/mod.rs | 13 + .../session_directory/aggregation.rs | 16 +- src-tauri/src/commands/handler_list.inc | 2 + .../src/orgtrack/external_cli_detection.rs | 15 +- src-tauri/src/orgtrack/history_commands.rs | 27 + .../imported/__tests__/sources.test.ts | 2 + .../externalHistory/imported/descriptors.ts | 11 + .../tauri/externalHistory/imported/index.ts | 8 + src/api/tauri/externalHistory/index.ts | 1 + src/api/tauri/externalHistory/sourceStats.ts | 2 + .../externalHistory/sources/qoder/index.ts | 24 + src/assets/modelIcons/qoder.svg | 1 + src/components/ModelIcon/config.ts | 9 + .../__tests__/turnTimingFormatting.test.ts | 8 +- .../ChatHistory/utils/turnTimingFormatting.ts | 9 +- src/features/TaskKanban/config.ts | 2 + .../hooks/data/useExternalRecentPaths.ts | 4 + src/types/session/externalHistory.ts | 1 + 25 files changed, 2471 insertions(+), 7 deletions(-) create mode 100644 src-tauri/crates/orgtrack-core/src/sources/qoder/history.rs create mode 100644 src-tauri/crates/orgtrack-core/src/sources/qoder/history_tests.rs create mode 100644 src-tauri/crates/orgtrack-core/src/sources/qoder/log_enrichment.rs create mode 100644 src-tauri/crates/orgtrack-core/src/sources/qoder/log_enrichment_tests.rs create mode 100644 src-tauri/crates/orgtrack-core/src/sources/qoder/mod.rs create mode 100644 src/api/tauri/externalHistory/sources/qoder/index.ts create mode 100644 src/assets/modelIcons/qoder.svg diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/metadata.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/metadata.rs index ea3c77228..e43ceddd5 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/metadata.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/metadata.rs @@ -18,6 +18,7 @@ pub const SOURCE_TRAE: &str = "trae"; pub const SOURCE_CLINE: &str = "cline"; pub const SOURCE_WARP: &str = "warp"; pub const SOURCE_ZCODE: &str = "zcode"; +pub const SOURCE_QODER: &str = "qoder"; // Hook-only sources: ORGII installs a managed PostToolUse command hook for // these CLIs and records their file-interaction provenance, but does not yet // import their session transcripts. Kept out of `is_imported_history_source` @@ -40,6 +41,7 @@ pub fn is_imported_history_source(source: &str) -> bool { | SOURCE_CLINE | SOURCE_WARP | SOURCE_ZCODE + | SOURCE_QODER ) } diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs index cc81e3773..07ef0d0b0 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs @@ -40,6 +40,7 @@ enum ImportedHistoryLoader { Cline, Warp, ZCode, + Qoder, } fn imported_history_loader(session_id: &str) -> Option { @@ -63,6 +64,8 @@ fn imported_history_loader(session_id: &str) -> Option { Some(ImportedHistoryLoader::Warp) } else if session_id.starts_with(super::zcode::history::ZCODE_SESSION_PREFIX) { Some(ImportedHistoryLoader::ZCode) + } else if session_id.starts_with(super::qoder::history::QODER_SESSION_PREFIX) { + Some(ImportedHistoryLoader::Qoder) } else { None } @@ -112,6 +115,9 @@ pub fn load_activity_chunks_for_session( Some(ImportedHistoryLoader::ZCode) => { super::zcode::history::load_zcode_history_for_session(session_id)? } + Some(ImportedHistoryLoader::Qoder) => { + super::qoder::history::load_qoder_history_for_session(conn, session_id)? + } None => return Ok(None), }; Ok(Some(chunks)) @@ -653,6 +659,7 @@ mod impact_tests { ("clineapp-id", ImportedHistoryLoader::Cline), ("warpapp-id", ImportedHistoryLoader::Warp), ("zcodeapp-id", ImportedHistoryLoader::ZCode), + ("qoderapp-id", ImportedHistoryLoader::Qoder), ]; for (session_id, expected) in cases { diff --git a/src-tauri/crates/orgtrack-core/src/sources/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/mod.rs index 3e53cb776..59f1090e9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/mod.rs @@ -45,6 +45,7 @@ pub mod imported_history; pub mod opencode; pub mod orgii_cli; pub mod orgii_rust_agents; +pub mod qoder; pub mod trae; pub mod warp; pub mod windsurf; diff --git a/src-tauri/crates/orgtrack-core/src/sources/qoder/history.rs b/src-tauri/crates/orgtrack-core/src/sources/qoder/history.rs new file mode 100644 index 000000000..af219c7b5 --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/qoder/history.rs @@ -0,0 +1,779 @@ +//! Qoder imported-history reader +//! +//! Reads Qoder's per-project conversation-history store (see the module docs +//! for the on-disk layout) and converts each transcript into ORGII's canonical +//! `ActivityChunk` shape for read-only replay. The transcript lines carry +//! Anthropic-style content blocks, so tool calls and their results are paired +//! back together like the Cline reader does. +//! +//! Task directory names are only unique within one project cache dir (they are +//! truncated task-id prefixes), so the source session id is the composite +//! `/` — directory names cannot contain `/`, which makes +//! the split-back unambiguous. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use core_types::activity::ActivityChunk; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde::Deserialize; +use serde_json::Value; + +use crate::sources::imported_history::{ + self, cache as imported_cache, + metadata::{ + ImportedHistoryCacheInput, ImportedHistoryDiscoveredRecord, ImportedHistoryImpactStats, + ImportedHistoryRecordSignature, SOURCE_QODER, + }, + paths as imported_paths, ImportedHistoryRecentPath, ImportedHistorySessionPage, + ImportedHistorySessionRow, ImportedToolCall, +}; + +pub const QODER_SESSION_PREFIX: &str = "qoderapp-"; +const QODER_PROVIDER_SLUG: &str = "qoder"; +// Version 2 derives per-session file impact from the chat-editing snapshot +// store (the transcript itself carries no edit data). +const QODER_METADATA_PARSER_VERSION: i64 = 2; +const CONVERSATION_HISTORY_DIR: &str = "conversation-history"; +/// Global-storage key holding the quest task list (title/status/timestamps). +const QUEST_SNAPSHOT_KEY: &str = "aicoding.questTaskListSnapshot"; +/// Cap a single tool-result body so a runaway command output can't bloat the +/// cache/replay payload. The replay UI virtualizes long text anyway. +pub(super) const MAX_TOOL_OUTPUT_CHARS: usize = 50_000; + +pub type QoderHistorySessionRow = ImportedHistorySessionRow; +pub type QoderHistorySessionPage = ImportedHistorySessionPage; +pub type QoderRecentPath = ImportedHistoryRecentPath; + +#[derive(Debug, Clone)] +struct QoderHistoryMeta { + source_session_id: String, + session_id: String, + source_path: String, + source_record_key: String, + source_mtime_ms: i64, + source_size_bytes: i64, + source_fingerprint: String, + name: String, + created_at_ms: i64, + updated_at_ms: i64, + repo_path: Option, + impact: ImportedHistoryImpactStats, +} + +#[derive(Debug, Clone)] +struct QoderDiscoveredRecord { + record: ImportedHistoryDiscoveredRecord, + snapshot: Option, +} + +impl QoderDiscoveredRecord { + fn signature(&self) -> ImportedHistoryRecordSignature { + self.record.signature() + } +} + +/// One task entry from the `aicoding.questTaskListSnapshot` global-storage key. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct QoderQuestTask { + id: String, + name: String, + title: String, + query: String, + create_time: i64, + updated_at_timestamp: i64, + last_user_query_at: i64, + file_path: String, +} + +/// One transcript JSONL line: `{role, message:{content:[blocks]}}`. +#[derive(Debug, Default, Deserialize)] +struct QoderTranscriptLine { + #[serde(default)] + role: String, + #[serde(default)] + message: QoderTranscriptMessage, +} + +#[derive(Debug, Default, Deserialize)] +struct QoderTranscriptMessage { + #[serde(default)] + content: Value, +} + +pub fn list_qoder_history_sessions_paginated( + conn: &mut Connection, + limit: usize, + offset: usize, +) -> Result { + sync_qoder_history_cache(conn)?; + imported_cache::query_imported_session_page_from_conn(conn, SOURCE_QODER, limit, offset) +} + +pub fn list_qoder_recent_paths( + conn: &mut Connection, + limit: usize, +) -> Result, String> { + sync_qoder_history_cache(conn)?; + imported_cache::query_imported_recent_paths_from_conn(conn, SOURCE_QODER, limit) +} + +pub fn load_qoder_history_for_session( + conn: &Connection, + session_id: &str, +) -> Result, String> { + let source_session_id = qoder_source_id_from_session_id(session_id)?; + let path = resolve_qoder_transcript_path(conn, source_session_id)?; + let transcript = read_qoder_transcript(&path)?; + let chunks = transcript_to_chunks(session_id, &transcript); + // Best-effort: recover the tool trajectory from Qoder's per-launch logs + // (see the log_enrichment module docs for what survives there). The + // cached workspace path sharpens invoke attribution when available. + let (project_dir_name, task_dir_name) = source_session_id + .split_once('/') + .unwrap_or(("", source_session_id)); + let workspace_path = imported_cache::query_cached_session_from_conn( + conn, + SOURCE_QODER, + source_session_id, + ) + .ok() + .flatten() + .and_then(|cached| cached.repo_path); + Ok(super::log_enrichment::enrich_with_agent_log( + session_id, + task_dir_name, + project_dir_name, + workspace_path.as_deref(), + chunks, + )) +} + +fn sync_qoder_history_cache(conn: &mut Connection) -> Result<(), String> { + let discovered = discover_qoder_history_records()?; + let signatures = discovered + .iter() + .map(QoderDiscoveredRecord::signature) + .collect::>(); + let changed = + imported_cache::changed_records_from_conn(conn, SOURCE_QODER, &discovered, |record| { + record.signature() + })?; + let inputs = changed + .into_iter() + .map(|record| session_meta_to_cache_input(parse_qoder_session_meta(record))) + .collect(); + imported_cache::sync_source_cache_from_conn( + conn, + SOURCE_QODER, + imported_cache::live_ids_from_signatures(&signatures), + inputs, + ) +} + +fn discover_qoder_history_records() -> Result, String> { + let snapshot_tasks = read_quest_snapshot_tasks(); + let mut records = Vec::new(); + for projects_dir in qoder_projects_dirs()? { + records.extend(discover_records_in_projects_dir( + &projects_dir, + &snapshot_tasks, + )?); + } + Ok(records) +} + +fn discover_records_in_projects_dir( + projects_dir: &Path, + snapshot_tasks: &[QoderQuestTask], +) -> Result, String> { + let mut records = Vec::new(); + if !projects_dir.is_dir() { + return Ok(records); + } + let Ok(project_entries) = fs::read_dir(projects_dir) else { + return Ok(records); + }; + for project_entry in project_entries.flatten() { + let project_dir = project_entry.path(); + let Some(project_dir_name) = project_dir.file_name().and_then(|name| name.to_str()) + else { + continue; + }; + let history_dir = project_dir.join(CONVERSATION_HISTORY_DIR); + let Ok(task_entries) = fs::read_dir(&history_dir) else { + continue; + }; + for task_entry in task_entries.flatten() { + let task_dir = task_entry.path(); + let Some(task_dir_name) = task_dir.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let transcript_path = task_dir.join(format!("{task_dir_name}.jsonl")); + if !transcript_path.is_file() { + continue; + } + let (source_mtime_ms, source_size_bytes) = + imported_paths::file_metadata_signature(&transcript_path, "Qoder")?; + let snapshot = + match_snapshot_task(snapshot_tasks, project_dir_name, task_dir_name).cloned(); + let source_session_id = format!("{project_dir_name}/{task_dir_name}"); + // Fold in the edit-store signature so edits landing after a sync + // re-parse the session even when the transcript is unchanged. + let source_fingerprint = format!( + "{}|edits:{}", + snapshot + .as_ref() + .map(quest_task_fingerprint) + .unwrap_or_default(), + super::log_enrichment::edit_store_signature( + task_dir_name, + snapshot.as_ref().map(|task| task.id.as_str()), + ), + ); + records.push(QoderDiscoveredRecord { + record: ImportedHistoryDiscoveredRecord { + source_session_id: source_session_id.clone(), + source_path: transcript_path, + source_record_key: source_session_id, + source_mtime_ms, + source_size_bytes, + source_fingerprint, + parser_version: QODER_METADATA_PARSER_VERSION, + }, + snapshot, + }); + } + } + Ok(records) +} + +/// Match a conversation-history dir to its quest-snapshot entry. The task dir +/// name is a truncated prefix of the full quest id, and the project cache dir +/// is `-`, so require both to line up. +fn match_snapshot_task<'a>( + tasks: &'a [QoderQuestTask], + project_dir_name: &str, + task_dir_name: &str, +) -> Option<&'a QoderQuestTask> { + tasks.iter().find(|task| { + !task.id.is_empty() + && task.id.starts_with(task_dir_name) + && project_dir_matches_workspace(project_dir_name, &task.file_path) + }) +} + +fn project_dir_matches_workspace(project_dir_name: &str, workspace_path: &str) -> bool { + let Some(basename) = Path::new(workspace_path.trim()) + .file_name() + .and_then(|name| name.to_str()) + else { + return false; + }; + project_dir_name + .strip_prefix(basename) + .is_some_and(|rest| rest.starts_with('-')) +} + +/// Fields that feed name/timestamps/repo-path, so a snapshot-side change +/// (rename, new activity) re-parses the session even when the JSONL is +/// untouched. +fn quest_task_fingerprint(task: &QoderQuestTask) -> String { + format!( + "{}|{}|{}|{}|{}|{}|{}|{}", + task.id, + task.name, + task.title, + task.query, + task.create_time, + task.updated_at_timestamp, + task.last_user_query_at, + task.file_path, + ) +} + +fn parse_qoder_session_meta(discovered: &QoderDiscoveredRecord) -> QoderHistoryMeta { + let record = &discovered.record; + let snapshot = discovered.snapshot.as_ref(); + let transcript = read_qoder_transcript(&record.source_path).unwrap_or_default(); + + // The signature mtime is nanoseconds (see `file_metadata_signature`); + // scale it down where a real epoch-ms value is needed. + let mtime_ms = record.source_mtime_ms / 1_000_000; + let created_at_ms = snapshot + .map(|task| task.create_time) + .filter(|ms| *ms > 0) + .unwrap_or(mtime_ms); + let updated_at_ms = snapshot + .map(|task| task.updated_at_timestamp.max(task.last_user_query_at)) + .filter(|ms| *ms > 0) + .unwrap_or(mtime_ms); + + let name = snapshot + .and_then(|task| { + [&task.title, &task.name, &task.query] + .into_iter() + .map(|value| value.trim()) + .find(|value| !value.is_empty()) + .map(str::to_string) + }) + .or_else(|| first_user_text(&transcript)) + .map(|value| imported_history::truncate_name(&value, 200)) + .unwrap_or_else(|| record.source_session_id.clone()); + + let repo_path = snapshot + .map(|task| task.file_path.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let session_id = format!("{QODER_SESSION_PREFIX}{}", record.source_session_id); + // Edits never appear in the transcript, so the +/- stats come from the + // chat-editing snapshot store; the transcript-derived impact is kept as a + // fallback in case a future Qoder version starts persisting tool blocks. + let task_dir_name = record + .source_session_id + .split_once('/') + .map(|(_, task)| task) + .unwrap_or(record.source_session_id.as_str()); + let edit_impact = super::log_enrichment::session_edit_impact( + task_dir_name, + snapshot.map(|task| task.id.as_str()), + ); + let impact = if edit_impact.files_changed > 0 { + edit_impact + } else { + imported_history::impact_from_edit_chunks(&transcript_to_chunks(&session_id, &transcript)) + }; + + QoderHistoryMeta { + source_session_id: record.source_session_id.clone(), + session_id, + source_path: record.source_path.to_string_lossy().to_string(), + source_record_key: record.source_record_key.clone(), + source_mtime_ms: record.source_mtime_ms, + source_size_bytes: record.source_size_bytes, + source_fingerprint: record.source_fingerprint.clone(), + name, + created_at_ms, + updated_at_ms, + repo_path, + impact, + } +} + +fn session_meta_to_cache_input(meta: QoderHistoryMeta) -> ImportedHistoryCacheInput { + ImportedHistoryCacheInput { + source: SOURCE_QODER, + source_session_id: meta.source_session_id, + session_id: meta.session_id, + source_path: meta.source_path, + source_record_key: meta.source_record_key, + source_mtime_ms: meta.source_mtime_ms, + source_size_bytes: meta.source_size_bytes, + source_fingerprint: meta.source_fingerprint, + parser_version: QODER_METADATA_PARSER_VERSION, + name: meta.name, + created_at_ms: meta.created_at_ms, + updated_at_ms: meta.updated_at_ms, + // The transcript/snapshot carry no per-session model or token usage. + model: None, + input_tokens: 0, + output_tokens: 0, + repo_path: meta.repo_path, + branch: None, + impact: meta.impact, + listable: true, + source_metadata_json: None, + parent_session_id: None, + } +} + +fn transcript_to_chunks(session_id: &str, transcript: &[QoderTranscriptLine]) -> Vec { + // Pass 1: collect tool results so each `tool_use` can be paired with the + // matching `tool_result` regardless of which later line carried it. + let mut tool_outputs: HashMap = HashMap::new(); + let mut tool_failures: HashMap = HashMap::new(); + for line in transcript { + for block in content_blocks(&line.message.content) { + if block_type(block) == "tool_result" { + if let Some(id) = block + .get("tool_use_id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + { + tool_failures.insert( + id.to_string(), + block.get("is_error").and_then(Value::as_bool) == Some(true), + ); + tool_outputs.insert( + id.to_string(), + block.get("content").cloned().unwrap_or(Value::Null), + ); + } + } + } + } + + // Pass 2: emit chunks in transcript order. The lines carry no timestamps, + // so chunk `created_at` stays empty and replay falls back to sequence + // order. + let mut chunks = Vec::new(); + let mut sequence = 0usize; + for line in transcript { + let is_user = line.role == "user"; + for block in content_blocks(&line.message.content) { + match block_type(block) { + "text" => { + let raw = block.get("text").and_then(Value::as_str).unwrap_or(""); + let text = if is_user { + extract_user_query(raw) + } else { + raw.trim().to_string() + }; + if text.is_empty() { + continue; + } + if is_user { + chunks.push(imported_history::user_message_chunk( + session_id, + QODER_PROVIDER_SLUG, + sequence, + "", + &text, + )); + } else { + chunks.push(imported_history::assistant_message_chunk( + session_id, + QODER_PROVIDER_SLUG, + sequence, + "", + &text, + )); + } + sequence += 1; + } + "thinking" => { + let thought = block + .get("thinking") + .or_else(|| block.get("text")) + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if thought.is_empty() { + continue; + } + chunks.push(imported_history::thinking_chunk( + session_id, + QODER_PROVIDER_SLUG, + sequence, + "", + thought, + )); + sequence += 1; + } + "tool_use" => { + let call_id = block + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let raw_name = block + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string(); + let output = value_to_text(tool_outputs.get(&call_id)); + let call = ImportedToolCall { + call_id: call_id.clone(), + raw_name: raw_name.clone(), + // Qoder's tool vocabulary is not mapped yet; pass the + // raw name through so the replay renders a generic + // tool card instead of dropping the call. + canonical_name: raw_name, + args: block.get("input").cloned().unwrap_or(Value::Null), + created_at: String::new(), + }; + let mut chunk = imported_history::tool_call_chunk( + session_id, + QODER_PROVIDER_SLUG, + sequence, + &call, + &output, + ); + if tool_failures.get(&call_id).copied().unwrap_or_default() { + if let Some(result) = chunk.result.as_object_mut() { + result.insert("success".to_string(), Value::Bool(false)); + result + .insert("status".to_string(), Value::String("failed".to_string())); + } + } + chunks.push(chunk); + sequence += 1; + } + // `tool_result` blocks were consumed in pass 1. + _ => {} + } + } + } + + chunks +} + +/// Qoder wraps the typed prompt as `` behind +/// injected `` blocks (locale directives etc.). Unwrap to the +/// inner query; when the wrapper is absent, just drop the reminder blocks. +fn extract_user_query(text: &str) -> String { + let trimmed = text.trim(); + if let Some(start) = trimmed.find("") { + let after = &trimmed[start + "".len()..]; + let inner = after + .find("") + .map(|end| &after[..end]) + .unwrap_or(after); + return inner.trim().to_string(); + } + strip_system_reminders(trimmed) +} + +fn strip_system_reminders(text: &str) -> String { + let mut out = String::new(); + let mut rest = text; + while let Some(start) = rest.find("") { + out.push_str(&rest[..start]); + match rest[start..].find("") { + Some(end) => rest = &rest[start + end + "".len()..], + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + out.trim().to_string() +} + +fn read_qoder_transcript(path: &Path) -> Result, String> { + let raw = fs::read_to_string(path) + .map_err(|err| format!("Failed to open Qoder history {}: {err}", path.display()))?; + // Tolerate individual malformed lines (e.g. a torn tail write) instead of + // failing the whole transcript. + Ok(raw + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str(line).ok()) + .collect()) +} + +/// Content is normally an array of blocks; tolerate anything else as empty. +fn content_blocks(content: &Value) -> Vec<&Value> { + match content { + Value::Array(items) => items.iter().collect(), + _ => Vec::new(), + } +} + +fn block_type(block: &Value) -> &str { + block.get("type").and_then(Value::as_str).unwrap_or("") +} + +fn first_user_text(transcript: &[QoderTranscriptLine]) -> Option { + for line in transcript { + if line.role != "user" { + continue; + } + for block in content_blocks(&line.message.content) { + if block_type(block) == "text" { + let text = + extract_user_query(block.get("text").and_then(Value::as_str).unwrap_or("")); + if !text.is_empty() { + return Some(text); + } + } + } + } + None +} + +/// Flatten a `tool_result.content` value (string, array of blocks, or object) +/// into readable text, capped so a huge output can't bloat the payload. +fn value_to_text(value: Option<&Value>) -> String { + let mut out = String::new(); + if let Some(value) = value { + append_value_text(value, &mut out); + } + let out = out.trim(); + if out.chars().count() > MAX_TOOL_OUTPUT_CHARS { + let truncated: String = out.chars().take(MAX_TOOL_OUTPUT_CHARS).collect(); + format!("{truncated}\n… (truncated)") + } else { + out.to_string() + } +} + +fn append_value_text(value: &Value, out: &mut String) { + match value { + Value::String(text) => push_line(out, text), + Value::Array(items) => { + for item in items { + append_value_text(item, out); + } + } + Value::Object(map) => { + if let Some(Value::String(text)) = map.get("text") { + push_line(out, text); + } else if let Some(Value::String(text)) = map.get("result") { + push_line(out, text); + } else { + push_line(out, &value.to_string()); + } + } + Value::Null => {} + other => push_line(out, &other.to_string()), + } +} + +fn push_line(out: &mut String, text: &str) { + let text = text.trim(); + if text.is_empty() { + return; + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(text); +} + +/// Best-effort read of the quest task list from Qoder's global `state.vscdb`. +/// The running app may hold the database, so any failure degrades to "no +/// enrichment" instead of failing discovery. +fn read_quest_snapshot_tasks() -> Vec { + qoder_global_state_db_candidates() + .into_iter() + .filter(|path| path.is_file()) + .find_map(|path| read_quest_snapshot_tasks_from_db(&path).ok()) + .unwrap_or_default() +} + +fn read_quest_snapshot_tasks_from_db(path: &Path) -> Result, String> { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|err| format!("Failed to open Qoder state db {}: {err}", path.display()))?; + let raw = conn + .query_row( + "SELECT value FROM ItemTable WHERE key = ?1", + [QUEST_SNAPSHOT_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|err| format!("Failed to read Qoder quest snapshot: {err}"))?; + Ok(raw + .as_deref() + .map(parse_quest_snapshot_tasks) + .unwrap_or_default()) +} + +/// Snapshot shape: `{version, updatedAt, folders: {: {tasks: [task…]}}}`. +fn parse_quest_snapshot_tasks(raw: &str) -> Vec { + let Ok(value) = serde_json::from_str::(raw) else { + return Vec::new(); + }; + let Some(folders) = value.get("folders").and_then(Value::as_object) else { + return Vec::new(); + }; + folders + .values() + .filter_map(|folder| folder.get("tasks").and_then(Value::as_array)) + .flatten() + .filter_map(|task| serde_json::from_value(task.clone()).ok()) + .collect() +} + +fn qoder_source_id_from_session_id(session_id: &str) -> Result<&str, String> { + let Some(rest) = session_id.strip_prefix(QODER_SESSION_PREFIX) else { + return Err(format!("Invalid Qoder history session id: {session_id}")); + }; + if rest.is_empty() { + return Err("Qoder history session id is missing its source id".to_string()); + } + Ok(rest) +} + +fn resolve_qoder_transcript_path( + conn: &Connection, + source_session_id: &str, +) -> Result { + if let Some(path) = + imported_cache::get_cached_source_path_from_conn(conn, SOURCE_QODER, source_session_id)? + { + let path = PathBuf::from(path); + if path.is_file() { + return Ok(path); + } + } + // Directory names cannot contain '/', so the composite splits cleanly. + if let Some((project_dir_name, task_dir_name)) = source_session_id.split_once('/') { + for projects_dir in qoder_projects_dirs()? { + let candidate = projects_dir + .join(project_dir_name) + .join(CONVERSATION_HISTORY_DIR) + .join(task_dir_name) + .join(format!("{task_dir_name}.jsonl")); + if candidate.is_file() { + return Ok(candidate); + } + } + } + Err(format!( + "Qoder history file not found for session: {source_session_id}" + )) +} + +/// Existing-store probe locations for the Data Sources inventory. +pub fn qoder_history_candidate_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.extend(qoder_projects_dir_candidates(&home)); + } + paths.extend(qoder_global_state_db_candidates()); + imported_paths::dedupe_paths(paths) +} + +fn qoder_projects_dirs() -> Result, String> { + let home = dirs::home_dir().ok_or_else(|| "Home directory not found".to_string())?; + Ok(qoder_projects_dir_candidates(&home)) +} + +/// `~/.qoder/cache/projects` — the per-project conversation-history root. +fn qoder_projects_dir_candidates(home: &Path) -> Vec { + vec![home.join(".qoder").join("cache").join("projects")] +} + +/// VS Code-family per-user data root: `Qoder/User/globalStorage/state.vscdb`. +fn qoder_global_state_db_candidates() -> Vec { + let mut roots = Vec::new(); + if let Some(data) = dirs::data_dir() { + roots.push(data); + } + if let Some(config) = dirs::config_dir() { + roots.push(config); + } + roots.sort(); + roots.dedup(); + roots + .into_iter() + .map(|root| { + root.join("Qoder") + .join("User") + .join("globalStorage") + .join("state.vscdb") + }) + .collect() +} + +#[cfg(test)] +#[path = "history_tests.rs"] +mod tests; diff --git a/src-tauri/crates/orgtrack-core/src/sources/qoder/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/qoder/history_tests.rs new file mode 100644 index 000000000..cc512bf88 --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/qoder/history_tests.rs @@ -0,0 +1,226 @@ +use super::*; + +#[test] +fn extracts_user_query_and_strips_reminders() { + // The observed wrapping: reminder block(s) first, then the typed prompt. + assert_eq!( + extract_user_query( + "\n[IMPORTANT] You must always respond in en-us.\n\n\n\n\n\nspawn a subagent to investigate RAM usage\n" + ), + "spawn a subagent to investigate RAM usage" + ); + // No wrapper → reminder blocks are dropped, the rest kept. + assert_eq!( + extract_user_query("respond in en-usplain ask"), + "plain ask" + ); + assert_eq!(extract_user_query(" plain text "), "plain text"); + // Unterminated query tag → inner text after the opening tag. + assert_eq!(extract_user_query("unterminated"), "unterminated"); +} + +#[test] +fn parses_quest_snapshot_tasks_from_folder_map() { + let raw = r#"{ + "version": 1, + "updatedAt": 1784202191335, + "folders": { + "__virtual__": { + "updatedAt": 1784202191335, + "tasks": [{ + "id": "task-3a297cb30e744f1baf72", + "name": "Spawn RAM investigation agent", + "title": "Spawn RAM investigation agent", + "query": "Spawn RAM investigation agent", + "status": "Completed", + "createTime": 1784202123440, + "updatedAtTimestamp": 1784202191140, + "lastUserQueryAt": 0, + "filePath": "/Users/u/Documents/Qoder/2026-07-16/chat-1" + }] + } + } + }"#; + + let tasks = parse_quest_snapshot_tasks(raw); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].id, "task-3a297cb30e744f1baf72"); + assert_eq!(tasks[0].title, "Spawn RAM investigation agent"); + assert_eq!(tasks[0].create_time, 1784202123440); + assert_eq!(tasks[0].updated_at_timestamp, 1784202191140); + assert_eq!(tasks[0].file_path, "/Users/u/Documents/Qoder/2026-07-16/chat-1"); + + assert!(parse_quest_snapshot_tasks("not json").is_empty()); + assert!(parse_quest_snapshot_tasks("{}").is_empty()); +} + +#[test] +fn matches_snapshot_task_by_id_prefix_and_workspace_basename() { + let task = QoderQuestTask { + id: "task-3a297cb30e744f1baf72".to_string(), + file_path: "/Users/u/Documents/Qoder/2026-07-16/chat-1".to_string(), + ..Default::default() + }; + let tasks = vec![task]; + + // Dir `chat-1-fdad7ab4` = `-`; task dir + // `task-3a2` = truncated id prefix. + assert!(match_snapshot_task(&tasks, "chat-1-fdad7ab4", "task-3a2").is_some()); + // Wrong workspace basename → no match even though the id prefix fits. + assert!(match_snapshot_task(&tasks, "other-proj-fdad7ab4", "task-3a2").is_none()); + // Wrong task prefix → no match. + assert!(match_snapshot_task(&tasks, "chat-1-fdad7ab4", "task-9ff").is_none()); + // A basename that only partially overlaps must not match (`chat-1` vs `chat-10`). + assert!(!project_dir_matches_workspace("chat-10-fdad7ab4", "/w/chat-1")); + assert!(!project_dir_matches_workspace("chat-1-fdad7ab4", "")); +} + +#[test] +fn source_id_round_trips_through_prefix() { + let sid = format!("{QODER_SESSION_PREFIX}chat-1-fdad7ab4/task-3a2"); + assert_eq!( + qoder_source_id_from_session_id(&sid).unwrap(), + "chat-1-fdad7ab4/task-3a2" + ); + assert!(qoder_source_id_from_session_id("bogus").is_err()); + assert!(qoder_source_id_from_session_id(QODER_SESSION_PREFIX).is_err()); +} + +#[test] +fn projects_dir_candidate_points_at_qoder_store() { + let home = std::path::Path::new("/home/u"); + assert_eq!( + qoder_projects_dir_candidates(home), + vec![home.join(".qoder").join("cache").join("projects")] + ); +} + +#[test] +fn transcript_to_chunks_unwraps_query_and_pairs_tools() { + let lines: Vec = [ + r#"{"role":"user","message":{"content":[{"type":"text","text":"respond in en-uscheck RAM"}]}}"#, + r#"{"role":"assistant","message":{"content":[{"type":"thinking","thinking":"look at vm_stat"},{"type":"tool_use","id":"call_1","name":"run_command","input":{"command":"vm_stat"}}]}}"#, + r#"{"role":"user","message":{"content":[{"type":"tool_result","tool_use_id":"call_1","content":[{"type":"text","text":"Pages free: 100"}]}]}}"#, + r#"{"role":"assistant","message":{"content":[{"type":"text","text":"RAM looks fine."}]}}"#, + ] + .into_iter() + .map(|line| serde_json::from_str(line).expect("parses")) + .collect(); + + let chunks = transcript_to_chunks("qoderapp-p/t", &lines); + // user text + thinking + tool_use + assistant text = 4 chunks; the + // tool_result block is consumed as the tool's output. + assert_eq!(chunks.len(), 4); + + assert_eq!(chunks[0].function, imported_history::FUNCTION_USER_MESSAGE); + assert_eq!(chunks[0].result["message"]["content"], "check RAM"); + + assert_eq!(chunks[1].action_type, imported_history::ACTION_TYPE_THINKING); + assert_eq!(chunks[1].result["thought"], "look at vm_stat"); + + let tool = &chunks[2]; + assert_eq!(tool.action_type, imported_history::ACTION_TYPE_TOOL_CALL); + assert_eq!(tool.function, "run_command"); + assert_eq!(tool.args["command"], "vm_stat"); + assert_eq!(tool.result["output"], "Pages free: 100"); + + assert_eq!(chunks[3].result["content"], "RAM looks fine."); +} + +#[test] +fn failed_tool_result_marks_chunk_failed() { + let lines: Vec = [ + r#"{"role":"assistant","message":{"content":[{"type":"tool_use","id":"call_1","name":"run_command","input":{"command":"boom"}}]}}"#, + r#"{"role":"user","message":{"content":[{"type":"tool_result","tool_use_id":"call_1","is_error":true,"content":"exploded"}]}}"#, + ] + .into_iter() + .map(|line| serde_json::from_str(line).expect("parses")) + .collect(); + + let chunks = transcript_to_chunks("qoderapp-p/t", &lines); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].result["success"], false); + assert_eq!(chunks[0].result["status"], "failed"); + assert_eq!(chunks[0].result["output"], "exploded"); +} + +#[test] +fn discovers_composite_ids_and_snapshot_metadata_from_fixture() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let projects_dir = std::env::temp_dir().join(format!("orgii-qoder-{unique}")); + let task_dir = projects_dir + .join("chat-1-fdad7ab4") + .join(CONVERSATION_HISTORY_DIR) + .join("task-3a2"); + std::fs::create_dir_all(&task_dir).expect("create task dir"); + std::fs::write( + task_dir.join("task-3a2.jsonl"), + concat!( + r#"{"role":"user","message":{"content":[{"type":"text","text":"check RAM"}]}}"#, + "\n", + r#"{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}"#, + "\n", + ), + ) + .expect("write transcript"); + + let snapshot = vec![QoderQuestTask { + id: "task-3a297cb30e744f1baf72".to_string(), + title: "Spawn RAM investigation agent".to_string(), + create_time: 1784202123440, + updated_at_timestamp: 1784202191140, + file_path: "/Users/u/Documents/Qoder/2026-07-16/chat-1".to_string(), + ..Default::default() + }]; + + let records = + discover_records_in_projects_dir(&projects_dir, &snapshot).expect("discover records"); + assert_eq!(records.len(), 1); + let input = session_meta_to_cache_input(parse_qoder_session_meta(&records[0])); + + assert_eq!(input.source_session_id, "chat-1-fdad7ab4/task-3a2"); + assert_eq!(input.session_id, "qoderapp-chat-1-fdad7ab4/task-3a2"); + assert_eq!(input.name, "Spawn RAM investigation agent"); + assert_eq!(input.created_at_ms, 1784202123440); + assert_eq!(input.updated_at_ms, 1784202191140); + assert_eq!( + input.repo_path.as_deref(), + Some("/Users/u/Documents/Qoder/2026-07-16/chat-1") + ); + + std::fs::remove_dir_all(&projects_dir).expect("remove temp dir"); +} + +#[test] +fn unmatched_transcript_falls_back_to_first_user_text_and_mtime() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let projects_dir = std::env::temp_dir().join(format!("orgii-qoder-fb-{unique}")); + let task_dir = projects_dir + .join("proj-aaaa1111") + .join(CONVERSATION_HISTORY_DIR) + .join("task-9ff"); + std::fs::create_dir_all(&task_dir).expect("create task dir"); + std::fs::write( + task_dir.join("task-9ff.jsonl"), + r#"{"role":"user","message":{"content":[{"type":"text","text":"fix the login bug"}]}}"#, + ) + .expect("write transcript"); + + let records = discover_records_in_projects_dir(&projects_dir, &[]).expect("discover records"); + assert_eq!(records.len(), 1); + let input = session_meta_to_cache_input(parse_qoder_session_meta(&records[0])); + + assert_eq!(input.name, "fix the login bug"); + assert!(input.repo_path.is_none()); + // Dates fall back to the file mtime (the signature carries nanoseconds). + assert_eq!(input.created_at_ms, records[0].record.source_mtime_ms / 1_000_000); + assert_eq!(input.updated_at_ms, input.created_at_ms); + + std::fs::remove_dir_all(&projects_dir).expect("remove temp dir"); +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/qoder/log_enrichment.rs b/src-tauri/crates/orgtrack-core/src/sources/qoder/log_enrichment.rs new file mode 100644 index 000000000..25be6dcfc --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/qoder/log_enrichment.rs @@ -0,0 +1,891 @@ +//! Best-effort tool-trajectory enrichment from Qoder's per-launch logs. +//! +//! Qoder's durable transcript (`conversation-history/*.jsonl`) carries only +//! user/assistant text — tool calls stream live over ACP and are never written +//! to a unified store. What survives on disk, per app launch: +//! +//! - `questWindow/agent.log` — ACP `tool_call` events (id + timestamp, no +//! payload), `SubAgentService` registrations (subagent type, prompt, +//! parent session id), and `ToolInvokeHandlerContribution` lines carrying +//! tool name + args for locally-dispatched tools (`read_file`, …). +//! - `questWindow/exthost/output_logging_*/1-Qoder.log` — `ToolInvoke : +//! ` lines followed by the args JSON on the next line (terminal +//! commands with their `cwd`, …). +//! - `cache/projects//agent-tools//.txt` — spill files +//! holding oversized tool outputs; the agent reads them back via +//! `read_file`, which is how their content re-enters the trajectory. +//! +//! Only events that carry real payload are emitted (subagent spawns and tool +//! invocations with args) — bare ACP `tool_call` ids alone render as empty +//! cards and are used here solely for activity windows and call-id pairing. +//! +//! Invoke lines carry no session id, so attribution is by **content first** +//! (args paths inside the session's workspace or project cache dir), falling +//! back to the session's activity window when the paths say nothing — and +//! dropped when several sessions' windows overlap the timestamp. Logs rotate +//! per launch, so old sessions simply get no enrichment. Any failure degrades +//! to the unenriched transcript, never an error. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{Local, NaiveDateTime, TimeZone}; +use core_types::activity::ActivityChunk; +use serde_json::{json, Value}; + +use crate::sources::imported_history::{ + self, metadata::ImportedHistoryImpactStats, ImportedToolCall, +}; + +use super::history::MAX_TOOL_OUTPUT_CHARS; + +const ACP_PROGRESS_MARKER: &str = "[ChatSessionService] ACP progress: "; +const SUBAGENT_MARKER: &str = "[SubAgentService] Registered SubAgent: "; +const TOOL_INVOKE_MARKER: &str = "[ToolInvokeHandlerContribution] Tool invoke request: "; +const EXTHOST_INVOKE_MARKER: &str = " ToolInvoke : "; +const FILE_CHANGE_MARKER: &str = "[FileChangeTracking] "; +const SESSION_ID_SUFFIX: &str = ".session.execution"; +/// Pad around a session's first/last ACP event when window-attributing +/// invoke lines with no content signal. +const WINDOW_PAD_MS: i64 = 2_000; +/// An invoke usually trails its ACP `tool_call` event by well under a second; +/// pair them within this window to recover the call id. +const CALL_ID_PAIR_MS: i64 = 2_500; + +#[derive(Debug, Clone)] +enum LogEvent { + /// Any ACP progress line; `tool_call_id` set only for `type=tool_call`. + Acp { + ts_ms: i64, + session_task_id: String, + tool_call_id: Option, + }, + /// `Registered SubAgent: {parentToolCallId, parentSessionId, agentType, …}` + Subagent { + ts_ms: i64, + session_task_id: String, + tool_call_id: String, + agent_type: String, + description: String, + prompt: String, + }, + /// A tool invocation with name + args — carries NO session id. + ToolInvoke { ts_ms: i64, name: String, args: Value }, + /// `[FileChangeTracking] | source=agent | session=, … | Agent ` — + /// an agent file edit, carrying the session as the truncated dir name. + FileEdit { + ts_ms: i64, + session_dir_name: String, + path: String, + operation: String, + }, +} + +/// Which session an invoke's args point at, judged purely by its paths. +#[derive(Debug, PartialEq)] +enum ContentSignal { + Ours, + Theirs, + Silent, +} + +/// Enrich a session's text-only chunks with the tool trajectory recovered +/// from Qoder's launch logs. `task_dir_name`/`project_dir_name` are the +/// conversation-history composite id halves; `workspace_path` is the +/// session's workspace when known (used for exact cwd attribution). +pub(super) fn enrich_with_agent_log( + session_id: &str, + task_dir_name: &str, + project_dir_name: &str, + workspace_path: Option<&str>, + chunks: Vec, +) -> Vec { + let mut events = Vec::new(); + for log_path in qoder_launch_log_paths() { + if let Ok(content) = fs::read_to_string(&log_path) { + parse_launch_log(&content, &mut events); + } + } + enrich_chunks_with_events( + session_id, + task_dir_name, + project_dir_name, + workspace_path, + chunks, + &events, + &edit_snapshots_for_task, + ) +} + +fn enrich_chunks_with_events( + session_id: &str, + task_dir_name: &str, + project_dir_name: &str, + workspace_path: Option<&str>, + chunks: Vec, + events: &[LogEvent], + edit_snapshots: &dyn Fn(&str) -> HashMap, +) -> Vec { + // Resolve the truncated dir name to the full task id seen in the logs. + // Two distinct matches would mean we cannot tell the sessions apart — + // back off rather than guess. + let mut matched_task_id: Option<&str> = None; + for event in events { + let candidate = match event { + LogEvent::Acp { + session_task_id, .. + } + | LogEvent::Subagent { + session_task_id, .. + } => session_task_id, + // Invoke lines carry no id; FileEdit lines carry only the + // truncated dir name, which cannot disambiguate a prefix clash. + LogEvent::ToolInvoke { .. } | LogEvent::FileEdit { .. } => continue, + }; + if !candidate.starts_with(task_dir_name) { + continue; + } + match matched_task_id { + None => matched_task_id = Some(candidate), + Some(existing) if existing == candidate => {} + Some(_) => return chunks, + } + } + let Some(task_id) = matched_task_id else { + return chunks; + }; + + // Activity window per session, for invoke lines whose paths say nothing. + let mut windows: HashMap<&str, (i64, i64)> = HashMap::new(); + for event in events { + if let LogEvent::Acp { + ts_ms, + session_task_id, + .. + } = event + { + windows + .entry(session_task_id.as_str()) + .and_modify(|(lo, hi)| { + *lo = (*lo).min(*ts_ms); + *hi = (*hi).max(*ts_ms); + }) + .or_insert((*ts_ms, *ts_ms)); + } + } + let our_window = windows + .get(task_id) + .map(|(lo, hi)| (lo - WINDOW_PAD_MS, hi + WINDOW_PAD_MS)); + let other_windows: Vec<(i64, i64)> = windows + .iter() + .filter(|(sid, _)| **sid != task_id) + .map(|(_, (lo, hi))| (lo - WINDOW_PAD_MS, hi + WINDOW_PAD_MS)) + .collect(); + + // Our ACP tool_call ids, kept only to pair invokes back to a call id and + // to anchor subagent cards — never emitted bare (an id alone renders as an + // empty card). + let mut our_acp_calls: Vec<(i64, &str)> = events + .iter() + .filter_map(|event| match event { + LogEvent::Acp { + ts_ms, + session_task_id, + tool_call_id: Some(id), + } if session_task_id == task_id => Some((*ts_ms, id.as_str())), + _ => None, + }) + .collect(); + our_acp_calls.sort_by_key(|(ts, _)| *ts); + + #[derive(Debug)] + struct PendingTool { + ts_ms: i64, + call_id: String, + name: String, + args: Value, + output: String, + } + let mut pending: Vec = Vec::new(); + + for event in events { + match event { + LogEvent::Subagent { + ts_ms, + session_task_id, + tool_call_id, + agent_type, + description, + prompt, + } if session_task_id == task_id => { + pending.push(PendingTool { + ts_ms: *ts_ms, + call_id: tool_call_id.clone(), + name: "subagent".to_string(), + args: json!({ + "agentType": agent_type, + "description": description, + "prompt": prompt, + }), + output: String::new(), + }); + } + LogEvent::ToolInvoke { ts_ms, name, args } => { + let owned = match invoke_content_signal(args, project_dir_name, workspace_path) { + ContentSignal::Ours => true, + ContentSignal::Theirs => false, + ContentSignal::Silent => { + // No path signal: fall back to the activity window, + // requiring it to be unambiguous. + our_window.is_some_and(|(lo, hi)| *ts_ms >= lo && *ts_ms <= hi) + && !other_windows + .iter() + .any(|(lo, hi)| *ts_ms >= *lo && *ts_ms <= *hi) + } + }; + if !owned { + continue; + } + let call_id = paired_call_id(&our_acp_calls, *ts_ms) + .unwrap_or_else(|| format!("invoke-{ts_ms}")); + pending.push(PendingTool { + ts_ms: *ts_ms, + call_id, + name: name.clone(), + args: args.clone(), + output: spill_file_output(args), + }); + } + LogEvent::FileEdit { + ts_ms, + session_dir_name, + path, + operation, + } if session_dir_name == task_dir_name => { + // The tracking line carries the session directly, but no + // content — the card still renders as a typed edit of the + // file. The diff body is not recoverable from any local store. + let call_id = paired_call_id(&our_acp_calls, *ts_ms) + .unwrap_or_else(|| format!("edit-{ts_ms}")); + pending.push(PendingTool { + ts_ms: *ts_ms, + call_id, + name: format!("file_{operation}"), + args: json!({ "file_path": path, "operation": operation }), + output: String::new(), + }); + } + _ => {} + } + } + + if pending.is_empty() { + return chunks; + } + pending.sort_by_key(|tool| tool.ts_ms); + // The same invocation can be logged by both the workbench and the exthost; + // collapse near-simultaneous duplicates. + pending.dedup_by(|a, b| { + a.name == b.name && a.args == b.args && (a.ts_ms - b.ts_ms).abs() <= 2_000 + }); + + // Attach real diff bodies from the chat-editing snapshot store. The store + // keeps one original→current pair per file spanning the whole session, so + // it lands on the file's LAST edit card; earlier edits of the same file + // stay operation markers. + if pending.iter().any(|tool| tool.name.starts_with("file_")) { + let snapshots = edit_snapshots(task_id); + let mut attached: std::collections::HashSet = std::collections::HashSet::new(); + for tool in pending.iter_mut().rev() { + if !tool.name.starts_with("file_") { + continue; + } + let Some(path) = tool.args.get("file_path").and_then(Value::as_str) else { + continue; + }; + if attached.contains(path) { + continue; + } + if let Some((old_content, new_content)) = snapshots.get(path) { + attached.insert(path.to_string()); + if let Some(map) = tool.args.as_object_mut() { + map.insert("old_string".to_string(), json!(old_content)); + map.insert("new_string".to_string(), json!(new_content)); + } + } + } + } + + let mut tool_chunks = Vec::with_capacity(pending.len()); + for (index, tool) in pending.iter().enumerate() { + let call = ImportedToolCall { + call_id: tool.call_id.clone(), + raw_name: tool.name.clone(), + canonical_name: canonical_tool_name(&tool.name), + args: normalized_args(&tool.name, &tool.args), + created_at: imported_history::epoch_ms_to_iso(tool.ts_ms), + }; + let mut chunk = imported_history::tool_call_chunk( + session_id, + "qoder-log", + index, + &call, + &tool.output, + ); + if let Some(result) = chunk.result.as_object_mut() { + // Flag the provenance so consumers can tell recovered trajectory + // from the durable transcript. + result.insert("recovered_from".to_string(), json!("agent_log")); + } + tool_chunks.push(chunk); + } + + // Insert after the last user message: quests are single-user-turn in the + // normal case, and the recovered activity belongs to the agent's work + // phase that follows it. Multi-turn sessions get the whole trajectory in + // the final turn — a documented best-effort placement. + let insert_at = chunks + .iter() + .rposition(|chunk| chunk.function == imported_history::FUNCTION_USER_MESSAGE) + .map(|position| position + 1) + .unwrap_or(0); + let mut enriched = chunks; + enriched.splice(insert_at..insert_at, tool_chunks); + enriched +} + +/// Judge which session an invoke belongs to from the paths in its args. +/// `file_path` under a project cache dir or `cwd` inside the workspace are +/// decisive; paths that name a *different* project cache dir disown it. +fn invoke_content_signal( + args: &Value, + project_dir_name: &str, + workspace_path: Option<&str>, +) -> ContentSignal { + let candidates = ["file_path", "cwd", "path"] + .iter() + .filter_map(|key| args.get(*key).and_then(Value::as_str)); + let our_cache_dir = format!("/cache/projects/{project_dir_name}/"); + let mut signal = ContentSignal::Silent; + for path in candidates { + if path.contains(&our_cache_dir) { + return ContentSignal::Ours; + } + if let Some(workspace) = workspace_path { + let workspace = workspace.trim_end_matches('/'); + if !workspace.is_empty() + && (path == workspace || path.starts_with(&format!("{workspace}/"))) + { + return ContentSignal::Ours; + } + } + if path.contains("/cache/projects/") { + signal = ContentSignal::Theirs; + } + } + signal +} + +/// Most recent ACP `tool_call` id preceding `ts_ms` within the pairing window. +fn paired_call_id(our_acp_calls: &[(i64, &str)], ts_ms: i64) -> Option { + our_acp_calls + .iter() + .rev() + .find(|(acp_ts, _)| *acp_ts <= ts_ms && ts_ms - acp_ts <= CALL_ID_PAIR_MS) + .map(|(_, id)| (*id).to_string()) +} + +/// Map Qoder tool names onto the canonical functions the replay UI has typed +/// cards for; unknown names pass through as generic cards. +fn canonical_tool_name(name: &str) -> String { + match name { + "read_file" => imported_history::FUNCTION_READ_FILE.to_string(), + "run_in_terminal" => imported_history::FUNCTION_RUN_COMMAND_LINE.to_string(), + // Qoder's diagnostics probe ↔ ORGII's LSP query card. + "get_problems" => "query_lsp".to_string(), + // Our own synthesis of FileChangeTracking ops (file_create, file_edit, …). + name if name.starts_with("file_") => imported_history::FUNCTION_EDIT_FILE.to_string(), + other => other.to_string(), + } +} + +/// Reshape args into the keys the frontend extractors read. +fn normalized_args(name: &str, args: &Value) -> Value { + if name == "run_in_terminal" { + if let Some(command) = args.get("command") { + let mut merged = args.clone(); + if let Some(map) = merged.as_object_mut() { + map.insert("cmd".to_string(), command.clone()); + } + return merged; + } + } + if name == "get_problems" { + // `{"filePaths": [...], "file_paths": [...]}` → surface the first + // path under the key the file extractors read. + let first_path = ["filePaths", "file_paths"] + .iter() + .filter_map(|key| args.get(*key).and_then(Value::as_array)) + .flat_map(|paths| paths.iter()) + .find_map(Value::as_str); + if let Some(path) = first_path { + let mut merged = args.clone(); + if let Some(map) = merged.as_object_mut() { + map.insert("file_path".to_string(), json!(path)); + } + return merged; + } + } + args.clone() +} + +/// When a `read_file` targets an `agent-tools` spill file, its content is the +/// missing tool OUTPUT — attach it (capped). Other paths are live workspace +/// files that may have changed since; leave those empty. +fn spill_file_output(args: &Value) -> String { + let Some(path) = args.get("file_path").and_then(Value::as_str) else { + return String::new(); + }; + if !path.contains("/agent-tools/") { + return String::new(); + } + let Ok(content) = fs::read_to_string(Path::new(path)) else { + return String::new(); + }; + if content.chars().count() > MAX_TOOL_OUTPUT_CHARS { + let truncated: String = content.chars().take(MAX_TOOL_OUTPUT_CHARS).collect(); + format!("{truncated}\n… (truncated)") + } else { + content.trim_end().to_string() + } +} + +/// Parse one launch log (agent.log or an exthost output log — the markers are +/// disjoint, so one parser handles both). +fn parse_launch_log(content: &str, events: &mut Vec) { + let mut lines = content.lines().peekable(); + while let Some(line) = lines.next() { + let Some(ts_ms) = parse_line_timestamp_ms(line) else { + continue; + }; + if let Some(rest) = substring_after(line, ACP_PROGRESS_MARKER) { + // `, rid=, type=[, toolCallId=]` + let mut session_task_id = String::new(); + let mut event_type = ""; + let mut tool_call_id = ""; + for (index, part) in rest.split(", ").enumerate() { + if index == 0 { + session_task_id = part + .trim() + .trim_end_matches(SESSION_ID_SUFFIX) + .to_string(); + } else if let Some(value) = part.trim().strip_prefix("type=") { + event_type = value; + } else if let Some(value) = part.trim().strip_prefix("toolCallId=") { + tool_call_id = value; + } + } + if session_task_id.is_empty() { + continue; + } + let tool_call_id = (event_type == "tool_call" && !tool_call_id.is_empty()) + .then(|| tool_call_id.to_string()); + events.push(LogEvent::Acp { + ts_ms, + session_task_id, + tool_call_id, + }); + } else if let Some(rest) = substring_after(line, SUBAGENT_MARKER) { + let Ok(payload) = serde_json::from_str::(rest.trim()) else { + continue; + }; + let field = |key: &str| { + payload + .get(key) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + let session_task_id = field("parentSessionId") + .trim_end_matches(SESSION_ID_SUFFIX) + .to_string(); + let tool_call_id = field("parentToolCallId"); + if session_task_id.is_empty() || tool_call_id.is_empty() { + continue; + } + events.push(LogEvent::Subagent { + ts_ms, + session_task_id, + tool_call_id, + agent_type: field("agentType"), + description: field("rawInputDescription"), + prompt: field("prompt"), + }); + } else if let Some(rest) = substring_after(line, TOOL_INVOKE_MARKER) { + // agent.log: `, , {args json}` — args may contain + // ", " so split only the first two fields. + let Some((_rid, rest)) = rest.split_once(", ") else { + continue; + }; + let Some((name, args_raw)) = rest.split_once(", ") else { + continue; + }; + push_invoke(events, ts_ms, name, args_raw); + } else if let Some(rest) = substring_after(line, FILE_CHANGE_MARKER) { + // ` | source=agent | session=, request= | Agent ` + // (the marker also logs a pipe-less "Agent file tracked:" shape — + // the parts count filters that out). + let parts: Vec<&str> = rest.split(" | ").collect(); + if parts.len() < 4 || !parts.contains(&"source=agent") { + continue; + } + let path = parts[0].trim(); + let session_dir_name = parts + .iter() + .flat_map(|part| part.split(", ")) + .find_map(|field| field.trim().strip_prefix("session=")); + let operation = parts + .last() + .and_then(|part| part.trim().strip_prefix("Agent ")) + .map(str::trim) + .filter(|op| !op.is_empty()); + let (Some(session_dir_name), Some(operation)) = (session_dir_name, operation) else { + continue; + }; + if path.is_empty() { + continue; + } + events.push(LogEvent::FileEdit { + ts_ms, + session_dir_name: session_dir_name.to_string(), + path: path.to_string(), + operation: operation.to_string(), + }); + } else if let Some(name) = substring_after(line, EXTHOST_INVOKE_MARKER) { + // exthost log: ` [info] ToolInvoke : ` with the args + // JSON on the following line. + let Some(args_line) = lines.peek() else { + continue; + }; + if args_line.trim_start().starts_with('{') { + let args_raw = lines.next().unwrap_or_default(); + push_invoke(events, ts_ms, name, args_raw); + } + } + } +} + +fn push_invoke(events: &mut Vec, ts_ms: i64, name: &str, args_raw: &str) { + let name = name.trim(); + if name.is_empty() { + return; + } + let args = serde_json::from_str(args_raw.trim()).unwrap_or(Value::Null); + events.push(LogEvent::ToolInvoke { + ts_ms, + name: name.to_string(), + args, + }); +} + +/// Real before/after contents for the files a session edited, from VS Code's +/// chat-editing snapshot store: +/// `workspaceStorage//chatEditingSessions/.session.execution/` +/// holds a `state.json` mapping each resource to `originalHash`/`currentHash`, +/// with the content-addressed snapshot bodies under `contents/`. +/// Returns `path → (old_content, new_content)`. +fn edit_snapshots_for_task(task_id: &str) -> HashMap { + edit_snapshots(task_id, Some(task_id)) +} + +fn edit_snapshots( + task_dir_name: &str, + full_task_id: Option<&str>, +) -> HashMap { + let mut snapshots = HashMap::new(); + for dir in edit_store_paths( + &qoder_workspace_storage_dirs(), + task_dir_name, + full_task_id, + ) { + for (path, contents) in edit_snapshots_from_session_dir(&dir) { + snapshots.entry(path).or_insert(contents); + } + } + snapshots +} + +/// Per-session file impact (`files changed / +lines / -lines`) derived from +/// the chat-editing snapshot store — the durable transcript carries no edit +/// data, so the sidebar/kanban counts come from here. Zeroed when no store +/// survives for the session. +pub(super) fn session_edit_impact( + task_dir_name: &str, + full_task_id: Option<&str>, +) -> ImportedHistoryImpactStats { + impact_from_snapshots(&edit_snapshots(task_dir_name, full_task_id)) +} + +fn impact_from_snapshots( + snapshots: &HashMap, +) -> ImportedHistoryImpactStats { + let mut touched_files: Vec = snapshots.keys().cloned().collect(); + touched_files.sort(); + let (mut lines_added, mut lines_removed) = (0_i64, 0_i64); + for (old_content, new_content) in snapshots.values() { + let (added, removed) = numstat_between(old_content, new_content); + lines_added += added; + lines_removed += removed; + } + ImportedHistoryImpactStats { + files_changed: touched_files.len() as i64, + lines_added, + lines_removed, + touched_files, + } +} + +/// Real line-level numstat between two full file bodies. +fn numstat_between(old_content: &str, new_content: &str) -> (i64, i64) { + similar::TextDiff::from_lines(old_content, new_content) + .iter_all_changes() + .fold((0, 0), |(added, removed), change| match change.tag() { + similar::ChangeTag::Insert => (added + 1, removed), + similar::ChangeTag::Delete => (added, removed + 1), + similar::ChangeTag::Equal => (added, removed), + }) +} + +/// Change-signature of the session's edit store (`state.json` mtime+size per +/// workspace). Folded into the discovery fingerprint so edits that land after +/// a sync re-parse the session even when the transcript itself is unchanged. +pub(super) fn edit_store_signature(task_dir_name: &str, full_task_id: Option<&str>) -> String { + edit_store_paths( + &qoder_workspace_storage_dirs(), + task_dir_name, + full_task_id, + ) + .iter() + .filter_map(|dir| { + let metadata = fs::metadata(dir.join("state.json")).ok()?; + let mtime_ns = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|since| since.as_nanos() as i64) + .unwrap_or_default(); + Some(format!("{mtime_ns}:{}", metadata.len())) + }) + .collect::>() + .join("|") +} + +/// The session's chat-editing store dirs across every workspace. With only a +/// truncated dir name, a prefix that matches two DISTINCT task ids is +/// ambiguous and resolves to nothing. +fn edit_store_paths( + storage_dirs: &[PathBuf], + task_dir_name: &str, + full_task_id: Option<&str>, +) -> Vec { + let mut found = Vec::new(); + let mut distinct_ids: std::collections::HashSet = std::collections::HashSet::new(); + for storage in storage_dirs { + let Ok(workspace_entries) = fs::read_dir(storage) else { + continue; + }; + for workspace in workspace_entries.flatten() { + let base = workspace.path().join("chatEditingSessions"); + if let Some(task_id) = full_task_id { + let dir = base.join(format!("{task_id}{SESSION_ID_SUFFIX}")); + if dir.join("state.json").is_file() { + found.push(dir); + } + continue; + } + let Ok(session_entries) = fs::read_dir(&base) else { + continue; + }; + for session_entry in session_entries.flatten() { + let name = session_entry.file_name().to_string_lossy().to_string(); + let Some(task_id) = name.strip_suffix(SESSION_ID_SUFFIX) else { + continue; + }; + if task_id.starts_with(task_dir_name) { + distinct_ids.insert(task_id.to_string()); + found.push(session_entry.path()); + } + } + } + } + if full_task_id.is_none() && distinct_ids.len() > 1 { + return Vec::new(); + } + found +} + +/// `/Qoder/User/workspaceStorage` candidates. +fn qoder_workspace_storage_dirs() -> Vec { + let mut roots = Vec::new(); + if let Some(data) = dirs::data_dir() { + roots.push(data); + } + if let Some(config) = dirs::config_dir() { + roots.push(config); + } + roots.sort(); + roots.dedup(); + roots + .into_iter() + .map(|root| root.join("Qoder").join("User").join("workspaceStorage")) + .collect() +} + +fn edit_snapshots_from_session_dir(session_dir: &Path) -> HashMap { + let mut snapshots = HashMap::new(); + let Ok(raw) = fs::read_to_string(session_dir.join("state.json")) else { + return snapshots; + }; + let Ok(state) = serde_json::from_str::(&raw) else { + return snapshots; + }; + let Some(entries) = state + .get("recentSnapshot") + .and_then(|snapshot| snapshot.get("entries")) + .and_then(Value::as_array) + else { + return snapshots; + }; + for entry in entries { + let Some(resource) = entry.get("resource").and_then(Value::as_str) else { + continue; + }; + let Some(path) = file_uri_to_path(resource) else { + continue; + }; + let content_for = |key: &str| { + entry + .get(key) + .and_then(Value::as_str) + .map(|hash| read_snapshot_content(session_dir, hash)) + .unwrap_or_default() + }; + let old_content = content_for("originalHash"); + let new_content = content_for("currentHash"); + if old_content.is_empty() && new_content.is_empty() { + continue; + } + snapshots.insert(path, (old_content, new_content)); + } + snapshots +} + +fn read_snapshot_content(session_dir: &Path, hash: &str) -> String { + if hash.is_empty() { + return String::new(); + } + let Ok(content) = fs::read_to_string(session_dir.join("contents").join(hash)) else { + return String::new(); + }; + if content.chars().count() > MAX_TOOL_OUTPUT_CHARS { + content.chars().take(MAX_TOOL_OUTPUT_CHARS).collect() + } else { + content + } +} + +/// `file:///a/b%20c.py` → `/a/b c.py`. +fn file_uri_to_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + Some(percent_decode(rest)) +} + +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && index + 2 < bytes.len() + && bytes[index + 1].is_ascii_hexdigit() + && bytes[index + 2].is_ascii_hexdigit() + { + // Both hex digits are ASCII, so this byte-range slice is safe. + if let Ok(byte) = u8::from_str_radix(&input[index + 1..index + 3], 16) { + out.push(byte); + index += 3; + continue; + } + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Log lines open with `YYYY-MM-DD HH:MM:SS.mmm` in local time. +fn parse_line_timestamp_ms(line: &str) -> Option { + let raw = line.get(..23)?; + let naive = NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S%.3f").ok()?; + Local + .from_local_datetime(&naive) + .single() + .map(|dt| dt.timestamp_millis()) +} + +fn substring_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> { + line.find(marker).map(|at| &line[at + marker.len()..]) +} + +/// Every trajectory-bearing log across launch folders: +/// `/Qoder/logs//questWindow/agent.log` and +/// `/Qoder/logs//questWindow/exthost/output_logging_*/1-Qoder.log`. +fn qoder_launch_log_paths() -> Vec { + let mut roots = Vec::new(); + if let Some(data) = dirs::data_dir() { + roots.push(data); + } + if let Some(config) = dirs::config_dir() { + roots.push(config); + } + roots.sort(); + roots.dedup(); + + let mut logs = Vec::new(); + for root in roots { + let logs_dir = root.join("Qoder").join("logs"); + let Ok(entries) = fs::read_dir(&logs_dir) else { + continue; + }; + for entry in entries.flatten() { + let quest_window = entry.path().join("questWindow"); + let agent_log = quest_window.join("agent.log"); + if agent_log.is_file() { + logs.push(agent_log); + } + let exthost = quest_window.join("exthost"); + let Ok(exthost_entries) = fs::read_dir(&exthost) else { + continue; + }; + for exthost_entry in exthost_entries.flatten() { + if !exthost_entry + .file_name() + .to_string_lossy() + .starts_with("output_logging") + { + continue; + } + let candidate = exthost_entry.path().join("1-Qoder.log"); + if candidate.is_file() { + logs.push(candidate); + } + } + } + } + logs +} + +#[cfg(test)] +#[path = "log_enrichment_tests.rs"] +mod tests; diff --git a/src-tauri/crates/orgtrack-core/src/sources/qoder/log_enrichment_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/qoder/log_enrichment_tests.rs new file mode 100644 index 000000000..65d1c0dbe --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/qoder/log_enrichment_tests.rs @@ -0,0 +1,417 @@ +use super::*; + +use crate::sources::imported_history; + +/// Line shapes copied verbatim from real Qoder logs (2026-07). Sessions A +/// (task-3a2…) and B (task-d60…) have OVERLAPPING activity windows, so only +/// content-based attribution can separate their invokes. +fn fixture_log() -> String { + [ + r#"2026-07-16 19:42:04.351 [info] [ChatSessionService] ACP progress: task-3a297cb30e744f1baf72.session.execution, rid=undefined, type=current_model_update"#, + r#"2026-07-16 19:42:09.761 [info] [ChatSessionService] ACP progress: task-3a297cb30e744f1baf72.session.execution, rid=8f5023af-2d32-4d90-b3bd-e3b23041f477, type=tool_call, toolCallId=call_72888b94a24d40a49eb072c4"#, + r#"2026-07-16 19:42:13.206 [info] [SubAgentService] Registered SubAgent: {"parentToolCallId":"call_72888b94a24d40a49eb072c4","parentSessionId":"task-3a297cb30e744f1baf72.session.execution","agentType":"GeneralPurpose","agentName":"","prompt":"Investigate the RAM usage on this macOS computer.","rawInputDescription":"Investigate RAM usage"}"#, + // exthost format: name line, args JSON on the next line. cwd = session + // A's workspace → attributed to A even inside B's window. + r#"2026-07-16 19:42:24.655 [info] ToolInvoke : run_in_terminal"#, + r#"{"command":"vm_stat","command_names":null,"cwd":"/Users/u/Documents/Qoder/2026-07-16/chat-1","exec_mode":"","has_risk":false,"run_mode":"autoRun","timeout":180000}"#, + // Session B starts while A is still active (overlapping windows). + r#"2026-07-16 19:42:30.000 [info] [ChatSessionService] ACP progress: task-d600ed4bb0614ffc94d0.session.execution, rid=undefined, type=current_model_update"#, + // agent.log invoke format; file_path under B's project cache dir. + r#"2026-07-16 19:42:41.575 [info] [ToolInvokeHandlerContribution] Tool invoke request: b22b5f8d-0ab5-4ded-a9de-c1df47388513, read_file, {"file_path":"/Users/u/.qoder/cache/projects/chat-2-fdad7ab5/agent-tools/0ee51bd5/d569f6e1.txt"}"#, + // Path-silent invoke while BOTH windows are open → ambiguous, dropped. + r#"2026-07-16 19:42:50.000 [info] [ToolInvokeHandlerContribution] Tool invoke request: 00000000-0000-0000-0000-000000000001, read_file, {"file_path":"/tmp/ambiguous.txt"}"#, + r#"2026-07-16 19:43:11.000 [info] [ChatSessionService] ACP progress: task-3a297cb30e744f1baf72.session.execution, rid=undefined, type=chat_finish"#, + // A path-silent invoke after A finished, inside B's window only. + r#"2026-07-16 19:43:20.000 [info] [ToolInvokeHandlerContribution] Tool invoke request: 00000000-0000-0000-0000-000000000002, grep_search, {"query":"orphaned"}"#, + r#"2026-07-16 19:43:30.000 [info] [ChatSessionService] ACP progress: task-d600ed4bb0614ffc94d0.session.execution, rid=undefined, type=chat_finish"#, + ] + .join("\n") +} + +fn text_chunks(session_id: &str) -> Vec { + vec![ + imported_history::user_message_chunk(session_id, "qoder", 0, "", "check RAM"), + imported_history::assistant_message_chunk(session_id, "qoder", 1, "", "RAM looks fine."), + ] +} + + +fn no_snapshots(_task_id: &str) -> HashMap { + HashMap::new() +} + +fn parse_fixture() -> Vec { + let mut events = Vec::new(); + parse_launch_log(&fixture_log(), &mut events); + events +} + +#[test] +fn attributes_by_workspace_cwd_despite_overlapping_windows() { + let events = parse_fixture(); + let enriched = enrich_chunks_with_events( + "qoderapp-chat-1-fdad7ab4/task-3a2", + "task-3a2", + "chat-1-fdad7ab4", + Some("/Users/u/Documents/Qoder/2026-07-16/chat-1"), + text_chunks("qoderapp-chat-1-fdad7ab4/task-3a2"), + &events, + &no_snapshots, + ); + + // user + subagent + run_in_terminal + assistant. The ambiguous and + // B-owned invokes must NOT land here. + assert_eq!(enriched.len(), 4); + + let subagent = &enriched[1]; + assert_eq!(subagent.function, "subagent"); + assert_eq!(subagent.args["agentType"], "GeneralPurpose"); + assert_eq!(subagent.args["description"], "Investigate RAM usage"); + assert_eq!(subagent.result["recovered_from"], "agent_log"); + + let terminal = &enriched[2]; + assert_eq!( + terminal.function, + imported_history::FUNCTION_RUN_COMMAND_LINE + ); + assert_eq!(terminal.args["command"], "vm_stat"); + assert_eq!(terminal.args["cmd"], "vm_stat"); + assert!(!terminal.created_at.is_empty()); +} + +#[test] +fn attributes_by_project_cache_dir_and_attaches_no_bare_markers() { + let events = parse_fixture(); + let enriched = enrich_chunks_with_events( + "qoderapp-chat-2-fdad7ab5/task-d60", + "task-d60", + "chat-2-fdad7ab5", + None, + text_chunks("qoderapp-chat-2-fdad7ab5/task-d60"), + &events, + &no_snapshots, + ); + + let tools: Vec<_> = enriched + .iter() + .filter(|chunk| chunk.action_type == imported_history::ACTION_TYPE_TOOL_CALL) + .collect(); + // The cache-dir read (ours by path) + the window-exclusive grep after A + // finished. The ambiguous one and A's terminal command must not appear, + // and no payload-less ACP markers are emitted. + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].function, imported_history::FUNCTION_READ_FILE); + assert!(tools[0].args["file_path"] + .as_str() + .unwrap() + .contains("chat-2-fdad7ab5")); + assert_eq!(tools[1].function, "grep_search"); + assert!(enriched.iter().all(|chunk| chunk.function != "tool_call")); +} + +#[test] +fn pairs_invoke_with_recent_acp_call_id() { + let log = [ + r#"2026-07-16 19:42:09.000 [info] [ChatSessionService] ACP progress: task-aaa.session.execution, rid=u, type=tool_call, toolCallId=call_pair_me"#, + r#"2026-07-16 19:42:09.500 [info] [ToolInvokeHandlerContribution] Tool invoke request: rid-1, read_file, {"file_path":"/tmp/x.txt"}"#, + ] + .join("\n"); + let mut events = Vec::new(); + parse_launch_log(&log, &mut events); + + let enriched = enrich_chunks_with_events( + "qoderapp-p/task-aaa", + "task-aaa", + "p", + None, + text_chunks("qoderapp-p/task-aaa"), + &events, + &no_snapshots, + ); + let tool = enriched + .iter() + .find(|chunk| chunk.action_type == imported_history::ACTION_TYPE_TOOL_CALL) + .expect("tool chunk"); + assert_eq!(tool.result["call_id"], "call_pair_me"); +} + +#[test] +fn maps_file_edits_and_problem_probes_to_typed_cards() { + // Verbatim shapes from a real agent.log: a create tracked by + // FileChangeTracking (session as truncated dir name) followed by a + // get_problems diagnostics probe. + let log = [ + r#"2026-07-16 21:46:40.100 [info] [ChatSessionService] ACP progress: task-031d2bde542c426da46f.session.execution, rid=u, type=tool_call, toolCallId=call_edit_1"#, + r#"2026-07-16 21:46:40.822 [info] [FileChangeTracking] Agent file tracked: /Users/u/Documents/Qoder/2026-07-16/chat-4/documents/test_sample.py (session=task-031)"#, + r#"2026-07-16 21:46:40.823 [info] [FileChangeTracking] /Users/u/Documents/Qoder/2026-07-16/chat-4/documents/test_sample.py | source=agent | session=task-031, request=337dc91c | Agent create"#, + r#"2026-07-16 21:46:42.130 [info] [ToolInvokeHandlerContribution] Tool invoke request: ff747a59-5cdb-4a5b-bbb7-ab4f7a91298a, get_problems, {"filePaths":["/Users/u/Documents/Qoder/2026-07-16/chat-4/documents/test_sample.py"],"file_paths":["/Users/u/Documents/Qoder/2026-07-16/chat-4/documents/test_sample.py"]}"#, + // A later edit of the same file: the whole-session snapshot diff must + // land on THIS card (the last edit), not the create above. + r#"2026-07-16 21:46:45.000 [info] [FileChangeTracking] /Users/u/Documents/Qoder/2026-07-16/chat-4/documents/test_sample.py | source=agent | session=task-031, request=448ee1d | Agent edit"#, + r#"2026-07-16 21:46:50.000 [info] [ChatSessionService] ACP progress: task-031d2bde542c426da46f.session.execution, rid=u, type=chat_finish"#, + ] + .join("\n"); + let mut events = Vec::new(); + parse_launch_log(&log, &mut events); + + let file_path = "/Users/u/Documents/Qoder/2026-07-16/chat-4/documents/test_sample.py"; + let snapshots = move |task_id: &str| { + assert_eq!(task_id, "task-031d2bde542c426da46f"); + HashMap::from([( + file_path.to_string(), + (String::new(), "import unittest\n".to_string()), + )]) + }; + + let enriched = enrich_chunks_with_events( + "qoderapp-chat-4-fdad7ab7/task-031", + "task-031", + "chat-4-fdad7ab7", + Some("/Users/u/Documents/Qoder/2026-07-16/chat-4"), + text_chunks("qoderapp-chat-4-fdad7ab7/task-031"), + &events, + &snapshots, + ); + + let tools: Vec<_> = enriched + .iter() + .filter(|chunk| chunk.action_type == imported_history::ACTION_TYPE_TOOL_CALL) + .collect(); + // create + probe + edit; the pipe-less "Agent file tracked:" duplicate is + // ignored. + assert_eq!(tools.len(), 3); + + let create = tools[0]; + assert_eq!(create.function, imported_history::FUNCTION_EDIT_FILE); + assert_eq!(create.args["file_path"], file_path); + assert_eq!(create.args["operation"], "create"); + assert_eq!(create.result["raw_tool_name"], "file_create"); + // Paired to the ACP tool_call that preceded it. + assert_eq!(create.result["call_id"], "call_edit_1"); + // The snapshot diff belongs to the LAST edit of the file, not this one. + assert!(create.args.get("new_string").is_none()); + + let lsp = tools[1]; + assert_eq!(lsp.function, "query_lsp"); + assert_eq!(lsp.result["raw_tool_name"], "get_problems"); + assert_eq!(lsp.args["file_path"], file_path); + + let edit = tools[2]; + assert_eq!(edit.function, imported_history::FUNCTION_EDIT_FILE); + assert_eq!(edit.args["operation"], "edit"); + assert_eq!(edit.args["old_string"], ""); + assert_eq!(edit.args["new_string"], "import unittest\n"); +} + +#[test] +fn impact_from_snapshots_counts_real_line_diffs() { + let snapshots = HashMap::from([ + ( + "/w/created.py".to_string(), + (String::new(), "line one\nline two\n".to_string()), + ), + ( + "/w/edited.py".to_string(), + // One line changed out of three: numstat must be +1/−1, not the + // naive full-file +3/−3. + ( + "keep\nold line\nkeep\n".to_string(), + "keep\nnew line\nkeep\n".to_string(), + ), + ), + ]); + + let impact = impact_from_snapshots(&snapshots); + assert_eq!(impact.files_changed, 2); + assert_eq!(impact.lines_added, 3); + assert_eq!(impact.lines_removed, 1); + assert_eq!(impact.touched_files, vec!["/w/created.py", "/w/edited.py"]); +} + +#[test] +fn edit_store_paths_resolve_by_prefix_and_back_off_on_ambiguity() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let storage = std::env::temp_dir().join(format!("orgii-qoder-storeres-{unique}")); + let sessions = storage.join("ws-1").join("chatEditingSessions"); + for task_id in ["task-031d2bde542c426da46f", "task-9ffaaaa", "task-9ffbbbb"] { + let dir = sessions.join(format!("{task_id}{SESSION_ID_SUFFIX}")); + std::fs::create_dir_all(&dir).expect("create session dir"); + std::fs::write(dir.join("state.json"), "{}").expect("write state"); + } + let storage_dirs = vec![storage.clone()]; + + // Exact full-id resolution. + assert_eq!( + edit_store_paths(&storage_dirs, "task-031", Some("task-031d2bde542c426da46f")).len(), + 1 + ); + // Unique prefix resolution. + assert_eq!(edit_store_paths(&storage_dirs, "task-031", None).len(), 1); + // Two distinct ids share the prefix → ambiguous → nothing. + assert!(edit_store_paths(&storage_dirs, "task-9ff", None).is_empty()); + + std::fs::remove_dir_all(&storage).expect("remove temp dir"); +} + +#[test] +fn reads_edit_snapshots_from_chat_editing_session_store() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let session_dir = std::env::temp_dir().join(format!("orgii-qoder-editsnap-{unique}")); + std::fs::create_dir_all(session_dir.join("contents")).expect("create contents dir"); + std::fs::write(session_dir.join("contents").join("da39a3e"), "").expect("write old"); + std::fs::write( + session_dir.join("contents").join("797f358"), + "import unittest\n", + ) + .expect("write new"); + // Shape copied from a real chatEditingSessions state.json (v2), with a + // percent-encoded space in the resource to exercise URI decoding. + std::fs::write( + session_dir.join("state.json"), + r#"{ + "version": 2, + "sessionId": "task-031d2bde542c426da46f.session.execution", + "recentSnapshot": { + "entries": [{ + "resource": "file:///Users/u/My%20Docs/test_sample.py", + "languageId": "python", + "originalHash": "da39a3e", + "currentHash": "797f358", + "state": 0 + }] + } + }"#, + ) + .expect("write state"); + + let snapshots = edit_snapshots_from_session_dir(&session_dir); + assert_eq!( + snapshots.get("/Users/u/My Docs/test_sample.py"), + Some(&(String::new(), "import unittest\n".to_string())) + ); + + std::fs::remove_dir_all(&session_dir).expect("remove temp dir"); +} + +#[test] +fn ambiguous_task_prefix_backs_off_unchanged() { + let log = [ + r#"2026-07-16 19:42:04.351 [info] [ChatSessionService] ACP progress: task-abc111.session.execution, rid=undefined, type=current_model_update"#, + r#"2026-07-16 19:42:05.351 [info] [ChatSessionService] ACP progress: task-abc222.session.execution, rid=undefined, type=current_model_update"#, + ] + .join("\n"); + let mut events = Vec::new(); + parse_launch_log(&log, &mut events); + + let chunks = text_chunks("qoderapp-p/task-abc"); + let enriched = enrich_chunks_with_events( + "qoderapp-p/task-abc", + "task-abc", + "p", + None, + chunks.clone(), + &events, + &no_snapshots, + ); + assert_eq!(enriched.len(), chunks.len()); +} + +#[test] +fn no_matching_session_backs_off_unchanged() { + let events = parse_fixture(); + let chunks = text_chunks("qoderapp-p/task-fff"); + let enriched = enrich_chunks_with_events( + "qoderapp-p/task-fff", + "task-fff", + "p", + None, + chunks.clone(), + &events, + &no_snapshots, + ); + assert_eq!(enriched.len(), chunks.len()); +} + +#[test] +fn spill_file_reads_attach_their_content_as_output() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("orgii-qoder-spill-{unique}")); + let spill_dir = root.join("cache/projects/proj-ab/agent-tools/ab"); + std::fs::create_dir_all(&spill_dir).expect("create spill dir"); + let spill_path = spill_dir.join("cd.txt"); + std::fs::write(&spill_path, "fetched doc body").expect("write spill"); + + let log = [ + r#"2026-07-16 19:42:04.351 [info] [ChatSessionService] ACP progress: task-aaa.session.execution, rid=undefined, type=current_model_update"#.to_string(), + format!( + r#"2026-07-16 19:42:05.000 [info] [ToolInvokeHandlerContribution] Tool invoke request: rid-1, read_file, {{"file_path":"{}"}}"#, + spill_path.display() + ), + ] + .join("\n"); + let mut events = Vec::new(); + parse_launch_log(&log, &mut events); + + let enriched = enrich_chunks_with_events( + "qoderapp-proj-ab/task-aaa", + "task-aaa", + "proj-ab", + None, + text_chunks("qoderapp-proj-ab/task-aaa"), + &events, + &no_snapshots, + ); + let tool = enriched + .iter() + .find(|chunk| chunk.action_type == imported_history::ACTION_TYPE_TOOL_CALL) + .expect("tool chunk"); + assert_eq!(tool.result["output"], "fetched doc body"); + + std::fs::remove_dir_all(&root).expect("remove temp dir"); +} + +#[test] +fn parses_both_log_formats_and_ignores_garbage() { + let mut events = Vec::new(); + parse_launch_log( + &[ + "not a log line", + r#"2026-07-16 19:42:09.761 [info] [ChatSessionService] ACP progress: task-x.session.execution, rid=u, type=tool_call, toolCallId=call_1"#, + r#"2026-07-16 19:42:24.655 [info] ToolInvoke : run_in_terminal"#, + r#"{"command":"ls","cwd":"/w"}"#, + // exthost name line with a non-JSON follower: skipped, not misparsed. + r#"2026-07-16 19:42:25.000 [info] ToolInvoke : broken_tool"#, + r#"2026-07-16 19:42:26.000 [info] some unrelated line"#, + ] + .join("\n"), + &mut events, + ); + assert_eq!(events.len(), 2); + match &events[0] { + LogEvent::Acp { + session_task_id, + tool_call_id: Some(id), + .. + } => { + assert_eq!(session_task_id, "task-x"); + assert_eq!(id, "call_1"); + } + other => panic!("unexpected event: {other:?}"), + } + match &events[1] { + LogEvent::ToolInvoke { name, args, .. } => { + assert_eq!(name, "run_in_terminal"); + assert_eq!(args["command"], "ls"); + } + other => panic!("unexpected event: {other:?}"), + } +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/qoder/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/qoder/mod.rs new file mode 100644 index 000000000..89f77a982 --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/qoder/mod.rs @@ -0,0 +1,13 @@ +//! Qoder (Alibaba's agentic IDE, VS Code family) imported history reader. +//! +//! Each agent ("quest") session transcript is a JSONL file under +//! `~/.qoder/cache/projects/-/conversation-history//.jsonl`, +//! where `` is a truncated prefix of the full quest task id. Each line is +//! `{role, message:{content:[…]}}` with Anthropic-style content blocks. +//! +//! Session metadata (title, timestamps, workspace path) lives in the app's +//! global `state.vscdb` under the `aicoding.questTaskListSnapshot` key and is +//! matched to a transcript by task-id prefix + workspace basename; the snapshot +//! is enrichment only — discovery works from the JSONL store alone. +pub mod history; +mod log_enrichment; diff --git a/src-tauri/src/agent_sessions/session_directory/aggregation.rs b/src-tauri/src/agent_sessions/session_directory/aggregation.rs index a58d094ef..2cd94cd1f 100644 --- a/src-tauri/src/agent_sessions/session_directory/aggregation.rs +++ b/src-tauri/src/agent_sessions/session_directory/aggregation.rs @@ -22,10 +22,11 @@ use orgtrack_core::sources::cursor_ide::history::CursorIdeSessionPage; use orgtrack_core::sources::imported_history::cache as imported_history_cache; use orgtrack_core::sources::imported_history::metadata::{ SOURCE_CLAUDE_CODE, SOURCE_CLINE, SOURCE_CODEX_APP, SOURCE_CURSOR_IDE, SOURCE_OPENCODE, - SOURCE_TRAE, SOURCE_WARP, SOURCE_WINDSURF, SOURCE_WORKBUDDY, SOURCE_ZCODE, + SOURCE_QODER, SOURCE_TRAE, SOURCE_WARP, SOURCE_WINDSURF, SOURCE_WORKBUDDY, SOURCE_ZCODE, }; use orgtrack_core::sources::imported_history::ImportedHistorySessionPage; use orgtrack_core::sources::opencode::history as opencode_history; +use orgtrack_core::sources::qoder::history as qoder_history; use orgtrack_core::sources::trae::history as trae_history; use orgtrack_core::sources::warp::history as warp_history; use orgtrack_core::sources::windsurf::history as windsurf_history; @@ -144,6 +145,15 @@ fn load_zcode_external_history_page( .map(ExternalHistoryPage::Imported) } +fn load_qoder_external_history_page( + conn: &mut rusqlite::Connection, + limit: usize, + offset: usize, +) -> Result { + qoder_history::list_qoder_history_sessions_paginated(conn, limit, offset) + .map(ExternalHistoryPage::Imported) +} + const EXTERNAL_HISTORY_SOURCE_LOADERS: &[ExternalHistorySourceLoader] = &[ ExternalHistorySourceLoader { source: SOURCE_CLAUDE_CODE, @@ -185,6 +195,10 @@ const EXTERNAL_HISTORY_SOURCE_LOADERS: &[ExternalHistorySourceLoader] = &[ source: SOURCE_ZCODE, load_page: load_zcode_external_history_page, }, + ExternalHistorySourceLoader { + source: SOURCE_QODER, + load_page: load_qoder_external_history_page, + }, ]; /// Force a source's on-disk store to be re-read and its metadata cache diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index ed05b16f0..21d0e2f1f 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -1010,6 +1010,8 @@ orgtrack::history_commands::warp_history_chunks, orgtrack::history_commands::warp_recent_paths, orgtrack::history_commands::zcode_history_chunks, orgtrack::history_commands::zcode_recent_paths, +orgtrack::history_commands::qoder_history_chunks, +orgtrack::history_commands::qoder_recent_paths, orgtrack::history_commands::external_cli_sources_detect, orgtrack::history_commands::external_cli_source_probe, orgtrack::history_commands::external_history_rescan_source, diff --git a/src-tauri/src/orgtrack/external_cli_detection.rs b/src-tauri/src/orgtrack/external_cli_detection.rs index 21e5e1d72..2f8d9cd79 100644 --- a/src-tauri/src/orgtrack/external_cli_detection.rs +++ b/src-tauri/src/orgtrack/external_cli_detection.rs @@ -59,6 +59,7 @@ const IMPORTABLE_HISTORY_SOURCE_IDS: &[&str] = &[ "cline", "warp", "zcode", + "qoder", ]; /// On-disk store format for a source's session history — the "file type" shown @@ -69,7 +70,7 @@ const IMPORTABLE_HISTORY_SOURCE_IDS: &[&str] = &[ fn store_kind_for(source_id: &str) -> &'static str { match source_id { // Importable — ORGII parses these. - "claude_code" | "codex_app" | "workbuddy" | "trae" | "cline" => "jsonl", + "claude_code" | "codex_app" | "workbuddy" | "trae" | "cline" | "qoder" => "jsonl", "cursor_ide" | "opencode" | "windsurf" | "warp" | "zcode" => "sqlite", // Known store format, not yet imported. "qwen_code" | "kimi" | "pi" | "omp" | "droid" => "jsonl", @@ -429,6 +430,17 @@ pub const EXTERNAL_CLI_SOURCES: &[ExternalCliSourceSpec] = &[ true, &[], ), + source( + "qoder", + "Qoder", + "qoder", + "qoder", + &[], + "qoder", + "Qoder", + true, + &[".qoder"], + ), ]; const fn source( @@ -553,6 +565,7 @@ fn importable_history_candidates(source_id: &str) -> Vec { "cline" => home_candidates(&[".cline/data/sessions", ".cline/data/db"]), "warp" => orgtrack_core::sources::warp::history::warp_history_candidate_paths(), "zcode" => orgtrack_core::sources::zcode::history::zcode_history_candidate_paths(), + "qoder" => orgtrack_core::sources::qoder::history::qoder_history_candidate_paths(), _ => Vec::new(), } } diff --git a/src-tauri/src/orgtrack/history_commands.rs b/src-tauri/src/orgtrack/history_commands.rs index 2061d889f..89d63a727 100644 --- a/src-tauri/src/orgtrack/history_commands.rs +++ b/src-tauri/src/orgtrack/history_commands.rs @@ -9,6 +9,7 @@ use orgtrack_core::sources::cursor_ide::{ }; use orgtrack_core::sources::imported_history; use orgtrack_core::sources::opencode::history as opencode_history; +use orgtrack_core::sources::qoder::history as qoder_history; use orgtrack_core::sources::trae::history as trae_history; use orgtrack_core::sources::warp::history as warp_history; use orgtrack_core::sources::windsurf::history as windsurf_history; @@ -123,6 +124,7 @@ fn imported_recent_paths() -> Result Result, String> { + tokio::task::spawn_blocking(move || { + let conn = open_cache_conn()?; + qoder_history::load_qoder_history_for_session(&conn, &session_id) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + +#[tauri::command] +pub async fn qoder_recent_paths( + limit: Option, +) -> Result, String> { + let limit = limit.unwrap_or(20); + tokio::task::spawn_blocking(move || { + let mut conn = open_cache_conn()?; + qoder_history::list_qoder_recent_paths(&mut conn, limit) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + #[tauri::command] pub async fn windsurf_history_chunks( session_id: String, diff --git a/src/api/tauri/externalHistory/imported/__tests__/sources.test.ts b/src/api/tauri/externalHistory/imported/__tests__/sources.test.ts index 747b36c72..4e034db9b 100644 --- a/src/api/tauri/externalHistory/imported/__tests__/sources.test.ts +++ b/src/api/tauri/externalHistory/imported/__tests__/sources.test.ts @@ -48,6 +48,7 @@ describe("imported history source registry", () => { "cline", "warp", "zcode", + "qoder", ]); expect( IMPORTED_HISTORY_SOURCES.map((source) => source.listCategory) @@ -62,6 +63,7 @@ describe("imported history source registry", () => { "external_history:cline", "external_history:warp", "external_history:zcode", + "external_history:qoder", ]); for (const source of IMPORTED_HISTORY_SOURCES) { expect(source.loadPreviewChunks).toBeTypeOf("function"); diff --git a/src/api/tauri/externalHistory/imported/descriptors.ts b/src/api/tauri/externalHistory/imported/descriptors.ts index fbaccde1c..e838a3200 100644 --- a/src/api/tauri/externalHistory/imported/descriptors.ts +++ b/src/api/tauri/externalHistory/imported/descriptors.ts @@ -129,4 +129,15 @@ export const IMPORTED_HISTORY_SOURCE_DESCRIPTORS: readonly ImportedHistorySource replayable: true, supportsWindowedReplay: false, }, + { + sourceId: "qoder", + listCategory: "external_history:qoder", + prefix: "qoderapp-", + iconId: "qoder", + displayName: "Qoder", + groupLabel: "Qoder", + listable: true, + replayable: true, + supportsWindowedReplay: false, + }, ]; diff --git a/src/api/tauri/externalHistory/imported/index.ts b/src/api/tauri/externalHistory/imported/index.ts index aae199fe1..19f05b990 100644 --- a/src/api/tauri/externalHistory/imported/index.ts +++ b/src/api/tauri/externalHistory/imported/index.ts @@ -11,6 +11,7 @@ import { import { clineHistoryChunks } from "../sources/cline"; import { codexAppChunks } from "../sources/codexApp"; import { opencodeHistoryChunks } from "../sources/opencode"; +import { qoderHistoryChunks } from "../sources/qoder"; import { traeHistoryChunks } from "../sources/trae"; import { warpHistoryChunks } from "../sources/warp"; import { windsurfHistoryChunks } from "../sources/windsurf"; @@ -140,6 +141,13 @@ export const IMPORTED_HISTORY_SOURCES: readonly ImportedHistorySource[] = [ loadPreviewChunks: zcodeHistoryChunks, loadFullTranscriptChunks: zcodeHistoryChunks, }, + { + ...descriptorFor("qoder"), + dispatchCategory: "external_history", + statTranscript: (sessionId) => importedHistoryStat("qoder", sessionId), + loadPreviewChunks: qoderHistoryChunks, + loadFullTranscriptChunks: qoderHistoryChunks, + }, ]; export function getImportedHistorySourceBySessionId( diff --git a/src/api/tauri/externalHistory/index.ts b/src/api/tauri/externalHistory/index.ts index 3a71d4714..9f65e641b 100644 --- a/src/api/tauri/externalHistory/index.ts +++ b/src/api/tauri/externalHistory/index.ts @@ -25,6 +25,7 @@ export * from "./sources/windsurf"; export * from "./sources/workbuddy"; export * from "./sources/warp"; export * from "./sources/zcode"; +export * from "./sources/qoder"; export interface ExternalHistoryImportedRepo { repoId: string; diff --git a/src/api/tauri/externalHistory/sourceStats.ts b/src/api/tauri/externalHistory/sourceStats.ts index b9add802e..e17a75814 100644 --- a/src/api/tauri/externalHistory/sourceStats.ts +++ b/src/api/tauri/externalHistory/sourceStats.ts @@ -7,6 +7,7 @@ import { claudeCodeRecentPaths } from "./sources/claudeCode"; import { clineRecentPaths } from "./sources/cline"; import { codexAppRecentPaths } from "./sources/codexApp"; import { opencodeRecentPaths } from "./sources/opencode"; +import { qoderRecentPaths } from "./sources/qoder"; import { traeRecentPaths } from "./sources/trae"; import { warpRecentPaths } from "./sources/warp"; import { windsurfRecentPaths } from "./sources/windsurf"; @@ -51,6 +52,7 @@ const RECENT_PATH_FETCHERS: Partial< cline: () => clineRecentPaths(), warp: () => warpRecentPaths(), zcode: () => zcodeRecentPaths(), + qoder: () => qoderRecentPaths(), }; function statsFromRecentPaths(rows: RecentPathLike[]): SourceCounts { diff --git a/src/api/tauri/externalHistory/sources/qoder/index.ts b/src/api/tauri/externalHistory/sources/qoder/index.ts new file mode 100644 index 000000000..fcdd9fd77 --- /dev/null +++ b/src/api/tauri/externalHistory/sources/qoder/index.ts @@ -0,0 +1,24 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { ActivityChunk } from "@src/types/session/session"; + +export interface QoderRecentPath { + path: string; + name?: string; + lastUsedAt: string; + sessionCount: number; +} + +export async function qoderRecentPaths(args?: { + limit?: number; +}): Promise { + return invoke("qoder_recent_paths", { + limit: args?.limit, + }); +} + +export async function qoderHistoryChunks( + sessionId: string +): Promise { + return invoke("qoder_history_chunks", { sessionId }); +} diff --git a/src/assets/modelIcons/qoder.svg b/src/assets/modelIcons/qoder.svg new file mode 100644 index 000000000..f26c71fdb --- /dev/null +++ b/src/assets/modelIcons/qoder.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/src/components/ModelIcon/config.ts b/src/components/ModelIcon/config.ts index 00f2ba2b2..ed94e81a6 100644 --- a/src/components/ModelIcon/config.ts +++ b/src/components/ModelIcon/config.ts @@ -61,6 +61,7 @@ import OpenRouterIcon from "@src/assets/modelIcons/openrouter.svg"; import OrgiiIcon from "@src/assets/modelIcons/orgii.svg"; import PerplexityIcon from "@src/assets/modelIcons/perplexity.svg"; import PiIcon from "@src/assets/modelIcons/pi.svg"; +import QoderIcon from "@src/assets/modelIcons/qoder.svg"; import QwenIcon from "@src/assets/modelIcons/qwen.svg"; import RovoIcon from "@src/assets/modelIcons/rovo.svg"; import SiliconFlowIcon from "@src/assets/modelIcons/siliconflow.svg"; @@ -140,6 +141,7 @@ export type IconProvider = | "yi" | "zhipu" | "zcode" + | "qoder" | "baichuan" | "minimax" | "longcat" @@ -210,6 +212,7 @@ export const ICON_MAP: Record< yi: YiIcon, zhipu: ZhipuIcon, zcode: ZcodeIcon, + qoder: QoderIcon, baichuan: BaichuanIcon, minimax: MinimaxIcon, longcat: LongCatIcon, @@ -282,6 +285,7 @@ export const SELECTABLE_ICON_PROVIDERS: IconProvider[] = [ "yi", "zhipu", "zcode", + "qoder", "baichuan", "minimax", "longcat", @@ -574,6 +578,11 @@ export function getIconProviderFromModelName( return "zcode"; } + // Qoder IDE (Alibaba's agentic IDE) + if (lower.includes("qoder")) { + return "qoder"; + } + // Zhipu/GLM models if ( lower.includes("zhipu") || diff --git a/src/engines/ChatPanel/ChatHistory/utils/__tests__/turnTimingFormatting.test.ts b/src/engines/ChatPanel/ChatHistory/utils/__tests__/turnTimingFormatting.test.ts index da99aeacb..31e5ae78f 100644 --- a/src/engines/ChatPanel/ChatHistory/utils/__tests__/turnTimingFormatting.test.ts +++ b/src/engines/ChatPanel/ChatHistory/utils/__tests__/turnTimingFormatting.test.ts @@ -12,9 +12,11 @@ describe("formatTurnDuration", () => { expect(formatTurnDuration(5 * 60_000 + 5_000)).toBe("5m 5s"); }); - it("normalizes missing and invalid durations", () => { - expect(formatTurnDuration(0)).toBe("0s"); - expect(formatTurnDuration(Number.NaN)).toBe("0s"); + it("normalizes missing and invalid durations to <1min", () => { + expect(formatTurnDuration(0)).toBe("<1min"); + expect(formatTurnDuration(Number.NaN)).toBe("<1min"); + // Sub-second real durations round to 0s, which reads as broken too. + expect(formatTurnDuration(400)).toBe("<1min"); }); }); diff --git a/src/engines/ChatPanel/ChatHistory/utils/turnTimingFormatting.ts b/src/engines/ChatPanel/ChatHistory/utils/turnTimingFormatting.ts index 363509579..60dd3636c 100644 --- a/src/engines/ChatPanel/ChatHistory/utils/turnTimingFormatting.ts +++ b/src/engines/ChatPanel/ChatHistory/utils/turnTimingFormatting.ts @@ -5,10 +5,15 @@ export interface TurnTimingLabels { showRange: boolean; } -/** Compact spoken duration: `5m 5s`, `5m`, or `42s`. */ +/** + * Compact spoken duration: `5m 5s`, `5m`, or `42s`. Missing/zero durations + * (e.g. imported transcripts that carry no timestamps) read as `<1min` + * rather than a broken-looking `0s`. + */ export function formatTurnDuration(durationMs: number): string { - if (!Number.isFinite(durationMs) || durationMs <= 0) return "0s"; + if (!Number.isFinite(durationMs) || durationMs <= 0) return "<1min"; const totalSeconds = Math.round(durationMs / 1000); + if (totalSeconds === 0) return "<1min"; if (totalSeconds < 60) return `${totalSeconds}s`; const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; diff --git a/src/features/TaskKanban/config.ts b/src/features/TaskKanban/config.ts index 364956124..a9fb04fb1 100644 --- a/src/features/TaskKanban/config.ts +++ b/src/features/TaskKanban/config.ts @@ -77,6 +77,7 @@ export const KANBAN_AGENT_TYPE_FILTER = { CLINE_APP: "cline_app", WARP_APP: "warp_app", ZCODE_APP: "zcode_app", + QODER_APP: "qoder_app", } as const; export type KanbanBuiltInAgentTypeFilter = @@ -97,6 +98,7 @@ export const EXTERNAL_HISTORY_FILTER_BY_SOURCE: Record< cline: KANBAN_AGENT_TYPE_FILTER.CLINE_APP, warp: KANBAN_AGENT_TYPE_FILTER.WARP_APP, zcode: KANBAN_AGENT_TYPE_FILTER.ZCODE_APP, + qoder: KANBAN_AGENT_TYPE_FILTER.QODER_APP, }; /** Widened column id used inside Agent Kanban only. */ diff --git a/src/scaffold/GlobalSpotlight/hooks/data/useExternalRecentPaths.ts b/src/scaffold/GlobalSpotlight/hooks/data/useExternalRecentPaths.ts index d3b0f5b02..3ead0b9e2 100644 --- a/src/scaffold/GlobalSpotlight/hooks/data/useExternalRecentPaths.ts +++ b/src/scaffold/GlobalSpotlight/hooks/data/useExternalRecentPaths.ts @@ -4,6 +4,7 @@ import { claudeCodeRecentPaths, codexAppRecentPaths, opencodeRecentPaths, + qoderRecentPaths, warpRecentPaths, windsurfRecentPaths, zcodeRecentPaths, @@ -88,6 +89,7 @@ export function useExternalRecentPaths({ windsurfRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), warpRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), zcodeRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), + qoderRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), ]).then( ([ codexPaths, @@ -96,6 +98,7 @@ export function useExternalRecentPaths({ windsurfPaths, warpPaths, zcodePaths, + qoderPaths, ]) => { if (!cancelled) { setPaths( @@ -106,6 +109,7 @@ export function useExternalRecentPaths({ ...windsurfPaths, ...warpPaths, ...zcodePaths, + ...qoderPaths, ]) ); } diff --git a/src/types/session/externalHistory.ts b/src/types/session/externalHistory.ts index cfaef17a3..a3bca7e59 100644 --- a/src/types/session/externalHistory.ts +++ b/src/types/session/externalHistory.ts @@ -10,6 +10,7 @@ export const IMPORTED_HISTORY_SOURCE_IDS = [ "cline", "warp", "zcode", + "qoder", ] as const; export type ImportedHistorySourceId =