diff --git a/Cargo.toml b/Cargo.toml index 20cdaac..22569d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,10 @@ nucleo-matcher = { version = "0.3.1", optional = true } rusqlite = { version = "0.40.1", features = ["bundled"], optional = true } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +# Kimi Code keys each workspace directory by a SHA-256 of the working +# directory, so writing a session Kimi can find needs this on every platform, +# not just the macOS keychain path that used it before. +sha2 = "0.10.9" thiserror = "2.0.18" futures-util = { version = "0.3.33", optional = true } tokio = { version = "1.52.3", default-features = false, features = ["rt", "time"], optional = true } @@ -66,7 +70,6 @@ aes = { version = "0.8.4", optional = true } cbc = { version = "0.1.2", features = ["alloc", "block-padding"], optional = true } pbkdf2 = { version = "0.12.2", optional = true } sha1 = { version = "0.10.6", optional = true } -sha2 = { version = "0.10.9", optional = true } [dev-dependencies] criterion = "0.8.2" @@ -118,7 +121,6 @@ claude_chat = [ "dep:cbc", "dep:pbkdf2", "dep:sha1", - "dep:sha2", ] # ChatGPT is a live, read-only remote store. Its codec remains available # featureless; the feature adds read-only Codex login reuse and diff --git a/README.md b/README.md index cd4adc5..130201d 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 <--> kimi["Kimi Code"] 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) | +| [Kimi Code](https://github.com/MoonshotAI/kimi-cli) | `kimi` | `~/.kimi-code/sessions/` | state JSON + wire JSONL | ⇄ | ✓ | [spec](docs/formats/kimi.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) | diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 32eb85c..dbb614a 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, kimi, fx, hermes, \ amp, antigravity, simple, cowork"; /// The `txcript` binary's command line. @@ -927,6 +927,13 @@ mod identity_tests { assert!(error.contains("pull-only")); } + #[test] + fn kimi_can_be_continued_in_place() { + // Kimi reads whatever its session index points at, so a Kimi session + // continues into Kimi like any writable file-backed harness. + assert!(ensure_resumable_source(HarnessId::Kimi, HarnessId::Kimi).is_ok()); + } + #[test] fn resume_alias_is_accepted_for_continue_command() { use clap::Parser; @@ -1069,6 +1076,7 @@ mod style { HarnessId::Cursor => "\x1b[34m", // blue HarnessId::CursorDesktop => "\x1b[96m", // bright cyan HarnessId::Grok => "\x1b[37m", // white + HarnessId::Kimi => "\x1b[38;5;141m", // violet HarnessId::Fx => "\x1b[38;5;39m", // azure HarnessId::Hermes => "\x1b[93m", // bright yellow HarnessId::Amp => "\x1b[95m", // bright magenta diff --git a/docs/formats/README.md b/docs/formats/README.md index 31e1cc7..09c01a1 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` | +| [kimi.md](kimi.md) | Kimi Code (Moonshot) | `src/harness/kimi.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/kimi.md b/docs/formats/kimi.md new file mode 100644 index 0000000..55c255d --- /dev/null +++ b/docs/formats/kimi.md @@ -0,0 +1,152 @@ +# Kimi Code + +Kimi Code CLI stores sessions under: + +```text +~/.kimi-code/sessions/wd__/session_/ + state.json + agents//wire.jsonl +``` + +`state.json` contains session metadata. Each agent has an append-only wire +log; `agents/main/wire.jsonl` is the main conversation. Other agent logs are +separate conversations and are not merged into the main transcript. + +## Native representation + +`txcript::harness::kimi::KimiSession` retains `state.json` as a JSON value and +the main wire log as a JSONL value list. Unknown Kimi event types remain in the +native body, so a native text load/render round trip does not discard events +that txcript does not understand. + +The `createdAt` field is accepted as either an RFC3339 string or epoch +milliseconds. Kimi versions have emitted both forms. + +### Session id and schema versions + +`state.json` has two observed shapes. Schema version 2 (`"version": 2`) records +the session id in `id` and the working directory in `cwd`. Version 1 carries no +`version` marker, uses `workDir`, and has no id field at all. Kimi's own +`session_index.jsonl` calls the same value `sessionId`. + +The id is therefore resolved in order: `sessionId`, `id`, then the +`session_` segment of `agents..homedir` — an absolute path every +observed schema records. The store falls back to the session directory name. +The homedir fallback matters for `from_text` and the wasm parser, which see the +JSON without its path. + +Discovery is gated on structure — a readable `state.json` plus +`agents/main/wire.jsonl` — never on the directory name, so a Kimi release that +renames its session directories still lists. + +## Common projection + +The following wire events become conversational blocks: + +| Kimi event | Common representation | +| --- | --- | +| `context.append_message` with `role=user` | user text message | +| `content.part` with `part.type=text` | assistant text block | +| `content.part` with `part.type=think` | assistant thinking block | +| `tool.call` | assistant tool-use block | +| `tool.result` | user tool-result block | +| `context.undo` | rewinds the last `count` turns | + +Tool IDs and arguments are retained. `isError` maps to the Common tool-result +error flag. A Kimi result note is appended to textual output so truncation or +permission annotations are not silently lost. Usage, timing, permission, +MCP-tool-snapshot, and step-bookkeeping events are not fabricated as messages. + +### Rewound context + +Kimi rewinds its context with `context.undo` after a failed or cancelled turn, +then re-sends the prompt as a fresh `turn.prompt`. Because `wire.jsonl` is +append-only, the rolled-back entries stay on disk. Replaying them would +resurrect prompts the user already retried — a session that hit ten provider +errors in a row reads back with the same prompt ten times — so the reader +applies the rewind. + +`count` is measured in turns, not entries: one turn can append several +messages (a prompt plus injected reminders), and a single `count: 1` undo drops +all of them. A wire log with no `turn.prompt` markers falls back to entry +granularity. + +## Store capabilities + +The store reads and writes. Kimi ships no import command, but it does not need +one: sessions are loaded from whatever `session_index.jsonl` points at, so +writing one is a matter of laying out the files Kimi expects. + +### The session index + +`/session_index.jsonl` is an append-only log, one JSON record per +line, sitting one level above `sessions/`: + +```json +{"sessionId": "session_", "sessionDir": "/abs/path", "workDir": "/abs/cwd"} +{"sessionId": "session_", "deleted": true} +``` + +**It is the only discovery path.** Kimi does not scan the sessions directory, +so a session written without an index record is invisible to `kimi session +list` and `kimi --session`. Removal is a `deleted` tombstone rather than a +rewrite, which is how txcript's `delete` retires a session too. + +Kimi validates each record on read: `sessionDir` must be absolute, must sit +inside the sessions directory, and its last path segment must equal +`sessionId`. + +### Workspace directory names + +A session directory lives under `sessions/wd__/`, where `slug` is +the working directory's last path segment — lowercased, every run of characters +outside `[a-z0-9._-]` collapsed to `-`, trimmed of leading and trailing +dashes, capped at 40 characters — and `hash` is the first 12 hex characters of +`sha256(workDir)`, with the path normalized to forward slashes and no trailing +slash. + +Getting this name wrong does not hide a session, because the index points at it +directly, but it does break Kimi's own `--cwd` filtering and `kimi -c`, both of +which resolve a working directory to this exact name. + +### What `save` writes + +- `sessions/wd__//state.json` +- `sessions/wd__//agents/main/wire.jsonl` +- one appended record in `/session_index.jsonl` + +An id that is not usable as a single path component is rejected before +anything is written. A session converted from another harness has no Kimi +`state.json`, so `save` fills in the identity fields Kimi and txcript's +directory-free `from_text` read back: `sessionId`, `workDir`, `title`, +`createdAt`, and `updatedAt`. The last one is not decorative — `kimi session +list` renders its timestamp column from `updatedAt` alone, so a session written +without it lists at the Unix epoch. + +`agents.main.homedir` is rewritten rather than preserved, because it names +where the session actually is: Kimi resolves the wire log through it, and +txcript's directory-free readers recover the session id from it. A session +saved under a second root would otherwise keep pointing at the first. Saving is +therefore idempotent rather than byte-preserving on `state.json` — a +`load → save → load` round trip is stable from the first save onward. + +The native Kimi resume command is `kimi --session `. + +## Provenance + +**Reverse-engineered.** The wire protocol comes from sessions observed locally +and the CLI surface documented by `kimi --help`. The index contract and the +workspace-name derivation come from the shipped `kimi` binary's own +`readSessionIndex` and `encodeWorkDirKey`, and were confirmed end to end +against an isolated `KIMI_CODE_HOME`: a session written by txcript is listed by +`kimi session list`, and `kimi export` reports a `sessionFirstActivity` derived +from the `time` field in the written wire log — so Kimi parses the events +back, not just the directory. The derived workspace names reproduce the +existing directory names of real local sessions exactly. + +The wire protocol is an implementation detail and may change between Kimi +releases; unknown events are retained to make the reader fail conservatively +rather than silently discarding native data. + +Last verified: 2026-09-07, against Kimi Code 0.41.0 (wire protocol 1.5) and +real local sessions. diff --git a/src/harness/kimi.rs b/src/harness/kimi.rs new file mode 100644 index 0000000..8c47a47 --- /dev/null +++ b/src/harness/kimi.rs @@ -0,0 +1,1030 @@ +//! Kimi Code CLI sessions: `~/.kimi-code/sessions/wd_*/session_*/`. +//! +//! Kimi keeps session metadata in `state.json` and the append-only event stream +//! for each agent in `agents//wire.jsonl`. The main conversation is the +//! `agents/main` stream. +//! +//! [`KimiStore`] reads and writes. Kimi ships no import command, but it does +//! not need one: it loads whatever `session_index.jsonl` points at, so writing +//! a session means laying out the two files and appending one index record. +//! The index is the only discovery path — a session on disk that is missing +//! from it does not exist as far as Kimi is concerned. +//! +//! The native body retains both JSON files as raw JSON. This makes loading and +//! rendering a session lossless even when Kimi adds bookkeeping events that +//! txcript does not understand. The Common projection interprets user messages, +//! assistant text/reasoning, tool calls, and tool results. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::common::{Block, Message, Meta, Role, StopReason, Tool, ToolOutput}; +use crate::error::Result; +use crate::harness::jsonl; +use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; + +/// The Kimi Code CLI harness marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Kimi; + +impl Harness for Kimi { + const NAME: &'static str = "kimi"; + type Body = KimiSession; +} + +/// The two JSON documents that make up the native main-agent session. +/// +/// `state` is the contents of `state.json`; `wire` is the parsed JSONL from +/// `agents/main/wire.jsonl`. Keeping them raw is intentional: Kimi's wire +/// protocol has many bookkeeping event types and they must not be discarded by +/// a native load/render round trip. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct KimiSession { + pub state: Value, + #[serde(default)] + pub wire: Vec, +} + +impl TextCodec for Kimi { + fn from_text(text: &str) -> Result> { + let body: KimiSession = 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 Kimi { + fn to_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + wire_to_messages(&transcript.body.wire, transcript.meta.timestamp), + )) + } + + fn from_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + body_from_common(transcript), + )) + } +} + +/// Read/write access to Kimi Code's session directories. +#[derive(Debug, Clone)] +pub struct KimiStore { + pub sessions_dir: PathBuf, +} + +impl KimiStore { + pub fn new(path: impl Into) -> Self { + Self { + sessions_dir: path.into(), + } + } + + /// `session_index.jsonl` sits in Kimi's data root, one level above + /// `sessions/`. + fn index_path(&self) -> Option { + self.sessions_dir + .parent() + .map(|home| home.join("session_index.jsonl")) + } + + /// Append one record to Kimi's session index, creating it if needed. + fn append_index(&self, record: &Value) -> Result<()> { + let Some(path) = self.index_path() else { + return Ok(()); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut line = serde_json::to_string(record)?; + line.push('\n'); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + std::io::Write::write_all(&mut file, line.as_bytes())?; + Ok(()) + } + + /// Resolve `$KIMI_HOME/sessions`, falling back to + /// `~/.kimi-code/sessions`. + #[must_use] + pub fn default_root() -> Option { + std::env::var_os("KIMI_HOME") + .filter(|v| !v.is_empty()) + .map(|home| Self::new(PathBuf::from(home).join("sessions"))) + .or_else(|| { + super::home_dir().map(|home| Self::new(home.join(".kimi-code").join("sessions"))) + }) + } +} + +impl Store for KimiStore { + type H = Kimi; + type Ref = PathBuf; + + fn discover(&self) -> Result>> { + let mut found = Vec::new(); + let Ok(entries) = fs::read_dir(&self.sessions_dir) else { + return Ok(found); + }; + for workspace in entries.flatten().map(|entry| entry.path()) { + if !workspace.is_dir() { + continue; + } + let Ok(sessions) = fs::read_dir(&workspace) else { + continue; + }; + for session_dir in sessions.flatten().map(|entry| entry.path()) { + if !session_dir.is_dir() { + continue; + } + let state_path = session_dir.join("state.json"); + let wire_path = session_dir.join("agents").join("main").join("wire.jsonl"); + if !state_path.is_file() || !wire_path.is_file() { + continue; + } + let Ok(body) = load_body(&session_dir) else { + continue; + }; + let mut meta = meta_from_body(&body); + if meta.id.is_empty() { + meta.id = session_id_from_path(&session_dir); + } + found.push(Discovered { + meta, + reference: session_dir, + }); + } + } + Ok(found) + } + + fn load(&self, reference: &PathBuf) -> Result> { + let body = load_body(reference)?; + let mut meta = meta_from_body(&body); + if meta.id.is_empty() { + meta.id = session_id_from_path(reference); + } + Ok(Transcript::new(meta, body)) + } + + fn save(&self, transcript: &Transcript) -> Result> { + let id = if transcript.meta.id.is_empty() { + format!("session_{}", Uuid::new_v4()) + } else { + transcript.meta.id.clone() + }; + super::checked_id_component(Kimi::NAME, &id)?; + let work_dir = transcript.meta.cwd.clone().unwrap_or_default(); + + let session_dir = self.sessions_dir.join(workspace_key(&work_dir)).join(&id); + let agent_dir = session_dir.join("agents").join("main"); + fs::create_dir_all(&agent_dir)?; + + let mut state = transcript.body.state.clone(); + ensure_state_identity(&mut state, &transcript.meta, &id, &agent_dir); + fs::write( + session_dir.join("state.json"), + serde_json::to_string_pretty(&state)?, + )?; + fs::write( + agent_dir.join("wire.jsonl"), + jsonl::render(&transcript.body.wire)?, + )?; + + // Kimi finds sessions only through the index; a session that is on + // disk but absent from it does not exist as far as the CLI is + // concerned. + self.append_index(&json!({ + "sessionId": id, + "sessionDir": session_dir.to_string_lossy(), + "workDir": work_dir, + }))?; + + Ok(Saved { + id, + reference: session_dir, + }) + } + + fn delete(&self, reference: &PathBuf) -> Result<()> { + let id = session_id_from_path(reference); + fs::remove_dir_all(reference)?; + // The index is append-only, so a removal is a tombstone record rather + // than a rewrite — matching how Kimi itself retires a session. + self.append_index(&json!({"sessionId": id, "deleted": true})) + } + + fn fingerprints(&self, refs: &[PathBuf]) -> Result> { + let mut output = HashMap::with_capacity(refs.len()); + for reference in refs { + let state = file_fingerprint(&reference.join("state.json")); + let wire = file_fingerprint(&reference.join("agents").join("main").join("wire.jsonl")); + output.insert( + reference.to_string_lossy().into_owned(), + format!("{state}:{wire}"), + ); + } + Ok(output) + } +} + +/// Kimi's workspace directory name: `wd__`, where +/// the slug is the working directory's last path segment, lowercased with +/// every run of non-`[a-z0-9._-]` characters collapsed to `-`, trimmed of +/// leading and trailing dashes and capped at 40 characters. +/// +/// Getting this wrong does not hide a session — the index still points at it — +/// but it breaks Kimi's own `--cwd` filtering and `kimi -c`, which resolve a +/// working directory to this exact name. +fn workspace_key(work_dir: &str) -> String { + const MAX_SLUG: usize = 40; + const HEX: &[u8; 16] = b"0123456789abcdef"; + + let normalized = work_dir.replace('\\', "/"); + let normalized = normalized.trim_end_matches('/'); + let base = normalized.rsplit('/').next().unwrap_or(normalized); + + let mut slug = String::with_capacity(base.len()); + let mut pending_dash = false; + for ch in base.chars() { + let ch = ch.to_ascii_lowercase(); + if ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-') { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + pending_dash = false; + slug.push(ch); + if slug.len() >= MAX_SLUG { + break; + } + } else { + pending_dash = true; + } + } + let slug = slug.trim_matches('-'); + + let digest = ::digest(normalized.as_bytes()); + let mut hash = String::with_capacity(12); + for &byte in digest.iter().take(6) { + hash.push(char::from(HEX[(byte >> 4) as usize])); + hash.push(char::from(HEX[(byte & 0x0f) as usize])); + } + format!("wd_{slug}_{hash}") +} + +/// Make the written `state.json` self-describing. Kimi's own loader and +/// txcript's directory-free `from_text` both read the id back out of it; +/// `updatedAt` is what Kimi renders a session's time from (without it the CLI +/// lists the session at the epoch); and `agents..homedir` is an absolute +/// path only the store can fill in. +/// +/// Every field but the id is filled in only when absent, so a native session's +/// own bookkeeping survives a load/save round trip untouched. +fn ensure_state_identity(state: &mut Value, meta: &Meta, id: &str, agent_dir: &Path) { + let Some(object) = state.as_object_mut() else { + return; + }; + object.insert("sessionId".to_string(), json!(id)); + if let Some(cwd) = &meta.cwd { + object.entry("workDir").or_insert_with(|| json!(cwd)); + } + if let Some(title) = &meta.title { + object.entry("title").or_insert_with(|| json!(title)); + } + let millis = meta.timestamp.timestamp_millis(); + object.entry("createdAt").or_insert_with(|| json!(millis)); + object.entry("updatedAt").or_insert_with(|| json!(millis)); + // `homedir` is bound to where the session actually is, so it is rewritten + // rather than preserved: a session saved under a different root must not + // keep pointing at the old one. + let main = json!({ + "homedir": agent_dir.to_string_lossy(), + "type": "main", + "parentAgentId": Value::Null, + }); + match object.entry("agents").or_insert_with(|| json!({})) { + Value::Object(agents) => { + agents.insert("main".to_string(), main); + } + other => *other = json!({"main": main}), + } +} + +fn load_body(reference: &Path) -> Result { + let state: Value = serde_json::from_str(&fs::read_to_string(reference.join("state.json"))?)?; + let wire = jsonl::parse(&fs::read_to_string( + reference.join("agents").join("main").join("wire.jsonl"), + )?); + Ok(KimiSession { state, wire }) +} + +/// Last-resort id: the session directory name, as every other file-backed +/// store does. Discovery already rejects anything without a `state.json` and a +/// main wire log, so the name is a label, not a filter — a Kimi release that +/// renames its directories must not make sessions vanish from `list`. +fn session_id_from_path(path: &Path) -> String { + jsonl::file_id(path) +} + +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: &KimiSession) -> Meta { + let state = body.state.as_object(); + let string = |key: &str| { + state + .and_then(|object| object.get(key)) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(String::from) + }; + let id = string("sessionId") + .or_else(|| string("id")) + .or_else(|| id_from_agent_homedir(&body.state)) + .unwrap_or_default(); + let timestamp = state + .and_then(|object| object.get("createdAt")) + .and_then(parse_timestamp) + .or_else(|| first_event_timestamp(&body.wire)) + .unwrap_or(DateTime::::UNIX_EPOCH); + let title = string("title").filter(|title| title != "New Session"); + let model = body.wire.iter().find_map(|event| { + (event.get("type") == Some(&Value::String("llm.request".to_string()))) + .then(|| event.get("model").and_then(Value::as_str).map(String::from)) + .flatten() + }); + Meta { + id, + timestamp, + cwd: string("workDir").or_else(|| string("cwd")), + git_branch: string("gitBranch").or_else(|| string("git_branch")), + title, + cli_version: string("cliVersion").or_else(|| string("cli_version")), + model, + } +} + +/// Recover the session id from `agents..homedir`. +/// +/// Kimi's `state.json` gained a top-level `id` in schema version 2; version 1 +/// carries no id field at all. Every version does record each agent's absolute +/// home directory, which lives inside the `session_` directory, so the +/// path is the only id a bare version-1 `state.json` carries. Without this the +/// text codec — and the wasm parser built on it — would silently drop the id +/// for older sessions. The store still falls back to the directory name. +fn id_from_agent_homedir(state: &Value) -> Option { + let agents = state.get("agents")?.as_object()?; + let homedir = agents + .get("main") + .into_iter() + .chain(agents.values()) + .find_map(|agent| agent.get("homedir").and_then(Value::as_str))?; + homedir + .split(['/', '\\']) + .find(|segment| segment.starts_with("session_")) + .map(String::from) +} + +fn parse_timestamp(value: &Value) -> Option> { + if let Some(text) = value.as_str() { + return text.parse().ok(); + } + value.as_i64().and_then(DateTime::from_timestamp_millis) +} + +fn first_event_timestamp(events: &[Value]) -> Option> { + events.iter().find_map(|event| { + event.get("time").and_then(parse_timestamp).or_else(|| { + event + .get("event") + .and_then(|inner| inner.get("time")) + .and_then(parse_timestamp) + }) + }) +} + +fn event_time(event: &Value, fallback: DateTime) -> DateTime { + event + .get("time") + .and_then(parse_timestamp) + .or_else(|| { + event + .get("event") + .and_then(|inner| inner.get("time")) + .and_then(parse_timestamp) + }) + .unwrap_or(fallback) +} + +#[derive(Default)] +struct MessageBuilder { + messages: Vec, + assistant: Vec, + assistant_time: Option>, + assistant_model: Option, + results: Vec, + result_time: Option>, + tool_sequence: usize, + /// Message count at each `turn.prompt`, so `context.undo` can rewind whole + /// turns rather than individual messages. + turn_starts: Vec, +} + +impl MessageBuilder { + fn flush_assistant(&mut self, fallback: DateTime) { + if self.assistant.is_empty() { + return; + } + let has_tool = self + .assistant + .iter() + .any(|block| matches!(block, Block::ToolUse { .. })); + self.messages.push(Message { + role: Role::Assistant, + content: std::mem::take(&mut self.assistant), + timestamp: self.assistant_time.take().unwrap_or(fallback), + model: self.assistant_model.take(), + stop_reason: Some(if has_tool { + StopReason::ToolUse + } else { + StopReason::EndTurn + }), + usage: None, + }); + } + + fn flush_results(&mut self, fallback: DateTime) { + if self.results.is_empty() { + return; + } + self.messages.push(Message { + role: Role::User, + content: std::mem::take(&mut self.results), + timestamp: self.result_time.take().unwrap_or(fallback), + model: None, + stop_reason: None, + usage: None, + }); + } + + fn add_assistant(&mut self, block: Block, timestamp: DateTime, model: Option) { + self.flush_results(timestamp); + self.assistant_time.get_or_insert(timestamp); + if model.is_some() { + self.assistant_model = model; + } + self.assistant.push(block); + } + + fn add_result(&mut self, block: Block, timestamp: DateTime) { + self.flush_assistant(timestamp); + self.result_time.get_or_insert(timestamp); + self.results.push(block); + } + + fn add_user(&mut self, blocks: Vec, timestamp: DateTime) { + self.flush_assistant(timestamp); + self.flush_results(timestamp); + if !blocks.is_empty() { + self.messages.push(Message { + role: Role::User, + content: blocks, + timestamp, + model: None, + stop_reason: None, + usage: None, + }); + } + } + + fn finish(mut self, fallback: DateTime) -> Vec { + self.flush_assistant(fallback); + self.flush_results(fallback); + self.messages + } + + /// Record a turn boundary from `turn.prompt`. + fn begin_turn(&mut self, fallback: DateTime) { + self.flush_assistant(fallback); + self.flush_results(fallback); + self.turn_starts.push(self.messages.len()); + } + + /// Apply a Kimi `context.undo`: rewind the last `count` turns. + /// + /// Kimi rewinds its context after a failed or cancelled turn, then re-sends + /// the prompt as a fresh turn. `wire.jsonl` is append-only, so the + /// rolled-back entries stay on disk; replaying them would resurrect prompts + /// the user already retried. `count` is measured in turns, not entries: a + /// single turn can append several messages, and one `count: 1` undo drops + /// all of them. Logs without `turn.prompt` markers fall back to entry + /// granularity. Pending buffers are flushed first because a half-assembled + /// turn belongs to the range being rewound. + fn undo(&mut self, count: usize, fallback: DateTime) { + self.flush_assistant(fallback); + self.flush_results(fallback); + if self.turn_starts.is_empty() { + let keep = self.messages.len().saturating_sub(count); + self.messages.truncate(keep); + return; + } + let rewind_to = self.turn_starts.len().saturating_sub(count); + let keep = self.turn_starts.get(rewind_to).copied().unwrap_or(0); + self.turn_starts.truncate(rewind_to); + self.messages.truncate(keep); + } +} + +#[allow(clippy::too_many_lines)] +fn wire_to_messages(wire: &[Value], fallback: DateTime) -> Vec { + let mut builder = MessageBuilder::default(); + let mut model: Option = None; + for event in wire { + match event.get("type").and_then(Value::as_str) { + Some("llm.request") => { + model = event.get("model").and_then(Value::as_str).map(String::from); + } + Some("turn.prompt") => builder.begin_turn(event_time(event, fallback)), + Some("context.undo") => { + let count = + usize::try_from(event.get("count").and_then(Value::as_u64).unwrap_or(1)) + .unwrap_or(usize::MAX); + builder.undo(count, event_time(event, fallback)); + } + Some("context.append_message") => { + let Some(message) = event.get("message") else { + continue; + }; + if message.get("role").and_then(Value::as_str) != Some("user") { + continue; + } + let blocks = message + .get("content") + .and_then(Value::as_array) + .map(|content| { + 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(), + }) + }) + .collect() + }) + .unwrap_or_default(); + builder.add_user(blocks, event_time(event, fallback)); + } + Some("context.append_loop_event") => { + let Some(inner) = event.get("event") else { + continue; + }; + let timestamp = event_time(event, fallback); + match inner.get("type").and_then(Value::as_str) { + Some("content.part") => { + let Some(part) = inner.get("part") else { + continue; + }; + match part.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = part.get("text").and_then(Value::as_str) { + builder.add_assistant( + Block::Text { + text: text.to_string(), + }, + timestamp, + model.clone(), + ); + } + } + Some("think") => { + if let Some(text) = part.get("think").and_then(Value::as_str) { + builder.add_assistant( + Block::Thinking { + text: text.to_string(), + signature: None, + encrypted: None, + }, + timestamp, + model.clone(), + ); + } + } + _ => {} + } + } + Some("tool.call") => { + builder.tool_sequence += 1; + let id = inner.get("toolCallId").and_then(Value::as_str).map_or_else( + || format!("kimi-tool-{}", builder.tool_sequence), + String::from, + ); + let name = inner + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let input = inner.get("args").cloned().unwrap_or(Value::Null); + builder.add_assistant( + Block::ToolUse { + id, + tool: Tool::from_canonical(name, input), + }, + timestamp, + model.clone(), + ); + } + Some("tool.result") => { + let id = inner + .get("toolCallId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let result = inner.get("result").cloned().unwrap_or(Value::Null); + let (content, is_error) = tool_result(result); + builder.add_result( + Block::ToolResult { + tool_use_id: id, + content, + is_error, + }, + timestamp, + ); + } + _ => {} + } + } + _ => {} + } + } + builder.finish(fallback) +} + +fn tool_result(result: Value) -> (ToolOutput, bool) { + let Some(object) = result.as_object() else { + return (tool_output(result), false); + }; + let is_error = object + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(false); + let output = object.get("output").cloned().unwrap_or(Value::Null); + let Some(note) = object.get("note").and_then(Value::as_str) else { + return (tool_output(output), is_error); + }; + // A note annotates the result — truncation, a permission decision — so it + // must survive whatever shape `output` has. Text takes it as a suffix; + // every other shape (object, array, null) keeps its structure and carries + // the note beside it rather than losing it. + let output = match output { + Value::String(text) => Value::String(format!("{text}\n\n[kimi note: {note}]")), + other => json!({"output": other, "note": note}), + }; + (tool_output(output), is_error) +} + +fn tool_output(value: Value) -> ToolOutput { + match value { + Value::String(text) => ToolOutput::Text(text), + other => ToolOutput::Json(other), + } +} + +/// Namespace for the bookkeeping ids txcript has to synthesize when rendering a +/// Kimi wire log. Kimi itself uses random v4 uuids; deriving v5 ids keeps +/// rendering deterministic so the same transcript always produces the same +/// bytes. +const NS: Uuid = Uuid::from_bytes([ + 0x7b, 0x2c, 0x41, 0xd6, 0x8f, 0x53, 0x4a, 0x19, 0xb0, 0x64, 0x3e, 0xa7, 0x15, 0xc8, 0x92, 0x0d, +]); + +/// The wire protocol version txcript renders. Observed in Kimi Code sessions; +/// the reader does not depend on it. +const PROTOCOL_VERSION: &str = "1.5"; + +/// Emit the text accumulated so far as one user entry, if any. +fn flush_user_text(pending: &mut Vec, wire: &mut Vec, time: i64) { + if pending.is_empty() { + return; + } + let content = std::mem::take(pending); + wire.push(json!({"type": "context.append_message", "time": time, "message": {"role": "user", "content": content, "toolCalls": []}})); +} + +fn body_from_common(transcript: &Transcript) -> KimiSession { + let timestamp = transcript.meta.timestamp.timestamp_millis(); + let state = json!({ + "sessionId": transcript.meta.id, + "createdAt": timestamp, + "title": transcript.meta.title, + "workDir": transcript.meta.cwd, + }); + let mut wire = vec![ + json!({"type": "metadata", "protocol_version": PROTOCOL_VERSION, "created_at": timestamp}), + ]; + for (index, message) in transcript.body.iter().enumerate() { + let time = message.timestamp.timestamp_millis(); + if message.role == Role::User { + // One ordered pass: emitting all text first and all tool results + // second would reorder a mixed message, so adjacent text is + // coalesced into one entry and a tool result flushes whatever text + // precedes it. + let mut pending = Vec::new(); + for block in &message.content { + match block { + Block::Text { text } => pending.push(json!({"type": "text", "text": text})), + Block::ToolResult { + tool_use_id, + content, + is_error, + } => { + flush_user_text(&mut pending, &mut wire, time); + let output = match content { + ToolOutput::Text(text) => Value::String(text.clone()), + ToolOutput::Json(value) => value.clone(), + }; + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "tool.result", "toolCallId": tool_use_id, "result": {"output": output, "isError": is_error}}})); + } + Block::Thinking { .. } + | Block::ToolUse { .. } + | Block::Image { .. } + | Block::Artifact { .. } => {} + } + } + flush_user_text(&mut pending, &mut wire, time); + } else { + let step_uuid = Uuid::new_v5( + &NS, + format!("{}:{index}:step", transcript.meta.id).as_bytes(), + ) + .to_string(); + wire.push(json!({"type": "llm.request", "time": time, "model": message.model})); + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "step.begin", "uuid": step_uuid, "turnId": "0", "step": 1}})); + for block in &message.content { + match block { + Block::Text { text } => wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "content.part", "part": {"type": "text", "text": text}}})), + Block::Thinking { text, .. } => wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "content.part", "part": {"type": "think", "think": text}}})), + Block::ToolUse { id, tool } => { + let (name, input) = tool.to_canonical(); + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "tool.call", "toolCallId": id, "name": name, "args": input}})); + } + Block::ToolResult { .. } | Block::Image { .. } | Block::Artifact { .. } => {} + } + } + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "step.end", "finishReason": "stop"}})); + } + } + KimiSession { state, wire } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_both_kimi_timestamp_shapes() { + assert_eq!( + parse_timestamp(&json!(1_785_766_971_574_i64)) + .unwrap() + .timestamp_millis(), + 1_785_766_971_574_i64 + ); + assert!(parse_timestamp(&json!("2026-08-03T14:22:52.557Z")).is_some()); + } + + #[test] + fn tool_result_keeps_note_and_error() { + let (content, error) = + tool_result(json!({"output": "boom", "note": "truncated", "isError": true})); + assert!(error); + assert!(matches!(content, ToolOutput::Text(text) if text.contains("truncated"))); + } + + #[test] + fn tool_result_keeps_note_for_every_output_shape() { + // A note must not depend on `output` being a string. + let note_of = |result: Value| match tool_result(result).0 { + ToolOutput::Json(value) => value.get("note").and_then(Value::as_str).map(String::from), + ToolOutput::Text(text) => text.contains("truncated").then(|| "truncated".to_string()), + }; + assert_eq!( + note_of(json!({"output": {"x": 1}, "note": "truncated"})).as_deref(), + Some("truncated") + ); + assert_eq!( + note_of(json!({"output": null, "note": "truncated"})).as_deref(), + Some("truncated") + ); + assert_eq!( + note_of(json!({"output": [1, 2], "note": "truncated"})).as_deref(), + Some("truncated") + ); + // The structured output itself is preserved alongside the note. + let ToolOutput::Json(value) = tool_result(json!({"output": {"x": 1}, "note": "n"})).0 + else { + panic!("structured output should stay structured"); + }; + assert_eq!(value.get("output"), Some(&json!({"x": 1}))); + // No note means no wrapper. + assert!(matches!( + tool_result(json!({"output": {"x": 1}})).0, + ToolOutput::Json(value) if value == json!({"x": 1}) + )); + } + + #[test] + fn mixed_user_blocks_keep_their_order() { + // `[ToolResult, Text]` must not come back as `[Text, ToolResult]`. + let message = Message { + role: Role::User, + content: vec![ + Block::ToolResult { + tool_use_id: "c1".to_string(), + content: ToolOutput::Text("result".to_string()), + is_error: false, + }, + Block::Text { + text: "after".to_string(), + }, + ], + timestamp: DateTime::::UNIX_EPOCH, + model: None, + stop_reason: None, + usage: None, + }; + let meta = Meta { + id: "session_probe".to_string(), + timestamp: DateTime::::UNIX_EPOCH, + cwd: None, + git_branch: None, + title: None, + cli_version: None, + model: None, + }; + let transcript = Transcript::new(meta, vec![message]); + let body = body_from_common(&transcript); + + let kinds: Vec<&str> = body + .wire + .iter() + .filter_map(|entry| match entry.get("type").and_then(Value::as_str) { + Some("context.append_message") => Some("text"), + Some("context.append_loop_event") => entry + .get("event") + .and_then(|event| event.get("type")) + .and_then(Value::as_str), + _ => None, + }) + .collect(); + assert_eq!(kinds, vec!["tool.result", "text"]); + + // And the order survives a full round trip through Common. + let back = wire_to_messages(&body.wire, DateTime::::UNIX_EPOCH); + let blocks: Vec<&Block> = back.iter().flat_map(|m| m.content.iter()).collect(); + assert!(matches!(blocks[0], Block::ToolResult { .. })); + assert!(matches!(blocks[1], Block::Text { text } if text == "after")); + } + + #[test] + fn session_id_is_recovered_from_every_state_schema() { + let meta = |state: Value| { + meta_from_body(&KimiSession { + state, + wire: Vec::new(), + }) + .id + }; + // Schema version 2 records the id directly. + assert_eq!( + meta(json!({"version": 2, "id": "session_abc", "cwd": "/repo"})), + "session_abc" + ); + // Version 1 has no id field; the agent home directory is the only + // copy a bare state.json carries. + assert_eq!( + meta(json!({ + "workDir": "/repo", + "agents": {"main": {"homedir": "/home/u/.kimi-code/sessions/wd_repo_1/session_abc/agents/main"}} + })), + "session_abc" + ); + // Kimi's own index calls the field sessionId. + assert_eq!(meta(json!({"sessionId": "session_abc"})), "session_abc"); + assert_eq!(meta(json!({"workDir": "/repo"})), ""); + } + + #[test] + fn context_undo_rewinds_whole_turns() { + let user = |text: &str, time: i64| { + json!({"type": "context.append_message", "time": time, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) + }; + // Kimi's retry shape: one turn appends the prompt *and* a system + // reminder, fails, and is rewound with `count: 1`. Counting entries + // instead of turns would leave the prompt behind and duplicate it. + let wire = vec![ + json!({"type": "turn.prompt", "time": 1_i64}), + user("continue", 2_i64), + user("", 3_i64), + json!({"type": "turn.ended", "turnId": 6, "reason": "failed"}), + json!({"type": "context.undo", "count": 1, "time": 4_i64}), + json!({"type": "turn.prompt", "time": 5_i64}), + user("continue", 6_i64), + user("", 7_i64), + ]; + let messages = wire_to_messages(&wire, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].timestamp.timestamp_millis(), 6_i64); + } + + #[test] + fn context_undo_rewinds_several_turns_at_once() { + let user = |text: &str, time: i64| { + json!({"type": "context.append_message", "time": time, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) + }; + let wire = vec![ + json!({"type": "turn.prompt", "time": 1_i64}), + user("keep me", 1_i64), + json!({"type": "turn.prompt", "time": 2_i64}), + user("first retry", 2_i64), + json!({"type": "turn.prompt", "time": 3_i64}), + user("second retry", 3_i64), + json!({"type": "context.undo", "count": 2, "time": 4_i64}), + ]; + let messages = wire_to_messages(&wire, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 1); + assert!(matches!( + &messages[0].content[0], + Block::Text { text } if text == "keep me" + )); + } + + #[test] + fn context_undo_without_turn_markers_falls_back_to_entries() { + let user = |text: &str, time: i64| { + json!({"type": "context.append_message", "time": time, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) + }; + let wire = vec![ + user("continue", 1_786_970_050_554_i64), + json!({"type": "context.undo", "count": 1, "time": 1_786_970_609_991_i64}), + user("continue", 1_786_970_613_673_i64), + ]; + let messages = wire_to_messages(&wire, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].timestamp.timestamp_millis(), + 1_786_970_613_673_i64 + ); + } + + #[test] + fn context_undo_rewinds_a_pending_assistant_turn() { + let wire = vec![ + json!({"type": "context.append_message", "time": 1_i64, + "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]}}), + json!({"type": "context.append_loop_event", "time": 2_i64, + "event": {"type": "content.part", "part": {"type": "text", "text": "half"}}}), + json!({"type": "context.undo", "count": 1, "time": 3_i64}), + ]; + let messages = wire_to_messages(&wire, DateTime::::UNIX_EPOCH); + // No turn markers, so entry granularity: the half-assembled assistant + // turn is the most recent entry and the user prompt survives. + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, Role::User); + } +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 6b87a32..9b05079 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -18,6 +18,7 @@ pub mod cursor_desktop; pub mod fx; pub mod grok; pub mod hermes; +pub mod kimi; pub mod opencode; pub mod pi; pub mod simple; diff --git a/src/lib.rs b/src/lib.rs index a340523..925b9a9 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, Kimi, 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..f442aec 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, fx, grok, kimi, 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::Kimi, out.len()); + scan(HarnessId::Kimi, kimi::KimiStore::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::Kimi, Locator::Path(p)) => go(kimi::KimiStore::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::Kimi, Locator::Path(p)) => go(kimi::KimiStore::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::Kimi => group.files(kimi::KimiStore::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()), @@ -719,6 +724,13 @@ pub fn write( common, |s| s.sessions_dir, ), + HarnessId::Kimi => go( + kimi::KimiStore::default_root(), + kimi::KimiStore::new, + root, + common, + |s| s.sessions_dir, + ), // Hermes's state.db is read-only in txcript and Hermes has no // session-import command. Sessions convert *from* Hermes, never // into it. @@ -951,6 +963,7 @@ 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::Kimi => ("kimi".into(), vec!["--session".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..c2ac2a5 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -456,6 +456,7 @@ pub enum HarnessId { Grok, Fx, Hermes, + Kimi, Amp, Antigravity, Simple, @@ -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, @@ -476,6 +477,7 @@ impl HarnessId { HarnessId::Grok, HarnessId::Fx, HarnessId::Hermes, + HarnessId::Kimi, HarnessId::Amp, HarnessId::Antigravity, HarnessId::Simple, @@ -498,6 +500,7 @@ impl HarnessId { HarnessId::Grok => "grok", HarnessId::Fx => "fx", HarnessId::Hermes => "hermes", + HarnessId::Kimi => "kimi", HarnessId::Amp => "amp", HarnessId::Antigravity => "antigravity", HarnessId::Simple => "simple", @@ -538,6 +541,7 @@ impl FromStr for HarnessId { } "fx" | "fx_cli" | "fx-cli" | "fxcli" | "vercel_fx" | "vercel-fx" => Ok(HarnessId::Fx), "hermes" | "hermes_agent" | "hermes-agent" | "hermesagent" => Ok(HarnessId::Hermes), + "kimi" | "kimi_code" | "kimi-code" | "kimicode" => Ok(HarnessId::Kimi), "amp" | "ampcode" | "amp_code" | "amp-code" => Ok(HarnessId::Amp), "antigravity" | "agy" | "antigravity_cli" | "antigravity-cli" | "anti-gravity" => { Ok(HarnessId::Antigravity) diff --git a/src/wasm.rs b/src/wasm.rs index d9e831e..e3f64af 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, fx, grok, hermes, kimi, opencode, pi, simple, }; use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; @@ -213,6 +213,7 @@ fn parse_to_common(harness: HarnessId, text: &str) -> crate::Result go::(text), HarnessId::Simple => go::(text), HarnessId::Cowork => go::(text), + HarnessId::Kimi => go::(text), } } @@ -237,6 +238,7 @@ fn render_from_common(harness: HarnessId, common: &Transcript) -> crate: HarnessId::Antigravity => go::(common), HarnessId::Simple => go::(common), HarnessId::Cowork => go::(common), + HarnessId::Kimi => go::(common), } } diff --git a/tests/integration/cross_harness.rs b/tests/integration/cross_harness.rs index 04c5ba2..4c33ac8 100644 --- a/tests/integration/cross_harness.rs +++ b/tests/integration/cross_harness.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use txcript::common; use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, - hermes, opencode, pi, simple, + hermes, kimi, opencode, pi, simple, }; use txcript::{Codec, Common, Transcript, convert}; @@ -191,6 +191,13 @@ fn conversation_survives_every_hop() { let fx = convert::(&grok).unwrap(); assert_eq!(signature(&fx::Fx::to_common(&fx).unwrap()), expected, "fx"); + let kimi = convert::(&fx).unwrap(); + assert_eq!( + signature(&kimi::Kimi::to_common(&kimi).unwrap()), + expected, + "kimi" + ); + let hermes = convert::(&fx).unwrap(); assert_eq!( signature(&hermes::Hermes::to_common(&hermes).unwrap()), diff --git a/tests/integration/kimi.rs b/tests/integration/kimi.rs new file mode 100644 index 0000000..5f6e991 --- /dev/null +++ b/tests/integration/kimi.rs @@ -0,0 +1,554 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +//! Integration tests for the Kimi Code harness. + +use chrono::{DateTime, Utc}; +use serde_json::json; +use txcript::common; +use txcript::harness::kimi; +use txcript::{Codec, Common, Store, TextCodec}; + +fn ts(s: &str) -> DateTime { + s.parse().unwrap() +} + +fn event(inner: &serde_json::Value, time: i64) -> serde_json::Value { + json!({"type": "context.append_loop_event", "time": time, "event": inner.clone()}) +} + +fn sample_body() -> kimi::KimiSession { + kimi::KimiSession { + state: json!({ + "createdAt": "2026-01-02T03:04:05.000Z", + "title": "Parser work", + "workDir": "/repo" + }), + wire: vec![ + json!({"type": "metadata", "protocol_version": "1.4"}), + json!({"type": "llm.request", "model": "kimi-k2", "time": 1_767_323_045_000_i64}), + json!({"type": "context.append_message", "time": 1_767_323_046_000_i64, + "message": {"role": "user", "content": [{"type": "text", "text": "edit the file"}]}}), + event( + &json!({"type": "content.part", "part": {"type": "think", "think": "inspect it"}}), + 1_767_323_047_000_i64, + ), + event( + &json!({"type": "content.part", "part": {"type": "text", "text": "On it."}}), + 1_767_323_047_000_i64, + ), + event( + &json!({"type": "tool.call", "toolCallId": "call-1", "name": "Edit", + "args": {"file_path": "/repo/a.rs", "old_string": "old", "new_string": "new"}}), + 1_767_323_047_000_i64, + ), + event( + &json!({"type": "tool.result", "toolCallId": "call-1", + "result": {"output": "done", "isError": false}}), + 1_767_323_048_000_i64, + ), + event( + &json!({"type": "content.part", "part": {"type": "text", "text": "finished"}}), + 1_767_323_049_000_i64, + ), + // An unknown event must remain in the native body. + json!({"type": "future.kimi.event", "payload": {"v": 1}}), + ], + } +} + +#[test] +fn metadata_and_messages_are_converted() { + let body = sample_body(); + let meta = common::Meta { + id: "abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: Some("kimi-k2".into()), + }; + let native = txcript::Transcript::new(meta, body); + // Build through TextCodec so the test exercises the public native text API. + let text = kimi::Kimi::to_text(&native).unwrap(); + let parsed = kimi::Kimi::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("kimi-k2")); + + let common = kimi::Kimi::to_common(&parsed).unwrap(); + assert_eq!(common.body.len(), 4); + assert!(matches!( + common.body[1].content[0], + common::Block::Thinking { .. } + )); + assert!(matches!( + common.body[1].content[2], + common::Block::ToolUse { .. } + )); + assert!(matches!( + common.body[2].content[0], + common::Block::ToolResult { .. } + )); + assert_eq!(common.body[3].timestamp, ts("2026-01-02T03:04:09.000Z")); +} + +#[test] +fn text_codec_recovers_the_id_without_a_directory() { + // The store can fall back to the session directory name, but `from_text` + // (and the wasm parser on top of it) only sees the JSON. Schema version 2 + // carries `id`; version 1 only carries the agent home directory. + let mut v2 = sample_body(); + v2.state = json!({"version": 2, "id": "session_abc", "cwd": "/repo", + "createdAt": "2026-01-02T03:04:05.000Z"}); + let rendered = kimi::Kimi::to_text(&txcript::Transcript::new( + common::Meta { + id: "session_abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: None, + cli_version: None, + model: None, + }, + v2, + )) + .unwrap(); + assert_eq!( + kimi::Kimi::from_text(&rendered).unwrap().meta.id, + "session_abc" + ); + + let mut v1 = sample_body(); + v1.state = json!({"workDir": "/repo", "createdAt": "2026-01-02T03:04:05.000Z", + "agents": {"main": {"homedir": "/home/u/.kimi-code/sessions/wd_repo_1/session_abc/agents/main"}}}); + let rendered = kimi::Kimi::to_text(&txcript::Transcript::new( + common::Meta { + id: "session_abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: None, + cli_version: None, + model: None, + }, + v1, + )) + .unwrap(); + assert_eq!( + kimi::Kimi::from_text(&rendered).unwrap().meta.id, + "session_abc" + ); +} + +#[test] +fn native_text_round_trip_retains_unknown_wire_events() { + let body = sample_body(); + let meta = common::Meta { + id: "session-id".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: Some("kimi-k2".into()), + }; + let transcript = txcript::Transcript::new(meta, body.clone()); + let text = kimi::Kimi::to_text(&transcript).unwrap(); + let parsed = kimi::Kimi::from_text(&text).unwrap(); + assert_eq!(parsed.body, body); + assert!(text.contains("future.kimi.event")); +} + +#[test] +fn store_discovers_and_loads_a_session() { + let root = tempfile::tempdir().unwrap(); + let session = root.path().join("wd_repo_hash").join("session_abc"); + write_session(&session, &sample_body()); + let body = sample_body(); + + let store = kimi::KimiStore::new(root.path()); + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, "session_abc"); + assert_eq!(found[0].meta.timestamp, ts("2026-01-02T03:04:05.000Z")); + + let loaded = store.load(&found[0].reference).unwrap(); + assert_eq!(loaded.body, body); +} + +#[test] +fn discovery_does_not_depend_on_the_directory_name() { + // Discovery is gated on structure — a state.json and a main wire log — + // exactly like every other file-backed store. A Kimi release that renames + // its session directories must not make sessions disappear from `list`. + let root = tempfile::tempdir().unwrap(); + write_session( + &root.path().join("workspace-1").join("conversation-7"), + &sample_body(), + ); + let found = kimi::KimiStore::new(root.path()).discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, "conversation-7"); +} + +/// Kimi resolves its data root first and `sessions/` beneath it, so a store +/// rooted at `/sessions` puts the session index at ``. +fn kimi_home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join("sessions")).unwrap(); + home +} + +fn saveable(id: &str, cwd: &str) -> txcript::Transcript { + let mut body = sample_body(); + // Shaped like a real state.json, which always carries a creation time. + body.state = json!({ + "sessionId": id, + "workDir": cwd, + "title": "Parser work", + "createdAt": 1_767_323_045_000_i64 + }); + txcript::Transcript::new( + common::Meta { + id: id.into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some(cwd.into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: None, + }, + body, + ) +} + +#[test] +fn save_writes_the_layout_kimi_discovers() { + // Kimi keys a workspace directory by `wd__` + // and finds sessions only through `session_index.jsonl` — there is no + // directory-scan fallback, so a save that skips the index is invisible. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let saved = store.save(&saveable("session_abc", "/repo")).unwrap(); + + assert_eq!(saved.id, "session_abc"); + let expected = home + .path() + .join("sessions") + .join("wd_repo_816fc349d3fa") + .join("session_abc"); + assert_eq!(saved.reference, expected); + assert!(expected.join("state.json").is_file()); + assert!(expected.join("agents/main/wire.jsonl").is_file()); + + let index = std::fs::read_to_string(home.path().join("session_index.jsonl")).unwrap(); + let entry: serde_json::Value = serde_json::from_str(index.trim()).unwrap(); + assert_eq!(entry["sessionId"], json!("session_abc")); + assert_eq!(entry["workDir"], json!("/repo")); + assert_eq!(entry["sessionDir"], json!(expected.to_str().unwrap())); +} + +#[test] +fn saved_session_is_discovered_and_loads_back() { + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + 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(); + // The wire log is carried through verbatim. + assert_eq!(loaded.body.wire, original.body.wire); + // `state` is normalized on the way out — `agents.main.homedir` names where + // the session actually landed — so the round-trip contract is that the + // normalization is a fixed point, not that it is a no-op. + let again = store.save(&loaded).unwrap(); + assert_eq!(store.load(&again.reference).unwrap().body, loaded.body); +} + +#[test] +fn save_points_the_agent_homedir_at_where_the_session_landed() { + // Kimi resolves an agent's wire log through `agents..homedir`, and + // txcript's directory-free readers recover the session id from it. Saving + // into a different root has to rewrite it, or the copy points at the + // original. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let saved = store.save(&saveable("session_abc", "/repo")).unwrap(); + + let elsewhere = kimi_home(); + let other = kimi::KimiStore::new(elsewhere.path().join("sessions")); + let moved = other.save(&store.load(&saved.reference).unwrap()).unwrap(); + + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(moved.reference.join("state.json")).unwrap()) + .unwrap(); + let agent_dir = moved.reference.join("agents").join("main"); + assert_eq!( + state["agents"]["main"]["homedir"], + json!(agent_dir.to_str().unwrap()) + ); +} + +#[test] +fn delete_removes_the_session_and_tombstones_the_index() { + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let saved = store.save(&saveable("session_abc", "/repo")).unwrap(); + + store.delete(&saved.reference).unwrap(); + assert!(!saved.reference.exists()); + // Kimi's index is append-only; a removal is a `deleted` record, not an + // edit, so a live Kimi reading the index still converges on "gone". + let index = std::fs::read_to_string(home.path().join("session_index.jsonl")).unwrap(); + let last: serde_json::Value = serde_json::from_str(index.lines().last().unwrap()).unwrap(); + assert_eq!(last["sessionId"], json!("session_abc")); + assert_eq!(last["deleted"], json!(true)); + assert_eq!(store.discover().unwrap().len(), 0); +} + +#[test] +fn save_fills_identity_a_converted_session_lacks() { + // A session converted from another harness has no Kimi state.json, so the + // fields Kimi and `from_text` read the session back from must be supplied. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let mut transcript = saveable("session_abc", "/repo"); + transcript.body.state = json!({}); + + let saved = store.save(&transcript).unwrap(); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(saved.reference.join("state.json")).unwrap()) + .unwrap(); + assert_eq!(state["sessionId"], json!("session_abc")); + assert_eq!(state["workDir"], json!("/repo")); + assert_eq!(state["createdAt"], json!(1_767_323_045_000_i64)); + // And the id survives without the directory, which is what the wasm + // parser and `from_text` depend on. + let text = kimi::Kimi::to_text(&store.load(&saved.reference).unwrap()).unwrap(); + assert_eq!(kimi::Kimi::from_text(&text).unwrap().meta.id, "session_abc"); +} + +/// Kimi accepts sessions written from outside — it reads whatever the index +/// points at — so `continue --with kimi` writes a real session rather than +/// refusing. +#[test] +fn continuing_into_kimi_writes_a_session() { + let home = tempfile::tempdir().unwrap(); + let sessions = home.path().join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + + let common = kimi::Kimi::to_common(&saveable("session_abc", "/repo")).unwrap(); + let written = txcript::local::write(txcript::HarnessId::Kimi, &common, Some(&sessions)) + .expect("kimi accepts written sessions"); + assert_eq!(written.id, "session_abc"); + + // Reach the session through discovery rather than the printed location, + // which is a Debug rendering shared by every harness. + let store = kimi::KimiStore::new(&sessions); + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + let dir = &found[0].reference; + assert!(dir.join("state.json").is_file()); + assert!(dir.join("agents/main/wire.jsonl").is_file()); + assert!(home.path().join("session_index.jsonl").is_file()); +} + +#[test] +fn saved_state_carries_what_kimi_reads_a_session_by() { + // Kimi renders a session's time from `updatedAt` — without it the CLI + // lists the session at the epoch — and locates each agent's log through + // `agents..homedir`, which only the store knows the absolute path + // for. Both must be written even when the source had neither. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let mut transcript = saveable("session_abc", "/repo"); + transcript.body.state = json!({}); + + let saved = store.save(&transcript).unwrap(); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(saved.reference.join("state.json")).unwrap()) + .unwrap(); + + assert_eq!(state["updatedAt"], json!(1_767_323_045_000_i64)); + let agent_dir = saved.reference.join("agents").join("main"); + assert_eq!( + state["agents"]["main"]["homedir"], + json!(agent_dir.to_str().unwrap()) + ); +} + +#[test] +fn save_preserves_state_a_native_session_already_had() { + // A real Kimi state.json owns these fields; a save must not overwrite the + // session's own bookkeeping with txcript's idea of it. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let mut transcript = saveable("session_abc", "/repo"); + transcript.body.state["updatedAt"] = json!(1_799_999_999_000_i64); + transcript.body.state["isCustomTitle"] = json!(true); + + let saved = store.save(&transcript).unwrap(); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(saved.reference.join("state.json")).unwrap()) + .unwrap(); + assert_eq!(state["updatedAt"], json!(1_799_999_999_000_i64)); + assert_eq!(state["isCustomTitle"], json!(true)); +} + +#[test] +fn save_refuses_an_id_that_would_escape_the_store() { + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let error = store + .save(&saveable("../../escape", "/repo")) + .expect_err("a traversing id must be rejected"); + assert!( + error.to_string().contains("not usable as a file name"), + "expected an id-shape rejection, got: {error}" + ); + assert!(!home.path().parent().unwrap().join("escape").exists()); +} + +fn write_session(dir: &std::path::Path, body: &kimi::KimiSession) { + std::fs::create_dir_all(dir.join("agents/main")).unwrap(); + std::fs::write( + dir.join("state.json"), + serde_json::to_string(&body.state).unwrap(), + ) + .unwrap(); + std::fs::write( + dir.join("agents/main/wire.jsonl"), + body.wire + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); +} + +#[test] +fn epoch_millisecond_created_at_is_supported() { + let mut body = sample_body(); + body.state["createdAt"] = json!(1_767_323_045_000_i64); + let native = txcript::Transcript::new( + common::Meta { + id: "abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: None, + cli_version: None, + model: None, + }, + body, + ); + let text = kimi::Kimi::to_text(&native).unwrap(); + let parsed = kimi::Kimi::from_text(&text).unwrap(); + assert_eq!(parsed.meta.timestamp, ts("2026-01-02T03:04:05.000Z")); +} + +fn common_sample() -> txcript::Transcript { + let meta = common::Meta { + id: "abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: Some("kimi-k2".into()), + }; + let body = vec![ + common::Message { + role: common::Role::User, + content: vec![common::Block::Text { + text: "edit the file".into(), + }], + timestamp: ts("2026-01-02T03:04:06.000Z"), + model: None, + stop_reason: None, + usage: None, + }, + common::Message { + role: common::Role::Assistant, + content: vec![common::Block::Text { + text: "On it.".into(), + }], + timestamp: ts("2026-01-02T03:04:07.000Z"), + model: Some("kimi-k2".into()), + stop_reason: Some(common::StopReason::EndTurn), + usage: None, + }, + ]; + txcript::Transcript::new(meta, body) +} + +#[test] +fn assistant_timestamps_survive_a_common_round_trip() { + let native = kimi::Kimi::from_common(&common_sample()).unwrap(); + let back = kimi::Kimi::to_common(&native).unwrap(); + assert_eq!(back.body[0].timestamp, ts("2026-01-02T03:04:06.000Z")); + // Assistant turns are rendered as loop events; they must carry their own + // `time` or every assistant message collapses onto the session timestamp. + assert_eq!(back.body[1].timestamp, ts("2026-01-02T03:04:07.000Z")); +} + +#[test] +fn rendering_is_deterministic() { + let common = common_sample(); + let first = kimi::Kimi::to_text(&kimi::Kimi::from_common(&common).unwrap()).unwrap(); + let second = kimi::Kimi::to_text(&kimi::Kimi::from_common(&common).unwrap()).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn context_undo_removes_the_retried_prompt() { + let mut body = sample_body(); + body.wire + .push(json!({"type": "turn.prompt", "time": 1_767_323_050_000_i64})); + body.wire.push( + json!({"type": "context.append_message", "time": 1_767_323_050_000_i64, + "message": {"role": "user", "content": [{"type": "text", "text": "continue"}]}}), + ); + body.wire + .push(json!({"type": "turn.ended", "turnId": 1, "reason": "failed"})); + body.wire + .push(json!({"type": "context.undo", "count": 1, "time": 1_767_323_051_000_i64})); + body.wire + .push(json!({"type": "turn.prompt", "time": 1_767_323_052_000_i64})); + body.wire.push( + json!({"type": "context.append_message", "time": 1_767_323_052_000_i64, + "message": {"role": "user", "content": [{"type": "text", "text": "continue"}]}}), + ); + + let native = txcript::Transcript::new( + common::Meta { + id: "abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: None, + git_branch: None, + title: None, + cli_version: None, + model: None, + }, + body, + ); + let converted = kimi::Kimi::to_common(&native).unwrap(); + let prompts = converted + .body + .iter() + .filter(|message| { + matches!( + message.content.first(), + Some(common::Block::Text { text }) if text == "continue" + ) + }) + .count(); + assert_eq!(prompts, 1, "the rolled-back prompt must not be replayed"); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index cced1d4..430f81f 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -19,6 +19,7 @@ mod cursor_desktop; mod fx; mod grok; mod hermes; +mod kimi; mod opencode; mod path_safety; mod pi; diff --git a/tests/integration/path_safety.rs b/tests/integration/path_safety.rs index 738561e..38e58d4 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, grok, kimi, 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(&kimi::KimiStore::new(root.to_path_buf()), root); assert_save_confined(&::AmpStore::new(root.to_path_buf()), root); assert_save_confined( &antigravity::AntigravityStore::new(root.to_path_buf()), diff --git a/tests/integration/properties.rs b/tests/integration/properties.rs index 63bab05..7430b8a 100644 --- a/tests/integration/properties.rs +++ b/tests/integration/properties.rs @@ -19,7 +19,7 @@ use serde_json::json; use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput}; use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, - hermes, opencode, pi, simple, + hermes, kimi, 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::("kimi", &common)?; assert_fixpoint::("fx", &common)?; assert_fixpoint::("hermes", &common)?; assert_fixpoint::("amp", &common)?; diff --git a/tests/integration/store_delete.rs b/tests/integration/store_delete.rs index fcdd9a1..16077ca 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, grok, kimi, pi}; use txcript::{Codec, Common, Store, Transcript}; #[cfg(feature = "opencode")] @@ -106,6 +106,20 @@ fn grok_delete_removes_the_session_directory() { ); } +#[test] +fn kimi_delete_removes_the_session_directory() { + // Kimi's index lives one level above `sessions/`, so the store is rooted + // the way Kimi itself lays a data directory out. + let home = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}")); + let sessions = home.path().join("sessions"); + std::fs::create_dir_all(&sessions).unwrap_or_else(|e| panic!("mkdir: {e}")); + let store = kimi::KimiStore::new(sessions.clone()); + roundtrip(&store); + // The workspace directory may remain, but no session files do. + let leftover: Vec<_> = walk_files(&sessions); + assert!(leftover.is_empty(), "no files left behind: {leftover:?}"); +} + #[cfg(feature = "opencode")] #[test] fn cursor_delete_removes_the_whole_session_dir() {