From 740e335e5c0d1670c0f54b31e9dbbac8b445c8e0 Mon Sep 17 00:00:00 2001 From: zzyking Date: Sat, 29 Aug 2026 23:34:40 +0800 Subject: [PATCH 1/3] feat: add read-only DeepSeek Harness (dsh) Discover, list, search, view, export, and convert from dsh sessions under $DSH_HOME/sessions (default ~/.dsh/sessions). The store is read-only: dsh has no documented session import command and its persistence seam does not delete logs. Logs are append-only JSONL, Zstandard-framed by default. The first line is a type:session header; the Common projection rebuilds the ordered surface (user/message, assistant/message, tool/result) and leaves packed chunk rows and unknown events in the native body. --- Cargo.lock | 1 + Cargo.toml | 6 + README.md | 13 +- cli/src/lib.rs | 8 +- docs/formats/README.md | 1 + docs/formats/dsh.md | 77 +++ examples/search_bench.rs | 9 +- src/harness/dsh.rs | 814 +++++++++++++++++++++++++++++ src/harness/mod.rs | 1 + src/lib.rs | 2 +- src/local.rs | 15 +- src/transcript.rs | 8 +- src/wasm.rs | 4 +- tests/integration/cross_harness.rs | 11 +- tests/integration/dsh.rs | 196 +++++++ tests/integration/main.rs | 1 + tests/integration/properties.rs | 3 +- 17 files changed, 1160 insertions(+), 10 deletions(-) create mode 100644 docs/formats/dsh.md create mode 100644 src/harness/dsh.rs create mode 100644 tests/integration/dsh.rs 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..a109879 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,15 @@ 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 + +DeepSeek Harness (`dsh`) is supported as a read-only source. Sessions are +discovered from `$DSH_HOME/sessions` (default `~/.dsh/sessions`) and can be +searched, exported, or continued into another harness. dsh has no documented +session import command and its persistence seam does not delete logs, so +txcript never writes into the session store. 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..3cbc528 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. @@ -1069,6 +1069,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 @@ -1610,6 +1611,11 @@ fn ensure_resumable_source(source: HarnessId, target: HarnessId) -> Result<(), S "ChatGPT is pull-only: choose another --with harness; txcript never continues conversations in ChatGPT" .to_string(), ) + } else if source == HarnessId::Dsh && target == HarnessId::Dsh { + Err( + "DeepSeek Harness is read-only: choose another --with harness; txcript never writes dsh session logs" + .to_string(), + ) } else { Ok(()) } 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..c5d54e9 --- /dev/null +++ b/docs/formats/dsh.md @@ -0,0 +1,77 @@ +# 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 | +| `tool/result` | user tool-result (`isError` kept) | + +The ordered surface is rebuilt before projection: a `replace` `surfaceOp` +truncates earlier surface nodes. Packed chunk rows and `assistant/chunk` +stream events are ignored for Common because the assembled `assistant/message` +already carries the step. + +## Store capabilities + +DeepSeek Harness has no documented session-import CLI. The persistence seam +also has no delete API. The txcript dsh store is therefore **read-only**: + +- `list`, `query`, `view`, and `export` work; +- a dsh session can be converted into any writable target harness; +- `save` and `delete` return an explicit read-only error; +- txcript never writes directly into `~/.dsh/sessions`. + +The native resume command documented for a TUI profile is +`dsh --profile tui --resume `. Cross-harness continuation is supported; +native dsh continuation is refused. + +## 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 reader was also checked against a local `session.jsonl.zstd` written by +dsh around 2026-08-14. + +Last verified: 2026-08-29 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..74a4f51 --- /dev/null +++ b/src/harness/dsh.rs @@ -0,0 +1,814 @@ +//! `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). +//! There is no documented session-import CLI, the on-disk format is version 0 +//! with no migration, and the persistence seam has no delete API, so +//! [`DshStore`] is read-only: sessions can be discovered, loaded, searched, +//! exported, and converted into another harness, but txcript never writes +//! `~/.dsh/sessions`. +//! +//! 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::io::Read; +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}; +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-only 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> { + Err(read_only_error()) + } + + fn delete(&self, _reference: &PathBuf) -> Result<()> { + Err(read_only_error()) + } + + 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) + } +} + +fn read_only_error() -> Error { + Error::Unconvertible { + harness: Dsh::NAME, + detail: "dsh session storage is read-only in txcript; dsh has no documented session import command and its persistence seam does not delete logs".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 { + // Official JSONL backend concatenates independent Zstandard frames + // (header frame, then one frame per append batch). Decode each. + let starts = zstd_frame_starts(bytes); + let starts = if starts.is_empty() { vec![0] } else { starts }; + let mut out = Vec::new(); + for (index, start) in starts.iter().enumerate() { + let end = starts.get(index + 1).copied().unwrap_or(bytes.len()); + let mut decoder = + zstd::stream::read::Decoder::new(std::io::Cursor::new(&bytes[*start..end]))?; + decoder.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(not(target_arch = "wasm32"))] +fn zstd_frame_starts(bytes: &[u8]) -> Vec { + const MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD]; + let mut starts = Vec::new(); + let mut index = 0; + while index + 4 <= bytes.len() { + if bytes[index..index + 4] == MAGIC { + starts.push(index); + index += 4; + } else { + index += 1; + } + } + starts +} + +#[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) +} + +fn json_usize(value: Option<&Value>) -> Option { + value + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) +} + +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) +} + +/// 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 { + let mut surface: Vec<&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; + } + match event.get("surfaceOp") { + Some(Value::Object(op)) if op.get("op").and_then(Value::as_str) == Some("replace") => { + let start = json_usize(op.get("start")).unwrap_or(0); + let end = json_usize(op.get("end")).unwrap_or(start); + if start < surface.len() { + let end = end.min(surface.len().saturating_sub(1)).max(start); + surface.drain(start..=end); + surface.insert(start, event); + } else { + surface.push(event); + } + } + _ => surface.push(event), + } + } + + 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 { .. })); + messages.push(Message { + role: Role::Assistant, + content: blocks, + timestamp, + model, + stop_reason: Some(if has_tool { + StopReason::ToolUse + } else { + StopReason::EndTurn + }), + usage: None, + }); + } + 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": [], + "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); + } + events.push(json!({ + "type": "assistant/message", + "seq": seq, + "time": time, + "data": { + "turn": 1, + "step": index + 1, + "message": { + "role": "assistant", + "id": msg_id, + "content": content, + "source": source + } + }, + "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 + )); + } + + #[test] + fn surface_replace_drops_shadowed_nodes() { + let events = vec![ + json!({"type": "user/message", "seq": 0, "time": 1, "data": { + "content": [{"type": "text", "text": "old"}] + }, "surfaceOp": "append"}), + json!({"type": "user/message", "seq": 1, "time": 2, "data": { + "content": [{"type": "text", "text": "kept"}] + }, "surfaceOp": "append"}), + json!({"type": "user/message", "seq": 2, "time": 3, "data": { + "content": [{"type": "text", "text": "summary"}] + }, "surfaceOp": {"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 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..cb419a7 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,10 @@ pub fn write( common, |s| s.sessions_dir, ), + HarnessId::Dsh => Err(Error::Unconvertible { + harness: "dsh", + detail: "DeepSeek Harness session storage is read-only and dsh has no documented session import command; sessions can be converted from dsh, but not continued into it".to_string(), + }), HarnessId::Fx => go( fx::FxStore::default_root(), fx::FxStore::new, @@ -951,6 +960,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..c82a2ab --- /dev/null +++ b/tests/integration/dsh.rs @@ -0,0 +1,196 @@ +#![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 read_only_store_discovers_and_refuses_writes() { + 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()); + assert!(store.save(&loaded).is_err()); + assert!(store.delete(&found[0].reference).is_err()); +} + +#[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/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)?; From ad82c0808616cd729f3bcb2edf0967967e31f0f9 Mon Sep 17 00:00:00 2001 From: zzyking Date: Mon, 7 Sep 2026 19:58:59 +0800 Subject: [PATCH 2/3] fix(dsh): resolve surface replacements by seq, keep usage and interruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings on the dsh harness, all confirmed against the official `@deepseek-ai/dsh-session` sources: - `surfaceOp` replace endpoints are event seqs, not surface-vector indexes. Log-only events sit between surface nodes, so the two diverge immediately and compaction shadowed the wrong range. The surface now tracks each node's seq and resolves both endpoints by seq, matching `replacementRange` in dsh's own `surface.ts`. The previous test masked this by using seqs that happened to equal vector indexes. - Durable `assistant/message` `usage` and `interrupted` were dropped. Map `interrupted: true` to `StopReason::Aborted` and translate the `TokenUsage` counts, in both directions. `reasoningTokens` has no Common counterpart and stays in the native body. - `std::io::Read` was imported unconditionally but only used by the native `decode_zstd`, failing the wasm32 job under `-D warnings`. - Zstandard frames were split by scanning for frame magic, which also occurs inside compressed payloads and checksums. The streaming decoder already walks frame structure and continues across boundaries, so decode in one pass. Also fixes an invariant violation in the writer: `tool/result` emitted `sourceEventSeqs: []`, which dsh's `assertProvenance` rejects — empty arrays are legal only on `assistant/message`. Verified by feeding a regenerated log from a real session to the official `foldSurface`, which now passes. --- docs/formats/dsh.md | 18 ++- src/harness/dsh.rs | 282 +++++++++++++++++++++++++++++++++----------- 2 files changed, 227 insertions(+), 73 deletions(-) diff --git a/docs/formats/dsh.md b/docs/formats/dsh.md index c5d54e9..549acf8 100644 --- a/docs/formats/dsh.md +++ b/docs/formats/dsh.md @@ -44,12 +44,22 @@ Each event is `{ type, seq, time, data, ... }`. Surface events | --- | --- | | `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: a `replace` `surfaceOp` -truncates earlier surface nodes. Packed chunk rows and `assistant/chunk` -stream events are ignored for Common because the assembled `assistant/message` -already carries the step. +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 diff --git a/src/harness/dsh.rs b/src/harness/dsh.rs index 74a4f51..3819bf2 100644 --- a/src/harness/dsh.rs +++ b/src/harness/dsh.rs @@ -15,7 +15,6 @@ use std::collections::HashMap; use std::fs; -use std::io::Read; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; @@ -23,7 +22,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; -use crate::common::{Block, Message, Meta, Role, StopReason, Tool, ToolOutput}; +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}; @@ -227,39 +226,23 @@ fn read_log_text(path: &Path) -> Result { #[cfg(not(target_arch = "wasm32"))] fn decode_zstd(bytes: &[u8]) -> Result { - // Official JSONL backend concatenates independent Zstandard frames - // (header frame, then one frame per append batch). Decode each. - let starts = zstd_frame_starts(bytes); - let starts = if starts.is_empty() { vec![0] } else { starts }; + 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(); - for (index, start) in starts.iter().enumerate() { - let end = starts.get(index + 1).copied().unwrap_or(bytes.len()); - let mut decoder = - zstd::stream::read::Decoder::new(std::io::Cursor::new(&bytes[*start..end]))?; - decoder.read_to_end(&mut out)?; - } + 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(not(target_arch = "wasm32"))] -fn zstd_frame_starts(bytes: &[u8]) -> Vec { - const MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD]; - let mut starts = Vec::new(); - let mut index = 0; - while index + 4 <= bytes.len() { - if bytes[index..index + 4] == MAGIC { - starts.push(index); - index += 4; - } else { - index += 1; - } - } - starts -} - #[cfg(target_arch = "wasm32")] fn decode_zstd(_bytes: &[u8]) -> Result { Err(Error::Malformed { @@ -349,10 +332,17 @@ fn first_event_time(events: &[Value]) -> Option> { .and_then(DateTime::from_timestamp_millis) } -fn json_usize(value: Option<&Value>) -> Option { - value - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) +/// `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 { @@ -363,10 +353,31 @@ fn event_time(event: &Value, fallback: DateTime) -> DateTime { .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 { - let mut surface: Vec<&Value> = Vec::new(); + // 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; @@ -374,24 +385,25 @@ fn events_to_messages(events: &[Value], fallback: DateTime) -> Vec 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") => { - let start = json_usize(op.get("start")).unwrap_or(0); - let end = json_usize(op.get("end")).unwrap_or(start); - if start < surface.len() { - let end = end.min(surface.len().saturating_sub(1)).max(start); - surface.drain(start..=end); - surface.insert(start, event); - } else { - surface.push(event); + 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(event), + _ => surface.push(node), } } let mut messages = Vec::new(); - for event in surface { + for (_, event) in surface { let timestamp = event_time(event, fallback); match event.get("type").and_then(Value::as_str) { Some("user/message") => { @@ -418,17 +430,26 @@ fn events_to_messages(events: &[Value], fallback: DateTime) -> Vec 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(if has_tool { - StopReason::ToolUse - } else { - StopReason::EndTurn - }), - usage: None, + stop_reason: Some(stop_reason), + usage: usage_from_data(data), }); } Some("tool/result") => { @@ -651,7 +672,8 @@ fn body_from_common(transcript: &Transcript) -> DshSession { }] } }, - "sourceEventSeqs": [], + // `sourceEventSeqs` is omitted, not empty: dsh rejects + // an empty array on anything but `assistant/message`. "surfaceOp": "append" })); seq += 1; @@ -688,20 +710,39 @@ fn body_from_common(transcript: &Transcript) -> DshSession { 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": { - "turn": 1, - "step": index + 1, - "message": { - "role": "assistant", - "id": msg_id, - "content": content, - "source": source - } - }, + "data": data, "surfaceOp": "append" })); seq += 1; @@ -787,18 +828,46 @@ mod tests { )); } + 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![ - json!({"type": "user/message", "seq": 0, "time": 1, "data": { - "content": [{"type": "text", "text": "old"}] - }, "surfaceOp": "append"}), - json!({"type": "user/message", "seq": 1, "time": 2, "data": { - "content": [{"type": "text", "text": "kept"}] - }, "surfaceOp": "append"}), - json!({"type": "user/message", "seq": 2, "time": 3, "data": { - "content": [{"type": "text", "text": "summary"}] - }, "surfaceOp": {"op": "replace", "start": 0, "end": 0}}), + 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); @@ -806,6 +875,81 @@ mod tests { 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(); From 96c04f62d009430da0f7153469f2e53176820595 Mon Sep 17 00:00:00 2001 From: zzyking Date: Mon, 7 Sep 2026 21:12:31 +0800 Subject: [PATCH 3/3] feat: make the DeepSeek Harness store writable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh ships no session import command, but it does not need one: it finds 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, which makes each a hard requirement on the writer: - `assertZstdHeaderFrame` — the first Zstandard frame must decode to exactly one line. Compressing the whole log as a single frame still round-trips through txcript's own reader, which decodes across frame boundaries, so only the official reader rejects it. - `assertStoredIdentity` — the header's `id` and `cwd` must name the path the log was found at. A copy given a new identity would otherwise keep pointing at the original's, and take the listing down with it. - `checkRootEncoding` — one root must not mix `.jsonl` with `.jsonl.zstd`. Writing the default encoding into a plaintext root breaks that root under *both* configurations, so the root decides the encoding and only an empty one falls back to dsh's `zstd` default. A duplicate id across two project directories is rejected the same way, so re-saving a session whose cwd changed retires the copy under the old project directory. Path derivation follows the official `encodeSegment` and `projectKey`: separators collapse to `-`, `[A-Za-z0-9._-]` survives, every other UTF-16 code unit becomes `~XXXX`, and the project key is wrapped in `--`. Escaping over code units rather than scalars is what keeps it injective over lone surrogates. Escaping the separators is also what contains a traversing id — `../../evil` becomes the literal directory `..~002F..~002Fevil`, matching dsh, which encodes rather than rejects. Verified by running the official persistence backend's own `listArtifacts` and `loadStored` against a txcript-written root: it lists both a normal session and a `_no-cwd` one, and decodes all 1257 event records of the former. Re-introducing the single-frame bug makes that same check fail with dsh's own "first frame is not exactly one header line". The derived project-directory names reproduce those of the real local session store exactly, including its non-ASCII path. `delete` removes the session directory. dsh's persistence seam has no delete API, but it keeps no index either — an absent directory is simply not scanned. --- README.md | 17 +- cli/src/lib.rs | 12 +- docs/formats/dsh.md | 70 ++++++-- src/harness/dsh.rs | 280 ++++++++++++++++++++++++++++-- src/local.rs | 11 +- tests/integration/dsh.rs | 162 ++++++++++++++++- tests/integration/path_safety.rs | 3 +- tests/integration/store_delete.rs | 13 +- 8 files changed, 522 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index a109879..a277506 100644 --- a/README.md +++ b/README.md @@ -94,7 +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) | +| [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) | @@ -113,11 +113,16 @@ Discovery, listing, search, and `view` work for every harness with a backing sto ### DeepSeek Harness -DeepSeek Harness (`dsh`) is supported as a read-only source. Sessions are -discovered from `$DSH_HOME/sessions` (default `~/.dsh/sessions`) and can be -searched, exported, or continued into another harness. dsh has no documented -session import command and its persistence seam does not delete logs, so -txcript never writes into the session store. See +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 diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 3cbc528..61c74af 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -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; @@ -1611,11 +1618,6 @@ fn ensure_resumable_source(source: HarnessId, target: HarnessId) -> Result<(), S "ChatGPT is pull-only: choose another --with harness; txcript never continues conversations in ChatGPT" .to_string(), ) - } else if source == HarnessId::Dsh && target == HarnessId::Dsh { - Err( - "DeepSeek Harness is read-only: choose another --with harness; txcript never writes dsh session logs" - .to_string(), - ) } else { Ok(()) } diff --git a/docs/formats/dsh.md b/docs/formats/dsh.md index 549acf8..5e3c72f 100644 --- a/docs/formats/dsh.md +++ b/docs/formats/dsh.md @@ -63,17 +63,54 @@ full log even though Common shows the model-visible surface. ## Store capabilities -DeepSeek Harness has no documented session-import CLI. The persistence seam -also has no delete API. The txcript dsh store is therefore **read-only**: +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. -- `list`, `query`, `view`, and `export` work; -- a dsh session can be converted into any writable target harness; -- `save` and `delete` return an explicit read-only error; -- txcript never writes directly into `~/.dsh/sessions`. +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 `. Cross-harness continuation is supported; -native dsh continuation is refused. +`dsh --profile tui --resume `. ## Provenance @@ -81,7 +118,16 @@ 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 reader was also checked against a local `session.jsonl.zstd` written by -dsh around 2026-08-14. - -Last verified: 2026-08-29 +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/src/harness/dsh.rs b/src/harness/dsh.rs index 3819bf2..8becb00 100644 --- a/src/harness/dsh.rs +++ b/src/harness/dsh.rs @@ -3,11 +3,13 @@ //! 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). -//! There is no documented session-import CLI, the on-disk format is version 0 -//! with no migration, and the persistence seam has no delete API, so -//! [`DshStore`] is read-only: sessions can be discovered, loaded, searched, -//! exported, and converted into another harness, but txcript never writes -//! `~/.dsh/sessions`. +//! +//! 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 @@ -77,7 +79,7 @@ impl Codec for Dsh { } } -/// Read-only access to `dsh` session directories. +/// Read/write access to `dsh` session directories. #[derive(Debug, Clone)] pub struct DshStore { pub sessions_dir: PathBuf, @@ -152,12 +154,45 @@ impl Store for DshStore { Ok(Transcript::new(meta, body)) } - fn save(&self, _transcript: &Transcript) -> Result> { - Err(read_only_error()) + 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<()> { - Err(read_only_error()) + fn delete(&self, reference: &PathBuf) -> Result<()> { + fs::remove_dir_all(reference)?; + Ok(()) } fn fingerprints(&self, refs: &[PathBuf]) -> Result> { @@ -171,11 +206,228 @@ impl Store for DshStore { } } -fn read_only_error() -> Error { - Error::Unconvertible { - harness: Dsh::NAME, - detail: "dsh session storage is read-only in txcript; dsh has no documented session import command and its persistence seam does not delete logs".to_string(), +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 { diff --git a/src/local.rs b/src/local.rs index cb419a7..65d87e7 100644 --- a/src/local.rs +++ b/src/local.rs @@ -717,10 +717,13 @@ pub fn write( common, |s| s.sessions_dir, ), - HarnessId::Dsh => Err(Error::Unconvertible { - harness: "dsh", - detail: "DeepSeek Harness session storage is read-only and dsh has no documented session import command; sessions can be converted from dsh, but not continued into it".to_string(), - }), + 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, diff --git a/tests/integration/dsh.rs b/tests/integration/dsh.rs index c82a2ab..5002caf 100644 --- a/tests/integration/dsh.rs +++ b/tests/integration/dsh.rs @@ -121,7 +121,7 @@ fn native_text_round_trip_retains_unknown_events() { } #[test] -fn read_only_store_discovers_and_refuses_writes() { +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()); @@ -131,8 +131,164 @@ fn read_only_store_discovers_and_refuses_writes() { assert_eq!(found[0].meta.id, "session-abc"); let loaded = store.load(&found[0].reference).unwrap(); assert_eq!(loaded.body, sample_body()); - assert!(store.save(&loaded).is_err()); - assert!(store.delete(&found[0].reference).is_err()); +} + +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] 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/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() {