diff --git a/Cargo.lock b/Cargo.lock index 9cc269e..579a100 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2846,6 +2846,7 @@ dependencies = [ "wasm-bindgen", "wreq", "wreq-util", + "zstd", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 20cdaac..e00fa8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,12 @@ wreq-util = { version = "3.0.0-rc.14", features = ["emulation-compression"], opt uuid = { version = "1.23.4", features = ["v4", "v5"] } wasm-bindgen = { version = "0.2.126", optional = true } +# DeepSeek Harness stores session logs as concatenated Zstandard frames by +# default. The codec's text form is uncompressed JSON; only the native store +# decoder needs this, and wasm builds never open those files. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +zstd = "0.13.3" + [target.'cfg(target_os = "macos")'.dependencies] aes = { version = "0.8.4", optional = true } cbc = { version = "0.1.2", features = ["alloc", "block-padding"], optional = true } diff --git a/README.md b/README.md index cd4adc5..a277506 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ txcript maps each harness's native transcript format through a typed common mode ## Highlights -- **16 harnesses, one model**: every format converts through `Transcript`, so adding a harness connects it to all the others. +- **17 harnesses, one model**: every format converts through `Transcript`, so adding a harness connects it to all the others. - **A format for everyone else**: agents txcript has never heard of emit the documented [Simple](docs/formats/simple.md) interchange JSON — a file or a stream, handed to txcript directly — and their transcripts continue in any supported harness. - **Byte-lossless round-trips**: loading and saving a session in its own format reproduces it exactly. - **Continue anywhere**: `txcript continue --with ` rewrites a session into another harness's native format and launches it. The original is never modified. @@ -71,6 +71,7 @@ flowchart LR common <--> cursor["Cursor CLI"] common <--> cursordesktop["Cursor desktop"] common <--> grok["Grok CLI"] + common <--> dsh["DeepSeek Harness"] common <--> fx["fx"] common <--> antigravity["Antigravity"] simple["Simple (any agent)"] --> common @@ -93,6 +94,7 @@ Discovery, listing, search, and `view` work for every harness with a backing sto | [Cursor CLI](https://cursor.com/cli) | `cursor` | `~/.cursor/chats/` | SQLite | ⇄ | ✓ | [spec](docs/formats/cursor.md) | | [Cursor desktop](https://cursor.com) | `cursor_desktop` | `/globalStorage/` | SQLite | ⇄ | ✓ | [spec](docs/formats/cursor-desktop.md) | | [Grok CLI](https://github.com/xai-org/grok-build) | `grok` | `~/.grok/sessions/` | JSON session dir | ⇄ | ✓ | [spec](docs/formats/grok.md) | +| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `dsh` | `~/.dsh/sessions/` | zstd JSONL event log | ⇄ | ✓ | [spec](docs/formats/dsh.md) | | [fx](https://fx.sh) | `fx` | `~/.fx/sessions/` | event-log session dir | ⇄ | ✓ | [spec](docs/formats/fx.md) | | Hermes Agent | `hermes` | `~/.hermes/state.db` | SQLite | → | — 3 | [spec](docs/formats/hermes.md) | | [Amp](https://ampcode.com) | `amp` | `~/.local/share/amp/threads/` | thread JSON | → | — 1 | [spec](docs/formats/amp.md) | @@ -109,6 +111,20 @@ Discovery, listing, search, and `view` work for every harness with a backing sto 5 ChatGPT is a live, pull-only source. Like Claude Chat reuses Claude Desktop, explicitly selecting `--from chatgpt` automatically reuses the ChatGPT login managed by Codex at `CODEX_HOME/auth.json` or `~/.codex/auth.json`; the account may differ from the one signed in through a browser. txcript only reads that credential file and never refreshes or rewrites it. Aggregate discovery does not contact ChatGPT, while an exact conversation UUID can be read directly without enumerating the account. txcript only reads: it refuses save, delete, same-harness continue, and `--with chatgpt`. ChatGPT has no supported conversation API, so this access may change or be restricted. ChatGPT data-export archives are not supported. +### DeepSeek Harness + +Sessions are discovered from `$DSH_HOME/sessions` (default `~/.dsh/sessions`). +dsh ships no session import command, but it finds sessions by walking that +root, so txcript writes the layout it scans for. + +It also validates what it finds, and three of its checks fail the whole listing +rather than skipping one session: the first Zstandard frame must decode to +exactly the header line, the header's id and cwd must name the path it was +found at, and a root must not mix `.jsonl` with `.jsonl.zstd`. Writes reproduce +dsh's own derivation rather than approximating it, and were verified by running +the official persistence backend against a txcript-written root. See +[`docs/formats/dsh.md`](docs/formats/dsh.md). + ## Install **CLI** (installs the `txcript` binary): diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 32eb85c..61c74af 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -64,7 +64,7 @@ pub mod mcp; mod pager; mod view; -pub const HARNESSES: &str = "harnesses: claude_code, claude_chat, chatgpt, codex, opencode, pi, campfire, cursor, cursor_desktop, grok, fx, hermes, \ +pub const HARNESSES: &str = "harnesses: claude_code, claude_chat, chatgpt, codex, opencode, pi, campfire, cursor, cursor_desktop, grok, dsh, fx, hermes, \ amp, antigravity, simple, cowork"; /// The `txcript` binary's command line. @@ -927,6 +927,13 @@ mod identity_tests { assert!(error.contains("pull-only")); } + #[test] + fn dsh_can_be_continued_in_place() { + // Only the two pull-only remote sources are refused here. dsh is a + // local store txcript writes, so it takes the normal write path. + assert!(ensure_resumable_source(HarnessId::Dsh, HarnessId::Dsh).is_ok()); + } + #[test] fn resume_alias_is_accepted_for_continue_command() { use clap::Parser; @@ -1069,6 +1076,7 @@ mod style { HarnessId::Cursor => "\x1b[34m", // blue HarnessId::CursorDesktop => "\x1b[96m", // bright cyan HarnessId::Grok => "\x1b[37m", // white + HarnessId::Dsh => "\x1b[38;5;43m", // teal HarnessId::Fx => "\x1b[38;5;39m", // azure HarnessId::Hermes => "\x1b[93m", // bright yellow HarnessId::Amp => "\x1b[95m", // bright magenta diff --git a/docs/formats/README.md b/docs/formats/README.md index 31e1cc7..c6123a8 100644 --- a/docs/formats/README.md +++ b/docs/formats/README.md @@ -49,6 +49,7 @@ than none. | [cursor-desktop.md](cursor-desktop.md) | Cursor desktop (IDE app) | `src/harness/cursor_desktop.rs` | | [amp.md](amp.md) | Amp (Sourcegraph) | `src/harness/amp.rs` | | [grok.md](grok.md) | Grok CLI (xAI) | `src/harness/grok.rs` | +| [dsh.md](dsh.md) | DeepSeek Harness (`dsh`) | `src/harness/dsh.rs` | | [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` | diff --git a/docs/formats/dsh.md b/docs/formats/dsh.md new file mode 100644 index 0000000..5e3c72f --- /dev/null +++ b/docs/formats/dsh.md @@ -0,0 +1,133 @@ +# DeepSeek Harness (`dsh`) + +DeepSeek Harness stores sessions under: + +```text +$DSH_HOME/sessions/ # default ~/.dsh/sessions + ----/ # or _no-cwd/ + / + session.jsonl.zstd # default: concatenated checksummed Zstandard frames + session.jsonl # only when compression: 'none' +``` + +Home resolution is configured path, then `$DSH_HOME`, then `~/.dsh`. An empty +`$DSH_HOME` is treated as unset. + +## Native representation + +`txcript::harness::dsh::DshSession` retains the first JSONL line as `header` +and every subsequent line as raw JSON values. Packed storage rows +(`text-chunks`, `reasoning-chunks`, `tool-call-chunks`) and unknown event +types stay in the native body so a text load/render round trip does not drop +bookkeeping the Common projection does not understand. + +The official on-disk format version is `0`. There is no migration; txcript +still loads the native body when the version field differs. + +### Header + +The first line is tagged `type: "session"` and carries `version`, `id`, +`createdAt` (epoch milliseconds), optional `cwd`, `parentSession`, +`seedLength`, `origin`, `delegationDepth`, and `agentPreset`. + +### Events + +Each event is `{ type, seq, time, data, ... }`. Surface events +(`user/message`, `assistant/message`, `tool/result`) may also carry +`surfaceOp` (`"append"` or `{ op: "replace", start, end }`) and +`sourceEventSeqs`. Log-only events (turn/step markers, `assistant/chunk`, +`request/header`, packed chunk rows, …) never enter the Common conversation. + +## Common projection + +| dsh event | Common representation | +| --- | --- | +| `user/message` with text parts | user text message | +| `assistant/message` `reasoning` / `text` / `tool-call` | thinking / text / tool-use | +| `assistant/message` `usage` / `interrupted` | `Usage` / `StopReason::Aborted` | +| `tool/result` | user tool-result (`isError` kept) | + +The ordered surface is rebuilt before projection. Surface nodes are tracked by +event `seq`, because log-only events sit between them and a node's seq is not +its surface position. A `replace` `surfaceOp` names the inclusive **seq** range +it shadows — both endpoints must be on the current surface — and substitutes +the replacing node for that whole run; a range txcript cannot resolve shadows +nothing. Packed chunk rows and `assistant/chunk` stream events are ignored for +Common because the assembled `assistant/message` already carries the step. + +`usage` maps `inputTokens`/`outputTokens` and the optional +`cacheReadTokens`/`cacheWriteTokens` onto Common's `Usage`. `reasoningTokens` +has no Common counterpart and survives in the native body only. Shadowed +surface nodes likewise stay in the native body, so a text round trip keeps the +full log even though Common shows the model-visible surface. + +## Store capabilities + +The store reads and writes. dsh ships no session-import command, but it does +not need one: it discovers sessions by walking its root, so writing one is a +matter of reproducing the layout it scans for. + +What makes that exact rather than approximate is dsh's validation. Three of its +checks fail the **entire** listing rather than skipping the one bad session, so +each is a hard requirement on the writer: + +| Check | Requirement | +| --- | --- | +| `assertZstdHeaderFrame` | the first Zstandard frame decodes to exactly one line — the header | +| `assertStoredIdentity` | the header's own `id` and `cwd` name the path the log was found at | +| `checkRootEncoding` | one root never mixes `.jsonl` with `.jsonl.zstd` | + +A duplicate session id across two project directories is rejected the same way. + +### Layout derivation + +- **Project directory** — `----`, where `key` collapses each run of `/`, + `\`, or `:` to a single `-`, keeps `[A-Za-z0-9._-]`, escapes every other + UTF-16 code unit as `~XXXX`, strips leading dashes, falls back to `root` if + nothing remains, and truncates to 251 characters. A session with no cwd goes + under `_no-cwd`. +- **Session directory** — the id under the same `~XXXX` escape, with `.` and + `..` special-cased whole. This is what contains a traversing id: escaping the + separators turns `../../evil` into the literal directory + `..~002F..~002Fevil`. + +Escaping operates on UTF-16 code units, not Unicode scalars, which is what +makes it injective over lone surrogates. + +### What `save` writes + +`///session.jsonl.zstd` — a checksummed Zstandard +frame holding the header line, then one holding the event lines. The header is +stamped with the id and cwd that built the path, because a copy given a new +identity would otherwise keep pointing at the original's. + +The physical encoding follows whatever the root already uses; only an empty +root falls back to dsh's own default of `zstd`. Re-saving a session whose cwd +changed removes the copy under the old project directory, since leaving it +would be the duplicate-id corruption above. + +`delete` removes the session directory. dsh's persistence seam has no delete +API, but it also keeps no index — an absent directory is simply not scanned. + +The native resume command documented for a TUI profile is +`dsh --profile tui --resume `. + +## Provenance + +Open source. Layout and event vocabulary follow the DeepSeek Harness +packages `@deepseek-ai/dsh-session` and +`@deepseek-ai/dsh-session-persistence-jsonl` (session format version 0, +developer preview; the project warns of compatibility-breaking changes). +The path derivation, frame layout, and validation rules above are that +package's own `encodeSegment`, `projectKey`, `encodeMaterialization`, and +`listArtifacts`. + +The reader was checked against a local `session.jsonl.zstd` written by dsh +around 2026-08-14. The writer was checked by running the official backend's +`listArtifacts` and `loadStored` against a txcript-written root: it lists the +session and decodes all 1148 of its event records. Compressing the log as one +frame instead of two — which txcript itself still reads — makes that same check +fail with dsh's `first frame is not exactly one header line`. + +Last verified: 2026-09-07, against `@deepseek-ai/dsh-session-persistence-jsonl` +0.0.1-rc.1. diff --git a/examples/search_bench.rs b/examples/search_bench.rs index 19275bd..f33e00f 100644 --- a/examples/search_bench.rs +++ b/examples/search_bench.rs @@ -10,7 +10,7 @@ use std::time::Instant; -use txcript::harness::{campfire, claude_code, codex, cursor, grok, pi}; +use txcript::harness::{campfire, claude_code, codex, cursor, dsh, grok, pi}; use txcript::search::{DocKey, Index, Origin, Query}; use txcript::{Codec, Common, HarnessId, Store, Transcript}; @@ -62,6 +62,13 @@ fn main() { &mut loaded, &mut failed, ); + load_files::( + HarnessId::Dsh, + dsh::DshStore::default_root(), + &mut index, + &mut loaded, + &mut failed, + ); let build = started.elapsed(); println!( diff --git a/src/harness/dsh.rs b/src/harness/dsh.rs new file mode 100644 index 0000000..8becb00 --- /dev/null +++ b/src/harness/dsh.rs @@ -0,0 +1,1210 @@ +//! `dsh` sessions: `$DSH_HOME/sessions` (default `~/.dsh/sessions`). +//! +//! Official persistence writes one append-only JSONL log per session, Zstandard +//! framed by default (`session.jsonl.zstd`). The first line is a `type: session` +//! header; the rest are `SessionEvent` records (and packed `*-chunks` rows). +//! +//! dsh finds sessions by walking its root, so no import command is needed — +//! but it also validates what it finds, and three of its checks fail the +//! *whole* listing rather than skipping one session: a first Zstandard frame +//! that is not exactly the header line, a header whose id and cwd do not name +//! the path it was found at, and a root that mixes `.jsonl` with +//! `.jsonl.zstd`. Writing therefore reproduces dsh's own layout exactly. +//! +//! Native body retains the header and every log line as raw JSON, including +//! packed chunk rows and unknown event types. The Common projection uses the +//! ordered surface (`user/message`, `assistant/message`, `tool/result`). + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::common::{Block, Message, Meta, Role, StopReason, Tool, ToolOutput, Usage}; +use crate::error::{Error, Result}; +use crate::harness::jsonl; +use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; + +/// On-disk format version stamped into every newly-written `SessionHeader`. +/// Official readers refuse any other value; txcript still loads the native +/// body so unknown future logs are not silently dropped. +const SESSION_FORMAT_VERSION: u64 = 0; + +/// The `dsh` harness marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Dsh; + +impl Harness for Dsh { + const NAME: &'static str = "dsh"; + type Body = DshSession; +} + +/// Header line plus the remaining JSONL records, kept raw. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DshSession { + pub header: Value, + #[serde(default)] + pub events: Vec, +} + +impl TextCodec for Dsh { + fn from_text(text: &str) -> Result> { + let body: DshSession = serde_json::from_str(text)?; + let meta = meta_from_body(&body); + Ok(Transcript::new(meta, body)) + } + + fn to_text(transcript: &Transcript) -> Result { + Ok(serde_json::to_string_pretty(&transcript.body)?) + } +} + +impl Codec for Dsh { + fn to_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + events_to_messages(&transcript.body.events, transcript.meta.timestamp), + )) + } + + fn from_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + body_from_common(transcript), + )) + } +} + +/// Read/write access to `dsh` session directories. +#[derive(Debug, Clone)] +pub struct DshStore { + pub sessions_dir: PathBuf, +} + +impl DshStore { + pub fn new(path: impl Into) -> Self { + Self { + sessions_dir: path.into(), + } + } + + /// `$DSH_HOME/sessions`, then `~/.dsh/sessions`. Empty `$DSH_HOME` is unset. + #[must_use] + pub fn default_root() -> Option { + std::env::var_os("DSH_HOME") + .filter(|value| !value.is_empty() && !value.to_string_lossy().trim().is_empty()) + .map(|home| Self::new(PathBuf::from(home).join("sessions"))) + .or_else(|| super::home_dir().map(|home| Self::new(home.join(".dsh").join("sessions")))) + } +} + +impl Store for DshStore { + type H = Dsh; + type Ref = PathBuf; + + fn discover(&self) -> Result>> { + let mut found = Vec::new(); + let Ok(projects) = fs::read_dir(&self.sessions_dir) else { + return Ok(found); + }; + for project in projects.flatten().map(|entry| entry.path()) { + if !project.is_dir() { + continue; + } + let Ok(sessions) = fs::read_dir(&project) else { + continue; + }; + for session_dir in sessions.flatten().map(|entry| entry.path()) { + if !session_dir.is_dir() { + continue; + } + let Some(log) = log_path(&session_dir) else { + continue; + }; + let Ok(body) = load_body(&log) else { + continue; + }; + let mut meta = meta_from_body(&body); + if meta.id.is_empty() { + meta.id = jsonl::file_id(&session_dir); + } + found.push(Discovered { + meta, + reference: session_dir, + }); + } + } + Ok(found) + } + + fn load(&self, reference: &PathBuf) -> Result> { + let log = log_path(reference).ok_or_else(|| Error::Malformed { + harness: Dsh::NAME, + detail: format!("no session.jsonl in {}", reference.display()), + })?; + let body = load_body(&log)?; + let mut meta = meta_from_body(&body); + if meta.id.is_empty() { + meta.id = jsonl::file_id(reference); + } + Ok(Transcript::new(meta, body)) + } + + fn save(&self, transcript: &Transcript) -> Result> { + let id = session_id(transcript); + let cwd = session_cwd(transcript); + let dir = self + .sessions_dir + .join(project_key(cwd.as_deref())) + .join(encode_segment(&id)?); + + let mut body = transcript.body.clone(); + stamp_identity(&mut body.header, &id, cwd.as_deref()); + + self.retire_other_copies(&id, &dir); + fs::create_dir_all(&dir)?; + let compressed = self.root_compresses(); + let log = dir.join(if compressed { + "session.jsonl.zstd" + } else { + "session.jsonl" + }); + let bytes = if compressed { + encode_log(&body)? + } else { + plain_log(&body)?.into_bytes() + }; + fs::write(&log, bytes)?; + // The opposite encoding in the same session directory is the same + // poison as one elsewhere in the root, and a re-save that switched + // encodings would leave it behind. + let _ = fs::remove_file(dir.join(if compressed { + "session.jsonl" + } else { + "session.jsonl.zstd" + })); + Ok(Saved { id, reference: dir }) + } + + fn delete(&self, reference: &PathBuf) -> Result<()> { + fs::remove_dir_all(reference)?; + Ok(()) + } + + fn fingerprints(&self, refs: &[PathBuf]) -> Result> { + let mut output = HashMap::with_capacity(refs.len()); + for reference in refs { + let cursor = + log_path(reference).map_or_else(String::new, |path| file_fingerprint(&path)); + output.insert(reference.to_string_lossy().into_owned(), cursor); + } + Ok(output) + } +} + +impl DshStore { + /// Whether a new log should be Zstandard framed. + /// + /// dsh refuses to list *any* session in a root that holds both `.jsonl` + /// and `.jsonl.zstd` artifacts — under either configuration, not just the + /// mismatched one. So the root's existing artifacts decide, and only an + /// empty root falls back to dsh's own default of `zstd`. + /// Remove copies of `id` filed under a different project directory. + /// + /// A session's project directory is keyed by its cwd, so a re-save after a + /// cwd change lands somewhere new. dsh treats one id under two project + /// directories as corruption and fails its whole listing, so the stale copy + /// cannot stay. Best effort: a copy that cannot be removed is not worth + /// failing an otherwise good write over. + fn retire_other_copies(&self, id: &str, keep: &Path) { + let Ok(encoded) = encode_segment(id) else { + return; + }; + let Ok(projects) = fs::read_dir(&self.sessions_dir) else { + return; + }; + for project in projects.flatten().map(|entry| entry.path()) { + let candidate = project.join(&encoded); + if candidate != keep && log_path(&candidate).is_some() { + let _ = fs::remove_dir_all(&candidate); + } + } + } + + fn root_compresses(&self) -> bool { + let Ok(projects) = fs::read_dir(&self.sessions_dir) else { + return true; + }; + for project in projects.flatten().map(|entry| entry.path()) { + let Ok(sessions) = fs::read_dir(&project) else { + continue; + }; + for session_dir in sessions.flatten().map(|entry| entry.path()) { + if session_dir.join("session.jsonl.zstd").is_file() { + return true; + } + if session_dir.join("session.jsonl").is_file() { + return false; + } + } + } + true + } +} + +/// `meta` is the canonical identity, not the retained native header: a copy is +/// given a new id and timestamp before it is written, and the header it +/// inherited still names the original. +fn session_id(transcript: &Transcript) -> String { + if !transcript.meta.id.is_empty() { + return transcript.meta.id.clone(); + } + transcript + .body + .header + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map_or_else( + || format!("session-{}", Uuid::new_v5(&NS, b"empty-id")), + String::from, + ) +} + +fn session_cwd(transcript: &Transcript) -> Option { + transcript + .meta + .cwd + .clone() + .or_else(|| { + transcript + .body + .header + .get("cwd") + .and_then(Value::as_str) + .map(String::from) + }) + .filter(|cwd| !cwd.is_empty()) +} + +/// dsh cross-checks a log it finds against the path its own header would +/// name, and a mismatch fails the whole listing rather than skipping the one +/// session. So the header is stamped with the id and cwd that built the path. +fn stamp_identity(header: &mut Value, id: &str, cwd: Option<&str>) { + let Some(object) = header.as_object_mut() else { + return; + }; + object.insert("id".to_string(), json!(id)); + match cwd { + Some(cwd) => { + object.insert("cwd".to_string(), json!(cwd)); + } + // `cwd: null` is not the same as an absent `cwd`: dsh keys `_no-cwd` + // off `undefined`, and a null would not round-trip through its header + // schema. + None => { + object.remove("cwd"); + } + } +} + +/// dsh's `encodeSegment`: injective over UTF-16 code units, which is what +/// neutralizes `..`, absolute paths, and separators in an id that is otherwise +/// an unvalidated string. +fn encode_segment(raw: &str) -> Result { + use std::fmt::Write as _; + + if raw.is_empty() { + return Err(Error::Unconvertible { + harness: Dsh::NAME, + detail: "a dsh session id cannot be empty".to_string(), + }); + } + match raw { + "." => return Ok("~002E".to_string()), + ".." => return Ok("~002E~002E".to_string()), + _ => {} + } + let mut out = String::with_capacity(raw.len()); + for unit in raw.encode_utf16() { + match char::from_u32(u32::from(unit)) { + Some(ch) if is_path_safe(ch) => out.push(ch), + _ => { + let _ = write!(out, "~{unit:04X}"); + } + } + } + Ok(out) +} + +/// dsh's `projectKey`: separators collapse to a single `-`, unsafe units take +/// the same `~XXXX` escape, and the result is wrapped in `--`. Deliberately +/// lossy — it groups sessions for humans, and the session id below it is what +/// identifies them. +fn project_key(cwd: Option<&str>) -> String { + use std::fmt::Write as _; + + let Some(cwd) = cwd else { + return "_no-cwd".to_string(); + }; + let mut readable = String::with_capacity(cwd.len()); + let mut separator_run = false; + for unit in cwd.encode_utf16() { + match char::from_u32(u32::from(unit)) { + Some('/' | '\\' | ':') => { + if !separator_run { + readable.push('-'); + } + separator_run = true; + } + Some(ch) if is_path_safe(ch) => { + readable.push(ch); + separator_run = false; + } + _ => { + let _ = write!(readable, "~{unit:04X}"); + separator_run = false; + } + } + } + // Every retained unit is ASCII — safe characters are, and escapes are — + // so truncating by byte matches dsh's truncation by UTF-16 unit. + let trimmed = readable.trim_start_matches('-'); + let name = if trimmed.is_empty() { "root" } else { trimmed }; + format!("--{}--", &name[..name.len().min(251)]) +} + +fn is_path_safe(ch: char) -> bool { + ch != '~' && (ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) +} + +/// The uncompressed artifact: the header line, then one line per event. +fn plain_log(body: &DshSession) -> Result { + Ok(format!("{}{}", header_line(body)?, event_lines(body)?)) +} + +/// The Zstandard artifact: one frame holding exactly the header line, then one +/// holding the event lines. dsh checks that the first frame decodes to a +/// single line, so the split is part of the format, not a writer's choice. +fn encode_log(body: &DshSession) -> Result> { + let mut out = compress_frame(header_line(body)?.as_bytes())?; + out.extend(compress_frame(event_lines(body)?.as_bytes())?); + Ok(out) +} + +fn header_line(body: &DshSession) -> Result { + Ok(format!("{}\n", serde_json::to_string(&body.header)?)) +} + +fn event_lines(body: &DshSession) -> Result { + let lines = body + .events + .iter() + .map(serde_json::to_string) + .collect::, _>>()? + .join("\n"); + Ok(format!("{lines}\n")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn compress_frame(bytes: &[u8]) -> Result> { + use std::io::Write as _; + + // dsh compresses every frame with the content checksum enabled, and + // validates it on read. + let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), 0)?; + encoder.include_checksum(true)?; + encoder.write_all(bytes)?; + Ok(encoder.finish()?) +} + +#[cfg(target_arch = "wasm32")] +fn compress_frame(_bytes: &[u8]) -> Result> { + Err(Error::Malformed { + harness: Dsh::NAME, + detail: "zstd session logs cannot be written in wasm".to_string(), + }) +} + +fn log_path(session_dir: &Path) -> Option { + let zstd = session_dir.join("session.jsonl.zstd"); + if zstd.is_file() { + return Some(zstd); + } + let plain = session_dir.join("session.jsonl"); + plain.is_file().then_some(plain) +} + +fn load_body(path: &Path) -> Result { + let text = read_log_text(path)?; + parse_log(&text) +} + +fn parse_log(text: &str) -> Result { + let mut lines = text.lines().filter(|line| !line.trim().is_empty()); + let Some(first) = lines.next() else { + return Err(Error::Malformed { + harness: Dsh::NAME, + detail: "empty session log".to_string(), + }); + }; + let header: Value = serde_json::from_str(first)?; + if header.get("type").and_then(Value::as_str) != Some("session") { + return Err(Error::Malformed { + harness: Dsh::NAME, + detail: "first line is not a session header".to_string(), + }); + } + let events = lines + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect(); + Ok(DshSession { header, events }) +} + +fn read_log_text(path: &Path) -> Result { + let bytes = fs::read(path)?; + if path.extension().is_some_and(|ext| ext == "zstd") { + return decode_zstd(&bytes); + } + String::from_utf8(bytes).map_err(|error| Error::Malformed { + harness: Dsh::NAME, + detail: format!("session log is not utf-8: {error}"), + }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn decode_zstd(bytes: &[u8]) -> Result { + use std::io::Read as _; + + // The official JSONL backend concatenates independent Zstandard frames + // (header frame, then one per append batch). The streaming decoder parses + // frame structure and continues across frame boundaries on its own, so the + // whole file decodes in one pass. Splitting on the frame magic instead + // would be wrong: those four bytes also occur inside compressed block + // payloads and checksums, and a false split turns a valid log into a + // decoder error. + let mut out = Vec::new(); + zstd::stream::read::Decoder::new(std::io::Cursor::new(bytes))?.read_to_end(&mut out)?; + String::from_utf8(out).map_err(|error| Error::Malformed { + harness: Dsh::NAME, + detail: format!("zstd session log is not utf-8: {error}"), + }) +} + +#[cfg(target_arch = "wasm32")] +fn decode_zstd(_bytes: &[u8]) -> Result { + Err(Error::Malformed { + harness: Dsh::NAME, + detail: "zstd session logs cannot be decoded in wasm".to_string(), + }) +} + +fn file_fingerprint(path: &Path) -> String { + let Ok(metadata) = fs::metadata(path) else { + return String::new(); + }; + let modified = metadata + .modified() + .ok() + .map(|time| { + DateTime::::from(time) + .timestamp_nanos_opt() + .unwrap_or_default() + }) + .unwrap_or_default(); + format!("{}:{modified}", metadata.len()) +} + +fn meta_from_body(body: &DshSession) -> Meta { + let header = &body.header; + let id = header + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let timestamp = header + .get("createdAt") + .and_then(Value::as_i64) + .and_then(DateTime::from_timestamp_millis) + .or_else(|| first_event_time(&body.events)) + .unwrap_or(DateTime::::UNIX_EPOCH); + let cwd = header + .get("cwd") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(String::from); + let title = body.events.iter().rev().find_map(|event| { + (event.get("type").and_then(Value::as_str) == Some("session/title")) + .then(|| { + event + .get("data") + .and_then(|data| data.get("title")) + .and_then(Value::as_str) + .filter(|title| !title.is_empty()) + .map(String::from) + }) + .flatten() + }); + let model = body.events.iter().rev().find_map(|event| { + match event.get("type").and_then(Value::as_str) { + Some("request/context") => event + .get("data") + .and_then(|data| data.get("model")) + .and_then(Value::as_str) + .map(String::from), + Some("request/header") => event + .get("data") + .and_then(|data| data.get("header")) + .and_then(|header| header.get("config")) + .and_then(|config| config.get("model")) + .and_then(Value::as_str) + .map(String::from), + _ => None, + } + }); + Meta { + id, + timestamp, + cwd, + git_branch: None, + title, + cli_version: None, + model, + } +} + +fn first_event_time(events: &[Value]) -> Option> { + events + .iter() + .find_map(|event| event.get("time").and_then(Value::as_i64)) + .and_then(DateTime::from_timestamp_millis) +} + +/// `data.usage` is dsh's `TokenUsage`: required `inputTokens`/`outputTokens` +/// plus optional cache and reasoning tallies. Common has no reasoning counter, +/// so `reasoningTokens` survives only in the native body. +fn usage_from_data(data: Option<&Value>) -> Option { + let usage = data?.get("usage")?; + Some(Usage { + input_tokens: usage.get("inputTokens").and_then(Value::as_u64)?, + output_tokens: usage.get("outputTokens").and_then(Value::as_u64)?, + cache_read_input_tokens: usage.get("cacheReadTokens").and_then(Value::as_u64), + cache_creation_input_tokens: usage.get("cacheWriteTokens").and_then(Value::as_u64), + }) +} + +fn event_time(event: &Value, fallback: DateTime) -> DateTime { + event + .get("time") + .and_then(Value::as_i64) + .and_then(DateTime::from_timestamp_millis) + .unwrap_or(fallback) +} + +/// Resolve a `replace` op's inclusive seq range to surface positions. `None` +/// when either endpoint is missing from the current surface or the range runs +/// backwards — the same conditions dsh itself rejects. +fn replacement_range( + surface: &[(Option, &Value)], + op: &serde_json::Map, +) -> Option<(usize, usize)> { + let position = |seq: Option<&Value>| { + let seq = seq.and_then(Value::as_u64)?; + surface.iter().position(|(node, _)| *node == Some(seq)) + }; + let start = position(op.get("start"))?; + let end = position(op.get("end"))?; + (start <= end).then_some((start, end)) +} + +/// Rebuild the ordered surface, then project those nodes into Common messages. +/// Packed `*-chunks` rows and log-only events stay in the native body. +fn events_to_messages(events: &[Value], fallback: DateTime) -> Vec { + // The surface is an ordered list of event seqs, not a list of log indexes: + // log-only events (turn/step markers, chunks, packed rows) sit between + // surface nodes, so a node's seq and its surface position diverge + // immediately. A `replace` op names the inclusive *seq* range it shadows, + // and both endpoints must currently be on the surface. + let mut surface: Vec<(Option, &Value)> = Vec::new(); + for event in events { + let Some(kind) = event.get("type").and_then(Value::as_str) else { + continue; + }; + if !matches!(kind, "user/message" | "assistant/message" | "tool/result") { + continue; + } + let seq = event.get("seq").and_then(Value::as_u64); + let node = (seq, event); + match event.get("surfaceOp") { + Some(Value::Object(op)) if op.get("op").and_then(Value::as_str) == Some("replace") => { + match replacement_range(&surface, op) { + Some((start, end)) => { + surface.splice(start..=end, std::iter::once(node)); + } + // A range we cannot resolve (truncated log, unknown seq) + // shadows nothing; keep the node rather than drop history. + None => surface.push(node), + } + } + _ => surface.push(node), + } + } + + let mut messages = Vec::new(); + for (_, event) in surface { + let timestamp = event_time(event, fallback); + match event.get("type").and_then(Value::as_str) { + Some("user/message") => { + let blocks = user_text_blocks(event.get("data")); + if !blocks.is_empty() { + messages.push(user_message(blocks, timestamp)); + } + } + Some("assistant/message") => { + let data = event.get("data"); + let content = data + .and_then(|value| value.get("message")) + .and_then(|message| message.get("content")); + let blocks = assistant_blocks(content); + if blocks.is_empty() { + continue; + } + let model = data + .and_then(|value| value.get("message")) + .and_then(|message| message.get("source")) + .and_then(|source| source.get("model")) + .and_then(Value::as_str) + .map(String::from); + let has_tool = blocks + .iter() + .any(|block| matches!(block, Block::ToolUse { .. })); + // `data.interrupted` is only ever `true`, and marks the partial + // prefix a cancelled turn had already streamed. + let interrupted = data + .and_then(|value| value.get("interrupted")) + .and_then(Value::as_bool) + .unwrap_or(false); + let stop_reason = if interrupted { + StopReason::Aborted + } else if has_tool { + StopReason::ToolUse + } else { + StopReason::EndTurn + }; + messages.push(Message { + role: Role::Assistant, + content: blocks, + timestamp, + model, + stop_reason: Some(stop_reason), + usage: usage_from_data(data), + }); + } + Some("tool/result") => { + if let Some(block) = tool_result_block(event.get("data")) { + messages.push(user_message(vec![block], timestamp)); + } + } + _ => {} + } + } + messages +} + +fn user_message(content: Vec, timestamp: DateTime) -> Message { + Message { + role: Role::User, + content, + timestamp, + model: None, + stop_reason: None, + usage: None, + } +} + +fn user_text_blocks(data: Option<&Value>) -> Vec { + let Some(content) = data + .and_then(|value| value.get("content")) + .and_then(Value::as_array) + else { + return Vec::new(); + }; + content + .iter() + .filter_map(|part| { + if part.get("type").and_then(Value::as_str) != Some("text") { + return None; + } + Some(Block::Text { + text: part + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) + }) + .filter(|block| match block { + Block::Text { text } => !text.is_empty(), + _ => true, + }) + .collect() +} + +fn assistant_blocks(content: Option<&Value>) -> Vec { + let Some(parts) = content.and_then(Value::as_array) else { + return Vec::new(); + }; + parts + .iter() + .filter_map(|part| match part.get("type").and_then(Value::as_str) { + Some("text") => { + let text = part.get("text").and_then(Value::as_str).unwrap_or_default(); + (!text.is_empty()).then(|| Block::Text { + text: text.to_string(), + }) + } + Some("reasoning") => { + let text = part.get("text").and_then(Value::as_str).unwrap_or_default(); + (!text.is_empty()).then(|| Block::Thinking { + text: text.to_string(), + signature: None, + encrypted: None, + }) + } + Some("tool-call") => { + let id = part + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let name = part + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let input = part + .get("arguments") + .and_then(Value::as_str) + .and_then(|raw| serde_json::from_str(raw).ok()) + .or_else(|| part.get("arguments").cloned()) + .unwrap_or(Value::Null); + Some(Block::ToolUse { + id, + tool: Tool::from_canonical(name, input), + }) + } + _ => None, + }) + .collect() +} + +fn tool_result_block(data: Option<&Value>) -> Option { + let message = data.and_then(|value| value.get("message"))?; + let parts = message.get("content")?.as_array()?; + let result = parts + .iter() + .find(|part| part.get("type").and_then(Value::as_str) == Some("tool-result"))?; + let tool_use_id = result + .get("toolCallId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let is_error = result + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(false); + let text = result + .get("content") + .and_then(Value::as_array) + .map(|inner| { + inner + .iter() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("text")) + .map(|part| part.get("text").and_then(Value::as_str).unwrap_or_default()) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + Some(Block::ToolResult { + tool_use_id, + content: ToolOutput::Text(text), + is_error, + }) +} + +const NS: Uuid = Uuid::from_bytes([ + 0x4d, 0x73, 0x68, 0x2d, 0x74, 0x78, 0x63, 0x72, 0x69, 0x70, 0x74, 0x2d, 0x64, 0x73, 0x68, 0x31, +]); + +#[allow(clippy::too_many_lines)] +fn body_from_common(transcript: &Transcript) -> DshSession { + let created = transcript.meta.timestamp.timestamp_millis(); + let id = if transcript.meta.id.is_empty() { + format!("session-{}", Uuid::new_v5(&NS, b"empty-id")) + } else { + transcript.meta.id.clone() + }; + let mut header = json!({ + "type": "session", + "version": SESSION_FORMAT_VERSION, + "id": id, + "createdAt": created, + "delegationDepth": 0, + }); + if let Some(cwd) = &transcript.meta.cwd { + header["cwd"] = json!(cwd); + } + let mut events = Vec::new(); + let mut seq = 0_u64; + if let Some(title) = &transcript.meta.title { + events.push(json!({ + "type": "session/title", + "seq": seq, + "time": created, + "data": { "title": title, "source": { "kind": "fallback" } } + })); + seq += 1; + } + for (index, message) in transcript.body.iter().enumerate() { + let time = message.timestamp.timestamp_millis(); + if message.role == Role::User { + let texts: Vec = message + .content + .iter() + .filter_map(|block| match block { + Block::Text { text } => Some(json!({"type": "text", "text": text})), + _ => None, + }) + .collect(); + if !texts.is_empty() { + let msg_id = Uuid::new_v5(&NS, format!("{id}:{index}:user").as_bytes()).to_string(); + events.push(json!({ + "type": "user/message", + "seq": seq, + "time": time, + "data": { + "role": "user", + "id": msg_id, + "content": texts, + "source": { "kind": "user" } + }, + "surfaceOp": "append" + })); + seq += 1; + } + for block in &message.content { + if let Block::ToolResult { + tool_use_id, + content, + is_error, + } = block + { + let text = match content { + ToolOutput::Text(text) => text.clone(), + ToolOutput::Json(value) => value.to_string(), + }; + events.push(json!({ + "type": "tool/result", + "seq": seq, + "time": time, + "data": { + "turn": 1, + "step": 1, + "message": { + "role": "user", + "source": { "kind": "tool", "callId": tool_use_id }, + "content": [{ + "type": "tool-result", + "toolCallId": tool_use_id, + "isError": is_error, + "content": [{ "type": "text", "text": text }] + }] + } + }, + // `sourceEventSeqs` is omitted, not empty: dsh rejects + // an empty array on anything but `assistant/message`. + "surfaceOp": "append" + })); + seq += 1; + } + } + } else { + let mut content = Vec::new(); + for block in &message.content { + match block { + Block::Text { text } => content.push(json!({"type": "text", "text": text})), + Block::Thinking { text, .. } => { + content.push(json!({"type": "reasoning", "text": text})); + } + Block::ToolUse { id: call_id, tool } => { + let (name, input) = tool.to_canonical(); + let arguments = + serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); + content.push(json!({ + "type": "tool-call", + "id": call_id, + "name": name, + "arguments": arguments + })); + } + Block::ToolResult { .. } | Block::Image { .. } | Block::Artifact { .. } => {} + } + } + if content.is_empty() { + continue; + } + let msg_id = + Uuid::new_v5(&NS, format!("{id}:{index}:assistant").as_bytes()).to_string(); + let mut source = json!({ "kind": "model" }); + if let Some(model) = &message.model { + source["model"] = json!(model); + } + let mut data = json!({ + "turn": 1, + "step": index + 1, + "message": { + "role": "assistant", + "id": msg_id, + "content": content, + "source": source + } + }); + if let Some(usage) = &message.usage { + let mut tokens = json!({ + "inputTokens": usage.input_tokens, + "outputTokens": usage.output_tokens, + }); + if let Some(read) = usage.cache_read_input_tokens { + tokens["cacheReadTokens"] = json!(read); + } + if let Some(write) = usage.cache_creation_input_tokens { + tokens["cacheWriteTokens"] = json!(write); + } + data["usage"] = tokens; + } + // dsh only ever writes `interrupted: true`; absence means a + // completed turn. + if message.stop_reason == Some(StopReason::Aborted) { + data["interrupted"] = json!(true); + } + events.push(json!({ + "type": "assistant/message", + "seq": seq, + "time": time, + "data": data, + "surfaceOp": "append" + })); + seq += 1; + } + } + let _ = seq; + DshSession { header, events } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(ms: i64) -> DateTime { + DateTime::from_timestamp_millis(ms).unwrap_or(DateTime::::UNIX_EPOCH) + } + + #[test] + fn header_line_feeds_meta() { + let body = DshSession { + header: json!({ + "type": "session", + "version": 0, + "id": "session-abc", + "createdAt": 1_786_637_231_769_i64, + "cwd": "/repo", + "delegationDepth": 0, + "agentPreset": "standard" + }), + events: vec![ + json!({"type": "session/title", "seq": 0, "time": 1, "data": {"title": "hello"}}), + json!({"type": "request/context", "seq": 1, "time": 2, "data": {"provider": "deepseek-official", "model": "deepseek-v4-flash"}}), + ], + }; + let meta = meta_from_body(&body); + assert_eq!(meta.id, "session-abc"); + assert_eq!(meta.cwd.as_deref(), Some("/repo")); + assert_eq!(meta.title.as_deref(), Some("hello")); + assert_eq!(meta.model.as_deref(), Some("deepseek-v4-flash")); + assert_eq!(meta.timestamp, ts(1_786_637_231_769)); + } + + #[test] + fn surface_projection_keeps_text_thinking_and_tools() { + let events = vec![ + json!({"type": "user/message", "seq": 0, "time": 10, "data": { + "role": "user", + "content": [{"type": "text", "text": "hi"}], + "source": {"kind": "user"} + }, "surfaceOp": "append"}), + json!({"type": "assistant/chunk", "seq": 1, "time": 11, "data": {"turn": 1, "step": 1, "chunk": {"type": "block-start"}}}), + json!({"type": "reasoning-chunks", "seq0": 2, "time0": 12, "data": {"texts": ["skip"]}}), + json!({"type": "assistant/message", "seq": 3, "time": 13, "data": { + "turn": 1, "step": 1, + "message": { + "role": "assistant", + "content": [ + {"type": "reasoning", "text": "think"}, + {"type": "text", "text": "ok"}, + {"type": "tool-call", "id": "c1", "name": "bash", "arguments": "{\"command\":\"ls\"}"} + ], + "source": {"kind": "model", "model": "deepseek-v4-flash"} + } + }, "surfaceOp": "append"}), + json!({"type": "tool/result", "seq": 4, "time": 14, "data": { + "message": {"content": [{ + "type": "tool-result", + "toolCallId": "c1", + "isError": false, + "content": [{"type": "text", "text": "a.rs"}] + }]} + }, "surfaceOp": "append"}), + ]; + let messages = events_to_messages(&events, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 3); + assert!(matches!(&messages[0].content[0], Block::Text { text } if text == "hi")); + assert!(matches!(&messages[1].content[0], Block::Thinking { text, .. } if text == "think")); + assert!(matches!(&messages[1].content[2], Block::ToolUse { id, .. } if id == "c1")); + assert_eq!(messages[1].model.as_deref(), Some("deepseek-v4-flash")); + assert!(matches!( + &messages[2].content[0], + Block::ToolResult { tool_use_id, is_error, .. } if tool_use_id == "c1" && !is_error + )); + } + + fn user_event(seq: u64, text: &str, surface_op: &Value) -> Value { + json!({"type": "user/message", "seq": seq, "time": seq, "data": { + "content": [{"type": "text", "text": text}] + }, "surfaceOp": surface_op}) + } + + #[test] + fn surface_replace_drops_shadowed_nodes() { + // Log-only events between surface nodes push each node's seq past its + // surface position, so a replace op must be resolved by seq. + let events = vec![ + user_event(0, "old", &json!("append")), + json!({"type": "step/start", "seq": 1, "time": 1, "data": {}}), + user_event(2, "kept", &json!("append")), + user_event( + 3, + "summary", + &json!({"op": "replace", "start": 0, "end": 0}), + ), + ]; + let messages = events_to_messages(&events, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 2); + assert!(matches!(&messages[0].content[0], Block::Text { text } if text == "summary")); + assert!(matches!(&messages[1].content[0], Block::Text { text } if text == "kept")); + } + + #[test] + fn surface_replace_spans_the_whole_seq_range() { + // Compaction shadows a contiguous run: nodes at seq 1 and 3 must both + // go, even though seq 3 sits at surface index 1. + let events = vec![ + user_event(1, "first", &json!("append")), + json!({"type": "assistant/chunk", "seq": 2, "time": 2, "data": {}}), + user_event(3, "second", &json!("append")), + user_event(4, "kept", &json!("append")), + user_event( + 9, + "summary", + &json!({"op": "replace", "start": 1, "end": 3}), + ), + ]; + let messages = events_to_messages(&events, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 2); + assert!(matches!(&messages[0].content[0], Block::Text { text } if text == "summary")); + assert!(matches!(&messages[1].content[0], Block::Text { text } if text == "kept")); + } + + #[test] + fn surface_replace_with_unresolvable_range_keeps_history() { + // A range naming a seq that never reached the surface shadows nothing. + let events = vec![ + user_event(4, "kept", &json!("append")), + user_event( + 7, + "summary", + &json!({"op": "replace", "start": 2, "end": 2}), + ), + ]; + let messages = events_to_messages(&events, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 2); + assert!(matches!(&messages[0].content[0], Block::Text { text } if text == "kept")); + assert!(matches!(&messages[1].content[0], Block::Text { text } if text == "summary")); + } + + #[test] + fn assistant_usage_and_interruption_survive_projection() { + let events = vec![ + json!({"type": "assistant/message", "seq": 0, "time": 5, "data": { + "turn": 1, "step": 1, + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "partial"}], + "source": {"kind": "model", "model": "deepseek-v4-flash"} + }, + "usage": { + "inputTokens": 13972, + "outputTokens": 205, + "cacheReadTokens": 64, + "cacheWriteTokens": 32, + "reasoningTokens": 106 + }, + "interrupted": true + }, "surfaceOp": "append"}), + ]; + let messages = events_to_messages(&events, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].stop_reason, Some(StopReason::Aborted)); + let usage = messages[0].usage.expect("usage projected"); + assert_eq!(usage.input_tokens, 13972); + assert_eq!(usage.output_tokens, 205); + assert_eq!(usage.cache_read_input_tokens, Some(64)); + assert_eq!(usage.cache_creation_input_tokens, Some(32)); + } + + #[test] + fn assistant_without_usage_keeps_normal_stop_reason() { + let events = vec![ + json!({"type": "assistant/message", "seq": 0, "time": 5, "data": { + "message": {"role": "assistant", "content": [{"type": "text", "text": "done"}]} + }, "surfaceOp": "append"}), + ]; + let messages = events_to_messages(&events, DateTime::::UNIX_EPOCH); + assert_eq!(messages[0].stop_reason, Some(StopReason::EndTurn)); + assert!(messages[0].usage.is_none()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn decode_zstd_spans_concatenated_frames() { + // The backend writes the header in its own frame, then one frame per + // append batch; all of them must decode as a single JSONL stream. + let header = b"{\"type\":\"session\",\"version\":0,\"id\":\"s1\"}\n"; + let batch = b"{\"type\":\"user/message\",\"seq\":0,\"time\":1,\"data\":{},\"surfaceOp\":\"append\"}\n"; + let mut bytes = zstd::encode_all(&header[..], 0).unwrap(); + bytes.extend(zstd::encode_all(&batch[..], 0).unwrap()); + + let text = decode_zstd(&bytes).unwrap(); + assert_eq!(text.lines().count(), 2); + assert!(text.starts_with("{\"type\":\"session\"")); + assert!(text.contains("user/message")); + } + + #[test] + fn parse_log_requires_session_header() { + let err = parse_log("{\"type\":\"user/message\"}\n").unwrap_err(); + assert!(err.to_string().contains("session header")); + } +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 6b87a32..3d5473d 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -15,6 +15,7 @@ pub mod codex; pub mod cowork; pub mod cursor; pub mod cursor_desktop; +pub mod dsh; pub mod fx; pub mod grok; pub mod hermes; diff --git a/src/lib.rs b/src/lib.rs index a340523..e01b516 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, `dsh`, Hermes, Amp, and Antigravity 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 68a3a07..65d87e7 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, pi, + amp, antigravity, campfire, claude_code, codex, cowork, cursor, dsh, fx, grok, pi, }; #[cfg(feature = "chatgpt")] @@ -118,6 +118,8 @@ pub fn discover_with(mut on_store: impl FnMut(HarnessId, usize)) -> Vec ); on_store(HarnessId::Grok, out.len()); scan(HarnessId::Grok, grok::GrokStore::default_root(), &mut out); + on_store(HarnessId::Dsh, out.len()); + scan(HarnessId::Dsh, dsh::DshStore::default_root(), &mut out); on_store(HarnessId::Fx, out.len()); scan(HarnessId::Fx, fx::FxStore::default_root(), &mut out); on_store(HarnessId::Amp, out.len()); @@ -326,6 +328,7 @@ impl Session { } (HarnessId::Cursor, Locator::Path(p)) => go(cursor::CursorStore::default_root(), p), (HarnessId::Grok, Locator::Path(p)) => go(grok::GrokStore::default_root(), p), + (HarnessId::Dsh, Locator::Path(p)) => go(dsh::DshStore::default_root(), p), (HarnessId::Fx, Locator::Path(p)) => go(fx::FxStore::default_root(), p), (HarnessId::Amp, Locator::Path(p)) => go(amp::AmpStore::default_root(), p), (HarnessId::Antigravity, Locator::Path(p)) => { @@ -380,6 +383,7 @@ impl Session { } (HarnessId::Cursor, Locator::Path(p)) => go(cursor::CursorStore::default_root(), p), (HarnessId::Grok, Locator::Path(p)) => go(grok::GrokStore::default_root(), p), + (HarnessId::Dsh, Locator::Path(p)) => go(dsh::DshStore::default_root(), p), (HarnessId::Fx, Locator::Path(p)) => go(fx::FxStore::default_root(), p), (HarnessId::Amp, Locator::Path(p)) => go(amp::AmpStore::default_root(), p), (HarnessId::Antigravity, Locator::Path(p)) => { @@ -448,6 +452,7 @@ pub fn fingerprints(sessions: &[Session]) -> Vec { HarnessId::Campfire => group.files(campfire::CampfireStore::default_root()), HarnessId::Cursor => group.files(cursor::CursorStore::default_root()), HarnessId::Grok => group.files(grok::GrokStore::default_root()), + HarnessId::Dsh => group.files(dsh::DshStore::default_root()), HarnessId::Fx => group.files(fx::FxStore::default_root()), HarnessId::Amp => group.files(amp::AmpStore::default_root()), HarnessId::Antigravity => group.files(antigravity::AntigravityStore::default_root()), @@ -712,6 +717,13 @@ pub fn write( common, |s| s.sessions_dir, ), + HarnessId::Dsh => go( + dsh::DshStore::default_root(), + dsh::DshStore::new, + root, + common, + |s| s.sessions_dir, + ), HarnessId::Fx => go( fx::FxStore::default_root(), fx::FxStore::new, @@ -951,6 +963,10 @@ pub fn resume_command(harness: HarnessId, id: &str) -> (String, Vec) { // the session is in the Agents sidebar. HarnessId::CursorDesktop => ("cursor".into(), Vec::new()), HarnessId::Grok => ("grok".into(), vec!["--resume".into(), id]), + HarnessId::Dsh => ( + "dsh".into(), + vec!["--profile".into(), "tui".into(), "--resume".into(), id], + ), HarnessId::Fx => ("fx".into(), vec!["--resume".into(), id]), HarnessId::Hermes => ("hermes".into(), vec!["--resume".into(), id]), HarnessId::Amp => ("amp".into(), vec!["threads".into(), "continue".into(), id]), diff --git a/src/transcript.rs b/src/transcript.rs index 1d9a8c5..4d6da8d 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -454,6 +454,7 @@ pub enum HarnessId { Cursor, CursorDesktop, Grok, + Dsh, Fx, Hermes, Amp, @@ -463,7 +464,7 @@ pub enum HarnessId { } impl HarnessId { - pub const ALL: [HarnessId; 16] = [ + pub const ALL: [HarnessId; 17] = [ HarnessId::ClaudeCode, HarnessId::ClaudeChat, HarnessId::ChatGpt, @@ -474,6 +475,7 @@ impl HarnessId { HarnessId::Cursor, HarnessId::CursorDesktop, HarnessId::Grok, + HarnessId::Dsh, HarnessId::Fx, HarnessId::Hermes, HarnessId::Amp, @@ -496,6 +498,7 @@ impl HarnessId { HarnessId::Cursor => "cursor", HarnessId::CursorDesktop => "cursor_desktop", HarnessId::Grok => "grok", + HarnessId::Dsh => "dsh", HarnessId::Fx => "fx", HarnessId::Hermes => "hermes", HarnessId::Amp => "amp", @@ -536,6 +539,9 @@ impl FromStr for HarnessId { "grok" | "grok_cli" | "grok-cli" | "grokcli" | "grok_build" | "grok-build" => { Ok(HarnessId::Grok) } + "dsh" | "deepseek" | "deepseek_harness" | "deepseek-harness" | "deepseekharness" => { + Ok(HarnessId::Dsh) + } "fx" | "fx_cli" | "fx-cli" | "fxcli" | "vercel_fx" | "vercel-fx" => Ok(HarnessId::Fx), "hermes" | "hermes_agent" | "hermes-agent" | "hermesagent" => Ok(HarnessId::Hermes), "amp" | "ampcode" | "amp_code" | "amp-code" => Ok(HarnessId::Amp), diff --git a/src/wasm.rs b/src/wasm.rs index d9e831e..452eca3 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -15,7 +15,7 @@ use wasm_bindgen::prelude::*; use crate::common; use crate::harness::{ amp, antigravity, campfire, chatgpt, claude_chat, claude_code, codex, cowork, cursor, - cursor_desktop, fx, grok, hermes, opencode, pi, simple, + cursor_desktop, dsh, fx, grok, hermes, opencode, pi, simple, }; use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; @@ -207,6 +207,7 @@ fn parse_to_common(harness: HarnessId, text: &str) -> crate::Result go::(text), HarnessId::CursorDesktop => go::(text), HarnessId::Grok => go::(text), + HarnessId::Dsh => go::(text), HarnessId::Fx => go::(text), HarnessId::Hermes => go::(text), HarnessId::Amp => go::(text), @@ -231,6 +232,7 @@ fn render_from_common(harness: HarnessId, common: &Transcript) -> crate: HarnessId::Cursor => go::(common), HarnessId::CursorDesktop => go::(common), HarnessId::Grok => go::(common), + HarnessId::Dsh => go::(common), HarnessId::Fx => go::(common), HarnessId::Hermes => go::(common), HarnessId::Amp => go::(common), diff --git a/tests/integration/cross_harness.rs b/tests/integration/cross_harness.rs index 04c5ba2..3992cdd 100644 --- a/tests/integration/cross_harness.rs +++ b/tests/integration/cross_harness.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use txcript::common; use txcript::harness::{ - amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, + amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, dsh, fx, grok, hermes, opencode, pi, simple, }; use txcript::{Codec, Common, Transcript, convert}; @@ -188,7 +188,14 @@ fn conversation_survives_every_hop() { "grok" ); - let fx = convert::(&grok).unwrap(); + let dsh = convert::(&grok).unwrap(); + assert_eq!( + signature(&dsh::Dsh::to_common(&dsh).unwrap()), + expected, + "dsh" + ); + + let fx = convert::(&dsh).unwrap(); assert_eq!(signature(&fx::Fx::to_common(&fx).unwrap()), expected, "fx"); let hermes = convert::(&fx).unwrap(); diff --git a/tests/integration/dsh.rs b/tests/integration/dsh.rs new file mode 100644 index 0000000..5002caf --- /dev/null +++ b/tests/integration/dsh.rs @@ -0,0 +1,352 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use chrono::{DateTime, Utc}; +use serde_json::json; +use txcript::common::{self, Block}; +use txcript::harness::dsh; +use txcript::{Codec, Common, Store, TextCodec}; + +fn ts(s: &str) -> DateTime { + s.parse().unwrap() +} + +fn sample_body() -> dsh::DshSession { + dsh::DshSession { + header: json!({ + "type": "session", + "version": 0, + "id": "session-abc", + "createdAt": 1_704_067_445_000_i64, + "cwd": "/repo", + "delegationDepth": 0, + "agentPreset": "standard" + }), + events: vec![ + json!({"type": "session/title", "seq": 0, "time": 1_704_067_445_000_i64, + "data": {"title": "Parser work", "source": {"kind": "fallback"}}}), + json!({"type": "request/context", "seq": 1, "time": 1_704_067_445_001_i64, + "data": {"provider": "deepseek-official", "model": "deepseek-v4-flash"}}), + json!({"type": "user/message", "seq": 2, "time": 1_704_067_445_002_i64, "data": { + "role": "user", + "id": "u1", + "content": [{"type": "text", "text": "list files"}], + "source": {"kind": "user"} + }, "surfaceOp": "append"}), + json!({"type": "reasoning-chunks", "seq0": 3, "time0": 1_704_067_445_003_i64, + "data": {"texts": ["should", " not", " appear"]}}), + json!({"type": "assistant/message", "seq": 4, "time": 1_704_067_445_004_i64, "data": { + "turn": 1, "step": 1, + "message": { + "role": "assistant", + "id": "a1", + "content": [ + {"type": "reasoning", "text": "plan"}, + {"type": "tool-call", "id": "c1", "name": "bash", + "arguments": "{\"command\":\"ls\"}"} + ], + "source": {"kind": "model", "provider": "deepseek-official", "model": "deepseek-v4-flash"} + } + }, "surfaceOp": "append"}), + json!({"type": "tool/result", "seq": 5, "time": 1_704_067_445_005_i64, "data": { + "turn": 1, "step": 1, + "message": { + "role": "user", + "content": [{ + "type": "tool-result", + "toolCallId": "c1", + "isError": false, + "content": [{"type": "text", "text": "a.rs"}] + }] + } + }, "surfaceOp": "append"}), + json!({"type": "future.dsh.event", "seq": 6, "time": 1_704_067_445_006_i64, "data": {}}), + ], + } +} + +#[test] +fn metadata_and_messages_are_converted() { + let native = txcript::Transcript::new( + common::Meta { + id: "session-abc".into(), + timestamp: ts("2024-01-01T00:00:45.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: Some("deepseek-v4-flash".into()), + }, + sample_body(), + ); + let text = dsh::Dsh::to_text(&native).unwrap(); + let parsed = dsh::Dsh::from_text(&text).unwrap(); + assert_eq!(parsed.meta.cwd.as_deref(), Some("/repo")); + assert_eq!(parsed.meta.title.as_deref(), Some("Parser work")); + assert_eq!(parsed.meta.model.as_deref(), Some("deepseek-v4-flash")); + + let common = dsh::Dsh::to_common(&parsed).unwrap(); + assert_eq!(common.body.len(), 3); + assert!(matches!( + common.body[1].content[0], + common::Block::Thinking { .. } + )); + assert!(matches!( + common.body[1].content[1], + common::Block::ToolUse { .. } + )); + assert!(matches!( + common.body[2].content[0], + common::Block::ToolResult { .. } + )); +} + +#[test] +fn native_text_round_trip_retains_unknown_events() { + let transcript = txcript::Transcript::new( + common::Meta { + id: "session-abc".into(), + timestamp: ts("2024-01-01T00:00:45.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: Some("deepseek-v4-flash".into()), + }, + sample_body(), + ); + let text = dsh::Dsh::to_text(&transcript).unwrap(); + let parsed = dsh::Dsh::from_text(&text).unwrap(); + assert_eq!(parsed.body, sample_body()); + assert!(text.contains("future.dsh.event")); +} + +#[test] +fn store_discovers_and_loads_a_session() { + let root = tempfile::tempdir().unwrap(); + let session = root.path().join("--repo--").join("session-abc"); + write_session(&session, &sample_body()); + let store = dsh::DshStore::new(root.path()); + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, "session-abc"); + let loaded = store.load(&found[0].reference).unwrap(); + assert_eq!(loaded.body, sample_body()); +} + +fn saveable(id: &str, cwd: &str) -> txcript::Transcript { + let mut body = sample_body(); + body.header["id"] = json!(id); + body.header["cwd"] = json!(cwd); + let meta = common::Meta { + id: id.into(), + timestamp: ts("2024-01-01T00:04:05.000Z"), + cwd: Some(cwd.into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: None, + }; + txcript::Transcript::new(meta, body) +} + +#[test] +fn save_writes_the_layout_dsh_scans_for() { + // dsh finds sessions by walking `///`, and + // then asserts the header's own id and cwd name that exact path. A layout + // that disagrees is not merely skipped — it fails dsh's whole listing. + let root = tempfile::tempdir().unwrap(); + let store = dsh::DshStore::new(root.path()); + let saved = store + .save(&saveable("session-abc", "/repo/Mobile Documents/想法")) + .unwrap(); + + let expected = root + .path() + .join("--repo-Mobile~0020Documents-~60F3~6CD5--") + .join("session-abc"); + assert_eq!(saved.reference, expected); + assert!(expected.join("session.jsonl.zstd").is_file()); +} + +#[test] +fn saved_session_is_discovered_and_loads_back() { + let root = tempfile::tempdir().unwrap(); + let store = dsh::DshStore::new(root.path()); + let original = saveable("session-abc", "/repo"); + store.save(&original).unwrap(); + + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, "session-abc"); + + let loaded = store.load(&found[0].reference).unwrap(); + assert_eq!(loaded.body, original.body); +} + +/// Decode only the first Zstandard frame of a written log, the way dsh's +/// `assertZstdHeaderFrame` does. +fn first_frame(log: &std::path::Path) -> String { + use std::io::Read as _; + + let bytes = std::fs::read(log).unwrap(); + let mut decoder = zstd::stream::read::Decoder::new(std::io::Cursor::new(bytes)) + .unwrap() + .single_frame(); + let mut first = String::new(); + decoder.read_to_string(&mut first).unwrap(); + first +} + +#[test] +fn the_first_frame_holds_exactly_the_header_line() { + // dsh reads a session's metadata by decompressing one frame and requiring + // it to be a single line. Compressing the whole log as one frame still + // decodes, and still round-trips through txcript, but dsh rejects it — + // the frame split is part of the format. + let root = tempfile::tempdir().unwrap(); + let store = dsh::DshStore::new(root.path()); + let saved = store.save(&saveable("session-abc", "/repo")).unwrap(); + + let first = first_frame(&saved.reference.join("session.jsonl.zstd")); + assert_eq!(first.matches('\n').count(), 1); + assert!(first.ends_with('\n')); + let header: serde_json::Value = serde_json::from_str(first.trim_end()).unwrap(); + assert_eq!(header["type"], json!("session")); + assert_eq!(header["id"], json!("session-abc")); +} + +#[test] +fn save_stamps_the_header_with_the_identity_that_built_the_path() { + // dsh cross-checks every log it finds against the path its own header + // would name, and a mismatch fails its entire listing rather than skipping + // the one session. A copy given a new id must not keep the old one. + let root = tempfile::tempdir().unwrap(); + let store = dsh::DshStore::new(root.path()); + let mut transcript = saveable("session-abc", "/repo"); + transcript.meta.id = "session-copy".into(); + transcript.meta.cwd = Some("/elsewhere".into()); + + let saved = store.save(&transcript).unwrap(); + assert_eq!(saved.id, "session-copy"); + assert_eq!( + saved.reference, + root.path().join("--elsewhere--").join("session-copy") + ); + + let header: serde_json::Value = + serde_json::from_str(first_frame(&saved.reference.join("session.jsonl.zstd")).trim_end()) + .unwrap(); + assert_eq!(header["id"], json!("session-copy")); + assert_eq!(header["cwd"], json!("/elsewhere")); +} + +#[test] +fn save_follows_the_encoding_the_root_already_uses() { + // A root that mixes `.jsonl` and `.jsonl.zstd` does not merely hide the + // odd session: dsh refuses to list *any* session in it, under either + // configuration. Writing the default encoding into a plaintext root would + // take the user's whole session list down, so the root decides. + let root = tempfile::tempdir().unwrap(); + write_session( + &root.path().join("--repo--").join("session-old"), + &sample_body(), + ); + + let store = dsh::DshStore::new(root.path()); + let saved = store.save(&saveable("session-abc", "/repo")).unwrap(); + assert!(saved.reference.join("session.jsonl").is_file()); + assert!(!saved.reference.join("session.jsonl.zstd").exists()); + + // And it still reads back. + let found = store.discover().unwrap(); + assert_eq!(found.len(), 2); +} + +#[test] +fn saving_under_a_new_cwd_leaves_no_duplicate_id_behind() { + // A project directory is keyed by cwd, so re-saving a session whose cwd + // changed lands it somewhere new. dsh treats one id appearing under two + // project directories as corruption and fails its whole listing, so the + // old copy has to go. + let root = tempfile::tempdir().unwrap(); + let store = dsh::DshStore::new(root.path()); + let first = store.save(&saveable("session-abc", "/a")).unwrap(); + let second = store.save(&saveable("session-abc", "/b")).unwrap(); + + assert_ne!(first.reference, second.reference); + assert!(!first.reference.exists()); + assert_eq!(store.discover().unwrap().len(), 1); +} + +#[test] +fn save_refuses_an_id_that_would_escape_the_store() { + let root = tempfile::tempdir().unwrap(); + let store = dsh::DshStore::new(root.path()); + // dsh encodes rather than rejects a traversing id, so the escape attempt + // becomes a literal directory name and stays inside the store. + let saved = store.save(&saveable("../../evil", "/repo")).unwrap(); + assert!(saved.reference.starts_with(root.path())); + // A dot is a safe character; only a segment that is entirely `.` or `..` + // is special-cased. Escaping the separators is what contains the traversal. + assert_eq!(saved.reference.file_name().unwrap(), "..~002F..~002Fevil"); +} + +#[test] +fn discovery_does_not_depend_on_the_directory_name() { + let root = tempfile::tempdir().unwrap(); + write_session( + &root.path().join("workspace-1").join("conversation-7"), + &sample_body(), + ); + let found = dsh::DshStore::new(root.path()).discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, "session-abc"); +} + +fn write_session(dir: &std::path::Path, body: &dsh::DshSession) { + std::fs::create_dir_all(dir).unwrap(); + let mut text = format!("{}\n", serde_json::to_string(&body.header).unwrap()); + for event in &body.events { + text.push_str(&serde_json::to_string(event).unwrap()); + text.push('\n'); + } + std::fs::write(dir.join("session.jsonl"), text).unwrap(); +} + +fn common_sample() -> txcript::Transcript { + txcript::Transcript::new( + common::Meta { + id: "session-xyz".into(), + timestamp: ts("2024-01-01T00:00:45.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("hi".into()), + cli_version: None, + model: Some("deepseek-v4-flash".into()), + }, + vec![common::Message { + role: common::Role::User, + content: vec![Block::Text { + text: "hello".into(), + }], + timestamp: ts("2024-01-01T00:00:46.000Z"), + model: None, + stop_reason: None, + usage: None, + }], + ) +} + +#[test] +fn rendering_is_deterministic() { + let a = dsh::Dsh::from_common(&common_sample()).unwrap(); + let b = dsh::Dsh::from_common(&common_sample()).unwrap(); + assert_eq!(a.body, b.body); +} + +#[test] +fn assistant_timestamps_survive_a_common_round_trip() { + let native = dsh::Dsh::from_common(&common_sample()).unwrap(); + let common = dsh::Dsh::to_common(&native).unwrap(); + assert_eq!(common.body[0].timestamp, ts("2024-01-01T00:00:46.000Z")); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index cced1d4..3522a46 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -16,6 +16,7 @@ mod cowork; mod cross_harness; mod cursor; mod cursor_desktop; +mod dsh; mod fx; mod grok; mod hermes; diff --git a/tests/integration/path_safety.rs b/tests/integration/path_safety.rs index 738561e..258817d 100644 --- a/tests/integration/path_safety.rs +++ b/tests/integration/path_safety.rs @@ -7,7 +7,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, cursor, grok, pi}; +use txcript::harness::{amp, antigravity, campfire, claude_code, codex, cursor, dsh, grok, pi}; use txcript::{Codec, Common, Store, Transcript}; fn small_common(id: &str) -> Transcript { @@ -80,6 +80,7 @@ fn hostile_ids_cannot_escape_any_file_backed_store() { 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); + assert_save_confined(&dsh::DshStore::new(root.to_path_buf()), root); assert_save_confined(&::AmpStore::new(root.to_path_buf()), root); assert_save_confined( &antigravity::AntigravityStore::new(root.to_path_buf()), diff --git a/tests/integration/properties.rs b/tests/integration/properties.rs index 63bab05..61187dc 100644 --- a/tests/integration/properties.rs +++ b/tests/integration/properties.rs @@ -18,7 +18,7 @@ use proptest::test_runner::TestCaseError; 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, + amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, dsh, fx, grok, hermes, opencode, pi, simple, }; use txcript::{Codec, Common, Transcript}; @@ -265,6 +265,7 @@ proptest! { assert_fixpoint::("cursor", &common)?; assert_fixpoint::("cursor_desktop", &common)?; assert_fixpoint::("grok", &common)?; + assert_fixpoint::("dsh", &common)?; assert_fixpoint::("fx", &common)?; assert_fixpoint::("hermes", &common)?; assert_fixpoint::("amp", &common)?; diff --git a/tests/integration/store_delete.rs b/tests/integration/store_delete.rs index fcdd9a1..69f6808 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::{campfire, claude_code, codex, grok, pi}; +use txcript::harness::{campfire, claude_code, codex, dsh, grok, pi}; use txcript::{Codec, Common, Store, Transcript}; #[cfg(feature = "opencode")] @@ -106,6 +106,17 @@ fn grok_delete_removes_the_session_directory() { ); } +#[test] +fn dsh_delete_removes_the_session_directory() { + let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}")); + let store = dsh::DshStore::new(dir.path().to_path_buf()); + roundtrip(&store); + // dsh scans project directories for session directories, so an empty + // project directory is harmless — a leftover log is not. + let leftovers: Vec<_> = walk_files(dir.path()); + assert!(leftovers.is_empty(), "no log left behind: {leftovers:?}"); +} + #[cfg(feature = "opencode")] #[test] fn cursor_delete_removes_the_whole_session_dir() {