diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fae10..782a634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Each release is published to [crates.io](https://crates.io/crates/txcript), ### Fixed +- Support OpenCode 2.1 ([#96](https://github.com/skillsynchq/txcript/issues/96)): discover and load sessions from its `session_v2`/`session_message` tables, write its flat transcript with the 2.1 tool names and input keys (`shell`, `subagent`, `path`, …), and import it with `opencode session import --directory `. A 2.0 database still reads, and a 2.0 CLI (found through `opencode --version`) still gets the `{info, parts}` export through `opencode import`. The `opencode` text form — the WASM `convert`/`fromCommon` output — is now the 2.1 document; `from_text` reads both layouts. +- Keep the results of parallel tool calls when converting to OpenCode ([#98](https://github.com/skillsynchq/txcript/issues/98)). A source that records each call in its own assistant message, with each result in its own later user message, used to be written with every call's output empty, because the writer only looked for a result in the very next message; it now looks through the following user messages of the turn. +- Drop the compaction summary from `claude_code` conversions ([#100](https://github.com/skillsynchq/txcript/issues/100)). The `user` line flagged `isCompactSummary` used to become a user prompt that repeated, condensed, the earlier history the file still holds before the `compact_boundary`. It now stays in the native body only. - Resolve a session id that exists in several stores to the most recently modified copy. `view`, `export`, `continue` and `crop` used to load whichever copy the file system listed first; discovery now breaks start-time ties by file mtime. ### Added diff --git a/docs/formats/claude-code.md b/docs/formats/claude-code.md index 4dfc0f3..c892dc2 100644 --- a/docs/formats/claude-code.md +++ b/docs/formats/claude-code.md @@ -60,6 +60,7 @@ everything else is preserved verbatim so a native load/save round-trips byte-los | `` markup | Slash command the user ran, as XML-ish tags filling a body | `Block::ToolUse` with `Tool::Command` | | `` markup | What the command printed | `Block::ToolResult` paired via `parentUuid` | | `` markup | Fixed boilerplate, regenerated by the CLI | dropped | +| `user` line with `isCompactSummary` | The summary a compaction writes after its `compact_boundary` (with `isVisibleInTranscriptOnly`); the file still holds the history it condenses, before the boundary | kept in the native body, no Common turn | | `summary` line | Session title with a `leafUuid` anchor | `Meta.title` (lowest precedence) | | `custom-title` / `agent-name` line | User-set or agent-derived title | `Meta.title` (custom-title wins) | | `teamName` / `agentName` envelope fields | Present on every line of a teammate session the team feature starts (distinct from the `agent-name` *line type* above) | `Meta.lineage` — see Lineage | diff --git a/docs/formats/opencode.md b/docs/formats/opencode.md index 402e0e2..c466103 100644 --- a/docs/formats/opencode.md +++ b/docs/formats/opencode.md @@ -1,25 +1,31 @@ # OpenCode OpenCode is an open-source terminal coding agent built by the SST team -([github.com/sst/opencode](https://github.com/sst/opencode)). There is no -published transcript spec; the open-source storage and export code serves as -the spec. This document is grounded in that source (permalinks below), in -txcript's parser (`src/harness/opencode.rs`), and in inspection of real local -sessions. +([github.com/anomalyco/opencode](https://github.com/anomalyco/opencode), +formerly `sst/opencode`). There is no published transcript spec; the +open-source storage and export code serves as the spec. This document is +grounded in that source (permalinks below), in txcript's parser +(`src/harness/opencode.rs` and `src/harness/opencode/v2.rs`), and in +inspection of real local sessions. ``` ~/.local/share/opencode/opencode.db (one SQLite DB for everything) │ -├─ session 1 row ─ id "ses_…", directory, title, version, -│ time_created, time_archived, model (JSON) -├─ message n rows ─ session_id, time_created, data (JSON: role, modelID, -│ finish, tokens, …) -└─ part m rows ─ message_id, session_id, data (JSON: one typed part) - -session ──< message ──< part reassembled as the `opencode export` shape: - { info, messages: [ { info, parts: […] } ] } +├─ session_v2 1 row ─ id "ses_…", project_id, directory, title, version, +│ model (JSON), cost, tokens_*, time_created, +│ time_updated, time_archived, parent_id +└─ session_message n rows ─ session_id, type, seq, time_created, + data (JSON: the record minus id and type) + +session_v2 ──< session_message reassembled as the `opencode session export` + shape: { info, messages: [ record, … ] } ``` +Up to 2.0 the same file held `session`, `message` and `part` tables, and +`opencode export` emitted `{ info, messages: [ { info, parts: […] } ] }`. +txcript reads both layouts; which one a database has is decided by the +presence of `session_v2`. + ## On disk Everything lives in one SQLite database, managed by Drizzle ORM inside @@ -30,115 +36,151 @@ OpenCode: `$XDG_DATA_HOME/opencode/opencode.db`, defaulting to directory of per-record JSON files is the pre-SQLite layout; OpenCode migrates it into the DB on upgrade and txcript reads only the DB. -The DB holds more than transcripts (`project`, `todo`, cost/token rollups on -`session`); txcript reads three tables. `message.data` and `part.data` are -JSON text columns — the schema is relational only down to the message/part +The DB holds more than transcripts (`project`, `kv`, inbox and pending +queues, instruction state); txcript reads two tables. `session_message.data` +is a JSON text column — the schema is relational only down to the message grain, opaque JSON below that. -Discovery is one query: `session` rows where `time_archived IS NULL`, newest -first. Since all sessions share the one file, a store `Ref` is just the -session id. Loading a session selects its `message` rows (ordered by -`time_created`, then id) and `part` rows (grouped by message, ordered by id), -and assembles the same `{info, messages: [{info, parts}]}` document that -`opencode export` emits — that export shape, not the raw rows, is txcript's -native `Body` for this harness. +Discovery is one query: `session_v2` rows where `time_archived IS NULL`, +newest first. Since all sessions share the one file, a store `Ref` is just +the session id. Loading a session selects its `session_message` rows in `seq` +order and puts `id` and `type` back on each record, which is the +`{info, messages}` document `opencode session export` emits. That document is +the harness's text form; internally the codec works on the 2.0 layout, and +the two are projected into each other by `src/harness/opencode/v2.rs`. ## Dissection of a transcript | Their name | What it is | Maps to | |---|---|---| -| session | Header row: id (`ses_…`), directory, title, version, created time, model, `parent_id` | `Meta` — id, timestamp, cwd, title, cli_version, model, lineage (`git_branch` is never available) | -| message | One turn: `data` JSON with `role`, plus `modelID`, `finish`, `tokens` on assistant turns | One or more Common `Message`s, `Role::User` / `Role::Assistant` | -| part `text` | Prose; `"synthetic": true` marks harness-injected text | `Block::Text` (synthetic dropped) | -| part `reasoning` | Model thinking | `Block::Thinking` (no signature) | -| part `file` | Attachment as a `data:` URL with a `mime` | `Block::Image` when the mime is `image/*` | -| part `tool` | Whole call lifecycle in one record: `tool`, `callID`, `state {status, input, output/error}` | `Block::ToolUse` + `Block::ToolResult`, paired by `callID` | -| part `step-start` / `step-finish` / `snapshot` / `patch` … | Agent-loop bookkeeping | Dropped from the conversation (kept in the native body) | +| session_v2 | Header row: id (`ses_…`), directory, title, version, created time, model, `parent_id` | `Meta` — id, timestamp, cwd, title, cli_version, model, lineage (`git_branch` is never available) | +| `user` record | One prompt: `text`, optional `files` (base64 attachments with a `mime`) | `Message` with `Role::User`: `Block::Text`, `Block::Image` for `image/*` files | +| `assistant` record | One step: `model`, `content[]`, `finish`, `tokens`, `cost` | One or more Common `Message`s with `Role::Assistant` | +| `content[]` `text` | Prose | `Block::Text` | +| `content[]` `reasoning` | Model thinking | `Block::Thinking` (no signature) | +| `content[]` `tool` | Whole call lifecycle in one item: `id`, `name`, `state {status, input, content/error}` | `Block::ToolUse` + `Block::ToolResult`, paired by `id` | +| `synthetic`, `system`, `skill`, `shell`, `compaction`, `idle`, `*-switched` records | Harness bookkeeping and the user's shell commands | Dropped from the conversation (kept in the native body, written back unchanged) | Two structural translations do most of the work. First, OpenCode keeps a tool -call and its result in a single `tool` part; txcript splits it Anthropic-style +call and its result in a single `tool` item; txcript splits it Anthropic-style into a `ToolUse` on an assistant message and a `ToolResult` on a following user message (an error status becomes `is_error: true`; a still -pending/running call emits the use with no result). Second, one OpenCode -assistant message becomes several Common messages, cut at each tool part; -the turn-level `tokens` and `finish` attach to the turn's last assistant -message as `Usage` and `StopReason` (`stop` → `EndTurn`, `length` → -`MaxTokens`, etc.). Lowercase tool names normalize to canonical ones — -`edit` → `Edit` with `filePath`/`oldString`/`newString` renamed to snake_case -— and unknown tools pass through as `Tool::Raw`. Placeholder titles +running/streaming call emits the use with no result; a completed result's +`content` text items are joined by newlines). Second, one OpenCode assistant +record becomes several Common messages, cut at each tool item; the step-level +`tokens` and `finish` attach to the step's last assistant message as `Usage` +and `StopReason` (`stop` → `EndTurn`, `length` → `MaxTokens`, `tool-calls` → +`ToolUse`, etc.). Lowercase tool names normalize to canonical ones — `edit` → +`Edit` with `path`/`oldString`/`newString` renamed to snake_case — and +unknown tools pass through as `Tool::Raw`. Placeholder titles (`New session - …`) are treated as absent. -A synthetic message record, shaped like the real thing: +2.1 renamed some built-in tools and input keys; the 2.1 document uses its +names and the 2.0 shape keeps the old ones (`TOOLS_2_1` in `v2.rs`): `bash` is +`shell` (`timeout_ms` → `timeout`, `run_in_background` → `background`), `task` +is `subagent` (`subagent_type` → `agent`), `Skill` is `skill` (`skill` → `id`), +`WebSearch` is `websearch`; `read`, `edit` and `write` take `path` for +`filePath`, and `grep` takes `include` for `glob`, `limit` for `head_limit` and +`caseSensitive` for the inverse of `-i`. Every other key is kept, so a call +reads back unchanged. + +A synthetic assistant record, shaped like the real thing: ```json { - "info": { - "id": "msg_0123abcd", "sessionID": "ses_0123abcd", "role": "assistant", - "modelID": "claude-opus-4-7", "providerID": "anthropic", - "finish": "stop", "cost": 0, - "tokens": { "input": 6, "output": 88, "cache": { "read": 10, "write": 21428 } }, - "time": { "created": 1778834704540, "completed": 1778834704999 } - }, - "parts": [ - { "type": "step-start" }, + "id": "msg_0123abcd", "type": "assistant", "agent": "build", + "model": { "id": "claude-opus-4-7", "providerID": "anthropic" }, + "finish": "stop", "cost": 0, + "tokens": { "input": 6, "output": 88, "reasoning": 0, "cache": { "read": 10, "write": 21428 } }, + "time": { "created": 1778834704540, "completed": 1778834704999 }, + "content": [ { "type": "reasoning", "text": "The rename is mechanical." }, - { "type": "tool", "tool": "edit", "callID": "call_1", + { "type": "tool", "id": "call_1", "name": "edit", + "time": { "created": 1778834704545, "completed": 1778834704550 }, "state": { "status": "completed", - "input": { "filePath": "/repo/a.rs", "oldString": "old", "newString": "new" }, - "output": "done" } }, + "input": { "path": "/repo/a.rs", "oldString": "old", "newString": "new" }, + "content": [{ "type": "text", "text": "done" }] } }, { "type": "text", "text": "Renamed it." } ] } ``` +The 2.0 shape, which `from_text` still reads, kept the same information as a +`message` (`{role, modelID, finish, tokens, …}`) plus `parts` of type `text`, +`reasoning`, `file` and `tool` (`{tool, callID, state {status, input, +output/error}}`), with bookkeeping parts (`step-start`/`step-finish`, +`snapshot`, `patch`) in between. + ## Lineage -`session.parent_id` names the session that spawned this one — OpenCode's subagent/task tool -starts a child session with its own row. A non-empty value always becomes a Spawn -`Meta.lineage`; it's a bare column read, so `discover` (one extra `SELECT` column) and `load` -both fill it with no added query. OpenCode has no fork or continue relation that reuses this -column. `from_common`/`opencode import` never writes it. +`session_v2.parent_id` names the session that spawned this one — OpenCode's +subagent/task tool starts a child session with its own row. A non-empty value +always becomes a Spawn `Meta.lineage`; it's a bare column read, so `discover` +(one extra `SELECT` column) and `load` both fill it with no added query. +OpenCode has no fork or continue relation that reuses this column. +`from_common`/`opencode session import` never writes it. ## Caveats - **Writes go through the CLI, not the DB.** txcript opens the database - read-only; `save` serializes the export shape to a private temp file and - shells out to `opencode import` (the binary must be on PATH), letting - OpenCode own its schema. `import` rejects session ids that don't start with - `ses`, so foreign UUIDs are deterministically re-shaped. + read-only; `save` serializes the document to a private temp file and shells + out to `opencode session import --directory ` (the binary must + be on PATH; `--directory` is the transcript's cwd when it exists on this + machine, otherwise OpenCode files the session under the current directory), + letting OpenCode own its schema. `import` rejects session ids that don't + start with `ses`, so foreign UUIDs are deterministically re-shaped. txcript + reads `opencode --version` first: a 2.0 or earlier CLI gets the old + `{info, parts}` export through `opencode import` instead. +- **2.1 validates the document.** The header must carry `projectID`, `cost`, + `tokens` and `location`; every message must be one of 2.1's record types. + `from_common` writes exactly that; a 2.0 export file is not accepted by + 2.1's importer (its legacy decoder only renames native-data keys), so + `continue` a 2.0 file through txcript rather than importing it directly. - **Delete is archive.** `delete` sets `time_archived`; rows remain and the session is recoverable from OpenCode's UI. Discovery filters archived rows. - **Schema drift.** The schema is Drizzle-migrated and moves quickly (the - `session` table keeps growing columns). txcript touches only the stable - core: `session(id, directory, title, version, time_created, time_archived, - model, parent_id)`, `message(id, session_id, time_created, data)`, + session table keeps growing columns). txcript touches only the stable + core: `session_v2(id, directory, title, version, time_created, + time_updated, time_archived, model, slug, parent_id)` and + `session_message(id, session_id, type, seq, time_created, data)` — or, on + a 2.0 database, `session`, `message(id, session_id, time_created, data)` and `part(id, message_id, session_id, data)`. Channel builds may write `opencode-.db`; only `opencode.db` is discovered. -- **Lossiness in Common.** Bookkeeping and synthetic parts are dropped; - per-part timestamps collapse to the message's; a tool result shares its - call's timestamp after a round-trip; `EndTurn`, `StopSequence`, `Aborted`, - and `Other` stop reasons all re-export as `"stop"` (OpenCode requires a - finish and has no spelling for the rest). Same-harness round-trips through +- **Lossiness in Common.** Bookkeeping records and synthetic text are + dropped; per-item timestamps collapse to the message's; a tool result + shares its call's timestamp after a round-trip; `EndTurn`, `StopSequence`, + `Aborted`, and `Other` stop reasons all re-export as `"stop"` (OpenCode + requires a finish and has no spelling for the rest). An assistant image has + no slot in a 2.1 assistant record and is dropped on write; the 2.1 header + has none for the CLI version (`Meta.cli_version` is read from the database + column, and the importer stamps its own). Same-harness round-trips through Common are fixpoint-tested, not byte-identical. -- **Hostile input.** A `part` row whose JSON doesn't parse is dropped and the - message survives; unknown or missing roles are skipped; an absent DB means - "no sessions", not an error. The import staging file is created - `create_new` + mode 0600 under a randomized name. +- **Hostile input.** A `session_message` row whose JSON doesn't parse is + dropped and the session survives; unknown record types are kept verbatim + and skipped by the codec; an absent DB means "no sessions", not an error. + The import staging file is created `create_new` + mode 0600 under a + randomized name. ## References -- Drizzle tables `session` / `message` / `part`: - -- Export shape `{info, messages: [{info, parts}]}`: +- Drizzle tables `session_v2` / `session_message`: + +- Message records (`Session.Message.Info`): + +- Import endpoint and the 2.0-export compatibility it offers: + , + +- `opencode session import` / `session export` commands: + , + +- Importer (`SessionTransfer.import`): + +- 2.0 tables `session` / `message` / `part` and export shape: + , -- DB filename and channel-suffix logic: - -- XDG data-dir resolution: - -- Legacy JSON `storage/` tree and its migration: - - -Last verified: 2026-09-26, against src/harness/opencode.rs and real local -sessions. The authoritative mapping is `src/harness/opencode.rs` (codec, -tool-name normalization, SQLite store) with `tests/integration/opencode.rs` -as executable shape examples. + +Last verified: 2026-10-27, against `src/harness/opencode.rs`, +`src/harness/opencode/v2.rs`, OpenCode 2.1.4 and real local sessions. The +authoritative mapping is `src/harness/opencode.rs` (codec, tool-name +normalization, SQLite store) and `src/harness/opencode/v2.rs` (the 2.1 +projection) with `tests/integration/opencode.rs` as executable shape examples. diff --git a/src/harness/claude_code.rs b/src/harness/claude_code.rs index 5466689..9e90871 100644 --- a/src/harness/claude_code.rs +++ b/src/harness/claude_code.rs @@ -181,6 +181,9 @@ pub(crate) fn records_to_messages(records: &[Record], fallback_ts: DateTime for record in records { let (role, entry) = match record { + // The file still holds the history a compaction summary condenses + // (#100). + Record::User(e) if is_compact_summary(e) => continue, Record::User(e) => (Role::User, e), Record::Assistant(e) => (Role::Assistant, e), // Newer CLI versions write the same command envelopes on @@ -224,6 +227,14 @@ pub(crate) fn records_to_messages(records: &[Record], fallback_ts: DateTime messages } +/// Whether a `user` line is the summary a compaction writes after its +/// `compact_boundary`. Reading in file order already carries the history it +/// condenses, which the file keeps before the boundary, so the summary would +/// repeat it as a user turn. +fn is_compact_summary(entry: &EntryLine) -> bool { + entry.extra.get("isCompactSummary") == Some(&Value::Bool(true)) +} + /// The native records for a canonical conversation: one entry line per /// message (local-command turns as their envelopes), a linear `parentUuid` /// chain, and a leading summary line when `meta.title` is set. `meta.id` is diff --git a/src/harness/opencode.rs b/src/harness/opencode.rs index 09da176..be4a8fc 100644 --- a/src/harness/opencode.rs +++ b/src/harness/opencode.rs @@ -1,12 +1,17 @@ -//! `OpenCode`: a single `SQLite` database (`~/.local/share/opencode/opencode.db`) -//! holding `session`, `message`, and `part` rows. +//! `OpenCode`: a single `SQLite` database (`~/.local/share/opencode/opencode.db`). +//! Up to 2.0 it held `session`, `message`, and `part` rows; since 2.1 it keeps +//! `session_v2` rows and one flat `session_message` record per message. //! -//! The native [`Body`](OpenCode) is `OpenCode`'s own export shape — -//! `{info, messages: [{info, parts}]}` — the format `opencode export` emits and -//! `opencode import` consumes. [`OpenCodeStore`] reads the DB read-only and -//! assembles that shape; `save` hands it to `opencode import`. The codec and -//! native types are pure JSON and compile without the `opencode` feature; -//! only the store pulls in `rusqlite`. +//! The native [`Body`](OpenCode) is the 2.0 export shape — +//! `{info, messages: [{info, parts}]}` — which the codec understands; the `v2` +//! module projects it to and from 2.1's `{info, messages: [record]}` document, +//! the form `opencode session export` emits and `opencode session import` +//! consumes. [`OpenCodeStore`] reads either database layout read-only and +//! assembles the native shape; `save` hands the document the installed CLI +//! understands to `opencode session import` (2.1) or `opencode import` +//! (earlier). The text form is the 2.1 document; `from_text` reads both. The +//! codec and native types are pure JSON and compile without the `opencode` +//! feature; only the store pulls in `rusqlite`. //! //! A `part` of type `tool` splits into a [`Block::ToolUse`] on an assistant //! message and a [`Block::ToolResult`] on the following user message, matching @@ -23,6 +28,8 @@ use crate::common::{Block, ImageSource, Message, Meta, Role, StopReason, Tool, T use crate::error::Result; use crate::transcript::{Codec, Common, Harness, TextCodec, Transcript}; +mod v2; + /// The `OpenCode` harness marker. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct OpenCode; @@ -76,15 +83,21 @@ impl Codec for OpenCode { } impl TextCodec for OpenCode { - /// The text form is the `opencode export` JSON document. + /// The text form is the `opencode session export` JSON document (2.1); + /// the 2.0 `opencode export` shape is read as well. fn from_text(text: &str) -> Result> { - let export: Export = serde_json::from_str(text)?; + let doc: Value = serde_json::from_str(text)?; + let export = if v2::is_document(&doc) { + v2::to_legacy(&doc)? + } else { + serde_json::from_value(doc)? + }; let meta = meta_from_info(&export.info); Ok(Transcript::new(meta, export)) } fn to_text(transcript: &Transcript) -> Result { - Ok(serde_json::to_string(&transcript.body)?) + Ok(serde_json::to_string(&v2::from_legacy(&transcript.body))?) } } @@ -113,7 +126,15 @@ fn meta_from_info(info: &Value) -> Meta { Meta { id: string("id").unwrap_or_default(), timestamp, - cwd: string("directory"), + // 2.0 spelled the working directory `directory`; 2.1 nests it in + // `location`. + cwd: string("directory").or_else(|| { + info.get("location") + .and_then(|location| location.get("directory")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(String::from) + }), git_branch: None, title, cli_version: string("version"), @@ -575,17 +596,8 @@ fn assistant_part( "metadata": {}, "time": time, }); - // Fold matching ToolResult from the following user turn into state.output. - if let Some(next) = messages.get(*idx + 1) - && matches!(next.role, Role::User) - && let Some(result) = next.content.iter().find_map(|b| match b { - Block::ToolResult { - tool_use_id, - content, - is_error, - } if tool_use_id == id => Some((content, *is_error)), - _ => None, - }) + // Fold the matching ToolResult into state.output. + if let Some(result) = following_result(messages, *idx, id) && let Value::Object(obj) = &mut state { if result.1 { @@ -613,6 +625,39 @@ fn assistant_part( p } +/// The result of call `id` made in `messages[idx]`: the first matching +/// `ToolResult` in the messages after it, up to the next user message that is +/// more than tool results (the next prompt). Parallel calls can be recorded one +/// per assistant message, with each result in its own later user message, so +/// the result is not always in the very next message. +fn following_result<'m>( + messages: &'m [Message], + idx: usize, + id: &str, +) -> Option<(&'m ToolOutput, bool)> { + for message in messages.iter().skip(idx + 1) { + if message.role != Role::User { + continue; + } + let mut prompt = false; + for block in &message.content { + match block { + Block::ToolResult { + tool_use_id, + content, + is_error, + } if tool_use_id == id => return Some((content, *is_error)), + Block::ToolResult { .. } => {} + _ => prompt = true, + } + } + if prompt { + return None; + } + } + None +} + // ── tool normalization ───────────────────────────────────────────────── fn normalize_tool(tool: &str, input: Value) -> (String, Value) { @@ -834,12 +879,12 @@ pub use store::OpenCodeStore; #[cfg(feature = "opencode")] mod store { use std::collections::HashMap; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use rusqlite::{Connection, OpenFlags}; use serde_json::Value; - use super::{Export, MessageRecord, OpenCode}; + use super::{Export, MessageRecord, OpenCode, v2}; use crate::common::{Lineage, Meta, Relation}; use crate::error::{Error, Result}; use crate::transcript::{Discovered, Saved, Store, Transcript}; @@ -889,6 +934,89 @@ mod store { } } + /// Which tables a database keeps its sessions in: 2.1 moved them to + /// `session_v2` and `session_message`; earlier releases used `session`, + /// `message` and `part`. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Layout { + Legacy, + V2, + } + + impl Layout { + fn detect(conn: &Connection) -> Result { + let v2: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master \ + WHERE type = 'table' AND name = 'session_v2')", + [], + |row| row.get(0), + ) + .map_err(sqlite_err)?; + Ok(if v2 { Self::V2 } else { Self::Legacy }) + } + + fn session_table(self) -> &'static str { + match self { + Self::Legacy => "session", + Self::V2 => "session_v2", + } + } + + fn message_table(self) -> &'static str { + match self { + Self::Legacy => "message", + Self::V2 => "session_message", + } + } + } + + /// Which generation the `opencode` on `PATH` is: 2.1 imports with + /// `opencode session import` and the flat document; earlier releases + /// with `opencode import` and the `{info, parts}` export. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Generation { + Legacy, + Current, + } + + impl Generation { + fn of_cli() -> Result { + use std::process::{Command, Stdio}; + + let output = Command::new("opencode") + .arg("--version") + .stdin(Stdio::null()) + .output() + .map_err(|e| Error::Unconvertible { + harness: "opencode", + detail: format!("running `opencode --version`: {e} (is OpenCode on PATH?)"), + })?; + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(Self::parse(&text)) + } + + /// `opencode v2.1.4`, `2.0.3`, `v1.17.20`: anything before 2.1 is the + /// legacy generation; a version that cannot be read is taken as + /// current. + fn parse(text: &str) -> Self { + let version = text.split_whitespace().find_map(|word| { + let mut numbers = word.trim_start_matches('v').split('.'); + let major: u32 = numbers.next()?.parse().ok()?; + let minor: u32 = numbers.next()?.parse().ok()?; + Some((major, minor)) + }); + match version { + Some(version) if version < (2, 1) => Self::Legacy, + _ => Self::Current, + } + } + } + impl Store for OpenCodeStore { type H = OpenCode; type Ref = String; @@ -896,11 +1024,13 @@ mod store { fn discover(&self) -> Result>> { if self.db_path.is_file() { let conn = self.open()?; + let layout = Layout::detect(&conn)?; let mut stmt = conn - .prepare( + .prepare(&format!( "SELECT id, directory, title, version, time_created, model, parent_id \ - FROM session WHERE time_archived IS NULL ORDER BY time_created DESC", - ) + FROM {} WHERE time_archived IS NULL ORDER BY time_created DESC", + layout.session_table() + )) .map_err(sqlite_err)?; let rows = stmt .query_map([], |row| { @@ -946,71 +1076,30 @@ mod store { fn load(&self, reference: &String) -> Result> { let conn = self.open()?; - - let (meta, info) = session_row(&conn, reference)?; - - let mut msg_stmt = conn - .prepare( - "SELECT id, data, time_created FROM message \ - WHERE session_id = ?1 ORDER BY time_created, id", - ) - .map_err(sqlite_err)?; - let message_rows: Vec<(String, String, i64)> = msg_stmt - .query_map([reference], |row| { - Ok((row.get(0)?, row.get(1)?, row.get(2)?)) - }) - .map_err(sqlite_err)? - .collect::>() - .map_err(sqlite_err)?; - drop(msg_stmt); - - let mut part_stmt = conn - .prepare( - "SELECT message_id, data FROM part \ - WHERE session_id = ?1 ORDER BY message_id, id", - ) - .map_err(sqlite_err)?; - let part_rows = part_stmt - .query_map([reference], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(sqlite_err)?; - let mut parts_by_message: HashMap> = HashMap::new(); - for row in part_rows { - let (message_id, data) = row.map_err(sqlite_err)?; - // A part row whose JSON doesn't parse is dropped; the message - // survives without it. - if let Ok(value) = serde_json::from_str::(&data) { - parts_by_message.entry(message_id).or_default().push(value); - } - } - drop(part_stmt); - - let mut messages = Vec::with_capacity(message_rows.len()); - for (message_id, data, time_created) in message_rows { - let mut msg_info: Value = serde_json::from_str(&data).unwrap_or(Value::Null); - // The export shape carries time on the message; the DB keeps it - // in a column. Inject it so the codec reads a uniform shape. - if let Value::Object(obj) = &mut msg_info { - obj.entry("time") - .or_insert_with(|| serde_json::json!({ "created": time_created, "completed": time_created })); - } - messages.push(MessageRecord { - info: msg_info, - parts: parts_by_message.remove(&message_id).unwrap_or_default(), - }); - } - + let layout = Layout::detect(&conn)?; + let (meta, info) = session_row(&conn, layout, reference)?; + let messages = match layout { + Layout::Legacy => legacy_messages(&conn, reference)?, + Layout::V2 => v2_messages(&conn, reference)?, + }; Ok(Transcript::new(meta, Export { info, messages })) } fn save(&self, transcript: &Transcript) -> Result> { - // OpenCode owns its schema; hand it the export and let `opencode - // import` normalize and persist. Requires the `opencode` binary. + // OpenCode owns its schema; hand it the export and let its import + // command normalize and persist. Requires the `opencode` binary, + // whose generation picks the command and the document layout. use std::io::Write; use std::process::{Command, Stdio}; - let export = serde_json::to_string(&transcript.body)?; + let generation = Generation::of_cli()?; + let (command, export) = match generation { + Generation::Current => ( + "opencode session import", + serde_json::to_string(&v2::from_legacy(&transcript.body))?, + ), + Generation::Legacy => ("opencode import", serde_json::to_string(&transcript.body)?), + }; // A randomized name plus `create_new` keeps the staging file off // any predictable, pre-plantable path (and never follows a // symlink); 0600 keeps the exported transcript private in a @@ -1028,16 +1117,33 @@ mod store { file.write_all(export.as_bytes())?; drop(file); - let output = Command::new("opencode") - .arg("import") - .arg(&tmp) + let mut import = Command::new("opencode"); + match generation { + Generation::Current => { + import.arg("session").arg("import").arg(&tmp); + // 2.1 files the session under the directory it is imported + // from; name the transcript's own when it exists here. + if let Some(cwd) = transcript + .meta + .cwd + .as_deref() + .filter(|dir| Path::new(dir).is_dir()) + { + import.arg("--directory").arg(cwd); + } + } + Generation::Legacy => { + import.arg("import").arg(&tmp); + } + } + let output = import .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .map_err(|e| Error::Unconvertible { harness: "opencode", - detail: format!("running `opencode import`: {e} (is OpenCode on PATH?)"), + detail: format!("running `{command}`: {e} (is OpenCode on PATH?)"), })?; if output.status.success() { let _ = std::fs::remove_file(&tmp); @@ -1055,7 +1161,7 @@ mod store { Err(Error::Unconvertible { harness: "opencode", detail: format!( - "`opencode import` failed: {}", + "`{command}` failed: {}", String::from_utf8_lossy(&output.stderr) ), }) @@ -1067,11 +1173,15 @@ mod store { /// archived sessions out; the rows stay, reversible from `OpenCode`. fn delete(&self, reference: &String) -> Result<()> { let conn = Connection::open(&self.db_path).map_err(sqlite_err)?; + let layout = Layout::detect(&conn)?; let now = chrono::Utc::now().timestamp_millis(); let updated = conn .execute( - "UPDATE session SET time_archived = ?1 \ - WHERE id = ?2 AND time_archived IS NULL", + &format!( + "UPDATE {} SET time_archived = ?1 \ + WHERE id = ?2 AND time_archived IS NULL", + layout.session_table() + ), rusqlite::params![now, reference], ) .map_err(sqlite_err)?; @@ -1088,10 +1198,12 @@ mod store { fn fingerprints(&self, refs: &[String]) -> Result> { if self.db_path.is_file() { let conn = self.open()?; + let layout = Layout::detect(&conn)?; let mut stmt = conn - .prepare( - "SELECT session_id, MAX(time_created) FROM message GROUP BY session_id", - ) + .prepare(&format!( + "SELECT session_id, MAX(time_created) FROM {} GROUP BY session_id", + layout.message_table() + )) .map_err(sqlite_err)?; let rows = stmt .query_map([], |r| { @@ -1115,13 +1227,113 @@ mod store { } } - fn session_row(conn: &Connection, id: &str) -> Result<(Meta, Value)> { + /// The 2.0 layout: `message` rows carry the message `info`, `part` rows + /// its parts. + fn legacy_messages(conn: &Connection, reference: &str) -> Result> { + let mut msg_stmt = conn + .prepare( + "SELECT id, data, time_created FROM message \ + WHERE session_id = ?1 ORDER BY time_created, id", + ) + .map_err(sqlite_err)?; + let message_rows: Vec<(String, String, i64)> = msg_stmt + .query_map([reference], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + }) + .map_err(sqlite_err)? + .collect::>() + .map_err(sqlite_err)?; + drop(msg_stmt); + + let mut part_stmt = conn + .prepare( + "SELECT message_id, data FROM part \ + WHERE session_id = ?1 ORDER BY message_id, id", + ) + .map_err(sqlite_err)?; + let part_rows = part_stmt + .query_map([reference], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(sqlite_err)?; + let mut parts_by_message: HashMap> = HashMap::new(); + for row in part_rows { + let (message_id, data) = row.map_err(sqlite_err)?; + // A part row whose JSON doesn't parse is dropped; the message + // survives without it. + if let Ok(value) = serde_json::from_str::(&data) { + parts_by_message.entry(message_id).or_default().push(value); + } + } + drop(part_stmt); + + let mut messages = Vec::with_capacity(message_rows.len()); + for (message_id, data, time_created) in message_rows { + let mut msg_info: Value = serde_json::from_str(&data).unwrap_or(Value::Null); + // The export shape carries time on the message; the DB keeps it + // in a column. Inject it so the codec reads a uniform shape. + if let Value::Object(obj) = &mut msg_info { + obj.entry("time").or_insert_with( + || serde_json::json!({ "created": time_created, "completed": time_created }), + ); + } + messages.push(MessageRecord { + info: msg_info, + parts: parts_by_message.remove(&message_id).unwrap_or_default(), + }); + } + Ok(messages) + } + + /// The 2.1 layout: one `session_message` row per message, its `data` + /// column the record minus `id` and `type`, in `seq` order. + fn v2_messages(conn: &Connection, reference: &str) -> Result> { let mut stmt = conn .prepare( + "SELECT id, type, data, time_created FROM session_message \ + WHERE session_id = ?1 ORDER BY seq, time_created, id", + ) + .map_err(sqlite_err)?; + let rows: Vec<(String, String, String, i64)> = stmt + .query_map([reference], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .map_err(sqlite_err)? + .collect::>() + .map_err(sqlite_err)?; + drop(stmt); + + let mut last_user_id = None; + let mut messages = Vec::with_capacity(rows.len()); + for (i, (id, kind, data, time_created)) in rows.into_iter().enumerate() { + // A row whose JSON doesn't parse is dropped; the session survives + // without it. + let Ok(Value::Object(mut record)) = serde_json::from_str::(&data) else { + continue; + }; + record.insert("id".into(), Value::String(id)); + record.insert("type".into(), Value::String(kind)); + record + .entry("time") + .or_insert_with(|| serde_json::json!({ "created": time_created })); + messages.push(v2::record_to_legacy( + &Value::Object(record), + reference, + i, + &mut last_user_id, + )); + } + Ok(messages) + } + + fn session_row(conn: &Connection, layout: Layout, id: &str) -> Result<(Meta, Value)> { + let mut stmt = conn + .prepare(&format!( "SELECT directory, title, version, time_created, model, slug, time_updated, \ parent_id \ - FROM session WHERE id = ?1", - ) + FROM {} WHERE id = ?1", + layout.session_table() + )) .map_err(sqlite_err)?; let row = stmt .query_row([id], |row| { @@ -1201,8 +1413,8 @@ mod store { #[cfg(test)] mod tests { use super::super::OpenCode; - use super::OpenCodeStore; - use crate::common::{Block, Relation, Role, Tool}; + use super::{Generation, OpenCodeStore}; + use crate::common::{Block, Relation, Role, Tool, ToolOutput}; use crate::transcript::{Codec, Store}; use rusqlite::{Connection, params}; @@ -1339,5 +1551,118 @@ mod store { assert_eq!(lineage.parent, "ses_1"); assert_eq!(lineage.relation, Relation::Spawn); } + + /// A 2.1 database: `session_v2` plus flat `session_message` records — + /// the conversation of `make_db` plus the `idle` marker 2.1 writes + /// when a turn ends. + fn make_db_v2() -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "transcript-oc-v2-test-{n}-{:?}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("opencode.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE session_v2 (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, \ + slug TEXT, directory TEXT, title TEXT, version TEXT, model TEXT, \ + time_created INTEGER, time_updated INTEGER, time_archived INTEGER);\ + CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, \ + seq INTEGER, time_created INTEGER, time_updated INTEGER, data TEXT);", + ) + .unwrap(); + conn.execute( + "INSERT INTO session_v2 VALUES ('ses_1','global',NULL,'clever-engine','/repo','Demo','2.1.4',\ + '{\"id\":\"claude-opus-4-7\",\"providerID\":\"anthropic\"}',1778834704515,1778834704516,NULL)", + [], + ) + .unwrap(); + for (id, kind, seq, created, data) in [ + ( + "msg_u", + "user", + 1, + 1_778_834_704_520_i64, + r#"{"text":"edit it","time":{"created":1778834704520}}"#, + ), + ( + "msg_a", + "assistant", + 2, + 1_778_834_704_540, + r#"{"agent":"build","model":{"id":"claude-opus-4-7","providerID":"anthropic"},"finish":"stop","cost":0,"tokens":{"input":6,"output":88,"reasoning":0,"cache":{"read":10,"write":21428}},"time":{"created":1778834704540,"completed":1778834704560},"content":[{"type":"reasoning","text":"thinking"},{"type":"text","text":"On it."},{"type":"tool","id":"c1","name":"edit","time":{"created":1778834704545,"completed":1778834704550},"state":{"status":"completed","input":{"path":"/repo/a.rs","oldString":"old","newString":"new"},"content":[{"type":"text","text":"done"}]}},{"type":"text","text":"Finished."}]}"#, + ), + ( + "msg_i", + "idle", + 3, + 1_778_834_704_570, + r#"{"outcome":"succeeded","time":{"created":1778834704570}}"#, + ), + ] { + conn.execute( + "INSERT INTO session_message VALUES (?1,'ses_1',?2,?3,?4,?4,?5)", + params![id, kind, seq, created, data], + ) + .unwrap(); + } + path + } + + #[test] + fn discover_and_load_from_a_2_1_database() { + let path = make_db_v2(); + let store = OpenCodeStore::new(&path); + + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + let d = &found[0]; + assert_eq!(d.reference, "ses_1"); + assert_eq!(d.meta.title.as_deref(), Some("Demo")); + assert_eq!(d.meta.model.as_deref(), Some("claude-opus-4-7")); + assert_eq!(d.meta.cwd.as_deref(), Some("/repo")); + assert_eq!(d.meta.cli_version.as_deref(), Some("2.1.4")); + + let native = store.load(&d.reference).unwrap(); + // The idle marker rides along verbatim; the codec skips it. + assert_eq!(native.body.messages.len(), 3); + assert_eq!(native.body.messages[2].info["type"], "idle"); + let msgs = OpenCode::to_common(&native).unwrap().body; + // user | assistant(reasoning+text) | assistant(tool) | user(result) | assistant(text) + assert_eq!(msgs.len(), 5); + assert_eq!(msgs[0].role, Role::User); + assert!(matches!( + &msgs[2].content[0], + Block::ToolUse { id, tool: Tool::Edit { file_path, .. } } + if id == "c1" && file_path == "/repo/a.rs" + )); + assert!(matches!( + &msgs[3].content[0], + Block::ToolResult { tool_use_id, content: ToolOutput::Text(text), is_error: false } + if tool_use_id == "c1" && text == "done" + )); + let usage = msgs[4].usage.unwrap(); + assert_eq!(usage.input_tokens, 6); + assert_eq!(usage.cache_creation_input_tokens, Some(21428)); + + let prints = store.fingerprints(&["ses_1".to_string()]).unwrap(); + assert_eq!(prints["ses_1"], "1778834704570"); + } + + #[test] + fn cli_generation_is_read_from_the_version_line() { + assert_eq!(Generation::parse("opencode v2.1.4\n"), Generation::Current); + assert_eq!(Generation::parse("2.0.3"), Generation::Legacy); + assert_eq!(Generation::parse("v1.17.20"), Generation::Legacy); + assert_eq!( + Generation::parse("opencode 2.1.0-beta.1"), + Generation::Current + ); + assert_eq!(Generation::parse("3.0.0"), Generation::Current); + assert_eq!(Generation::parse(""), Generation::Current); + } } } diff --git a/src/harness/opencode/v2.rs b/src/harness/opencode/v2.rs new file mode 100644 index 0000000..349a33d --- /dev/null +++ b/src/harness/opencode/v2.rs @@ -0,0 +1,740 @@ +//! `OpenCode` 2.1's transcript layout. +//! +//! 2.1 replaced the `{info, parts}` message pair with one flat record per +//! message — `user`, `assistant`, `shell`, `compaction`, … — stored in a +//! `session_message` table, and `opencode session export` / `session import` +//! move that `{info, messages}` document. The codec keeps the 2.0 export as +//! the native [`Export`]; this module projects between the two layouts, so a +//! 2.1 database, export file or import is a pure function of the same body. +//! +//! Records the 2.0 layout has no slot for (`compaction`, `idle`, `shell`, +//! `system`, …) ride along as a [`MessageRecord`] whose `info` is the record +//! itself — no `role`, no parts — so the codec skips them and they go back out +//! unchanged. + +use serde_json::{Map, Value, json}; + +use super::{Export, MessageRecord, det_hex}; +use crate::error::{Error, Result}; + +/// Whether a parsed export document is the 2.1 layout: flat message records, +/// or (with no messages) a 2.1 header, which names the working directory in +/// `location` rather than `directory`. +pub(super) fn is_document(doc: &Value) -> bool { + match doc + .get("messages") + .and_then(Value::as_array) + .and_then(|messages| messages.first()) + { + Some(first) => first.get("type").is_some() && first.get("parts").is_none(), + None => doc + .get("info") + .is_some_and(|info| info.get("projectID").is_some() || info.get("location").is_some()), + } +} + +/// The 2.1 document for a native body. +pub(super) fn from_legacy(export: &Export) -> Value { + let session_id = str_of(export.info.get("id")).unwrap_or_default(); + let mut messages = Vec::with_capacity(export.messages.len()); + for (i, record) in export.messages.iter().enumerate() { + messages.extend(record_from_legacy(record, session_id, i)); + } + json!({ "info": header_from_legacy(&export.info), "messages": messages }) +} + +/// A native body from a 2.1 document. +pub(super) fn to_legacy(doc: &Value) -> Result { + let Some(Value::Object(info)) = doc.get("info") else { + return Err(Error::Malformed { + harness: "opencode", + detail: "export document has no `info` object".to_string(), + }); + }; + let mut header = info.clone(); + // The 2.0 header spells the working directory `directory`; keep both so + // every reader of the native body finds the field it knows. + if header.get("directory").is_none() + && let Some(dir) = directory(&Value::Object(info.clone())) + { + header.insert("directory".into(), json!(dir)); + } + let session_id = str_of(header.get("id")).unwrap_or_default().to_string(); + let mut last_user_id: Option = None; + let messages = doc + .get("messages") + .and_then(Value::as_array) + .map(|records| { + records + .iter() + .enumerate() + .map(|(i, record)| record_to_legacy(record, &session_id, i, &mut last_user_id)) + .collect() + }) + .unwrap_or_default(); + Ok(Export { + info: Value::Object(header), + messages, + }) +} + +// ── 2.0 → 2.1 ────────────────────────────────────────────────────────── + +fn header_from_legacy(info: &Value) -> Value { + let created = millis(info.get("time").and_then(|t| t.get("created"))).unwrap_or(0); + let updated = millis(info.get("time").and_then(|t| t.get("updated"))).unwrap_or(created); + let mut header = Map::new(); + header.insert( + "id".into(), + json!(str_of(info.get("id")).unwrap_or_default()), + ); + // The importer files the session under the project it resolves from the + // import directory; the header only has to name one. + header.insert( + "projectID".into(), + json!(str_of(info.get("projectID")).unwrap_or("global")), + ); + header.insert( + "cost".into(), + json!(info.get("cost").and_then(Value::as_f64).unwrap_or(0.0)), + ); + header.insert("tokens".into(), tokens_full(info.get("tokens"))); + header.insert( + "time".into(), + json!({ "created": created, "updated": updated }), + ); + if let Some(title) = str_of(info.get("title")) { + header.insert("title".into(), json!(title)); + } + if let Some(dir) = directory(info) { + header.insert("location".into(), json!({ "directory": dir })); + } + if let Some(model) = model_ref(info.get("model"), None) { + header.insert("model".into(), model); + } + if let Some(parent) = str_of(info.get("parentID")) { + header.insert("parentID".into(), json!(parent)); + } + Value::Object(header) +} + +/// The 2.1 records for one native message: a user record (plus one +/// `synthetic` record per synthetic text part), one assistant record, or a +/// 2.1 record kept verbatim by [`record_to_legacy`]. +fn record_from_legacy(record: &MessageRecord, session_id: &str, i: usize) -> Vec { + let info = &record.info; + match info.get("role").and_then(Value::as_str) { + Some("user") => user_records_from_legacy(record, session_id, i), + Some("assistant") => vec![assistant_record_from_legacy(record, session_id, i)], + // Already a 2.1 record: it has a `type` and no `role`. + _ => info + .get("type") + .is_some() + .then(|| info.clone()) + .into_iter() + .collect(), + } +} + +fn user_records_from_legacy(record: &MessageRecord, session_id: &str, i: usize) -> Vec { + let id = message_id(&record.info, session_id, i); + let created = created_ms(&record.info); + let mut text = Vec::new(); + let mut synthetic = Vec::new(); + let mut files = Vec::new(); + for part in &record.parts { + match part.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(body) = part.get("text").and_then(Value::as_str) { + if super::is_synthetic(part) { + synthetic.push(body); + } else { + text.push(body); + } + } + } + Some("file") => { + if let Some(attachment) = attachment_from_part(part) { + files.push(attachment); + } + } + _ => {} + } + } + let mut out = Vec::new(); + if !text.is_empty() || !files.is_empty() { + let mut user = base(&id, "user", created); + user.insert("text".into(), json!(text.join("\n\n"))); + if !files.is_empty() { + user.insert("files".into(), Value::Array(files)); + } + out.push(Value::Object(user)); + } + for (j, body) in synthetic.into_iter().enumerate() { + let mut record = base(&format!("{id}_s{j}"), "synthetic", created); + record.insert("text".into(), json!(body)); + out.push(Value::Object(record)); + } + out +} + +fn assistant_record_from_legacy(record: &MessageRecord, session_id: &str, i: usize) -> Value { + let info = &record.info; + let id = message_id(info, session_id, i); + let created = created_ms(info); + let mut out = base(&id, "assistant", created); + if let Some(completed) = millis(info.get("time").and_then(|t| t.get("completed"))) { + out.insert( + "time".into(), + json!({ "created": created, "completed": completed }), + ); + } + out.insert( + "agent".into(), + json!(str_of(info.get("agent")).unwrap_or("build")), + ); + // 2.0 keeps the model flat on the assistant (`modelID`/`providerID`). + let model = model_ref(None, str_of(info.get("modelID"))).map_or_else( + || json!({ "id": "unknown", "providerID": "anthropic" }), + |mut model| { + if let Value::Object(obj) = &mut model + && let Some(provider) = str_of(info.get("providerID")) + { + obj.insert("providerID".into(), json!(provider)); + } + model + }, + ); + out.insert("model".into(), model); + if let Some(finish) = info.get("finish").and_then(Value::as_str) { + out.insert("finish".into(), json!(finish_v2(finish))); + } + if info.get("tokens").is_some_and(Value::is_object) { + out.insert("tokens".into(), tokens_full(info.get("tokens"))); + } + if let Some(cost) = info.get("cost").and_then(Value::as_f64) { + out.insert("cost".into(), json!(cost)); + } + let content: Vec = record + .parts + .iter() + .filter_map(|part| content_from_part(part, created)) + .collect(); + out.insert("content".into(), Value::Array(content)); + Value::Object(out) +} + +fn content_from_part(part: &Value, created: i64) -> Option { + match part.get("type").and_then(Value::as_str)? { + // Synthetic text is harness bookkeeping; 2.1 has no assistant slot for it. + "text" if super::is_synthetic(part) => None, + "text" => Some( + json!({ "type": "text", "text": part.get("text").and_then(Value::as_str).unwrap_or_default() }), + ), + "reasoning" => Some( + json!({ "type": "reasoning", "text": part.get("text").and_then(Value::as_str).unwrap_or_default() }), + ), + "tool" => tool_from_part(part, created), + // step-start/finish, snapshot, patch, file: bookkeeping, or content + // the 2.1 assistant record cannot carry. + _ => None, + } +} + +fn tool_from_part(part: &Value, created: i64) -> Option { + let name = part.get("tool").and_then(Value::as_str)?; + let call_id = part + .get("callID") + .and_then(Value::as_str) + .unwrap_or_default(); + let state = part.get("state"); + let status = state + .and_then(|s| s.get("status")) + .and_then(Value::as_str) + .unwrap_or_default(); + // 2.1 requires an object input; anything else is kept, wrapped as + // `{ "input": }`. + let input = match state.and_then(|s| s.get("input")) { + Some(Value::Object(obj)) => Value::Object(obj.clone()), + Some(Value::Null) | None => Value::Object(Map::new()), + Some(other) => json!({ "input": other }), + }; + let (name, input) = tool_to_v2(name, input); + let started = millis( + state + .and_then(|s| s.get("time")) + .and_then(|t| t.get("start")), + ) + .unwrap_or(created); + let ended = millis(state.and_then(|s| s.get("time")).and_then(|t| t.get("end"))); + let mut time = Map::new(); + time.insert("created".into(), json!(started)); + let state_v2 = match status { + "completed" => { + let output = state + .and_then(|s| s.get("output")) + .map(value_to_text) + .unwrap_or_default(); + time.insert("completed".into(), json!(ended.unwrap_or(started))); + let mut completed = Map::new(); + completed.insert("status".into(), json!("completed")); + completed.insert("input".into(), input); + completed.insert( + "content".into(), + json!([{ "type": "text", "text": output }]), + ); + if let Some(metadata) = state + .and_then(|s| s.get("metadata")) + .filter(|m| m.is_object()) + { + completed.insert("metadata".into(), metadata.clone()); + } + Value::Object(completed) + } + "error" => { + let message = state + .and_then(|s| s.get("error")) + .map(value_to_text) + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| "Tool call failed".to_string()); + time.insert("completed".into(), json!(ended.unwrap_or(started))); + json!({ "status": "error", "input": input, "error": { "type": "unknown", "message": message } }) + } + // pending / running: still in flight. + _ => json!({ "status": "running", "input": input, "metadata": {} }), + }; + Some(json!({ + "type": "tool", + "id": call_id, + "name": name, + "state": state_v2, + "time": Value::Object(time), + })) +} + +/// A built-in tool 2.1 renamed, or whose input keys it renamed: the name the +/// codec writes, the 2.1 name, and the input keys as `(codec key, 2.1 key)`. +type ToolRename = ( + &'static str, + &'static str, + &'static [(&'static str, &'static str)], +); + +/// Every [`ToolRename`] (`packages/core/src/tool/plugin/*.ts`). Keys not +/// listed are kept, so a call survives a round trip unchanged. +const TOOLS_2_1: &[ToolRename] = &[ + ( + "bash", + "shell", + &[ + ("timeout_ms", "timeout"), + ("run_in_background", "background"), + ], + ), + ("read", "read", &[("filePath", "path")]), + ("edit", "edit", &[("filePath", "path")]), + ("write", "write", &[("filePath", "path")]), + ( + "grep", + "grep", + &[("glob", "include"), ("head_limit", "limit")], + ), + ("task", "subagent", &[("subagent_type", "agent")]), + ("Skill", "skill", &[("skill", "id")]), + ("WebSearch", "websearch", &[]), +]; + +/// The 2.1 name and input of a tool call the codec names the 2.0 way. +fn tool_to_v2(name: &str, input: Value) -> (String, Value) { + let Some((_, v2_name, keys)) = TOOLS_2_1.iter().find(|(legacy, ..)| *legacy == name) else { + return (name.to_string(), input); + }; + let mut input = super::rename_keys(input, keys); + // grep's case switch is inverted: `-i` means insensitive, 2.1's + // `caseSensitive` the opposite. + if *v2_name == "grep" { + flip_flag(&mut input, "-i", "caseSensitive"); + } + ((*v2_name).to_string(), input) +} + +/// The codec's name and input of a 2.1 tool call; the inverse of +/// [`tool_to_v2`]. +fn tool_from_v2(name: &str, input: Value) -> (String, Value) { + let Some((legacy, _, keys)) = TOOLS_2_1.iter().find(|(_, v2_name, _)| *v2_name == name) else { + return (name.to_string(), input); + }; + let reversed: Vec<(&str, &str)> = keys.iter().map(|(from, to)| (*to, *from)).collect(); + let mut input = super::rename_keys(input, &reversed); + if name == "grep" { + flip_flag(&mut input, "caseSensitive", "-i"); + } + ((*legacy).to_string(), input) +} + +/// Moves a boolean from `from` to `to`, negated; any other value is left in +/// place. +fn flip_flag(input: &mut Value, from: &str, to: &str) { + if let Value::Object(obj) = input + && let Some(Value::Bool(flag)) = obj.get(from).cloned() + { + obj.remove(from); + obj.insert(to.to_string(), Value::Bool(!flag)); + } +} + +fn attachment_from_part(part: &Value) -> Option { + let mime = part.get("mime").and_then(Value::as_str)?; + let url = part.get("url").and_then(Value::as_str)?; + if let Some((header, data)) = url.split_once(',') + && header.starts_with("data:") + && header.ends_with(";base64") + { + return Some(json!({ "data": data, "mime": mime, "source": { "type": "inline" } })); + } + None +} + +// ── 2.1 → 2.0 ────────────────────────────────────────────────────────── + +/// The native record for one 2.1 message. `last_user_id` threads the parent +/// link 2.0 assistant records carry. +pub(super) fn record_to_legacy( + record: &Value, + session_id: &str, + i: usize, + last_user_id: &mut Option, +) -> MessageRecord { + let id = message_id(record, session_id, i); + match record.get("type").and_then(Value::as_str) { + Some("user") => { + *last_user_id = Some(id.clone()); + user_to_legacy(record, session_id, i, &id) + } + Some("assistant") => { + assistant_to_legacy(record, session_id, i, &id, last_user_id.as_deref()) + } + // shell, compaction, idle, synthetic, system, skill, *-switched: no 2.0 + // shape; kept verbatim, skipped by the codec. + _ => MessageRecord { + info: record.clone(), + parts: Vec::new(), + }, + } +} + +/// A 2.0 part's identity: deterministic id, session and message. +fn stamp( + part: Map, + session_id: &str, + message_id: &str, + i: usize, + j: usize, +) -> Value { + let mut part = part; + part.insert( + "id".into(), + json!(format!("prt_{}", det_hex(session_id, i, j + 1))), + ); + part.insert("sessionID".into(), json!(session_id)); + part.insert("messageID".into(), json!(message_id)); + Value::Object(part) +} + +fn user_to_legacy(record: &Value, session_id: &str, i: usize, id: &str) -> MessageRecord { + let created = created_ms(record); + let mut info = Map::new(); + info.insert("id".into(), json!(id)); + info.insert("sessionID".into(), json!(session_id)); + info.insert("role".into(), json!("user")); + info.insert( + "agent".into(), + json!(str_of(record.get("agent")).unwrap_or("build")), + ); + info.insert( + "time".into(), + json!({ "created": created, "completed": created }), + ); + let mut parts = Vec::new(); + if let Some(text) = str_of(record.get("text")) { + let mut part = Map::new(); + part.insert("type".into(), json!("text")); + part.insert("text".into(), json!(text)); + part.insert("time".into(), json!({ "start": created, "end": created })); + parts.push(stamp(part, session_id, id, i, parts.len())); + } + for file in record + .get("files") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(part) = file_part(file) { + parts.push(stamp(part, session_id, id, i, parts.len())); + } + } + MessageRecord { + info: Value::Object(info), + parts, + } +} + +fn assistant_to_legacy( + record: &Value, + session_id: &str, + i: usize, + id: &str, + parent_id: Option<&str>, +) -> MessageRecord { + let created = created_ms(record); + let mut info = Map::new(); + info.insert("id".into(), json!(id)); + info.insert("sessionID".into(), json!(session_id)); + info.insert("role".into(), json!("assistant")); + info.insert( + "agent".into(), + json!(str_of(record.get("agent")).unwrap_or("build")), + ); + info.insert("mode".into(), json!("build")); + if let Some(parent) = parent_id { + info.insert("parentID".into(), json!(parent)); + } + let completed = millis(record.get("time").and_then(|t| t.get("completed"))).unwrap_or(created); + info.insert( + "time".into(), + json!({ "created": created, "completed": completed }), + ); + let model = record.get("model"); + info.insert( + "modelID".into(), + json!(str_of(model.and_then(|m| m.get("id"))).unwrap_or("unknown")), + ); + info.insert( + "providerID".into(), + json!(str_of(model.and_then(|m| m.get("providerID"))).unwrap_or("anthropic")), + ); + if let Some(finish) = record.get("finish").and_then(Value::as_str) { + info.insert("finish".into(), json!(finish)); + } + if let Some(tokens) = record.get("tokens").filter(|t| t.is_object()) { + info.insert("tokens".into(), tokens.clone()); + } + if let Some(cost) = record.get("cost").and_then(Value::as_f64) { + info.insert("cost".into(), json!(cost)); + } + let parts = record + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(part_from_content) + .enumerate() + .map(|(j, part)| stamp(part, session_id, id, i, j)) + .collect(); + MessageRecord { + info: Value::Object(info), + parts, + } +} + +fn part_from_content(item: &Value) -> Option> { + let mut part = Map::new(); + match item.get("type").and_then(Value::as_str)? { + "text" => { + part.insert("type".into(), json!("text")); + part.insert( + "text".into(), + json!(item.get("text").and_then(Value::as_str).unwrap_or_default()), + ); + } + "reasoning" => { + part.insert("type".into(), json!("reasoning")); + part.insert( + "text".into(), + json!(item.get("text").and_then(Value::as_str).unwrap_or_default()), + ); + } + "tool" => { + let state = item.get("state"); + let status = state + .and_then(|s| s.get("status")) + .and_then(Value::as_str) + .unwrap_or_default(); + let input = state + .and_then(|s| s.get("input")) + .filter(|input| input.is_object()) + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())); + let (name, input) = tool_from_v2( + item.get("name").and_then(Value::as_str).unwrap_or_default(), + input, + ); + let mut legacy = Map::new(); + legacy.insert("input".into(), input); + match status { + "completed" => { + legacy.insert("status".into(), json!("completed")); + legacy.insert( + "output".into(), + json!(content_text(state.and_then(|s| s.get("content")))), + ); + if let Some(metadata) = state + .and_then(|s| s.get("metadata")) + .filter(|m| m.is_object()) + { + legacy.insert("metadata".into(), metadata.clone()); + } + } + "error" => { + legacy.insert("status".into(), json!("error")); + let message = state + .and_then(|s| s.get("error")) + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .unwrap_or("Tool call failed"); + legacy.insert("error".into(), json!(message)); + } + // streaming (input still a string) / running: in flight. + _ => { + legacy.insert("status".into(), json!("running")); + } + } + if let Some(time) = item.get("time") { + let start = millis(time.get("created")); + let end = millis(time.get("completed")).or(start); + if let Some(start) = start { + legacy.insert( + "time".into(), + json!({ "start": start, "end": end.unwrap_or(start) }), + ); + } + } + part.insert("type".into(), json!("tool")); + part.insert("tool".into(), json!(name)); + part.insert( + "callID".into(), + json!(item.get("id").and_then(Value::as_str).unwrap_or_default()), + ); + part.insert("state".into(), Value::Object(legacy)); + } + _ => return None, + } + Some(part) +} + +fn file_part(file: &Value) -> Option> { + let mime = file.get("mime").and_then(Value::as_str)?; + let url = match file + .get("source") + .and_then(|s| s.get("type")) + .and_then(Value::as_str) + { + Some("uri") => file + .get("source") + .and_then(|s| s.get("uri")) + .and_then(Value::as_str)? + .to_string(), + _ => format!( + "data:{mime};base64,{}", + file.get("data").and_then(Value::as_str)? + ), + }; + let mut part = Map::new(); + part.insert("type".into(), json!("file")); + part.insert("mime".into(), json!(mime)); + part.insert("url".into(), json!(url)); + Some(part) +} + +/// The text of a 2.1 tool result: its text items, joined by newlines. +fn content_text(content: Option<&Value>) -> String { + content + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") +} + +// ── small helpers ────────────────────────────────────────────────────── + +fn base(id: &str, kind: &str, created: i64) -> Map { + let mut record = Map::new(); + record.insert("id".into(), json!(id)); + record.insert("type".into(), json!(kind)); + record.insert("time".into(), json!({ "created": created })); + record +} + +fn str_of(value: Option<&Value>) -> Option<&str> { + value.and_then(Value::as_str).filter(|s| !s.is_empty()) +} + +fn millis(value: Option<&Value>) -> Option { + value.and_then(Value::as_i64) +} + +fn created_ms(record: &Value) -> i64 { + millis(record.get("time").and_then(|t| t.get("created"))).unwrap_or(0) +} + +/// 2.1 requires `msg_` ids; a record without one gets a deterministic id. +fn message_id(record: &Value, session_id: &str, i: usize) -> String { + match str_of(record.get("id")) { + Some(id) if id.starts_with("msg_") => id.to_string(), + Some(id) => format!("msg_{id}"), + None => format!("msg_{}", det_hex(session_id, i, 0)), + } +} + +fn directory(info: &Value) -> Option<&str> { + str_of(info.get("location").and_then(|l| l.get("directory"))) + .or_else(|| str_of(info.get("directory"))) +} + +/// A `Model.Ref` from a 2.0 `model` object or a bare id; `None` without an id. +fn model_ref(model: Option<&Value>, id: Option<&str>) -> Option { + let id = id.or_else(|| str_of(model.and_then(|m| m.get("id"))))?; + let provider = str_of(model.and_then(|m| m.get("providerID"))).unwrap_or("anthropic"); + Some(json!({ "id": id, "providerID": provider })) +} + +/// The full `TokenUsage.Info` object 2.1 validates; absent counts are 0. +fn tokens_full(tokens: Option<&Value>) -> Value { + let count = |path: &[&str]| -> u64 { + let mut cursor = tokens; + for key in path { + cursor = cursor.and_then(|v| v.get(key)); + } + cursor.and_then(Value::as_u64).unwrap_or(0) + }; + json!({ + "input": count(&["input"]), + "output": count(&["output"]), + "reasoning": count(&["reasoning"]), + "cache": { "read": count(&["cache", "read"]), "write": count(&["cache", "write"]) }, + }) +} + +/// 2.1's `FinishReason`: `stop`, `length`, `tool-calls`, `content-filter`, +/// `error`, `unknown`. 2.0 spelled tool calls `tool_use`. +fn finish_v2(finish: &str) -> &'static str { + match finish { + "stop" => "stop", + "length" => "length", + "tool_use" | "tool_calls" | "tool-calls" => "tool-calls", + "content-filter" => "content-filter", + "error" => "error", + _ => "unknown", + } +} + +fn value_to_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Null => String::new(), + other => other.to_string(), + } +} diff --git a/src/local.rs b/src/local.rs index 916a4fa..90d02b8 100644 --- a/src/local.rs +++ b/src/local.rs @@ -906,14 +906,14 @@ pub fn write_with( from Simple documents, not written into them" .to_string(), }), - // `opencode import` writes into the live database wherever it lives; - // honoring a root override is impossible, and ignoring it would send - // an "export" into the user's real store. + // `opencode session import` writes into the live database wherever it + // lives; honoring a root override is impossible, and ignoring it would + // send an "export" into the user's real store. HarnessId::OpenCode if root.is_some() => Err(Error::Unconvertible { harness: "opencode", detail: "opencode sessions can only be imported into the live \ - opencode database (via `opencode import`); writing to \ - an alternate directory is not supported" + opencode database (via `opencode session import`); \ + writing to an alternate directory is not supported" .to_string(), }), HarnessId::OpenCode => write_opencode(common), @@ -1090,7 +1090,7 @@ fn write_opencode(common: &Transcript) -> Result { let saved = store.save(&native)?; Ok(Written { id: saved.id, - location: "imported via `opencode import`".to_string(), + location: "imported via the opencode CLI".to_string(), }) } diff --git a/tests/integration/claude_code.rs b/tests/integration/claude_code.rs index 0e6033f..d6fd58f 100644 --- a/tests/integration/claude_code.rs +++ b/tests/integration/claude_code.rs @@ -702,3 +702,26 @@ fn discover_leaves_lineage_none_when_the_lead_prefix_is_ambiguous() { let teammate = found.iter().find(|d| d.meta.id == "teammate").unwrap(); assert!(teammate.meta.lineage.is_none()); } + +/// The compaction summary repeats history the file still holds before its +/// `compact_boundary`: it is not a user turn (#100). +#[test] +fn the_compaction_summary_is_not_a_user_turn() { + let jsonl = r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-01-02T03:04:05.000Z","message":{"role":"user","content":"first prompt"}} +{"type":"system","subtype":"compact_boundary","uuid":"b1","parentUuid":null,"logicalParentUuid":"u1","sessionId":"s","timestamp":"2026-01-02T03:04:06.000Z","content":"Conversation compacted"} +{"type":"user","uuid":"c1","parentUuid":"b1","isCompactSummary":true,"isVisibleInTranscriptOnly":true,"sessionId":"s","timestamp":"2026-01-02T03:04:07.000Z","message":{"role":"user","content":"summary"}} +{"type":"user","uuid":"u2","parentUuid":"c1","sessionId":"s","timestamp":"2026-01-02T03:04:08.000Z","message":{"role":"user","content":"second prompt"}} +"#; + let native = claude_code::ClaudeCode::from_text(jsonl).unwrap(); + let conversation = claude_code::ClaudeCode::to_common(&native).unwrap(); + let texts: Vec<&str> = conversation + .body + .iter() + .flat_map(|m| &m.content) + .filter_map(|b| match b { + common::Block::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["first prompt", "second prompt"]); +} diff --git a/tests/integration/opencode.rs b/tests/integration/opencode.rs index 77fee27..f5f2add 100644 --- a/tests/integration/opencode.rs +++ b/tests/integration/opencode.rs @@ -9,7 +9,7 @@ use chrono::{DateTime, Utc}; use serde_json::json; use txcript::common; use txcript::harness::opencode; -use txcript::{Codec, Common, Transcript}; +use txcript::{Codec, Common, TextCodec, Transcript}; fn ts(s: &str) -> DateTime { s.parse().unwrap() @@ -446,3 +446,68 @@ fn custom_and_mcp_tool_names_preserve_exact_casing() { } } } + +#[test] +fn to_text_writes_the_2_1_document_and_reads_it_back() { + let common = sample_common(); + let native = opencode::OpenCode::from_common(&common).unwrap(); + let text = opencode::OpenCode::to_text(&native).unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + + // The header carries what 2.1's importer validates. + let info = &doc["info"]; + assert_eq!(info["id"], "ses_1"); + assert_eq!(info["projectID"], "global"); + assert_eq!(info["cost"], 0.0); + assert_eq!(info["tokens"]["cache"]["read"], 0); + assert_eq!(info["location"]["directory"], "/repo"); + assert_eq!(info["title"], "Demo"); + assert_eq!(info["model"]["id"], "claude-opus-4-7"); + assert!( + info.get("directory").is_none(), + "the 2.0 spelling stays out of the 2.1 header" + ); + + // Messages are flat records, not `{info, parts}` pairs. + let messages = doc["messages"].as_array().unwrap(); + assert_eq!(messages[0]["type"], "user"); + assert_eq!(messages[0]["text"], "edit it"); + assert!(messages[0].get("parts").is_none()); + assert!( + messages + .iter() + .all(|m| m["id"].as_str().unwrap().starts_with("msg_")) + ); + let tool_turn = messages + .iter() + .find(|m| { + m["content"] + .as_array() + .is_some_and(|c| c.iter().any(|item| item["type"] == "tool")) + }) + .unwrap(); + assert_eq!(tool_turn["finish"], "tool-calls"); + let tool = tool_turn["content"] + .as_array() + .unwrap() + .iter() + .find(|item| item["type"] == "tool") + .unwrap(); + assert_eq!(tool["name"], "edit"); + assert_eq!(tool["id"], "call-1"); + assert_eq!(tool["state"]["status"], "completed"); + assert_eq!(tool["state"]["input"]["path"], "/repo/a.rs"); + assert_eq!(tool["state"]["content"][0]["text"], "done"); + + // And the document reads back to the same conversation. The 2.1 header + // has no slot for the CLI version (the importer stamps its own), so that + // is the one `Meta` field a text round-trip cannot keep. + let back = + opencode::OpenCode::to_common(&opencode::OpenCode::from_text(&text).unwrap()).unwrap(); + assert_eq!(common.body, back.body); + assert_eq!(back.meta.id, common.meta.id); + assert_eq!(back.meta.cwd, common.meta.cwd); + assert_eq!(back.meta.title, common.meta.title); + assert_eq!(back.meta.model, common.meta.model); + assert_eq!(back.meta.cli_version, None); +} diff --git a/tests/regression/main.rs b/tests/regression/main.rs index dc8cae5..a113b9d 100644 --- a/tests/regression/main.rs +++ b/tests/regression/main.rs @@ -13,7 +13,7 @@ mod codex_pairing; use chrono::{DateTime, Utc}; use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput}; -use txcript::harness::{claude_code, codex, grok}; +use txcript::harness::{claude_code, codex, grok, opencode}; use txcript::{Codec, HarnessId, Store, TextCodec, Transcript}; #[cfg(feature = "search")] @@ -349,3 +349,86 @@ fn search_substring_matches_flag_shaped_needles() { assert_eq!(hits[0].origin, Origin::ToolUse); } } + +/// Fixed for issue #98. A source can record parallel tool calls one call +/// per assistant message, each result in its own later user message +/// (call A, call B, result A, result B). The `OpenCode` writer looked for a +/// result only in the message right after its call, so both calls were +/// written with an empty output and their results were lost. +#[test] +fn opencode_keeps_the_results_of_parallel_calls_written_one_per_message() { + let call = |id: &str, path: &str| Block::ToolUse { + id: id.to_string(), + tool: Tool::Read { + file_path: path.to_string(), + offset: None, + limit: None, + }, + }; + let result = |id: &str, text: &str| Block::ToolResult { + tool_use_id: id.to_string(), + content: ToolOutput::Text(text.to_string()), + is_error: false, + }; + let common = Transcript::new( + meta("ses_parallel"), + vec![ + msg( + Role::User, + vec![Block::Text { + text: "read both".to_string(), + }], + 0, + ), + msg(Role::Assistant, vec![call("call_a", "/a")], 1), + msg(Role::Assistant, vec![call("call_b", "/b")], 1), + msg(Role::User, vec![result("call_a", "content a")], 2), + msg(Role::User, vec![result("call_b", "content b")], 2), + msg( + Role::Assistant, + vec![Block::Text { + text: "done".to_string(), + }], + 3, + ), + ], + ); + + let native = opencode::OpenCode::from_common(&common).unwrap(); + let outputs: Vec<(String, String)> = native + .body + .messages + .iter() + .flat_map(|m| m.parts.iter()) + .filter(|part| part["type"] == "tool") + .map(|part| { + ( + part["callID"].as_str().unwrap_or_default().to_string(), + part["state"]["output"] + .as_str() + .unwrap_or_default() + .to_string(), + ) + }) + .collect(); + let expected = vec![ + ("call_a".to_string(), "content a".to_string()), + ("call_b".to_string(), "content b".to_string()), + ]; + assert_eq!(outputs, expected); + + let back = opencode::OpenCode::to_common(&native).unwrap().body; + let results: Vec<(String, String)> = back + .iter() + .flat_map(|m| m.content.iter()) + .filter_map(|block| match block { + Block::ToolResult { + tool_use_id, + content: ToolOutput::Text(text), + .. + } => Some((tool_use_id.clone(), text.clone())), + _ => None, + }) + .collect(); + assert_eq!(results, expected); +}