From d4e5bb866c4d6e4380492dec4a3249f80639de97 Mon Sep 17 00:00:00 2001 From: Yunpeng Date: Mon, 5 Oct 2026 00:15:01 -0700 Subject: [PATCH] feat(qoder): add Qoder CLI harness Qoder CLI stores sessions in Claude Code's JSONL format, so the harness reuses the Claude Code codec and adds Qoder's own store: - the project directory name: every UTF-16 unit outside [A-Za-z0-9] becomes "-", and names over 200 characters are cut to 200 and suffixed with "-" and a hash of the full path - titles (custom-title wins over ai-title) and fork lineage (forkedFrom) - discovery that lists only files holding a session record - a delete that removes only the .jsonl, like `qodercli --delete-session` from_common writes the conversation lines, an ai-title line and a final active-leaf line. Resume was checked against qodercli 1.1.65 in both directions with Claude Code and Codex. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PsZXW7oFJkaFWqY6GmtQAG --- README.md | 1 + cli/src/lib.rs | 3 +- docs/formats/README.md | 1 + docs/formats/qoder.md | 89 +++++ docs/usage.md | 4 +- src/harness/mod.rs | 1 + src/harness/qoder.rs | 467 ++++++++++++++++++++++ src/lib.rs | 2 +- src/local.rs | 19 +- src/transcript.rs | 6 +- src/wasm.rs | 8 +- tests/integration/cross_harness.rs | 11 +- tests/integration/main.rs | 1 + tests/integration/path_safety.rs | 40 +- tests/integration/properties.rs | 3 +- tests/integration/qoder.rs | 599 +++++++++++++++++++++++++++++ tests/integration/store_delete.rs | 28 +- 17 files changed, 1269 insertions(+), 14 deletions(-) create mode 100644 docs/formats/qoder.md create mode 100644 src/harness/qoder.rs create mode 100644 tests/integration/qoder.rs diff --git a/README.md b/README.md index 9727819..f064c65 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Each name links to its format documentation. Use the ID with `--from` and `--wit | [Grok Bot](docs/formats/grok-bot.md) | `grok_bot` | Yes | Via local gateway | | [fx](docs/formats/fx.md) | `fx` | Yes | Yes | | [Antigravity](docs/formats/antigravity.md) | `antigravity` | Yes | Yes | +| [Qoder](docs/formats/qoder.md) | `qoder` | Yes | Yes | | [Hermes Agent](docs/formats/hermes.md) | `hermes` | Yes | No | | [Amp](docs/formats/amp.md) | `amp` | Yes | No | | [Cloud Cowork](docs/formats/cowork-remote.md) | `cowork_remote` | Live account | No | diff --git a/cli/src/lib.rs b/cli/src/lib.rs index fadca96..f06a440 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -69,7 +69,7 @@ mod pager; mod view; pub const HARNESSES: &str = "harnesses: claude_code, claude_chat, chatgpt, codex, opencode, pi, campfire, cursor, cursor_desktop, grok, fx, hermes, \ - amp, antigravity, simple, cowork, cowork_remote"; + amp, antigravity, qoder, simple, cowork, cowork_remote"; /// The `txcript` binary's command line. #[derive(Parser)] @@ -1322,6 +1322,7 @@ mod style { HarnessId::Hermes => "\x1b[93m", // bright yellow HarnessId::Amp => "\x1b[95m", // bright magenta HarnessId::Antigravity => "\x1b[94m", // bright blue + HarnessId::Qoder => "\x1b[38;5;99m", // violet HarnessId::Simple => "\x1b[92m", // bright green HarnessId::Cowork => "\x1b[38;5;208m", // orange } diff --git a/docs/formats/README.md b/docs/formats/README.md index 633fdde..15bd861 100644 --- a/docs/formats/README.md +++ b/docs/formats/README.md @@ -54,6 +54,7 @@ than none. | [fx.md](fx.md) | fx (Vercel) | `src/harness/fx.rs` | | [hermes.md](hermes.md) | Hermes Agent | `src/harness/hermes.rs` | | [antigravity.md](antigravity.md) | Antigravity (Google) | `src/harness/antigravity.rs` | +| [qoder.md](qoder.md) | Qoder CLI | `src/harness/qoder.rs` | | [pi.md](pi.md) | pi | `src/harness/pi.rs` | | [campfire.md](campfire.md) | Campfire (embeds pi) | `src/harness/campfire.rs` | | [simple.md](simple.md) | Simple (txcript's own interchange format) | `src/harness/simple.rs` | diff --git a/docs/formats/qoder.md b/docs/formats/qoder.md new file mode 100644 index 0000000..d5383a9 --- /dev/null +++ b/docs/formats/qoder.md @@ -0,0 +1,89 @@ +# Qoder + +Qoder CLI uses [Claude Code's wire format](claude-code.md): JSONL conversation +records wrapping Anthropic messages, linked by `parentUuid`. Its surrounding +bookkeeping, titles, and storage layout differ. This document is +**reverse-engineered** from real local qodercli 1.1.65 sessions; +`src/harness/qoder.rs` is the authoritative mapping. + +``` +~/.qoder/projects/ ($QODER_CONFIG_DIR/projects overrides) +└── / + ├── .jsonl conversation + bookkeeping records + └── / Qoder-internal sidecars + ├── state.json + └── compression-v2/ +``` + +## On disk + +Sessions live at `~/.qoder/projects//.jsonl`. +`QODER_CONFIG_DIR` overrides the config root when set and non-empty. +The China build, `qoderclicn`, uses `~/.qoder-cn` and ignores that variable; +set `QODER_CONFIG_DIR="$HOME/.qoder-cn"` to point txcript at its sessions. + +Project names replace every UTF-16 code unit outside `[A-Za-z0-9]` with +`-`. If the encoded name exceeds 200 characters, it becomes the first 200 +characters plus `-` plus `base36(abs(djb2-xor(original path)))`. The hash +uses the original path's UTF-16 units, seed 5381, and signed 32-bit +`h = (h * 33) ^ unit` arithmetic. + +`QoderStore` discovers JSONL files exactly two levels below the projects +root, without descending into sidecar directories, and lists a file only +when it holds at least one line with a `type` and a `sessionId`. Neither `state.json` +nor `compression-v2/` is read or written; resume with full history was +verified without them. Native delete mirrors `qodercli --delete-session`: +only the `.jsonl` is removed, leaving the sidecar directory. + +## Dissection of a transcript + +`Qoder` shares Claude Code's native `Record` body and conversation codec. +Tool names and arguments are the same (`Read`, `Edit`, `Write`, `Bash`, +`Grep`, `Glob`, …); tool results ride on `user` lines. See +[claude-code.md](claude-code.md#dissection-of-a-transcript) for block mappings. + +| Their name | What it is | Maps to | +|---|---|---| +| `user` / `assistant` | Envelope wrapping an Anthropic message | User / assistant `Message` | +| `attachment` | Hook output, skill and agent listings | `Record::Other`, no Common turn | +| `active-leaf` | Last-turn pointer | `Record::Other`, bookkeeping | +| `runtime-config`, `workspace-directories`, `worktree-state` | Runtime and workspace state | `Record::Other`, bookkeeping | +| `last-prompt` | Prompt bookkeeping | `Record::Other`, no Common turn | +| `custom-title` | User-set title (`qodercli -n `) | `Meta.title`, highest precedence | +| `ai-title` | Generated title | `Meta.title` when no custom title exists | +| `relocated`, `file-history-snapshot` | Relocation and file-history bookkeeping | `Record::Other`, no Common turn | +| informational `system` | Notices and other bookkeeping | `Record::Other`, no Common turn | + +Title precedence is `custom-title` > `ai-title`; Qoder has no `summary` +line. `from_common` writes conversation lines as Claude Code does, with an +`ai-title` line first when a title exists and a trailing `active-leaf` +naming the last turn. Model names, message ids, and usage from other +harnesses are accepted as-is. + +## Lineage + +`--fork-session` stamps copied lines with +`forkedFrom: {sessionId, messageUuid}`. The first such `sessionId` supplies +a Fork `Meta.lineage` parent, both at discovery and on load. + +## Caveats + +Common conversion loses bookkeeping, envelope extras, and unmodeled blocks +as in [Claude Code](claude-code.md#caveats), and `Meta.lineage` is read but +not written back. A foreign thinking signature is written through unchanged; +Qoder resumes with it. Native load/save retains bookkeeping records. + +Resume with `qodercli --resume `. Override txcript's launch command with +`TRANSCRIPT_QODER_RESUME_CMD`, for example +`TRANSCRIPT_QODER_RESUME_CMD='qoderclicn --resume {id}'` for the China build. + +## References + +- Observed local qodercli 1.1.65 sessions, including forks, long project + paths, deletion, and resume without sidecars. +- Conversation mapping: [claude-code.md](claude-code.md). + +The authoritative txcript mapping is `src/harness/qoder.rs`, sharing the +conversation codec in `src/harness/claude_code.rs`. + +Last verified: 2026-10-05, against src/harness/qoder.rs and real local qodercli 1.1.65 sessions. diff --git a/docs/usage.md b/docs/usage.md index ca51e64..0a721bf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -222,7 +222,7 @@ writeFileSync("session.jsonl", convert(input, "codex", "claude_code")); const common = JSON.parse(toCommon(input, "codex")); // { meta, messages } const pi = fromCommon(JSON.stringify(common), "pi"); -harnesses(); // ["claude_code","claude_chat","cowork_remote","chatgpt","codex","opencode","pi","campfire","cursor","cursor_desktop","grok","grok_bot","fx","hermes","amp","antigravity","simple","cowork"] +harnesses(); // ["claude_code","claude_chat","cowork_remote","chatgpt","codex","opencode","pi","campfire","cursor","cursor_desktop","grok","grok_bot","fx","hermes","amp","antigravity","qoder","simple","cowork"] ``` Text-in / text-out: `input` is the source harness's native session text and the result is the target's. Invalid harness names or unparseable input throw a JS `Error`. @@ -243,7 +243,7 @@ const matches = JSON.parse(index.query(JSON.stringify({ pattern: "relay bug" })) | Harness | Session text | |---|---| -| `claude_code`, `codex`, `pi`, `campfire` | session JSONL | +| `claude_code`, `codex`, `pi`, `campfire`, `qoder` | session JSONL | | `cowork_remote` | a native document with `session` detail response and ordered `events` (source-only) | | `claude_chat` | one live conversation detail response (source-only; no account export arrays) | | `chatgpt` | one live conversation detail response (source-only; no account export arrays) | diff --git a/src/harness/mod.rs b/src/harness/mod.rs index b550c47..05d8a5f 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -23,6 +23,7 @@ pub mod grok_bot; pub mod hermes; pub mod opencode; pub mod pi; +pub mod qoder; pub mod simple; pub(crate) mod jsonl; diff --git a/src/harness/qoder.rs b/src/harness/qoder.rs new file mode 100644 index 0000000..c85c5d8 --- /dev/null +++ b/src/harness/qoder.rs @@ -0,0 +1,467 @@ +//! Qoder CLI: `~/.qoder/projects//.jsonl`. +//! +//! Qoder's transcript is Claude Code's wire format: one JSON object per line, +//! `user`/`assistant` lines wrapping an Anthropic message, threaded by +//! `parentUuid`. Tool calls use Claude's own tool names and argument keys +//! (`Read`, `Edit`, `Write`, `Bash`, `Grep`, `Glob`, ...) and tool results ride +//! on `user` lines. So `Qoder` shares Claude Code's native [`Record`] body and +//! conversation codec. What differs is everything around the conversation: +//! +//! - **Bookkeeping lines** (`active-leaf`, `runtime-config`, +//! `workspace-directories`, `worktree-state`, `last-prompt`, `relocated`, +//! `file-history-snapshot`, `attachment`, informational `system`) are kept +//! verbatim in [`Record::Other`] and carry no turn. Hook output, skill and +//! agent listings are `attachment` lines, so they never reach the +//! conversation. +//! - **Titles** come from a `custom-title` line (`qodercli -n `), else +//! from the generated `ai-title` line. Qoder has no `summary` line, so +//! `from_common` writes `ai-title`. +//! - **Lineage**: a session made with `--fork-session` stamps every copied +//! line with `forkedFrom: {sessionId, messageUuid}`; the first such +//! `sessionId` is a [`Relation::Fork`] parent, read at discovery as well as +//! on load. +//! - **Directory encoding**: the project directory is the cwd with every +//! UTF-16 code unit outside `[A-Za-z0-9]` replaced by `-`. Past 200 +//! characters it is cut to 200, then `-`, then the base-36 absolute value +//! of a djb2-xor hash of the original path (observed on a 315-character +//! cwd). +//! - **Sidecars**: `/state.json` and `/compression-v2/` are +//! Qoder-internal. They are neither read nor written; a session without +//! them resumes with its full history, and `delete` mirrors +//! `qodercli --delete-session` by removing only the `.jsonl`. +//! - **Discovery** lists `//.jsonl` files that +//! hold at least one line with a `type` and a `sessionId`; `delete` only +//! removes a file at that same depth. +//! +//! What `from_common` writes: the conversation lines (exactly Claude Code's), +//! an `ai-title` line when the session has a title, and a trailing +//! `active-leaf` line naming the last turn. Resume needs only the +//! conversation chain; the extra lines mirror what Qoder itself writes. +//! Model names, message ids and usage from other harnesses are accepted +//! as-is; a thinking block's foreign signature is written through and Qoder +//! resumes with it. Known losses are Claude Code's, plus `Meta::lineage`, +//! which is read but not written back. +//! +//! The CLI has two regional builds with this one format. The global build +//! (`qodercli`, `~/.qoder`) honors `QODER_CONFIG_DIR`, which this store +//! follows. The China build (`qoderclicn`, `~/.qoder-cn`) ignores that +//! variable; point txcript at it with `QODER_CONFIG_DIR="$HOME/.qoder-cn"`. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::DateTime; +use serde::Deserialize; +use serde_json::json; + +use crate::common::{Lineage, Meta, Relation}; +use crate::error::{Error, Result}; +use crate::harness::claude_code::{self, Record}; +use crate::harness::jsonl; +use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; + +/// The Qoder harness marker. Shares Claude Code's native [`Record`] body. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Qoder; + +impl Harness for Qoder { + const NAME: &'static str = "qoder"; + type Body = Vec; +} + +// ── codec ────────────────────────────────────────────────────────────── + +impl Codec for Qoder { + fn to_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + claude_code::records_to_messages(&transcript.body, transcript.meta.timestamp), + )) + } + + fn from_common(transcript: &Transcript) -> Result> { + // Qoder has no `summary` line to anchor a title to, so the donor + // writes none and the title goes on an `ai-title` line below. + let untitled = Meta { + title: None, + ..transcript.meta.clone() + }; + let mut records = claude_code::messages_to_records(&untitled, &transcript.body); + + // The donor stamps one session id on every line (a fresh one when + // `meta.id` is empty); the lines added here must carry the same. + let session_id = records + .iter() + .find_map(|record| match record { + Record::User(entry) | Record::Assistant(entry) => entry.session_id.clone(), + _ => None, + }) + .unwrap_or_else(|| transcript.meta.id.clone()); + + if let Some(title) = transcript.meta.title.as_deref().filter(|t| !t.is_empty()) { + records.insert( + 0, + Record::Other(json!({ + "type": "ai-title", + "sessionId": session_id, + "aiTitle": title, + })), + ); + } + + // The last conversation line is the leaf, and its own timestamp is + // the line's (a trailing local-command line is not a leaf). + let leaf = records.iter().rev().find_map(|record| match record { + Record::User(entry) | Record::Assistant(entry) => Some(entry), + _ => None, + }); + if let Some(entry) = leaf { + let millis = entry + .timestamp + .as_deref() + .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok()) + .map(|ts| ts.timestamp_millis()); + records.push(Record::Other(json!({ + "type": "active-leaf", + "sessionId": session_id, + "leafUuid": entry.uuid, + "explicit": false, + "timestamp": millis, + }))); + } + + Ok(Transcript::new(transcript.meta.clone(), records)) + } +} + +impl TextCodec for Qoder { + fn from_text(text: &str) -> Result> { + let records: Vec = text + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(claude_code::record_from_line) + .collect(); + let mut meta = claude_code::meta_from_records(&records); + overlay_meta(&mut meta, text); + Ok(Transcript::new(meta, records)) + } + + fn to_text(transcript: &Transcript) -> Result { + jsonl::render(&transcript.body) + } +} + +/// The few `Meta` fields Qoder keeps on lines the donor ignores, found in the +/// same order from the raw text on every path — `from_text` and discovery +/// both call this, so `discover` and `load` agree by construction. +#[derive(Deserialize)] +struct SideLine { + #[serde(rename = "type", default)] + kind: Option, + #[serde(rename = "aiTitle", default)] + ai_title: Option, + #[serde(rename = "forkedFrom", default)] + forked_from: Option, +} + +#[derive(Deserialize)] +struct ForkedFrom { + #[serde(rename = "sessionId", default)] + session_id: Option, +} + +/// Fill `meta.title` from the generated title (a `custom-title` the donor +/// already read wins) and `meta.lineage` from `forkedFrom`. +fn overlay_meta(meta: &mut Meta, text: &str) { + let mut ai_title: Option = None; + let mut parent: Option = None; + for line in text.lines() { + // Cheap gate: only these two keys matter, and most lines are large. + if !(line.contains("\"ai-title\"") || line.contains("\"forkedFrom\"")) { + continue; + } + let Ok(side) = serde_json::from_str::(line) else { + continue; + }; + if side.kind.as_deref() == Some("ai-title") + && let Some(title) = side.ai_title.filter(|t| !t.is_empty()) + { + ai_title = Some(title); + } + if parent.is_none() + && let Some(id) = side + .forked_from + .and_then(|f| f.session_id) + .filter(|id| !id.is_empty()) + { + parent = Some(id); + } + } + if meta.title.is_none() { + meta.title = ai_title; + } + if meta.lineage.is_none() + && let Some(parent) = parent + { + meta.lineage = Some(Lineage { + parent, + relation: Relation::Fork, + }); + } +} + +/// Session metadata from a shallow scan of the raw text: the donor's +/// user/assistant/title scan plus [`overlay_meta`]. Equivalent to +/// `from_text(text)?.meta`, without building message payloads. +fn meta_from_text(text: &str) -> Meta { + let mut meta = claude_code::meta_from_text(text); + overlay_meta(&mut meta, text); + meta +} + +/// Settle the id and lineage once the file is known: an id no record carries +/// is the file stem, and a session is never its own fork parent. Shared by +/// `discover` and `load` so they agree. +fn finish_meta(meta: &mut Meta, path: &Path) { + if meta.id.is_empty() { + meta.id = jsonl::file_id(path); + } + if meta.lineage.as_ref().is_some_and(|l| l.parent == meta.id) { + meta.lineage = None; + } +} + +/// The two keys every Qoder line carries, however it is typed. +#[derive(Deserialize)] +struct Sniff { + #[serde(rename = "type", default)] + kind: Option, + #[serde(rename = "sessionId", default)] + session_id: Option, +} + +/// Whether `text` holds at least one record of a Qoder session: a JSON line +/// with a string `type` and a `sessionId`. Bookkeeping-only sessions (opened +/// and closed without a prompt) qualify; an empty or foreign file does not. +fn looks_like_session(text: &str) -> bool { + text.lines().any(|line| { + serde_json::from_str::(line) + .is_ok_and(|sniff| sniff.kind.is_some() && sniff.session_id.is_some()) + }) +} + +// ── store ────────────────────────────────────────────────────────────── + +/// The longest project-directory name Qoder writes before it truncates. +const MAX_DIR_LEN: usize = 200; + +/// Reads and writes Qoder sessions under a projects root (default +/// `~/.qoder/projects`). +#[derive(Debug, Clone)] +pub struct QoderStore { + pub root: PathBuf, +} + +impl QoderStore { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// The default projects root: `$QODER_CONFIG_DIR/projects` when set, else + /// `~/.qoder/projects`. + #[must_use] + pub fn default_root() -> Option { + std::env::var_os("QODER_CONFIG_DIR") + .filter(|v| !v.is_empty()) + .map(|dir| Self::new(PathBuf::from(dir).join("projects"))) + .or_else(|| super::home_dir().map(|h| Self::new(h.join(".qoder").join("projects")))) + } + + /// Every `//.jsonl`, in path order. Sessions sit + /// exactly two levels down; the `/` sidecar directories beside + /// them are not descended into. + fn session_files(&self) -> Vec { + let mut out = Vec::new(); + // An unreadable directory lists nothing. + for project in fs::read_dir(&self.root).into_iter().flatten().flatten() { + // `file_type` doesn't follow symlinks, so a link can't send the + // walk out of the root; symlinked session files still list. + if !project.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + for entry in fs::read_dir(project.path()).into_iter().flatten().flatten() { + let path = entry.path(); + if !entry.file_type().is_ok_and(|t| t.is_dir()) + && path.extension().is_some_and(|e| e == "jsonl") + { + out.push(path); + } + } + } + out.sort(); + out + } +} + +impl Store for QoderStore { + type H = Qoder; + type Ref = PathBuf; + + fn discover(&self) -> Result>> { + if !self.root.is_dir() { + // A missing root means no sessions, not an error. + return Ok(Vec::new()); + } + let files = self.session_files(); + Ok(super::filter_map_parallel(&files, |path| { + // A file that fails to read, or holds no session record, is + // skipped: a `.jsonl` extension alone doesn't make a session. + fs::read_to_string(path) + .ok() + .filter(|text| looks_like_session(text)) + .map(|text| { + let mut meta = meta_from_text(&text); + finish_meta(&mut meta, path); + Discovered { + meta, + reference: path.clone(), + } + }) + })) + } + + fn load(&self, reference: &PathBuf) -> Result> { + let mut transcript = Qoder::from_text(&fs::read_to_string(reference)?)?; + finish_meta(&mut transcript.meta, reference); + Ok(transcript) + } + + fn save(&self, transcript: &Transcript) -> Result> { + let id = transcript.meta.id.clone(); + super::checked_id_component(Qoder::NAME, &id)?; + let cwd = transcript.meta.cwd.as_deref().unwrap_or_default(); + // An unknown cwd files under the filesystem root's project (`/` → + // `-`) rather than loose in the projects root, where neither Qoder + // nor `discover` would look. + let project = if cwd.is_empty() { + "-".to_string() + } else { + encode_project_dir(cwd) + }; + let dir = self.root.join(project); + fs::create_dir_all(&dir)?; + let path = dir.join(format!("{id}.jsonl")); + fs::write(&path, Qoder::to_text(transcript)?)?; + Ok(Saved { + id, + reference: path, + }) + } + + /// Removes a Qoder session log, as `qodercli --delete-session` does: the + /// `.jsonl` only, leaving the `/` sidecar. Guarded on shape and + /// containment: the reference must be a `.jsonl` file resolving within + /// `root`, so a foreign or stale reference never removes files outside + /// the configured projects. + fn delete(&self, reference: &PathBuf) -> Result<()> { + if reference.extension().is_none_or(|ext| ext != "jsonl") { + return Err(Error::Malformed { + harness: Qoder::NAME, + detail: format!("not a qoder session file: {}", reference.display()), + }); + } + let canon = reference.canonicalize()?; + let root = self.root.canonicalize()?; + if canon.strip_prefix(&root).is_err() || canon == root { + return Err(Error::Malformed { + harness: Qoder::NAME, + detail: format!( + "refusing to delete outside the projects root: {}", + reference.display() + ), + }); + } + // A session sits exactly at `//.jsonl`, the + // layout `discover` lists. The reference's own location must match + // it, and that location is what gets removed: a symlink is unlinked + // itself, never resolved to a sidecar or another project's session. + let parent = reference + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")) + .canonicalize()?; + let name = reference.file_name().ok_or_else(|| Error::Malformed { + harness: Qoder::NAME, + detail: format!("not a qoder session file: {}", reference.display()), + })?; + if parent.parent() != Some(root.as_path()) { + return Err(Error::Malformed { + harness: Qoder::NAME, + detail: format!( + "not a session at //.jsonl: {}", + reference.display() + ), + }); + } + Ok(fs::remove_file(parent.join(name))?) + } + + fn fingerprints(&self, refs: &[PathBuf]) -> Result> { + Ok(refs + .iter() + .map(|path| { + ( + path.to_string_lossy().into_owned(), + claude_code::file_fingerprint(path), + ) + }) + .collect()) + } +} + +/// Qoder's project-directory name for `cwd`: every UTF-16 code unit outside +/// `[A-Za-z0-9]` becomes `-` (so a character beyond the BMP becomes two), +/// and past [`MAX_DIR_LEN`] the name is cut and suffixed with a hash of the +/// original path so distinct long paths stay distinct. +fn encode_project_dir(cwd: &str) -> String { + let mut name = String::with_capacity(cwd.len()); + for c in cwd.chars() { + if c.is_ascii_alphanumeric() { + name.push(c); + } else { + name.extend(std::iter::repeat_n('-', c.len_utf16())); + } + } + if name.len() <= MAX_DIR_LEN { + return name; + } + // The name is ASCII by now, so truncating by bytes is by characters. + name.truncate(MAX_DIR_LEN); + format!("{name}-{}", base36(path_hash(cwd))) +} + +/// The hash Qoder suffixes onto a truncated project directory: djb2 with xor +/// (`h = (h * 33) ^ unit`, seeded 5381) over the path's UTF-16 code units in +/// 32-bit signed arithmetic, then the absolute value. +fn path_hash(path: &str) -> u32 { + let mut hash: i32 = 5381; + for unit in path.encode_utf16() { + hash = hash.wrapping_mul(33) ^ i32::from(unit); + } + hash.unsigned_abs() +} + +fn base36(mut n: u32) -> String { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if n == 0 { + return "0".to_string(); + } + let mut out = Vec::new(); + while n > 0 { + out.push(DIGITS[(n % 36) as usize]); + n /= 36; + } + out.reverse(); + String::from_utf8_lossy(&out).into_owned() +} diff --git a/src/lib.rs b/src/lib.rs index a340523..3decd3c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Typed conversion for coding-agent session transcripts. //! //! Claude Code, Claude Chat, Cowork, Codex, `OpenCode`, pi, Campfire, Cursor, -//! Grok, Hermes, Amp, and Antigravity record similar conversation data in +//! Grok, Hermes, Amp, Antigravity, and Qoder record similar conversation data in //! different stores. This crate maps each format through [`Transcript`] and //! converts with [`convert::`](convert): `A` -> [`Common`] -> `B`. //! diff --git a/src/local.rs b/src/local.rs index 4c8dd57..b2e8dc9 100644 --- a/src/local.rs +++ b/src/local.rs @@ -23,7 +23,7 @@ use chrono::{DateTime, Utc}; use crate::common::{ArtifactSource, Block, Meta}; use crate::harness::{ - amp, antigravity, campfire, claude_code, codex, cowork, cursor, fx, grok, grok_bot, pi, + amp, antigravity, campfire, claude_code, codex, cowork, cursor, fx, grok, grok_bot, pi, qoder, }; #[cfg(feature = "chatgpt")] @@ -141,6 +141,12 @@ pub fn discover_with(mut on_store: impl FnMut(HarnessId, usize)) -> Vec antigravity::AntigravityStore::default_root(), &mut out, ); + on_store(HarnessId::Qoder, out.len()); + scan( + HarnessId::Qoder, + qoder::QoderStore::default_root(), + &mut out, + ); on_store(HarnessId::Cowork, out.len()); scan( HarnessId::Cowork, @@ -384,6 +390,7 @@ impl Session { (HarnessId::Antigravity, Locator::Path(p)) => { go(antigravity::AntigravityStore::default_root(), p) } + (HarnessId::Qoder, Locator::Path(p)) => go(qoder::QoderStore::default_root(), p), (HarnessId::Cowork, Locator::Path(p)) => go(cowork::CoworkStore::default_root(), p), #[cfg(feature = "hermes")] (HarnessId::Hermes, Locator::Id(id)) => { @@ -443,6 +450,7 @@ impl Session { (HarnessId::Antigravity, Locator::Path(p)) => { go(antigravity::AntigravityStore::default_root(), p) } + (HarnessId::Qoder, Locator::Path(p)) => go(qoder::QoderStore::default_root(), p), (HarnessId::Cowork, Locator::Path(p)) => go(cowork::CoworkStore::default_root(), p), // Errors: the Hermes store is read-only. #[cfg(feature = "hermes")] @@ -510,6 +518,7 @@ pub fn fingerprints(sessions: &[Session]) -> Vec { HarnessId::Fx => group.files(fx::FxStore::default_root()), HarnessId::Amp => group.files(amp::AmpStore::default_root()), HarnessId::Antigravity => group.files(antigravity::AntigravityStore::default_root()), + HarnessId::Qoder => group.files(qoder::QoderStore::default_root()), HarnessId::Cowork => group.files(cowork::CoworkStore::default_root()), #[cfg(feature = "hermes")] HarnessId::Hermes => group.ids(hermes::HermesStore::default_root()), @@ -870,6 +879,13 @@ pub fn write_with( common, |s| s.root, ), + HarnessId::Qoder => go( + qoder::QoderStore::default_root(), + qoder::QoderStore::new, + root, + common, + |s| s.root, + ), HarnessId::Cowork => { let store = match root { Some(dir) => cowork::CoworkStore::new(dir), None => required(cowork::CoworkStore::default_root())? }; // Resolve the active account before writing any files. @@ -1123,6 +1139,7 @@ pub fn resume_command(harness: HarnessId, id: &str) -> (String, Vec) { HarnessId::Hermes => ("hermes".into(), vec!["--resume".into(), id]), HarnessId::Amp => ("amp".into(), vec!["threads".into(), "continue".into(), id]), HarnessId::Antigravity => ("agy".into(), vec![format!("--conversation={id}")]), + HarnessId::Qoder => ("qodercli".into(), vec!["--resume".into(), id]), // No per-session entry point: the Claude desktop app lists the // session under Cowork once it restarts or rescans. HarnessId::Cowork if cfg!(target_os = "macos") => { diff --git a/src/transcript.rs b/src/transcript.rs index c89ba1e..07fa34b 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -460,12 +460,13 @@ pub enum HarnessId { Hermes, Amp, Antigravity, + Qoder, Simple, Cowork, } impl HarnessId { - pub const ALL: [HarnessId; 18] = [ + pub const ALL: [HarnessId; 19] = [ HarnessId::ClaudeCode, HarnessId::ClaudeChat, HarnessId::CoworkRemote, @@ -482,6 +483,7 @@ impl HarnessId { HarnessId::Hermes, HarnessId::Amp, HarnessId::Antigravity, + HarnessId::Qoder, HarnessId::Simple, HarnessId::Cowork, ]; @@ -506,6 +508,7 @@ impl HarnessId { HarnessId::Hermes => "hermes", HarnessId::Amp => "amp", HarnessId::Antigravity => "antigravity", + HarnessId::Qoder => "qoder", HarnessId::Simple => "simple", HarnessId::Cowork => "cowork", } @@ -554,6 +557,7 @@ impl FromStr for HarnessId { "antigravity" | "agy" | "antigravity_cli" | "antigravity-cli" | "anti-gravity" => { Ok(HarnessId::Antigravity) } + "qoder" | "qodercli" | "qoder_cli" | "qoder-cli" => Ok(HarnessId::Qoder), "simple" | "simple_json" | "simple-json" => Ok(HarnessId::Simple), "cowork" | "claude_cowork" | "claude-cowork" | "claude_desktop" | "claude-desktop" => { Ok(HarnessId::Cowork) diff --git a/src/wasm.rs b/src/wasm.rs index e1bbb6f..374cdc6 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -15,13 +15,13 @@ use wasm_bindgen::prelude::*; use crate::common; use crate::harness::{ amp, antigravity, campfire, chatgpt, claude_chat, claude_code, codex, cowork, cowork_remote, - cursor, cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, simple, + cursor, cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, qoder, simple, }; use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; /// Continue/convert a session from one harness's native text into another's. /// -/// `input` is the source session text (JSONL for `claude_code`/codex/pi/campfire, +/// `input` is the source session text (JSONL for `claude_code`/codex/pi/campfire/qoder, /// one live conversation detail object for `claude_chat` or `chatgpt`, /// a session-and-events document for `cowork_remote`, /// the Cursor JSON DB export for cursor, the JSON dump of the session's @@ -35,7 +35,7 @@ use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; /// record, transcript and audit log for cowork); `from`/`to` are harness /// names (`"claude_code"`, `"claude_chat"`, `"cowork_remote"`, `"chatgpt"`, `"codex"`, `"opencode"`, `"pi"`, `"campfire"`, /// `"cursor"`, `"cursor_desktop"`, `"grok"`, `"grok_bot"`, `"fx"`, `"hermes"`, `"amp"`, -/// `"antigravity"`, `"simple"`, `"cowork"`). Returns the target harness's +/// `"antigravity"`, `"qoder"`, `"simple"`, `"cowork"`). Returns the target harness's /// native text. #[wasm_bindgen] pub fn convert(input: &str, from: &str, to: &str) -> Result { @@ -215,6 +215,7 @@ fn parse_to_common(harness: HarnessId, text: &str) -> crate::Result go::(text), HarnessId::Amp => go::(text), HarnessId::Antigravity => go::(text), + HarnessId::Qoder => go::(text), HarnessId::Simple => go::(text), HarnessId::Cowork => go::(text), } @@ -241,6 +242,7 @@ fn render_from_common(harness: HarnessId, common: &Transcript) -> crate: HarnessId::Hermes => go::(common), HarnessId::Amp => go::(common), HarnessId::Antigravity => go::(common), + HarnessId::Qoder => go::(common), HarnessId::Simple => go::(common), HarnessId::Cowork => go::(common), } diff --git a/tests/integration/cross_harness.rs b/tests/integration/cross_harness.rs index 7a80ba5..f99217d 100644 --- a/tests/integration/cross_harness.rs +++ b/tests/integration/cross_harness.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use txcript::common; use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, - grok_bot, hermes, opencode, pi, simple, + grok_bot, hermes, opencode, pi, qoder, simple, }; use txcript::{Codec, Common, Transcript, convert}; @@ -194,6 +194,7 @@ fn sample_with_raw_tool(tool_name: &str) -> Transcript { Transcript::new(meta, body) } +#[allow(clippy::too_many_lines)] fn assert_cycle(common: &Transcript, context: &str) { let expected = signature(common); @@ -233,7 +234,13 @@ fn assert_cycle(common: &Transcript, context: &str) { "{context}: campfire" ); - let cursor = convert::(&campfire).unwrap(); + let qoder = convert::(&campfire).unwrap(); + assert_eq!( + signature(&qoder::Qoder::to_common(&qoder).unwrap()), + expected, + "{context}: qoder" + ); + let cursor = convert::(&qoder).unwrap(); assert_eq!( signature(&cursor::Cursor::to_common(&cursor).unwrap()), expected, diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 4eb9cce..8fc6d52 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -24,6 +24,7 @@ mod opencode; mod path_safety; mod pi; mod properties; +mod qoder; mod simple; mod store_delete; diff --git a/tests/integration/path_safety.rs b/tests/integration/path_safety.rs index 64b82bc..467b855 100644 --- a/tests/integration/path_safety.rs +++ b/tests/integration/path_safety.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use chrono::{TimeZone, Utc}; use txcript::common::{Block, Message, Meta, Role}; use txcript::harness::{ - amp, antigravity, campfire, claude_code, codex, cowork, cursor, fx, grok, grok_bot, pi, + amp, antigravity, campfire, claude_code, codex, cowork, cursor, fx, grok, grok_bot, pi, qoder, }; use txcript::{Codec, Common, Store, Transcript}; @@ -80,6 +80,7 @@ fn hostile_ids_cannot_escape_any_file_backed_store() { assert_save_confined(&claude_code::ClaudeStore::new(root.to_path_buf()), root); assert_save_confined(&codex::CodexStore::new(root.to_path_buf()), root); assert_save_confined(&pi::PiStore::new(root.to_path_buf()), root); + assert_save_confined(&qoder::QoderStore::new(root.to_path_buf()), root); assert_save_confined(&campfire::CampfireStore::new(root.to_path_buf()), root); assert_save_confined(&cursor::CursorStore::new(root.to_path_buf()), root); assert_save_confined(&grok::GrokStore::new(root.to_path_buf()), root); @@ -406,3 +407,40 @@ fn codex_delete_refuses_symlink_escapes_from_both_roots() { ); } } + +#[test] +fn qoder_delete_refuses_paths_outside_the_sessions_root() { + let sessions = tempfile::tempdir().unwrap(); + let victim = tempfile::tempdir().unwrap(); + let foreign = victim.path().join("session.jsonl"); + std::fs::write(&foreign, b"{}").unwrap(); + + let store = qoder::QoderStore::new(sessions.path().to_path_buf()); + assert!( + store.delete(&foreign).is_err(), + "a session.jsonl outside the sessions root must be refused" + ); + assert!(victim.path().is_dir(), "the foreign directory must survive"); + assert!(foreign.is_file(), "the foreign file must survive"); +} + +#[cfg(unix)] +#[test] +fn qoder_delete_refuses_symlink_escapes_and_discovery_ignores_project_links() { + let root = tempfile::tempdir().unwrap(); + let victim = tempfile::tempdir().unwrap(); + let foreign = victim.path().join("session.jsonl"); + std::fs::write(&foreign, b"{}").unwrap(); + let project = root.path().join("-work-proj"); + std::fs::create_dir_all(&project).unwrap(); + let link = project.join("linked.jsonl"); + std::os::unix::fs::symlink(&foreign, &link).unwrap(); + let store = qoder::QoderStore::new(root.path()); + assert!(store.delete(&link).is_err()); + assert!(foreign.is_file()); + assert!(link.is_symlink()); + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(victim.path(), root.path().join("outside-project")).unwrap(); + std::os::unix::fs::symlink(root.path(), project.join("loop")).unwrap(); + assert!(store.discover().unwrap().is_empty()); +} diff --git a/tests/integration/properties.rs b/tests/integration/properties.rs index 176c2da..3582f66 100644 --- a/tests/integration/properties.rs +++ b/tests/integration/properties.rs @@ -19,7 +19,7 @@ use serde_json::json; use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput}; use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, - grok_bot, hermes, opencode, pi, simple, + grok_bot, hermes, opencode, pi, qoder, simple, }; use txcript::{Codec, Common, Transcript}; @@ -269,6 +269,7 @@ proptest! { assert_fixpoint::("codex", &common)?; assert_fixpoint::("opencode", &common)?; assert_fixpoint::("pi", &common)?; + assert_fixpoint::("qoder", &common)?; assert_fixpoint::("campfire", &common)?; assert_fixpoint::("cursor", &common)?; assert_fixpoint::("cursor_desktop", &common)?; diff --git a/tests/integration/qoder.rs b/tests/integration/qoder.rs new file mode 100644 index 0000000..c8c1f6c --- /dev/null +++ b/tests/integration/qoder.rs @@ -0,0 +1,599 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +//! Qoder's native disk fidelity, shared conversation codec, and store contracts. +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +use txcript::common; +use txcript::harness::qoder::{Qoder, QoderStore}; +use txcript::{Codec, Common, HarnessId, Store, TextCodec, Transcript}; + +const ID: &str = "11111111-2222-4333-8444-555555555555"; +const TIME: &str = "2026-01-02T03:04:05.000Z"; +fn ts() -> DateTime { + TIME.parse().unwrap() +} +fn render(lines: &[Value]) -> String { + lines + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + + "\n" +} +fn lines() -> Vec { + let mut lines = vec![ + json!({"type":"ai-title","sessionId":ID,"aiTitle":"Generated title"}), + json!({"type":"user","uuid":"u1","parentUuid":null,"sessionId":ID,"cwd":"/work/proj","gitBranch":"main","version":"1.1.65","timestamp":TIME,"message":{"role":"user","content":"inspect the project"}}), + json!({"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":ID,"timestamp":TIME,"message":{"role":"assistant","model":"qfmodel","content":[ + {"type":"thinking","thinking":"Check the files","signature":""}, + {"type":"tool_use","id":"read","name":"Read","input":{"file_path":"/work/proj/a.rs","offset":1,"limit":20}}, + {"type":"tool_use","id":"edit","name":"Edit","input":{"file_path":"/work/proj/a.rs","old_string":"old","new_string":"new","replace_all":true}}, + {"type":"tool_use","id":"write","name":"Write","input":{"file_path":"/work/proj/b.rs","content":"hello"}}, + {"type":"tool_use","id":"bash","name":"Bash","input":{"command":"exit 1","description":"Fail deliberately"}}, + {"type":"tool_use","id":"grep","name":"Grep","input":{"pattern":"hello","path":"/work/proj"}}, + {"type":"tool_use","id":"glob","name":"Glob","input":{"pattern":"*.rs","path":"/work/proj"}} + ]}}), + json!({"type":"user","uuid":"u2","parentUuid":"a1","sessionId":ID,"timestamp":TIME,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"bash","content":"exit status 1","is_error":true}]}}), + ]; + for kind in [ + "runtime-config", + "workspace-directories", + "worktree-state", + "last-prompt", + "file-history-snapshot", + "active-leaf", + "relocated", + ] { + lines.push(json!({"type":kind,"sessionId":ID,"opaque":{"kept":true},"timestamp":TIME})); + } + lines.push(json!({"type":"system","subtype":"informational","sessionId":ID,"content":"internal notice","timestamp":TIME})); + for kind in [ + "hook_output", + "skill_listing", + "agent_listing_delta", + "hook_permission_decision", + "critical_system_reminder", + ] { + lines.push(json!({"type":"attachment","sessionId":ID,"attachment":{"type":kind,"content":"internal data"},"timestamp":TIME})); + } + lines +} +fn native() -> Transcript { + Qoder::from_text(&render(&lines())).unwrap() +} +/// Independent Common fixture: exercise fields not inferred from native parsing. +fn common_fixture() -> Transcript { + let meta = common::Meta { + id: ID.into(), + timestamp: ts(), + cwd: Some("/work/proj".into()), + git_branch: Some("main".into()), + title: Some("Generated title".into()), + cli_version: Some("1.1.65".into()), + model: Some("qfmodel".into()), + lineage: None, + }; + let message = |role, content, model| common::Message { + role, + content, + timestamp: ts(), + model, + stop_reason: None, + usage: None, + }; + let mut assistant = message( + common::Role::Assistant, + vec![ + common::Block::Thinking { + text: "Check the files".into(), + signature: Some(String::new()), + encrypted: None, + }, + common::Block::Text { + text: "Running the command".into(), + }, + common::Block::ToolUse { + id: "bash".into(), + tool: common::Tool::Bash { + command: "exit 1".into(), + workdir: None, + timeout_ms: None, + description: Some("Fail deliberately".into()), + run_in_background: false, + }, + }, + ], + Some("qfmodel".into()), + ); + assistant.stop_reason = Some(common::StopReason::ToolUse); + assistant.usage = Some(common::Usage { + input_tokens: 10, + output_tokens: 20, + cache_read_input_tokens: Some(3), + cache_creation_input_tokens: Some(4), + }); + Transcript::new( + meta, + vec![ + message( + common::Role::User, + vec![common::Block::Text { + text: "inspect the project".into(), + }], + None, + ), + assistant, + message( + common::Role::User, + vec![common::Block::ToolResult { + tool_use_id: "bash".into(), + content: common::ToolOutput::Text("exit status 1".into()), + is_error: true, + }], + None, + ), + ], + ) +} + +#[test] +fn store_round_trip_is_lossless_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let store = QoderStore::new(dir.path()); + let src = dir.path().join("original.jsonl"); + std::fs::write(&src, render(&lines())).unwrap(); + let loaded = store.load(&src).unwrap(); + let saved = store.save(&loaded).unwrap(); + assert_eq!( + saved.reference, + dir.path().join("-work-proj").join(format!("{ID}.jsonl")) + ); + assert_eq!(loaded, store.load(&saved.reference).unwrap()); + assert_eq!(loaded.body.len(), lines().len()); +} + +#[test] +fn discover_extracts_metadata() { + for (custom, ai, fork) in [ + (true, true, true), + (false, true, false), + (false, false, false), + ] { + let dir = tempfile::tempdir().unwrap(); + let store = QoderStore::new(dir.path()); + let mut rows = lines(); + if !ai { + rows.remove(0); + } + if custom { + rows.insert( + 0, + json!({"type":"custom-title","sessionId":ID,"customTitle":"Named session"}), + ); + } + if fork { + rows[2]["forkedFrom"] = + json!({"sessionId":"parent-session","messageUuid":"parent-message"}); + } + let native = Qoder::from_text(&render(&rows)).unwrap(); + let saved = store.save(&native).unwrap(); + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + let meta = &found[0].meta; + assert_eq!(*meta, store.load(&saved.reference).unwrap().meta); + assert_eq!(meta.id, ID); + assert_eq!(meta.cwd.as_deref(), Some("/work/proj")); + assert_eq!(meta.git_branch.as_deref(), Some("main")); + assert_eq!(meta.cli_version.as_deref(), Some("1.1.65")); + assert_eq!(meta.model.as_deref(), Some("qfmodel")); + assert_eq!(meta.timestamp, ts()); + assert_eq!( + meta.title.as_deref(), + if custom { + Some("Named session") + } else if ai { + Some("Generated title") + } else { + None + } + ); + assert_eq!( + meta.lineage, + fork.then(|| common::Lineage { + parent: "parent-session".into(), + relation: common::Relation::Fork + }) + ); + } +} + +#[test] +fn to_common_extracts_typed_tools_and_skips_bookkeeping() { + let c = Qoder::to_common(&native()).unwrap(); + assert_eq!(c.body.len(), 3); + assert!( + matches!(&c.body[1].content[0],common::Block::Thinking {signature:Some(s),text,..} if s.is_empty() && text == "Check the files") + ); + for (block, expected) in c.body[1].content[1..].iter().zip([ + json!({"file_path":"/work/proj/a.rs","offset":1,"limit":20}), + json!({"file_path":"/work/proj/a.rs","old_string":"old","new_string":"new","replace_all":true}), + json!({"file_path":"/work/proj/b.rs","content":"hello"}), + json!({"command":"exit 1","description":"Fail deliberately"}), + json!({"pattern":"hello","path":"/work/proj"}), + json!({"pattern":"*.rs","path":"/work/proj"}), + ]) { + let common::Block::ToolUse {tool,..} = block else {panic!("expected tool")}; + // Common currently models Grep/Glob as Raw; preserve their names and keys. + if matches!(tool, common::Tool::Raw { tool_name, .. } if tool_name == "Grep" || tool_name == "Glob") { + assert!(matches!(block, common::Block::ToolUse { id, .. } if id == "grep" || id == "glob")); + } else { + assert!(!matches!(tool, common::Tool::Raw {..}), "unexpected raw: {tool:?}"); + } + assert_eq!(*tool,common::Tool::from_canonical(match block {common::Block::ToolUse {id,..} => match id.as_str() {"read"=>"Read","edit"=>"Edit","write"=>"Write","bash"=>"Bash","grep"=>"Grep",_=>"Glob"},_=>unreachable!()},expected)); + } + assert!( + matches!(&c.body[2].content[0],common::Block::ToolResult {tool_use_id,content:common::ToolOutput::Text(t),is_error:true} if tool_use_id=="bash" && t=="exit status 1") + ); +} + +#[test] +fn to_common_preserves_local_model_command_and_output() { + let mut rows = lines(); + for (uuid, parent, content) in [ + ( + "caveat", + "u2", + "Ignore local command scaffolding.", + ), + ( + "cmd", + "caveat", + "/modelmodel", + ), + ( + "output", + "cmd", + "Model set to qfmodel", + ), + ] { + rows.push(json!({"type":"user","uuid":uuid,"parentUuid":parent,"sessionId":ID,"timestamp":TIME,"isMeta":true,"message":{"role":"user","content":content}})); + } + let c = Qoder::to_common(&Qoder::from_text(&render(&rows)).unwrap()).unwrap(); + assert_eq!(c.body.len(), 5); + assert!( + matches!(&c.body[3].content[0],common::Block::ToolUse {id,tool:common::Tool::Command {command,args:None}} if id=="cmd" && command=="/model") + ); + assert!( + matches!(&c.body[4].content[0],common::Block::ToolResult {tool_use_id,content:common::ToolOutput::Text(t),is_error:false} if tool_use_id=="cmd" && t=="Model set to qfmodel") + ); +} + +#[test] +fn codec_fixpoint_through_common_loses_nothing() { + let c = common_fixture(); + assert_eq!( + Qoder::to_common(&Qoder::from_common(&c).unwrap()).unwrap(), + c + ); +} +#[test] +fn from_common_is_deterministic() { + let c = common_fixture(); + assert_eq!( + Qoder::to_text(&Qoder::from_common(&c).unwrap()).unwrap(), + Qoder::to_text(&Qoder::from_common(&c).unwrap()).unwrap() + ); +} +#[test] +fn from_common_writes_ai_title_and_trailing_active_leaf() { + for empty_id in [false, true] { + let mut c = common_fixture(); + if empty_id { + c.meta.id.clear(); + } + c.body.last_mut().unwrap().timestamp = "2026-01-02T03:04:08.123Z".parse().unwrap(); + let n = Qoder::from_common(&c).unwrap(); + let rows: Vec = Qoder::to_text(&n) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let session = &rows[1]["sessionId"]; + assert!(session.as_str().is_some_and(|s| !s.is_empty())); + assert_eq!( + rows[0], + json!({"type":"ai-title","sessionId":session,"aiTitle":c.meta.title}) + ); + assert!( + rows.iter() + .all(|r| r["sessionId"] == *session && r["type"] != "summary") + ); + assert_eq!( + rows.last().unwrap(), + &json!({"type":"active-leaf","sessionId":session,"leafUuid":rows[rows.len()-2]["uuid"],"explicit":false,"timestamp":c.body.last().unwrap().timestamp.timestamp_millis()}) + ); + } + let mut c = common_fixture(); + c.meta.title = None; + let n = Qoder::from_common(&c).unwrap(); + assert!(!Qoder::to_text(&n).unwrap().contains("ai-title")); + c.body.clear(); + c.meta.title = Some("Empty session".into()); + let rows = Qoder::to_text(&Qoder::from_common(&c).unwrap()).unwrap(); + assert!(!rows.contains("active-leaf")); +} + +#[test] +fn save_encodes_project_directories_using_observed_qoder_goldens() { + let long = format!( + "/tmp/txq-long/{}", + (1..=14) + .map(|i| format!("segment_number_{i}")) + .collect::>() + .join("/") + ); + // Non-ASCII path (an astral emoji is two UTF-16 units, so two dashes and + // two hash units); 239 units, observed from qodercli 1.1.65. + let second = format!( + "/tmp/txq-long2/{}", + (1..=18) + .map(|i| format!("ünï_😀_seg{i}")) + .collect::>() + .join("/") + ); + let second_expected = "-tmp-txq-long2--n-----seg1--n-----seg2--n-----seg3--n-----seg4--n-----seg5--n-----seg6--n-----seg7--n-----seg8--n-----seg9--n-----seg10--n-----seg11--n-----seg12--n-----seg13--n-----seg14--n-----seg15-2lws58".to_string(); + assert_eq!(long.len(), 256); + assert_eq!(second.encode_utf16().count(), 239); + assert_eq!(second_expected.len(), 207); + for (cwd,expected) in [ + ("/tmp/a.b_c d/sub.dir".into(),"-tmp-a-b-c-d-sub-dir".into()), + ("/é项目-x".into(),"-----x".into()), + ("/😀x".into(),"---x".into()), + (r"C:\Users\me".into(),"C--Users-me".into()), + ("a".repeat(199),"a".repeat(199)), + ("a".repeat(200),"a".repeat(200)), + (long,"-tmp-txq-long-segment-number-1-segment-number-2-segment-number-3-segment-number-4-segment-number-5-segment-number-6-segment-number-7-segment-number-8-segment-number-9-segment-number-10-segment-number--75ig72".into()), + (second,second_expected), + ] { + let dir = tempfile::tempdir().unwrap(); + let mut n = native(); n.meta.cwd = Some(cwd); + let saved = QoderStore::new(dir.path()).save(&n).unwrap(); + assert_eq!(saved.reference,dir.path().join(expected).join(format!("{ID}.jsonl"))); + } +} +#[test] +fn missing_cwd_saves_under_root_project_and_is_discovered() { + let dir = tempfile::tempdir().unwrap(); + let store = QoderStore::new(dir.path()); + let mut c = common_fixture(); + c.meta.cwd = None; + let saved = store.save(&Qoder::from_common(&c).unwrap()).unwrap(); + assert_eq!( + saved.reference, + dir.path().join("-").join(format!("{ID}.jsonl")) + ); + assert_eq!(store.discover().unwrap().len(), 1); +} +#[test] +fn delete_removes_only_session_log_and_discovery_ignores_sidecars() { + let dir = tempfile::tempdir().unwrap(); + let store = QoderStore::new(dir.path()); + let saved = store.save(&native()).unwrap(); + let sidecar = saved.reference.with_extension(""); + std::fs::create_dir_all(&sidecar).unwrap(); + std::fs::write(sidecar.join("state.json"), "{\"kept\":true}").unwrap(); + std::fs::write(sidecar.join("something.jsonl"), render(&lines())).unwrap(); + assert_eq!(store.discover().unwrap().len(), 1); + let other = saved.reference.with_extension("txt"); + std::fs::write(&other, "keep").unwrap(); + assert!(store.delete(&other).is_err()); + store.delete(&saved.reference).unwrap(); + assert!(!saved.reference.exists()); + assert_eq!( + std::fs::read_to_string(sidecar.join("state.json")).unwrap(), + "{\"kept\":true}" + ); + assert!(sidecar.join("something.jsonl").is_file()); + assert!(store.discover().unwrap().is_empty()); +} +#[test] +fn discovery_tolerates_missing_root_invalid_lines_and_unreadable_files() { + let dir = tempfile::tempdir().unwrap(); + assert!( + QoderStore::new(dir.path().join("missing")) + .discover() + .unwrap() + .is_empty() + ); + let project = dir.path().join("-work-proj"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("valid.jsonl"), + format!("garbage\n{}\n{{broken\n", render(&lines())), + ) + .unwrap(); + std::fs::write(project.join("unreadable.jsonl"), [0xff, 0xfe]).unwrap(); + let mut rows = lines(); + // Conversation lines without an id: the session id falls back to the + // file stem. Bookkeeping lines keep theirs, so it is still a session. + for row in &mut rows { + if matches!(row["type"].as_str(), Some("user" | "assistant")) { + row.as_object_mut().unwrap().remove("sessionId"); + } + } + std::fs::write(project.join("fallback.jsonl"), render(&rows)).unwrap(); + let store = QoderStore::new(dir.path()); + let found = store.discover().unwrap(); + assert_eq!(found.len(), 2); + assert!(found.iter().any(|d| d.meta.id == "fallback")); + for d in found { + assert_eq!(d.meta, store.load(&d.reference).unwrap().meta); + } +} +#[test] +fn text_round_trip_preserves_ai_title_and_forked_from() { + let mut rows = lines(); + rows[1]["forkedFrom"] = json!({"sessionId":"parent-session","messageUuid":"parent-message"}); + let n = Qoder::from_text(&render(&rows)).unwrap(); + assert_eq!(n, Qoder::from_text(&Qoder::to_text(&n).unwrap()).unwrap()); + assert_eq!(n.meta.title.as_deref(), Some("Generated title")); + assert_eq!(n.meta.lineage.unwrap().parent, "parent-session"); +} +#[test] +fn friendly_aliases_resolve_to_qoder_and_all_ids_round_trip() { + assert!(HarnessId::ALL.contains(&HarnessId::Qoder)); + for id in HarnessId::ALL { + assert_eq!(id.as_str().parse::().unwrap(), id); + } + for alias in ["qoder", "qodercli", "qoder_cli", "qoder-cli"] { + assert_eq!(alias.parse::().unwrap(), HarnessId::Qoder); + } +} + +/// The shared Claude codec preserves native `timeout` verbatim because Common +/// types Bash's canonical timeout as `timeout_ms`. This is a donor limitation, +/// not a Qoder-specific mapping: the fallback must retain every argument. +#[test] +fn to_common_preserves_native_bash_timeout_in_raw_fallback() { + let mut rows = lines(); + rows[2]["message"]["content"][4]["input"]["timeout"] = json!(1000); + let c = Qoder::to_common(&Qoder::from_text(&render(&rows)).unwrap()).unwrap(); + assert_eq!(c.body.len(), 3); + assert!(matches!(&c.body[1].content[4], common::Block::ToolUse { + id, tool: common::Tool::Raw { tool_name, input } + } if id == "bash" && tool_name == "Bash" && input == &json!({"command":"exit 1","description":"Fail deliberately","timeout":1000}))); +} + +#[test] +fn discover_lists_only_files_that_hold_a_session_record() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("-work-proj"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("empty.jsonl"), "").unwrap(); + std::fs::write(project.join("garbage.jsonl"), "garbage\n").unwrap(); + std::fs::write(project.join("foreign.jsonl"), "{\"a\":1}\n[1,2]\n").unwrap(); + std::fs::write(project.join("typeless.jsonl"), "{\"sessionId\":\"x\"}\n").unwrap(); + // A session opened and closed without a prompt has bookkeeping lines + // only, and is still a session. + let idle = json!({"type":"workspace-directories","sessionId":ID,"directories":["/work/proj"]}); + std::fs::write(project.join(format!("{ID}.jsonl")), format!("{idle}\n")).unwrap(); + let found = QoderStore::new(dir.path()).discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, ID); +} + +#[cfg(unix)] +#[test] +fn delete_unlinks_a_link_itself_and_refuses_files_outside_the_session_layout() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let store = QoderStore::new(dir.path()); + let saved = store.save(&native()).unwrap(); + let project = saved.reference.parent().unwrap().to_path_buf(); + let sidecar = project.join(ID); + std::fs::create_dir_all(sidecar.join("compression-v2")).unwrap(); + let state = sidecar.join("state.json"); + std::fs::write(&state, "{}").unwrap(); + + // A link into a sidecar is unlinked itself; the sidecar file survives. + let link = project.join("linked.jsonl"); + symlink(&state, &link).unwrap(); + store.delete(&link).unwrap(); + assert!(link.symlink_metadata().is_err()); + assert!(state.exists()); + + // A link to another project's session removes the link, not the session. + let mut other = native(); + other.meta.id = "22222222-3333-4444-8555-666666666666".into(); + other.meta.cwd = Some("/work/other".into()); + let other_saved = store.save(&other).unwrap(); + let cross = project.join("cross.jsonl"); + symlink(&other_saved.reference, &cross).unwrap(); + store.delete(&cross).unwrap(); + assert!(cross.symlink_metadata().is_err()); + assert!(other_saved.reference.exists()); + + // Files that are not at //.jsonl are refused. + let nested = sidecar.join("compression-v2").join("data.jsonl"); + std::fs::write(&nested, "{}\n").unwrap(); + let loose = dir.path().join("loose.jsonl"); + std::fs::write(&loose, "{}\n").unwrap(); + let notes = project.join("notes.txt"); + std::fs::write(¬es, "keep").unwrap(); + for reference in [&nested, &loose, ¬es] { + assert!(store.delete(reference).is_err(), "{}", reference.display()); + assert!(reference.exists(), "{}", reference.display()); + } + + // A real session still deletes, and its sidecar stays. + store.delete(&saved.reference).unwrap(); + assert!(!saved.reference.exists()); + assert!(state.exists()); +} + +#[test] +fn a_session_is_never_its_own_fork_parent() { + // Conversation lines carry no sessionId, so the id comes from the file + // stem, which here equals the `forkedFrom` id. + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("-work-proj"); + std::fs::create_dir_all(&project).unwrap(); + let mut rows = lines(); + for row in &mut rows { + if matches!(row["type"].as_str(), Some("user" | "assistant")) { + row.as_object_mut().unwrap().remove("sessionId"); + } + } + rows[1]["forkedFrom"] = json!({"sessionId":ID,"messageUuid":"m"}); + let path = project.join(format!("{ID}.jsonl")); + std::fs::write(&path, render(&rows)).unwrap(); + let store = QoderStore::new(dir.path()); + let found = store.discover().unwrap(); + assert_eq!(found[0].meta.id, ID); + assert_eq!(found[0].meta.lineage, None); + assert_eq!(store.load(&path).unwrap().meta, found[0].meta); +} + +#[test] +fn active_leaf_names_the_last_conversation_line_when_a_command_ends_the_session() { + let mut rows = lines(); + for (uuid, parent, content) in [ + ( + "cmd", + "u2", + "/modelmodel", + ), + ( + "output", + "cmd", + "Model set to qfmodel", + ), + ] { + rows.push(json!({"type":"user","uuid":uuid,"parentUuid":parent,"sessionId":ID,"timestamp":"2026-01-02T03:04:09.000Z","isMeta":true,"message":{"role":"user","content":content}})); + } + let c = Qoder::to_common(&Qoder::from_text(&render(&rows)).unwrap()).unwrap(); + let written = Qoder::from_common(&c).unwrap(); + let values: Vec = Qoder::to_text(&written) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let leaf = values.last().unwrap(); + assert_eq!(leaf["type"], "active-leaf"); + // The scenario holds only if a command line really trails the leaf. + let leaf_at = values + .iter() + .position(|v| v["uuid"] == leaf["leafUuid"]) + .unwrap(); + assert!( + values[leaf_at + 1..values.len() - 1] + .iter() + .any(|v| v["type"] == "system") + ); + let leaf_ts: DateTime = values[leaf_at]["timestamp"] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert_eq!(leaf["timestamp"].as_i64(), Some(leaf_ts.timestamp_millis())); +} diff --git a/tests/integration/store_delete.rs b/tests/integration/store_delete.rs index 2c03d38..4748435 100644 --- a/tests/integration/store_delete.rs +++ b/tests/integration/store_delete.rs @@ -4,7 +4,7 @@ use chrono::{TimeZone, Utc}; use txcript::common::{Block, Message, Meta, Role}; -use txcript::harness::{amp, antigravity, campfire, claude_code, codex, grok, grok_bot, pi}; +use txcript::harness::{amp, antigravity, campfire, claude_code, codex, grok, grok_bot, pi, qoder}; use txcript::{Codec, Common, Store, Transcript}; #[cfg(feature = "opencode")] @@ -318,3 +318,29 @@ fn codex_active_delete_works_when_archive_directory_is_missing() { roundtrip(&store); assert!(!archived.exists()); } + +#[test] +fn qoder_delete_roundtrip() { + let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}")); + roundtrip(&qoder::QoderStore::new(dir.path().to_path_buf())); +} + +#[test] +fn qoder_delete_refuses_a_non_session_path() { + let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}")); + let store = qoder::QoderStore::new(dir.path().to_path_buf()); + assert!( + store + .delete(&dir.path().join("somewhere/else.jsonl")) + .is_err() + ); + // Existing files that are not sessions are refused and left alone. + let notes = dir.path().join("notes.txt"); + let loose = dir.path().join("loose.jsonl"); + std::fs::write(¬es, "keep").unwrap_or_else(|e| panic!("write: {e}")); + std::fs::write(&loose, "{}\n").unwrap_or_else(|e| panic!("write: {e}")); + for path in [¬es, &loose] { + assert!(store.delete(path).is_err()); + assert!(path.exists()); + } +}