diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce2d196..bbeceafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to the Toolpath workspace are documented here. +## Preserve pi metadata entries and opencode user model + snapshot SHAs — 2026-07-08 + +Plumbs previously-dropped provider data through the existing IR +affordances (`ConversationView.events`, `Turn.model`) instead of merely +documenting the loss. No IR changes; `toolpath-convo` is unchanged. + +- **`toolpath-pi`** (0.6.2; 0.6.1 is claimed by a sibling PR in flight): + `session_to_view` now routes `ModelChange` / `ThinkingLevelChange` / + `Label` entries into `ConversationView.events` (`model_change` / + `thinking_level_change` / `label`) instead of discarding them, and + `PiProjector` re-materializes those events as real Pi entries with + their original ids and parentIds — so the entries survive a full + pi → view → Path → view → pi round-trip. Each re-emitted entry is + inserted directly after its parent for sane file order. +- **`toolpath-opencode`** (0.5.2; 0.5.1 is claimed by a sibling PR in + flight): user turns now carry `Turn.model` from the wire + `model.modelID` (same bare-modelID convention as assistant turns) and + the projector writes it back into the projected user message. + Snapshot SHAs from `step-start` / `step-finish` / `snapshot` parts + now also ride `ConversationView.events` as `part.snapshot` events + (chained per message so the derived Path stays linear), and the + projector restores them onto the emitted step parts — snapshot SHAs + survive a full Path round-trip, so a re-imported projection can + regenerate diffs. + ## Derive: resolve duplicate step ids — 2026-07-01 - **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived diff --git a/Cargo.lock b/Cargo.lock index a799d3c7..f9996581 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4006,7 +4006,7 @@ dependencies = [ [[package]] name = "toolpath-opencode" -version = "0.5.0" +version = "0.5.2" dependencies = [ "anyhow", "chrono", @@ -4024,7 +4024,7 @@ dependencies = [ [[package]] name = "toolpath-pi" -version = "0.6.0" +version = "0.6.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index cf3d6237..34c8acd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,12 +29,12 @@ toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" } -toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } +toolpath-opencode = { version = "0.5.2", path = "crates/toolpath-opencode" } toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } -toolpath-pi = { version = "0.6.0", path = "crates/toolpath-pi" } +toolpath-pi = { version = "0.6.2", path = "crates/toolpath-pi" } path-cli = { version = "0.14.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } diff --git a/RFC.md b/RFC.md index fa819525..26476e24 100644 --- a/RFC.md +++ b/RFC.md @@ -296,10 +296,11 @@ unrecognized URIs should be treated as a generic path. Kind URIs are immutable, semver-versioned, and revisions ship at a new version URI. Defined kinds are listed at . The only one defined -so far is `https://toolpath.net/kinds/agent-coding-session/v1.0.0` — a path -recording an AI coding conversation, where each conversational-turn step -carries a `"conversation.append"` structural change with the turn's role, -text, and so on. See the linked spec for the full contract. +so far is `https://toolpath.net/kinds/agent-coding-session/v1.1.0` (the +superseded `v1.0.0` URI is still recognized) — a path recording an AI coding +conversation, where each conversational-turn step carries a +`"conversation.append"` structural change with the turn's role, text, and so +on. See the linked spec for the full contract. #### Actor Definitions diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 675dbd8c..71ed89c4 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -17,7 +17,7 @@ //! Without `--harness`, an `fzf` picker shows installed harnesses //! with the source harness pre-selected. Source comes from //! `path.meta.source` (`claude-code`, `gemini-cli`, `codex`, -//! `opencode`, `pi`) with actor-string fallback. +//! `opencode`, `cursor`, `pi`) with actor-string fallback. //! //! ## Project directory //! diff --git a/crates/toolpath-claude/README.md b/crates/toolpath-claude/README.md index 6c3423c1..cbfd19b4 100644 --- a/crates/toolpath-claude/README.md +++ b/crates/toolpath-claude/README.md @@ -208,22 +208,25 @@ cache read, cache write) into a single aggregate. (deduplicated, first-touch order), derived from `FileWrite`-categorized tool inputs. **Provider-specific metadata** — Claude log entries often carry extra fields -(e.g. `subtype`, `data`) that don't map to the common `Turn` schema. These are -forwarded into `Turn.extra["claude"]` so trait-only consumers can access them -without importing Claude-specific types: +(e.g. `subtype`, `data`) that don't map to the common `Turn` schema. `Turn` +itself has no field to carry them, so a `conversation.append` turn drops +them; but a non-turn entry (one with no clean `Turn` mapping) surfaces its +full payload as a `WatcherEvent::Progress` event, with the entry's catch-all +fields nested under `data["claude"]`: ```rust,ignore -// State inference from provider metadata -if let Some(claude) = turn.extra.get("claude") { - if claude.get("subtype").and_then(|v| v.as_str()) == Some("init") { - // This is a session initialization entry - } +// State inference from a Progress event's provider metadata +if let WatcherEvent::Progress { data, .. } = event + && let Some(claude) = data.get("claude") + && claude.get("subtype").and_then(|v| v.as_str()) == Some("init") +{ + // This is a session initialization entry } ``` -For `WatcherEvent::Progress` events, the full entry payload is similarly -available under `data["claude"]` — carrying fields like `data.type`, -`data.hookName`, `data.agentId`, and `data.message`. +The full entry payload is available under `data["claude"]` — carrying +fields like `data.type`, `data.hookName`, `data.agentId`, and +`data.message`. See [`toolpath-convo`](https://crates.io/crates/toolpath-convo) for the full trait and type definitions. diff --git a/crates/toolpath-claude/src/project.rs b/crates/toolpath-claude/src/project.rs index 85969d7d..fb223dd5 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -309,12 +309,13 @@ fn tool_result_event_to_entry( /// Apply Claude-specific metadata from a [`Turn`] onto a [`ConversationEntry`]. /// -/// Populates `cwd` and `git_branch` from [`Turn::environment`], and -/// `version`, `user_type`, `request_id` from `Turn::extra["claude"]`. -/// Remaining keys from the `"claude"` extras are merged into the entry's -/// own `extra` map so they serialize as top-level fields (via `#[serde(flatten)]`). +/// Populates `cwd` and `git_branch` from [`Turn::environment`] when the +/// entry doesn't already carry them. The IR has no field for `version`, +/// `user_type`, `request_id`, or a per-entry catch-all, so a +/// claude → IR → claude round-trip can't recover those — the projected +/// entry's fields stay `None` and the harness fills in defaults at +/// write time. fn apply_turn_metadata(entry: &mut ConversationEntry, turn: &Turn) { - // From Turn.environment if let Some(env) = &turn.environment { if entry.cwd.is_none() { entry.cwd = env.working_dir.clone(); @@ -323,12 +324,6 @@ fn apply_turn_metadata(entry: &mut ConversationEntry, turn: &Turn) { entry.git_branch = env.vcs_branch.clone(); } } - - // Source-format details (`version`, `user_type`, `request_id`, - // per-entry catch-all) used to ride through `Turn.extra["claude"]` for - // claude → IR → claude round-trip. The IR no longer carries - // provider-specific extras; the projected entry's fields stay `None` - // and the harness fills in defaults at write time. } /// Build a `ConversationEntry` for a user turn. diff --git a/crates/toolpath-codex/src/derive.rs b/crates/toolpath-codex/src/derive.rs index e48cbb13..06d64015 100644 --- a/crates/toolpath-codex/src/derive.rs +++ b/crates/toolpath-codex/src/derive.rs @@ -18,8 +18,9 @@ use toolpath::v1::Path; /// OpenAI's servers — not useful in a human-readable digest. Plaintext /// reasoning summaries (rare) land on `Turn.thinking` automatically /// and surface in the derived path without a flag. The raw ciphertext -/// is preserved under `Turn.extra["codex"]["reasoning_encrypted"]` for -/// round-trip fidelity but never rendered. +/// is dropped: the IR carries no provider-namespaced extras field to +/// preserve it in, so it never reaches `Turn` and cannot be recovered +/// on a round-trip (see `encrypted_reasoning_does_not_land_on_thinking`). #[derive(Debug, Clone, Default)] pub struct DeriveConfig { /// Override `path.base.uri`. Defaults to the cwd from session_meta. diff --git a/crates/toolpath-codex/src/project.rs b/crates/toolpath-codex/src/project.rs index 2ec3dd51..1ca81ee3 100644 --- a/crates/toolpath-codex/src/project.rs +++ b/crates/toolpath-codex/src/project.rs @@ -8,22 +8,18 @@ //! line, followed by a `turn_context`, then per-turn `response_item` //! and `event_msg` lines). //! -//! The projector consumes provider-specific data the forward path -//! stashed under `Turn.extra["codex"]`: -//! -//! - `reasoning_encrypted`: opaque ciphertext blobs that round-trip -//! verbatim as `Reasoning::encrypted_content` -//! - `tool_extras`: per-`call_id` extras (exec exit_codes, custom-call -//! statuses, patch-change manifests, etc.) preserved on the matching -//! `function_call_output` / `custom_tool_call_output` line +//! The provider-agnostic IR (`Turn`) carries no provider-namespaced +//! extras, so a Codex→View→Codex round-trip cannot recover +//! Codex-specific data the forward path didn't lift into a typed +//! `Turn` field: reasoning ciphertext (`Reasoning::encrypted_content`) +//! and per-`call_id` tool extras (exec exit codes, custom-call +//! statuses, patch-change manifests) are dropped. `Turn.thinking` +//! (the public reasoning summary) and `ToolResult::is_error` are +//! reconstructed from typed fields instead. //! //! For non-Codex sources, the projector synthesizes sensible defaults //! (originator: `"codex-toolpath"`, source: `"cli"`, model_provider: //! `"openai"`). -//! -//! Foreign-namespace extras (`Turn.extra["claude"]`, -//! `Turn.extra["gemini"]`, …) are dropped — they have no meaning in -//! Codex's protocol and would pollute the JSONL. use std::collections::HashMap; use std::path::PathBuf; @@ -184,9 +180,8 @@ fn project_view( current_group = Some(group); } } - let codex = codex_extras(turn).cloned().unwrap_or_default(); let is_final_assistant = Some(idx) == last_assistant_idx; - emit_turn_lines(turn, &codex, is_final_assistant, &cwd, &mut lines, &mut running); + emit_turn_lines(turn, is_final_assistant, &cwd, &mut lines, &mut running); } Ok(crate::types::Session { @@ -260,17 +255,8 @@ fn make_turn_context_line( } } -/// Used to return `Turn.extra["codex"]`; the IR no longer carries -/// provider-namespaced extras. Always `None`. Callers fall back to -/// reconstructing source-format details from typed IR fields and -/// reasonable defaults. -fn codex_extras(_turn: &Turn) -> Option<&'static Map> { - None -} - fn emit_turn_lines( turn: &Turn, - codex: &Map, is_final_assistant: bool, session_cwd: &str, lines: &mut Vec, @@ -278,9 +264,7 @@ fn emit_turn_lines( ) { match &turn.role { Role::User => emit_user_message(turn, lines), - Role::Assistant => { - emit_assistant(turn, codex, is_final_assistant, session_cwd, lines, running) - } + Role::Assistant => emit_assistant(turn, is_final_assistant, session_cwd, lines, running), Role::System => emit_developer_message(turn, lines), Role::Other(_) => { // Unknown roles don't have a clean Codex analog; emit them @@ -348,7 +332,6 @@ fn emit_developer_message(turn: &Turn, lines: &mut Vec) { fn emit_assistant( turn: &Turn, - codex: &Map, is_final_assistant: bool, session_cwd: &str, lines: &mut Vec, @@ -357,38 +340,13 @@ fn emit_assistant( // Order matches what Codex itself emits per turn: // reasoning? → message → (function_call → function_call_output)* // - // For Codex→View→Codex round-trips, encrypted ciphertext lives on - // `Turn.extra["codex"]["reasoning_encrypted"]` and round-trips - // verbatim. For cross-harness sources, `Turn.thinking` is the - // public reasoning summary; we put it in `summary` instead of - // re-encrypting it. - let encrypted_blobs = codex - .get("reasoning_encrypted") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - if !encrypted_blobs.is_empty() { - for blob in encrypted_blobs { - let enc = blob.as_str().map(str::to_string); - let r = Reasoning { - id: None, - summary: vec![], - content: None, - encrypted_content: enc, - extra: HashMap::new(), - }; - lines.push(response_item_line( - &turn.timestamp, - "reasoning", - serde_json::to_value(&r).unwrap_or(Value::Null), - )); - } - } else if let Some(thinking) = &turn.thinking + // The IR carries no reasoning ciphertext, so a Codex→View→Codex + // round-trip can't restore `Reasoning::encrypted_content`. + // `Turn.thinking` is the public reasoning summary; render it as a + // single summary entry instead of re-encrypting it. + if let Some(thinking) = &turn.thinking && !thinking.is_empty() { - // Foreign-source: render thinking as a single summary entry. - // This is how Codex serializes plain reasoning summaries when - // it has them. let r = Reasoning { id: None, summary: vec![json!({"type": "summary_text", "text": thinking})], @@ -448,14 +406,9 @@ fn emit_assistant( } } - let tool_extras = codex - .get("tool_extras") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); for tu in &turn.tool_uses { let name = tool_native_name(tu); - emit_tool_call(turn, tu, &name, &tool_extras, session_cwd, lines); + emit_tool_call(turn, tu, &name, session_cwd, lines); } // Advance the session-cumulative counter by this step's contribution @@ -499,30 +452,22 @@ fn emit_tool_call( turn: &Turn, tu: &ToolInvocation, name: &str, - tool_extras: &Map, session_cwd: &str, lines: &mut Vec, ) { - let extras_for_call = tool_extras - .get(&tu.id) - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - if name == "apply_patch" { let input_str = match &tu.input { Value::String(s) => s.clone(), other => serde_json::to_string(other).unwrap_or_default(), }; - let status = extras_for_call - .get("status") - .and_then(Value::as_str) - .map(str::to_string); + // The IR carries no per-call-id status extras, so a + // Codex→View→Codex round-trip can't restore the original + // `custom_tool_call.status`. let call = CustomToolCall { name: name.to_string(), input: input_str, call_id: tu.id.clone(), - status, + status: None, id: None, extra: HashMap::new(), }; diff --git a/crates/toolpath-codex/src/provider.rs b/crates/toolpath-codex/src/provider.rs index 00bcdf4c..d7aeea7a 100644 --- a/crates/toolpath-codex/src/provider.rs +++ b/crates/toolpath-codex/src/provider.rs @@ -14,9 +14,9 @@ //! see the matching `*_output` by `call_id`. //! 5. `event_msg.exec_command_end` enriches the already-emitted tool //! invocation with the exit code / stdout / stderr. -//! 6. `event_msg.patch_apply_end` is captured on the current turn's -//! `extra["codex"]["patch_changes"]` — the derive layer consumes it -//! for file-artifact sibling changes. +//! 6. `event_msg.patch_apply_end` populates the current turn's +//! `Turn.file_mutations` — the derive layer consumes it for +//! file-artifact sibling changes. //! 7. Token accounting. `turn_context` / `task_started` open an API round //! (`turn_id`); assistant turns in it share that ID as `Turn.group_id`. //! `event_msg.token_count` carries the SESSION-cumulative @@ -297,11 +297,12 @@ impl<'a> Builder<'a> { }; // Producer (originator + cli_version) lifts onto the typed view - // field. `model_provider` already lives on each assistant - // `ActorDefinition.provider`. Codex's `source` and `forked_from_id` - // are wire-level fields with no cross-harness analog — the codex - // projector hard-codes defaults on the return path, so we let them - // drop on this side. + // field. `model_provider`, `source`, and `forked_from_id` are + // wire-level fields with no cross-harness analog and no typed + // home in the IR — the codex projector hard-codes defaults on + // the return path, so we let them drop on this side. (Note: + // `ActorDefinition.provider` is the shared derive's static + // `view.provider_id` ("codex"), not `session_meta.model_provider`.) let producer = meta.as_ref().map(|m| ProducerInfo { name: m.originator.clone(), version: Some(m.cli_version.clone()), diff --git a/crates/toolpath-convo/README.md b/crates/toolpath-convo/README.md index 90de3d57..2ca34b89 100644 --- a/crates/toolpath-convo/README.md +++ b/crates/toolpath-convo/README.md @@ -133,7 +133,7 @@ if let Some(id) = event.turn_id() { } ``` -Provider-specific metadata lives in `Turn.extra`, namespaced by provider (e.g. `turn.extra["claude"]`). This keeps the common schema clean while giving consumers opt-in access to provider internals. +`Turn` has no provider-namespaced extras field — it's a fixed, typed schema, so provider-specific data that doesn't map onto one of its fields is dropped on the `ConversationView` projection. Non-turn provider metadata (e.g. session-init markers) is still available at the edges: `WatcherEvent::Progress { kind, data }` carries the full source payload for entries that don't map to a `Turn` at all, with the provider's own fields nested under `data[""]` by convention (e.g. `data["claude"]`). ## Provider implementations diff --git a/crates/toolpath-gemini/README.md b/crates/toolpath-gemini/README.md index 3cf6930d..46479045 100644 --- a/crates/toolpath-gemini/README.md +++ b/crates/toolpath-gemini/README.md @@ -148,8 +148,10 @@ Per-turn `TokenUsage` includes: Gemini log entries often carry extra fields (`thoughts`, `tokens.tool`, `tokens.total`, `kind`, `summary`) that don't map to the common schema. -These are forwarded into `Turn.extra["gemini"]` so trait-only consumers -can access them without importing Gemini-specific types. +`Turn` has no field to carry them, so `thoughts` is folded into +`output_tokens` (recorded separately in `breakdowns["output"]["reasoning"]` +for a lossless round-trip), and `tokens.tool`/`tokens.total`/`kind`/`summary` +are simply dropped on the `ConversationView` projection. ## Round-trip fidelity @@ -158,7 +160,7 @@ The crate exposes three progressively lossy views of a conversation: | Layer | Lossless? | Use it when | |---|---|---| | `ChatFile` / `Conversation` (the raw on-disk schema) | **Yes** — verified by `tests/roundtrip.rs` on live fixtures | You need to re-emit the Gemini JSON byte-equivalent (archival, editing, redaction) | -| `ConversationView` (provider-agnostic projection) | No — Gemini-specific fields live under `Turn.extra["gemini"]` | You want to work across providers with one set of types | +| `ConversationView` (provider-agnostic projection) | No — Gemini-specific fields (`tokens.tool`/`tokens.total`/`kind`/`summary`) have no home in the IR and are dropped | You want to work across providers with one set of types | | `toolpath::v1::Path` (provenance digest) | No — tool results/args are summarized; only file-write bodies are preserved as full diffs | You want a compact Toolpath document for blame, queries, rendering | **For a true round-trip** — Gemini → Toolpath → Gemini — stay at the diff --git a/crates/toolpath-gemini/src/project.rs b/crates/toolpath-gemini/src/project.rs index 39bde557..52719076 100644 --- a/crates/toolpath-gemini/src/project.rs +++ b/crates/toolpath-gemini/src/project.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; -use serde_json::{Map, Value}; +use serde_json::Value; use toolpath_convo::{ ConversationProjector, ConversationView, ConvoError, DelegatedWork, Result, Role, TokenUsage, ToolCategory, ToolInvocation, Turn, @@ -133,12 +133,11 @@ fn project_view( // ── Turn → GeminiMessage ───────────────────────────────────────────── fn turn_to_message(turn: &Turn) -> GeminiMessage { - // `Turn.extra` is gone; previously the Gemini projector pulled - // `extra["gemini"]` for structured thought meta, full tokens, and - // per-tool-call status. With that source removed, `build_thoughts` / - // `build_tokens` / `build_tool_calls` fall back to the typed IR - // fields (`Turn.thinking` as a string, `Turn.token_usage`, etc.). - let gemini_extras: Map = Map::new(); + // The IR carries no provider-namespaced extras, so `build_thoughts` / + // `build_tokens` / `build_tool_calls` synthesize everything from the + // typed IR fields (`Turn.thinking` as a string, `Turn.token_usage`, + // `Turn.tool_uses`, etc.) rather than recovering a Gemini-native + // structured form. let msg_extras: HashMap = HashMap::new(); GeminiMessage { @@ -146,10 +145,10 @@ fn turn_to_message(turn: &Turn) -> GeminiMessage { timestamp: turn.timestamp.clone(), role: role_to_gemini_role(&turn.role), content: build_content(turn), - thoughts: build_thoughts(turn, &gemini_extras), - tokens: build_tokens(turn, &gemini_extras), + thoughts: build_thoughts(turn), + tokens: build_tokens(turn), model: turn.model.clone(), - tool_calls: build_tool_calls(turn, &gemini_extras), + tool_calls: build_tool_calls(turn), extra: msg_extras, } } @@ -178,43 +177,14 @@ fn build_content(turn: &Turn) -> GeminiContent { } } -/// Rebuild `Thought[]`. +/// Rebuild `Thought[]` from `Turn.thinking`. /// -/// Preferred source: `extra["gemini"]["thoughts_meta"]`, which carries -/// `{subject, description, timestamp}` triples written by the forward -/// path. Falls back to splitting `Turn.thinking` on `"\n\n"` and -/// extracting subject/description from the `**subject**\n{description}` -/// shape used by `flatten_thoughts`. -fn build_thoughts(turn: &Turn, gemini_extras: &Map) -> Option> { - if let Some(Value::Array(arr)) = gemini_extras.get("thoughts_meta") { - let thoughts: Vec = arr - .iter() - .filter_map(|v| { - let obj = v.as_object()?; - Some(Thought { - subject: obj - .get("subject") - .and_then(Value::as_str) - .map(str::to_string), - description: obj - .get("description") - .and_then(Value::as_str) - .map(str::to_string), - timestamp: obj - .get("timestamp") - .and_then(Value::as_str) - .map(str::to_string), - }) - }) - .collect(); - return if thoughts.is_empty() { - None - } else { - Some(thoughts) - }; - } - - // Fallback: parse the flattened string. +/// The IR carries no provider-namespaced extras, so the source's +/// original structured `{subject, description, timestamp}` triples +/// can't be recovered; instead this splits `Turn.thinking` on `"\n\n"` +/// and extracts subject/description from the `**subject**\n +/// {description}` shape used by `flatten_thoughts`. +fn build_thoughts(turn: &Turn) -> Option> { let thinking = turn.thinking.as_deref()?; let chunks: Vec<&str> = thinking.split("\n\n").collect(); if chunks.is_empty() { @@ -257,16 +227,12 @@ fn split_flattened_thought(chunk: &str) -> Thought { } } -/// Rebuild `Tokens`. +/// Rebuild `Tokens` from `Turn.token_usage`. /// -/// Preferred source: `extra["gemini"]["tokens"]` (the full struct). -/// Fallback: derive a partial struct from `Turn.token_usage`. -fn build_tokens(turn: &Turn, gemini_extras: &Map) -> Option { - if let Some(v) = gemini_extras.get("tokens") - && let Ok(t) = serde_json::from_value::(v.clone()) - { - return Some(t); - } +/// The IR carries no provider-namespaced extras, so the source's full +/// `Tokens` struct can't be recovered verbatim; this derives a partial +/// struct from the typed `TokenUsage` fields instead. +fn build_tokens(turn: &Turn) -> Option { turn.token_usage.as_ref().map(tokens_from_common) } @@ -292,45 +258,27 @@ fn tokens_from_common(u: &TokenUsage) -> Tokens { } } -/// Rebuild `toolCalls[]` by zipping `Turn.tool_uses` with -/// `extra["gemini"]["tool_call_meta"]`. Missing meta entries fall back -/// to a minimal `ToolCall` with `status` derived from `result.is_error`. -fn build_tool_calls(turn: &Turn, gemini_extras: &Map) -> Option> { +/// Rebuild `toolCalls[]` from `Turn.tool_uses`. +/// +/// The IR carries no provider-namespaced extras, so the source's +/// original per-call `tool_call_meta` (status/description/display_name/ +/// result_display) can't be recovered; every call gets a synthesized +/// `ToolCall` instead, with `status` derived from `result.is_error`. +fn build_tool_calls(turn: &Turn) -> Option> { if turn.tool_uses.is_empty() { return None; } - let meta_by_id: HashMap = gemini_extras - .get("tool_call_meta") - .and_then(Value::as_array) - .map(|arr| { - arr.iter() - .filter_map(|v| { - let id = v.get("id")?.as_str()?.to_string(); - Some((id, v)) - }) - .collect() - }) - .unwrap_or_default(); - let calls: Vec = turn .tool_uses .iter() - .map(|tu| { - tool_invocation_to_tool_call(tu, meta_by_id.get(&tu.id).copied(), &turn.timestamp) - }) + .map(|tu| tool_invocation_to_tool_call(tu, &turn.timestamp)) .collect(); Some(calls) } -fn tool_invocation_to_tool_call( - tu: &ToolInvocation, - meta: Option<&Value>, - fallback_timestamp: &str, -) -> ToolCall { - let meta_obj = meta.and_then(Value::as_object); - +fn tool_invocation_to_tool_call(tu: &ToolInvocation, fallback_timestamp: &str) -> ToolCall { // Pick the output tool name. If the source tool is already a known // Gemini tool (e.g. for Gemini→Path→Gemini round-trips), keep the // source name verbatim. Otherwise — when projecting from a foreign @@ -348,29 +296,17 @@ fn tool_invocation_to_tool_call( tu.name.clone() }; - let status = meta_obj - .and_then(|m| m.get("status").and_then(Value::as_str)) - .map(str::to_string) - .unwrap_or_else(|| match &tu.result { - Some(r) if r.is_error => "error".to_string(), - Some(_) => "success".to_string(), - None => "pending".to_string(), - }); + let status = match &tu.result { + Some(r) if r.is_error => "error".to_string(), + Some(_) => "success".to_string(), + None => "pending".to_string(), + }; - let description = meta_obj - .and_then(|m| m.get("description").and_then(Value::as_str)) - .map(str::to_string) - .or_else(|| synthesize_description(&name, &tu.input)); + let description = synthesize_description(&name, &tu.input); - let display_name = meta_obj - .and_then(|m| m.get("display_name").and_then(Value::as_str)) - .map(str::to_string) - .or_else(|| synthesize_display_name(&name, tu.category)); + let display_name = synthesize_display_name(&name, tu.category); - let result_display = meta_obj - .and_then(|m| m.get("result_display")) - .and_then(|v| if v.is_null() { None } else { Some(v.clone()) }) - .or_else(|| synthesize_result_display(tu.result.as_ref())); + let result_display = synthesize_result_display(tu.result.as_ref()); let result = tu .result diff --git a/crates/toolpath-gemini/src/provider.rs b/crates/toolpath-gemini/src/provider.rs index f39da516..f1c6aa5a 100644 --- a/crates/toolpath-gemini/src/provider.rs +++ b/crates/toolpath-gemini/src/provider.rs @@ -154,8 +154,9 @@ fn message_to_turn(msg: &GeminiMessage, working_dir: Option<&str>) -> Turn { /// absent the map stays empty and is omitted from serialization. /// /// `tool` is prompt-side (tool-result tokens billed separately) and -/// `total` is a Gemini-side sum; neither is folded here — both remain -/// available raw via `Turn.extra["gemini"]["tokens"]`. +/// `total` is a Gemini-side sum; neither is folded here, and the IR +/// has no provider-namespaced extras field to preserve them in, so +/// both are simply dropped. fn tokens_to_usage(t: &Tokens) -> TokenUsage { let output = t.output.unwrap_or(0); let thoughts = t.thoughts.unwrap_or(0); diff --git a/crates/toolpath-opencode/Cargo.toml b/crates/toolpath-opencode/Cargo.toml index 82e90bd4..af337941 100644 --- a/crates/toolpath-opencode/Cargo.toml +++ b/crates/toolpath-opencode/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-opencode" -version = "0.5.0" +version = "0.5.2" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-opencode/README.md b/crates/toolpath-opencode/README.md index d8357d78..2d87defe 100644 --- a/crates/toolpath-opencode/README.md +++ b/crates/toolpath-opencode/README.md @@ -19,9 +19,12 @@ git repositories. - **Provider**: implements [`toolpath_convo::ConversationProvider`](https://docs.rs/toolpath-convo), pairing `tool` parts by `callID`, folding `reasoning` parts onto - `Turn.thinking`, and capturing `step-start` / `step-finish` - snapshot SHAs on `Turn.extra["opencode"]` for file-artifact - reconstruction. + `Turn.thinking`, and computing `Turn.file_mutations` inline from the + ordered `step-start` / `step-finish` / `snapshot` snapshot SHAs + (tree↔tree diffs against the sibling bare git repo) for file-artifact + reconstruction. Snapshot SHAs also ride `ConversationView.events` as + `part.snapshot` events, so the projector can restore them onto a + re-projected session's step parts after a full Path round-trip. - **Derivation**: produces `toolpath::v1::Path` documents. When the matching snapshot git repo is still on disk, file changes per turn surface as sibling artifacts with a real `git diff` as the @@ -40,8 +43,9 @@ git repositories. | `text` part | appended to `Turn.text` | | `tool` part (state: completed) | `Turn.tool_uses[]` with `input` + `result` | | `tool` part (state: error) | same, `result.is_error = true` | -| `step-start` / `step-finish` snapshot SHA | sibling file artifacts on the turn — unified diff as `raw` | -| `compaction` / `retry` / `patch` / unknown parts | `ConversationEvent` | +| `step-start` / `step-finish` snapshot SHA | sibling file artifacts on the turn — unified diff as `raw`; the SHA itself also rides a `part.snapshot` `ConversationEvent` for round-trip restore | +| User `message.model.modelID` | `Turn.model` (bare modelID; `providerID` is dropped) | +| `compaction` / `retry` / `file` / `agent` / unknown parts | `ConversationEvent` | ## Usage diff --git a/crates/toolpath-opencode/src/project.rs b/crates/toolpath-opencode/src/project.rs index 0f34ae45..b73da7b7 100644 --- a/crates/toolpath-opencode/src/project.rs +++ b/crates/toolpath-opencode/src/project.rs @@ -159,6 +159,8 @@ fn project_view( .clone() .unwrap_or_else(|| DEFAULT_MODEL_ID.to_string()); + let snaps_by_turn = snapshots_by_turn(view); + for turn in &view.turns { match turn.role { Role::User => { @@ -186,6 +188,7 @@ fn project_view( &agent, &default_provider, &default_model, + snaps_by_turn.get(&turn.id).map(Vec::as_slice), ); prev_msg_id = Some(msg.id.clone()); messages.push(msg); @@ -220,6 +223,55 @@ fn project_view( }) } +/// Group `part.snapshot` events by owning turn: `turn id → [(sha, part +/// kind)]` in event order. +/// +/// The forward path chains a message's snapshot events (first parents to +/// the message, subsequent ones to the previous snapshot event) to avoid +/// intra-message fan-out (not to make the whole Path linear — opencode +/// user turns are parentless roots, so the Path is already a forest). The +/// Path round-trip may also re-anchor a chained event onto another event +/// of the same message, so attribution walks parent links: an event +/// parented to a turn belongs to that turn; an event parented to an +/// already-attributed event inherits its turn. +fn snapshots_by_turn(view: &ConversationView) -> HashMap> { + let turn_ids: std::collections::HashSet<&str> = + view.turns.iter().map(|t| t.id.as_str()).collect(); + let mut event_turn: HashMap<&str, String> = HashMap::new(); + let mut by_turn: HashMap> = HashMap::new(); + for event in &view.events { + let attributed: Option = event.parent_id.as_deref().and_then(|pid| { + if turn_ids.contains(pid) { + Some(pid.to_string()) + } else { + event_turn.get(pid).cloned() + } + }); + if let Some(turn_id) = &attributed { + event_turn.insert(event.id.as_str(), turn_id.clone()); + } + if event.event_type != "part.snapshot" { + continue; + } + if let (Some(turn_id), Some(sha)) = ( + attributed, + event.data.get("snapshot").and_then(|v| v.as_str()), + ) { + let kind = event + .data + .get("part") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + by_turn + .entry(turn_id) + .or_default() + .push((sha.to_string(), kind)); + } + } + by_turn +} + fn build_user_message( turn: &Turn, session_id: &str, @@ -232,16 +284,17 @@ fn build_user_message( let msg_id = mint_message_id(session_id, *counter); let time_created = parse_timestamp_ms(&turn.timestamp).unwrap_or(0); - let opencode_extras = opencode_extras(turn); - let model = opencode_extras - .as_ref() - .and_then(|m| m.get("model")) - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - .unwrap_or_else(|| ModelRef { - provider_id: default_provider.to_string(), - model_id: default_model.to_string(), - variant: None, - }); + // Mirror the assistant convention: the wire `model.modelID` rides + // `Turn.model` (bare modelID). The providerID has no IR home, so it + // falls back to the configured default. + let model = ModelRef { + provider_id: default_provider.to_string(), + model_id: turn + .model + .clone() + .unwrap_or_else(|| default_model.to_string()), + variant: None, + }; let user = UserMessage { time: MessageTime { @@ -302,28 +355,19 @@ fn build_assistant_message( agent: &str, default_provider: &str, default_model: &str, + snapshots: Option<&[(String, String)]>, ) -> Message { *counter += 1; let msg_id = mint_message_id(session_id, *counter); let time_created = parse_timestamp_ms(&turn.timestamp).unwrap_or(0); - let extras = opencode_extras(turn); - let provider_id = extras - .as_ref() - .and_then(|m| m.get("providerID")) - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| default_provider.to_string()); + // The IR carries no provider-namespaced extras, so `providerID` can't + // be recovered on a round-trip; fall back to the configured default. + // `turn.model` covers `modelID` when the source reported one. + let provider_id = default_provider.to_string(); let model_id = turn .model .clone() - .or_else(|| { - extras - .as_ref() - .and_then(|m| m.get("modelID")) - .and_then(Value::as_str) - .map(str::to_string) - }) .unwrap_or_else(|| default_model.to_string()); let tokens = turn @@ -373,13 +417,22 @@ fn build_assistant_message( }; let mut parts: Vec = Vec::new(); - let snapshot = extras - .as_ref() - .and_then(|m| m.get("snapshots")) - .and_then(Value::as_array) - .and_then(|a| a.first()) - .and_then(Value::as_str) - .map(str::to_string); + // Snapshot SHAs ride `view.events` as `part.snapshot` events (see + // `snapshots_by_turn`); restore the step-start SHA onto the opening + // StepStart and the step-finish SHA onto the closing StepFinish, + // falling back to first/last when part kinds weren't recorded. + let snapshot_start: Option = snapshots.and_then(|s| { + s.iter() + .find(|(_, kind)| kind == "step-start") + .or_else(|| s.first()) + .map(|(sha, _)| sha.clone()) + }); + let snapshot_finish: Option = snapshots.and_then(|s| { + s.iter() + .rfind(|(_, kind)| kind == "step-finish") + .or_else(|| s.last()) + .map(|(sha, _)| sha.clone()) + }); *counter += 1; parts.push(Part { @@ -389,7 +442,7 @@ fn build_assistant_message( time_created, time_updated: time_created, data: PartData::StepStart(StepStartPart { - snapshot: snapshot.clone(), + snapshot: snapshot_start, extra: HashMap::new(), }), }); @@ -463,7 +516,7 @@ fn build_assistant_message( .stop_reason .clone() .unwrap_or_else(|| "stop".to_string()), - snapshot, + snapshot: snapshot_finish, cost: 0.0, tokens, extra: HashMap::new(), @@ -668,10 +721,6 @@ fn synthesize_edit_diff(input: &Value) -> Option { Some(out) } -fn opencode_extras(_turn: &Turn) -> Option<&'static Map> { - None -} - fn mint_session_id(seed: &str) -> String { format!("ses_{}", stable_hex24(seed)) } @@ -954,4 +1003,92 @@ mod tests { _ => panic!("expected assistant"), } } + + #[test] + fn user_message_model_id_comes_from_turn_model() { + let mut turn = user_turn("hello"); + turn.model = Some("big-pickle".into()); + let s = OpencodeProjector::default() + .project(&view_with(vec![turn])) + .unwrap(); + match &s.messages[0].data { + MessageData::User(u) => assert_eq!(u.model.model_id, "big-pickle"), + _ => panic!("expected user"), + } + } + + #[test] + fn user_message_model_id_falls_back_to_default_when_turn_has_none() { + let s = OpencodeProjector::default() + .project(&view_with(vec![user_turn("hello")])) + .unwrap(); + match &s.messages[0].data { + MessageData::User(u) => assert_eq!(u.model.model_id, DEFAULT_MODEL_ID), + _ => panic!("expected user"), + } + } + + fn snapshot_event( + id: &str, + parent: &str, + sha: &str, + part_kind: &str, + ) -> toolpath_convo::ConversationEvent { + toolpath_convo::ConversationEvent { + id: id.into(), + timestamp: "2026-04-21T12:00:02.000Z".into(), + parent_id: Some(parent.into()), + event_type: "part.snapshot".into(), + data: [ + ("snapshot".to_string(), json!(sha)), + ("part".to_string(), json!(part_kind)), + ] + .into_iter() + .collect(), + } + } + + #[test] + fn snapshot_events_restore_step_start_and_finish_snapshots() { + let mut view = view_with(vec![user_turn("hi"), assistant_turn("ok")]); + view.events.push(snapshot_event("snapshot-p2", "a1", "snap_a", "step-start")); + // Chained: parent is the previous snapshot event, not the turn. + view.events + .push(snapshot_event("snapshot-p7", "snapshot-p2", "snap_b", "step-finish")); + let s = OpencodeProjector::default().project(&view).unwrap(); + let assistant = &s.messages[1]; + let start = assistant + .parts + .iter() + .find_map(|p| match &p.data { + PartData::StepStart(ss) => Some(ss.snapshot.clone()), + _ => None, + }) + .expect("step-start part"); + let finish = assistant + .parts + .iter() + .find_map(|p| match &p.data { + PartData::StepFinish(sf) => Some(sf.snapshot.clone()), + _ => None, + }) + .expect("step-finish part"); + assert_eq!(start.as_deref(), Some("snap_a")); + assert_eq!(finish.as_deref(), Some("snap_b")); + } + + #[test] + fn without_snapshot_events_step_parts_have_no_snapshot() { + let s = OpencodeProjector::default() + .project(&view_with(vec![assistant_turn("ok")])) + .unwrap(); + let assistant = &s.messages[0]; + for p in &assistant.parts { + match &p.data { + PartData::StepStart(ss) => assert!(ss.snapshot.is_none()), + PartData::StepFinish(sf) => assert!(sf.snapshot.is_none()), + _ => {} + } + } + } } diff --git a/crates/toolpath-opencode/src/provider.rs b/crates/toolpath-opencode/src/provider.rs index 32158689..1e6bded0 100644 --- a/crates/toolpath-opencode/src/provider.rs +++ b/crates/toolpath-opencode/src/provider.rs @@ -7,7 +7,10 @@ //! cross-part assembly: //! //! 1. For each user [`Message`], emit a [`Turn`] with `role: User` -//! whose `text` is the concatenation of the message's text parts. +//! whose `text` is the concatenation of the message's text parts and +//! whose `model` is the wire `model.modelID` (same bare-modelID +//! convention as assistant turns; the `providerID` has no IR home +//! and is dropped). //! 2. For each assistant [`Message`], emit a [`Turn`] with //! `role: Assistant`: //! - `text` ← concatenation of its `text` parts. @@ -16,13 +19,17 @@ //! - `token_usage` ← summed across all `step-finish` parts (each //! is a per-step delta). Falls back to the message-level //! `tokens` field if no step-finish parts exist. -//! - `extra["opencode"]["snapshots"]` ← ordered list of snapshot -//! SHAs from `step-start`/`step-finish`/`snapshot` parts, used -//! by the derive layer to fetch file diffs. -//! - `extra["opencode"]["patches"]` ← any `patch` parts (their -//! `{hash, files}` records). +//! - `file_mutations` ← computed inline from the ordered snapshot +//! SHAs seen across `step-start`/`step-finish`/`snapshot` parts +//! (tree↔tree diffs against the sibling bare git repo) plus any +//! `patch` parts' `{hash, files}` records feeding the seen-files +//! set. //! 3. Non-turn parts land in `ConversationView.events`: -//! `compaction`, `retry`, unknown types. +//! `compaction`, `retry`, `file`, `agent`, unknown types — and every +//! snapshot SHA as a `part.snapshot` event, which the projector +//! restores onto the emitted `step-start`/`step-finish` parts so +//! SHAs survive a full Path round-trip (a re-imported projection +//! can regenerate diffs). //! 4. `subtask` parts are captured on the turn's `delegations` //! (empty-turn list — the sub-agent's own session lives under //! its own id, linked by `session.parent_id`). @@ -178,6 +185,10 @@ struct Builder<'a> { /// `before` of the next turn's snapshot pair so intermediate state /// captures correctly. prev_snapshot_after: Option, + /// `(message id, event id)` of the most recent `part.snapshot` event, + /// so a message's snapshot events chain off each other rather than + /// fanning out from the message (see [`Self::push_snapshot_event`]). + last_snapshot_event: Option<(String, String)>, } impl<'a> Builder<'a> { @@ -192,6 +203,7 @@ impl<'a> Builder<'a> { total_usage_set: false, snapshot_repo: None, prev_snapshot_after: None, + last_snapshot_event: None, } } @@ -275,7 +287,7 @@ impl<'a> Builder<'a> { } } - fn handle_user_message(&mut self, msg: &Message, _u: &UserMessage) { + fn handle_user_message(&mut self, msg: &Message, u: &UserMessage) { let text = concat_text_parts(&msg.parts); let environment = Some(EnvironmentSnapshot { working_dir: Some(self.session.directory.to_string_lossy().to_string()), @@ -292,7 +304,12 @@ impl<'a> Builder<'a> { text, thinking: None, tool_uses: Vec::new(), - model: None, + // Same convention as assistant turns: bare modelID, empty → None. + model: if u.model.model_id.is_empty() { + None + } else { + Some(u.model.model_id.clone()) + }, stop_reason: None, token_usage: None, attributed_token_usage: None, @@ -332,17 +349,19 @@ impl<'a> Builder<'a> { )); } PartData::StepStart(s) => { - if let Some(sh) = &s.snapshot - && snapshots.last().is_none_or(|l| l != sh) - { - snapshots.push(sh.clone()); + if let Some(sh) = &s.snapshot { + if snapshots.last().is_none_or(|l| l != sh) { + snapshots.push(sh.clone()); + } + self.push_snapshot_event(p, msg, sh, "step-start"); } } PartData::StepFinish(sf) => { - if let Some(sh) = &sf.snapshot - && snapshots.last().is_none_or(|l| l != sh) - { - snapshots.push(sh.clone()); + if let Some(sh) = &sf.snapshot { + if snapshots.last().is_none_or(|l| l != sh) { + snapshots.push(sh.clone()); + } + self.push_snapshot_event(p, msg, sh, "step-finish"); } accumulate_tokens(&mut step_usage, &sf.tokens); step_usage_set = true; @@ -352,6 +371,7 @@ impl<'a> Builder<'a> { if snapshots.last().is_none_or(|l| l != &s.snapshot) { snapshots.push(s.snapshot.clone()); } + self.push_snapshot_event(p, msg, &s.snapshot, "snapshot"); } PartData::Patch(pp) => { for f in &pp.files { @@ -479,6 +499,41 @@ impl<'a> Builder<'a> { }); } + /// Record a snapshot SHA as a `part.snapshot` event. Snapshot SHAs + /// are how opencode reconstructs file diffs (tree↔tree against the + /// sibling bare git repo); riding `view.events` lets them survive + /// the Path round-trip so the projector can restore them onto the + /// emitted step parts. + /// + /// The first snapshot event of a message parents to the message id; + /// each subsequent one chains onto the previous snapshot event, so a + /// message's snapshot events avoid intra-message fan-out (they form a + /// chain off the message rather than a star). This does not make the + /// whole Path linear — opencode user messages have no `parentID`, so + /// every user turn is already a root and a multi-exchange session is a + /// forest with dead-end steps independent of snapshots. The projector + /// re-attributes chained events to their turn by walking parent links. + fn push_snapshot_event(&mut self, p: &Part, msg: &Message, sha: &str, part_kind: &str) { + let id = format!("snapshot-{}", p.id); + let parent = self + .last_snapshot_event + .take() + .filter(|(m, _)| m == &msg.id) + .map(|(_, prev)| prev) + .unwrap_or_else(|| msg.id.clone()); + self.last_snapshot_event = Some((msg.id.clone(), id.clone())); + self.events.push(ConversationEvent { + id, + timestamp: millis_to_iso(p.time_created), + parent_id: Some(parent), + event_type: "part.snapshot".into(), + data: HashMap::from([ + ("snapshot".to_string(), Value::String(sha.to_string())), + ("part".to_string(), Value::String(part_kind.to_string())), + ]), + }); + } + fn compute_turn_mutations( &mut self, snapshots: &[String], @@ -1152,4 +1207,61 @@ mod tests { let v = ConversationProvider::load_conversation(&mgr, "", "ses_x").unwrap(); assert_eq!(v.turns.len(), 2); } + + #[test] + fn user_turn_carries_model_id() { + // The user message wire carries `model.modelID`; mirror the + // assistant convention: bare modelID on `Turn.model`, empty → None. + let (_t, mgr) = setup(BASIC_SQL); + let view = to_view(&mgr.read_session("ses_x").unwrap()); + assert_eq!(view.turns[0].model.as_deref(), Some("big-pickle")); + } + + #[test] + fn snapshot_shas_become_events() { + // step-start / step-finish / snapshot SHAs ride `view.events` + // (event_type "part.snapshot") so a re-imported projection can + // regenerate diffs. The first snapshot event of a message parents + // to the message; subsequent ones chain onto the previous + // snapshot event, so a message's snapshot events avoid + // intra-message fan-out (the Path is a forest regardless, since + // opencode user turns are parentless roots). + let (_t, mgr) = setup(BASIC_SQL); + let view = to_view(&mgr.read_session("ses_x").unwrap()); + let snaps: Vec<(String, String, Option)> = view + .events + .iter() + .filter(|e| e.event_type == "part.snapshot") + .map(|e| { + ( + e.data + .get("snapshot") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + e.data + .get("part") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + e.parent_id.clone(), + ) + }) + .collect(); + assert_eq!( + snaps, + vec![ + ( + "snap_a".to_string(), + "step-start".to_string(), + Some("m2".to_string()) + ), + ( + "snap_b".to_string(), + "step-finish".to_string(), + Some("snapshot-p2".to_string()) + ), + ] + ); + } } diff --git a/crates/toolpath-opencode/tests/compaction_roundtrip.rs b/crates/toolpath-opencode/tests/compaction_roundtrip.rs index e3a046ad..247c75ab 100644 --- a/crates/toolpath-opencode/tests/compaction_roundtrip.rs +++ b/crates/toolpath-opencode/tests/compaction_roundtrip.rs @@ -16,15 +16,18 @@ //! the IR derive/extract round-trip and the projector emits a //! functionally equivalent `Session`. //! -//! Known limitation (documented, not asserted as fully preserved): the -//! `ConversationEvent` carrying the compaction metadata does not -//! survive the `derive → extract` round-trip today — `derive_path` does -//! not emit `conversation.event` steps for `view.events`, and the -//! opencode projector does not consume `view.events`. The compaction -//! marker is purely structural metadata (the surrounding messages -//! carry the actual content), so for "good UX" today this is an -//! acceptable loss; if/when we close the gap, this test gets -//! tightened. +//! The `ConversationEvent` carrying the compaction metadata DOES +//! survive the `derive → extract` round-trip — `derive_path` emits a +//! `conversation.event` step per `view.events` entry and +//! `extract_conversation` restores it (asserted below). +//! +//! Known limitation (documented, not asserted): the opencode projector +//! consumes `view.events` only for `part.snapshot` restoration; it does +//! not re-materialize a `compaction` part into the projected `Session`. +//! The compaction marker is purely structural metadata (the surrounding +//! messages carry the actual content), so for "good UX" today this is +//! an acceptable loss; if/when we close the gap, this test gets +//! tightened further. use std::fs; @@ -150,6 +153,19 @@ fn to_view_surfaces_compaction_as_event() { ); } +#[test] +fn compaction_event_survives_derive_extract_roundtrip() { + let (_temp, session) = setup_session(); + let view = to_view(&session); + let after = ir_roundtrip(&view); + let event = after + .events + .iter() + .find(|e| e.event_type == "part.compaction") + .expect("part.compaction event should survive derive → extract"); + assert_eq!(event.id, "compaction-prt_a1_3"); +} + #[test] fn pre_compact_user_turn_survives_roundtrip() { let (_temp, session) = setup_session(); diff --git a/crates/toolpath-opencode/tests/projection_roundtrip.rs b/crates/toolpath-opencode/tests/projection_roundtrip.rs index 2f28e763..f6f43c6f 100644 --- a/crates/toolpath-opencode/tests/projection_roundtrip.rs +++ b/crates/toolpath-opencode/tests/projection_roundtrip.rs @@ -254,3 +254,72 @@ fn assistant_thinking_emits_reasoning_part() { let rb = count_reasoning(&rebuilt); assert!(rb >= src.min(1), "reasoning lost: src={}, rb={}", src, rb); } + +#[test] +fn snapshot_shas_survive_full_roundtrip() { + // step-start/step-finish snapshot SHAs ride `view.events` as + // `part.snapshot` events, survive the Path round-trip as + // `conversation.event` steps, and get restored onto the projected + // step parts — so a re-imported projection can regenerate diffs. + let (_t, source) = setup_session(); + let (_, rebuilt, _) = roundtrip(&source); + + // First assistant message: had snap_a (step-start) and snap_b + // (step-finish) in the source. + let assistants: Vec<_> = rebuilt + .messages + .iter() + .filter(|m| matches!(m.data, MessageData::Assistant(_))) + .collect(); + assert_eq!(assistants.len(), 2); + + let step_snapshots = |m: &toolpath_opencode::types::Message| { + let start = m.parts.iter().find_map(|p| match &p.data { + PartData::StepStart(ss) => Some(ss.snapshot.clone()), + _ => None, + }); + let finish = m.parts.iter().find_map(|p| match &p.data { + PartData::StepFinish(sf) => Some(sf.snapshot.clone()), + _ => None, + }); + (start.flatten(), finish.flatten()) + }; + + let (a1_start, a1_finish) = step_snapshots(assistants[0]); + assert_eq!(a1_start.as_deref(), Some("snap_a")); + assert_eq!(a1_finish.as_deref(), Some("snap_b")); + + // Second assistant message had no snapshots; none must be invented. + let (a2_start, a2_finish) = step_snapshots(assistants[1]); + assert_eq!(a2_start, None); + assert_eq!(a2_finish, None); +} + +#[test] +fn user_model_id_survives_view_level_roundtrip() { + // The user message's `model.modelID` rides `Turn.model` (same + // convention as assistant turns). The view-level chain preserves it. + // + // Known limitation: it does NOT survive the Path layer — the shared + // derive attributes model via the step actor (`agent:{model}`), and + // user steps carry `human:user`, so `extract_conversation` cannot + // recover a user turn's model. Closing that would need a + // toolpath-convo change, out of scope here. + let (_t, source) = setup_session(); + let view = to_view(&source); + assert_eq!( + view.turns[0].model.as_deref(), + Some("big-pickle"), + "forward path should lift the user model onto Turn.model" + ); + + let projector = OpencodeProjector::new() + .with_directory(source.directory.clone()) + .with_project_id(source.project_id.clone()) + .with_version(source.version.clone()); + let rebuilt = projector.project(&view).expect("project"); + match &rebuilt.messages[0].data { + MessageData::User(u) => assert_eq!(u.model.model_id, "big-pickle"), + _ => panic!("expected user message first"), + } +} diff --git a/crates/toolpath-pi/Cargo.toml b/crates/toolpath-pi/Cargo.toml index 8ce22347..89ab3c48 100644 --- a/crates/toolpath-pi/Cargo.toml +++ b/crates/toolpath-pi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-pi" -version = "0.6.0" +version = "0.6.2" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-pi/src/project.rs b/crates/toolpath-pi/src/project.rs index a82b15d1..d91cd35c 100644 --- a/crates/toolpath-pi/src/project.rs +++ b/crates/toolpath-pi/src/project.rs @@ -6,21 +6,36 @@ //! view, `PiProjector` serializes that view back into Pi's on-disk //! shape (a [`SessionHeader`] plus a list of [`Entry`]). //! -//! The projector consumes provider-specific data the forward path -//! stashed under `Turn.extra["pi"]` — `api`/`provider`, `stopReason`, -//! `toolCallId`, bash-execution metadata, custom-message markers, and -//! synthetic-turn structures (`compaction`, `branchSummary`, `custom`, -//! `customMessage`). For `ConversationView`s from non-Pi sources, the -//! projector synthesizes sensible defaults (api: "anthropic", -//! stop_reason: "stop", etc.). +//! Pi's metadata-only entries (`ModelChange` / `ThinkingLevelChange` / +//! `Label`) ride `ConversationView.events` and are re-materialized here +//! as real Pi entries with their original ids and parentIds (see the +//! private `insert_event_entries` helper). //! -//! Foreign-namespace extras (`Turn.extra["claude"]`, -//! `Turn.extra["gemini"]`, …) are dropped — they have no meaning in -//! Pi's format and would pollute the JSONL. +//! One fidelity edge: the metadata entries' own ids/parentIds survive, +//! but a *message* entry whose source `parentId` pointed at a metadata +//! entry (e.g. `a1.parentID = "mc-1"`) loses that one parent edge on a +//! Pi→View→Path→View→Pi round-trip. `derive_path` resolves a turn's +//! parent only against other turns, not against event steps, so a turn +//! parented to an eventified entry becomes a root and its re-emitted +//! entry drops the `parentId`. Pi's reader is id/order-tolerant so +//! nothing breaks; closing the gap would need `derive_path` to resolve +//! turn parents against event step ids too (a future toolpath-convo +//! change). +//! +//! Everything else Pi-specific that the forward path didn't lift into a +//! typed `Turn` field cannot be recovered on a Pi→View→Pi round-trip: +//! `api`/`provider`, the structured `stopReason`, bash-execution +//! metadata (command/exit code/etc. beyond what's folded into +//! `Turn.text` and the synthetic `bash` tool call), +//! `SessionHeader.parent_session`, and the synthetic-turn markers +//! (`compaction`, `branchSummary`, `custom`, `customMessage`) — those +//! turns are indistinguishable from ordinary ones once round-tripped, +//! so the projector always synthesizes sensible defaults (api: +//! "anthropic", stop_reason: "stop", etc.) instead. use std::collections::HashMap; -use serde_json::{Map, Value, json}; +use serde_json::json; use toolpath_convo::{ ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn, }; @@ -61,11 +76,13 @@ pub struct PiProjector { /// pulls it from the first turn's environment (or falls back to /// `"/"` if absent). pub cwd: Option, - /// Default `api` field for assistant turns when not present in - /// `Turn.extra["pi"]["api"]`. Defaults to `"anthropic"`. + /// Default `api` field for assistant turns — the IR has no field + /// to recover the source's original value from. Defaults to + /// `"anthropic"`. pub default_api: Option, - /// Default `provider` field for assistant turns when not present - /// in `Turn.extra["pi"]["api"]`. Defaults to `"anthropic"`. + /// Default `provider` field for assistant turns — the IR has no + /// field to recover the source's original value from. Defaults to + /// `"anthropic"`. pub default_provider: Option, } @@ -120,15 +137,10 @@ fn project_view( .or_else(|| view.turns.first().map(|t| t.timestamp.clone())) .unwrap_or_default(); - // Pi's session header optionally carries `parentSession` — the - // forward path stashed it on the first turn's extras. Round-trip - // it when present. - let parent_session = view - .turns - .first() - .and_then(|t| pi_extras(t)) - .and_then(|pi| pi.get("parentSession").and_then(Value::as_str)) - .map(str::to_string); + // Pi's session header optionally carries `parentSession`, but the + // IR has no field to preserve it in, so a Pi→View→Pi round-trip + // can't recover it. + let parent_session = None; let header = SessionHeader { version: 3, @@ -142,32 +154,24 @@ fn project_view( let mut entries: Vec = Vec::new(); entries.push(Entry::Session(header.clone())); - // Pre-pass: collect tool_call_ids that are *already* covered by a - // dedicated `Role::Other("tool")` turn elsewhere in the view. For - // those, we must NOT synthesize an extra tool-result entry from - // the owning assistant's `tool_uses[i].result` — otherwise we'd - // emit two `toolResult` entries for the same call (which is what - // a Pi → View → Pi round-trip would produce, since forward path - // both populates `tool_uses[i].result` AND keeps the original - // tool-result message as a separate turn). - let covered: std::collections::HashSet = view - .turns - .iter() - .filter(|t| matches!(t.role, Role::Other(ref s) if s == "tool")) - .filter_map(|t| { - pi_extras(t) - .and_then(|m| m.get("toolCallId")) - .and_then(Value::as_str) - .map(str::to_string) - }) - .collect(); - + // A dedicated `Role::Other("tool")` turn elsewhere in the view (as + // some non-Pi providers emit) can represent the same call as an + // assistant's `tool_uses[i].result`; without a preserved call id to + // correlate them, we can no longer tell whether one covers the + // other, so every tool use with a result gets its own synthesized + // `toolResult` entry. for turn in &view.turns { - let pi = pi_extras(turn).cloned().unwrap_or_default(); - emit_pending_meta(&mut entries, turn, &pi); - emit_turn_entries(cfg, turn, &pi, &covered, &mut entries); + emit_turn_entries(cfg, turn, &mut entries); } + // Re-materialize Pi metadata entries the forward path routed into + // `view.events` (`model_change` / `thinking_level_change` / `label`). + // Original ids and parentIds are preserved, so Pi's id/parentId tree + // is faithful regardless of file position; for sane reading order, + // insert each entry right after its parent (falling back to the tail + // when the parent isn't in this session). + insert_event_entries(&mut entries, &view.events); + Ok(PiSession { header, entries, @@ -176,97 +180,93 @@ fn project_view( }) } -/// Used to return `Turn.extra["pi"]`; the IR no longer carries -/// provider-namespaced extras. Always `None`. Callers fall back to -/// reconstructing source-format details from typed IR fields and -/// reasonable defaults. -fn pi_extras(_turn: &Turn) -> Option<&'static Map> { - None -} - -/// Emit `ModelChange` / `ThinkingLevelChange` / `Label` entries that the -/// forward path buffered into the next turn's `extra["pi"]`. -fn emit_pending_meta(entries: &mut Vec, turn: &Turn, pi: &Map) { - if let Some(mc) = pi.get("modelChange").and_then(Value::as_object) { - let id = mc - .get("id") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| format!("{}-mc", turn.id)); - let timestamp = mc - .get("timestamp") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| turn.timestamp.clone()); - let provider = mc - .get("provider") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let model_id = mc - .get("modelId") - .and_then(Value::as_str) +/// Map a `ConversationEvent` back to the Pi entry it was derived from. +/// Returns `None` for event types with no Pi analog (foreign-provider +/// events like Claude attachments), which are dropped rather than +/// emitted as garbage entries. +fn event_to_entry(event: &toolpath_convo::ConversationEvent) -> Option { + let base = EntryBase { + id: event.id.clone(), + parent_id: event.parent_id.clone(), + timestamp: event.timestamp.clone(), + }; + let str_field = |key: &str| -> String { + event + .data + .get(key) + .and_then(|v| v.as_str()) .unwrap_or("") - .to_string(); - entries.push(Entry::ModelChange { - base: EntryBase { - id, - parent_id: None, - timestamp, - }, - provider, - model_id, - extra: extra_map_from(mc.get("rawExtra")), - }); + .to_string() + }; + let extra_without = |consumed: &[&str]| -> HashMap { + event + .data + .iter() + .filter(|(k, _)| !consumed.contains(&k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }; + match event.event_type.as_str() { + "model_change" => Some(Entry::ModelChange { + base, + provider: str_field("provider"), + model_id: str_field("modelId"), + extra: extra_without(&["provider", "modelId"]), + }), + "thinking_level_change" => Some(Entry::ThinkingLevelChange { + base, + thinking_level: str_field("thinkingLevel"), + extra: extra_without(&["thinkingLevel"]), + }), + "label" => Some(Entry::Label { + base, + extra: event.data.clone(), + }), + _ => None, } - if let Some(tlc) = pi.get("thinkingLevelChange").and_then(Value::as_object) { - let id = tlc - .get("id") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| format!("{}-tlc", turn.id)); - let timestamp = tlc - .get("timestamp") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| turn.timestamp.clone()); - let thinking_level = tlc - .get("thinkingLevel") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - entries.push(Entry::ThinkingLevelChange { - base: EntryBase { - id, - parent_id: None, - timestamp, - }, - thinking_level, - extra: extra_map_from(tlc.get("rawExtra")), +} + +/// Insert re-materialized event entries into `entries`, each directly +/// after the entry whose id matches its `parent_id`. Events whose parent +/// isn't present (or who have none) append at the end. Siblings keep +/// their `view.events` order: insertion skips past event entries already +/// placed after the same parent. +fn insert_event_entries(entries: &mut Vec, events: &[toolpath_convo::ConversationEvent]) { + let entry_id = |e: &Entry| -> Option { + match e { + Entry::Session(_) => None, + Entry::Message { base, .. } + | Entry::ModelChange { base, .. } + | Entry::ThinkingLevelChange { base, .. } + | Entry::Compaction { base, .. } + | Entry::BranchSummary { base, .. } + | Entry::Custom { base, .. } + | Entry::CustomMessage { base, .. } + | Entry::Label { base, .. } => Some(base.id.clone()), + } + }; + let mut inserted_ids: std::collections::HashSet = std::collections::HashSet::new(); + for event in events { + let Some(entry) = event_to_entry(event) else { + continue; + }; + inserted_ids.insert(event.id.clone()); + let parent_pos = event.parent_id.as_ref().and_then(|pid| { + entries + .iter() + .position(|e| entry_id(e).as_deref() == Some(pid.as_str())) }); - } - if let Some(labels) = pi.get("labels").and_then(Value::as_array) { - for (i, label) in labels.iter().enumerate() { - let lo = label.as_object(); - let id = lo - .and_then(|m| m.get("id")) - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| format!("{}-lbl-{}", turn.id, i)); - let timestamp = lo - .and_then(|m| m.get("timestamp")) - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| turn.timestamp.clone()); - let extra = extra_map_from(lo.and_then(|m| m.get("rawExtra"))); - entries.push(Entry::Label { - base: EntryBase { - id, - parent_id: None, - timestamp, - }, - extra, - }); + match parent_pos { + Some(pos) => { + let mut at = pos + 1; + while at < entries.len() + && entry_id(&entries[at]).is_some_and(|id| inserted_ids.contains(&id)) + { + at += 1; + } + entries.insert(at, entry); + } + None => entries.push(entry), } } } @@ -275,43 +275,26 @@ fn emit_pending_meta(entries: &mut Vec, turn: &Turn, pi: &Map, - covered_tool_ids: &std::collections::HashSet, - entries: &mut Vec, -) { - // Synthetic compaction / branch_summary / custom turns map to - // their own Entry variants rather than `Entry::Message`. - if let Some(comp) = pi.get("compaction").and_then(Value::as_object) { - emit_compaction(turn, comp, entries); - return; - } - if let Some(bs) = pi.get("branchSummary").and_then(Value::as_object) { - emit_branch_summary(turn, bs, entries); - return; - } - if let Some(c) = pi.get("custom").and_then(Value::as_object) { - emit_custom(turn, c, entries); - return; - } - if let Some(cm) = pi.get("customMessage").and_then(Value::as_object) { - emit_custom_message(turn, cm, entries); - return; - } - +/// +/// Metadata entries (`ModelChange`/`ThinkingLevelChange`/`Label`) never +/// reach this function — they ride `view.events` and are re-emitted by +/// [`insert_event_entries`]. The IR has no field to distinguish a +/// Pi-native `Entry::Compaction`/`BranchSummary`/`Custom`/ +/// `CustomMessage` from an ordinary turn once round-tripped through +/// `Turn`, so those entry types are never re-synthesized — they always +/// fall through to the generic role-based mapping below. +fn emit_turn_entries(cfg: &PiProjector, turn: &Turn, entries: &mut Vec) { match &turn.role { Role::User => emit_user(turn, entries), - Role::Assistant => emit_assistant(cfg, turn, pi, covered_tool_ids, entries), + Role::Assistant => emit_assistant(cfg, turn, entries), Role::System => { // System turns from non-Pi sources don't have a direct // analog; fold them into a custom-system message. emit_system_as_custom(turn, entries); } Role::Other(other) => match other.as_str() { - "tool" => emit_tool_result(turn, pi, entries), - "bash" => emit_bash_execution(turn, pi, entries), + "tool" => emit_tool_result(turn, entries), + "bash" => emit_bash_execution(turn, entries), o if o.starts_with("custom:") => { let custom_type = o.strip_prefix("custom:").unwrap_or(o).to_string(); emit_custom_role_message(turn, &custom_type, entries); @@ -339,13 +322,7 @@ fn emit_user(turn: &Turn, entries: &mut Vec) { }); } -fn emit_assistant( - cfg: &PiProjector, - turn: &Turn, - pi: &Map, - covered_tool_ids: &std::collections::HashSet, - entries: &mut Vec, -) { +fn emit_assistant(cfg: &PiProjector, turn: &Turn, entries: &mut Vec) { // Build the content blocks: optional thinking, then text, then // each tool call. Real Pi assistant turns interleave these in // arbitrary order, but for projection a thinking-then-text-then- @@ -374,32 +351,21 @@ fn emit_assistant( }); } - let api_obj = pi.get("api").and_then(Value::as_object); - let api = api_obj - .and_then(|m| m.get("api")) - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| { - cfg.default_api - .clone() - .unwrap_or_else(|| "anthropic".to_string()) - }); - let provider = api_obj - .and_then(|m| m.get("provider")) - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| { - cfg.default_provider - .clone() - .unwrap_or_else(|| "anthropic".to_string()) - }); + // The IR carries no provider-namespaced extras, so the source's + // original `api`/`provider`/`errorMessage` can't be recovered on a + // round-trip; fall back to the configured defaults. + let api = cfg + .default_api + .clone() + .unwrap_or_else(|| "anthropic".to_string()); + let provider = cfg + .default_provider + .clone() + .unwrap_or_else(|| "anthropic".to_string()); let model = turn.model.clone().unwrap_or_default(); let usage = build_usage(turn); - let stop_reason = parse_stop_reason(turn.stop_reason.as_deref(), pi.get("stopReason")); - let error_message = pi - .get("errorMessage") - .and_then(Value::as_str) - .map(str::to_string); + let stop_reason = parse_stop_reason(turn.stop_reason.as_deref()); + let error_message = None; let timestamp = ts_millis(&turn.timestamp); let assistant_id = turn.id.clone(); @@ -427,17 +393,11 @@ fn emit_assistant( // Each tool invocation with a result produces a separate // `toolResult` entry parented to the assistant entry, mirroring - // how Pi separates calls from results in the JSONL stream. Skip - // calls that are already covered by an explicit `Role::Other( - // "tool")` turn elsewhere in the view (Pi → View → Pi sources - // have both forms; emitting both would duplicate the result). + // how Pi separates calls from results in the JSONL stream. let mut prev_id = assistant_id; let mut suffix = 0usize; for tu in &turn.tool_uses { let Some(result) = &tu.result else { continue }; - if covered_tool_ids.contains(&tu.id) { - continue; - } suffix += 1; let tr_id = format!("{}-tr-{}", turn.id, suffix); let entry = Entry::Message { @@ -465,19 +425,14 @@ fn emit_assistant( } } -fn emit_tool_result(turn: &Turn, pi: &Map, entries: &mut Vec) { - let tool_call_id = pi - .get("toolCallId") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_default(); - let tool_name = pi - .get("toolName") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_default(); - let is_error = pi.get("isError").and_then(Value::as_bool).unwrap_or(false); - let details = pi.get("details").cloned(); +fn emit_tool_result(turn: &Turn, entries: &mut Vec) { + // The IR carries no provider-namespaced extras, so the original + // `toolCallId`/`toolName`/`isError`/`details` can't be recovered; + // only `turn.text` survives. + let tool_call_id = String::new(); + let tool_name = String::new(); + let is_error = false; + let details = None; let content = vec![ToolResultContent::Text { text: turn.text.clone(), extra: HashMap::new(), @@ -497,38 +452,18 @@ fn emit_tool_result(turn: &Turn, pi: &Map, entries: &mut Vec, entries: &mut Vec) { - let command = pi - .get("command") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_default(); - let exit_code = pi.get("exitCode").and_then(Value::as_i64); - let cancelled = pi - .get("cancelled") - .and_then(Value::as_bool) - .unwrap_or(false); - let truncated = pi - .get("truncated") - .and_then(Value::as_bool) - .unwrap_or(false); - let full_output_path = pi - .get("fullOutputPath") - .and_then(Value::as_str) - .map(str::to_string); - - // The forward path put `$ \n` in - // turn.text; if we can recover the output, we use it. Otherwise - // we strip the leading `$ \n` to get the output. - let output = if let Some(rest) = turn - .text - .strip_prefix(&format!("$ {}\n", command)) - .map(str::to_string) - { - rest.trim_end_matches("…(truncated)").to_string() - } else { - turn.text.clone() - }; +fn emit_bash_execution(turn: &Turn, entries: &mut Vec) { + // The IR carries no provider-namespaced extras, so the original + // `command`/`exitCode`/`cancelled`/`truncated`/`fullOutputPath` + // can't be recovered; `turn.text` (the forward path's `$ + // \n` rendering) is the only surviving + // record, and it's used verbatim as the output. + let command = String::new(); + let exit_code = None; + let cancelled = false; + let truncated = false; + let full_output_path = None; + let output = turn.text.clone(); entries.push(Entry::Message { base: base_for(turn), @@ -547,101 +482,6 @@ fn emit_bash_execution(turn: &Turn, pi: &Map, entries: &mut Vec, entries: &mut Vec) { - let summary = comp - .get("summary") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| { - // Fall back to extracting from the text the forward path - // wrote ("Compacted (summary): X"). - turn.text - .strip_prefix("Compacted (summary): ") - .unwrap_or(&turn.text) - .to_string() - }); - let first_kept_entry_id = comp - .get("firstKeptEntryId") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_default(); - let tokens_before = comp - .get("tokensBefore") - .and_then(Value::as_u64) - .unwrap_or(0); - let details = comp.get("details").cloned(); - let from_hook = comp.get("fromHook").and_then(Value::as_bool); - entries.push(Entry::Compaction { - base: base_for(turn), - summary, - first_kept_entry_id, - tokens_before, - details, - from_hook, - extra: HashMap::new(), - }); -} - -fn emit_branch_summary(turn: &Turn, bs: &Map, entries: &mut Vec) { - let from_id = bs - .get("fromId") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_default(); - let summary = turn - .text - .strip_prefix("Branch summary: ") - .unwrap_or(&turn.text) - .to_string(); - let details = bs.get("details").cloned(); - let from_hook = bs.get("fromHook").and_then(Value::as_bool); - entries.push(Entry::BranchSummary { - base: base_for(turn), - from_id, - summary, - details, - from_hook, - extra: HashMap::new(), - }); -} - -fn emit_custom(turn: &Turn, c: &Map, entries: &mut Vec) { - let custom_type = c - .get("customType") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| "custom".to_string()); - let data = c - .get("data") - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default(); - entries.push(Entry::Custom { - base: base_for(turn), - custom_type, - data, - extra: HashMap::new(), - }); -} - -fn emit_custom_message(turn: &Turn, cm: &Map, entries: &mut Vec) { - let custom_type = cm - .get("customType") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| "custom".to_string()); - let display = cm.get("display").and_then(Value::as_bool).unwrap_or(true); - let details = cm.get("details").cloned(); - let content = MessageContent::Text(turn.text.clone()); - entries.push(Entry::CustomMessage { - base: base_for(turn), - custom_type, - content, - display, - details, - extra: HashMap::new(), - }); -} - fn emit_custom_role_message(turn: &Turn, custom_type: &str, entries: &mut Vec) { let timestamp = ts_millis(&turn.timestamp); entries.push(Entry::Message { @@ -708,15 +548,11 @@ fn build_usage(turn: &Turn) -> Usage { } } -/// Resolve the assistant's `stopReason`. Prefer the structured -/// `pi.stopReason` (preserves any Pi-specific values verbatim); fall -/// back to `Turn.stop_reason` (a string), then to `Stop`. -fn parse_stop_reason(turn_stop: Option<&str>, pi_stop: Option<&Value>) -> StopReason { - if let Some(v) = pi_stop - && let Ok(sr) = serde_json::from_value::(v.clone()) - { - return sr; - } +/// Resolve the assistant's `stopReason` from `Turn.stop_reason` (a +/// string), defaulting to `Stop`. The IR carries no provider-namespaced +/// extras, so a structured Pi-specific stop reason can't be recovered +/// verbatim on a round-trip. +fn parse_stop_reason(turn_stop: Option<&str>) -> StopReason { let s = turn_stop.unwrap_or("stop"); serde_json::from_value::(json!(s)) .unwrap_or(StopReason::Known(KnownStopReason::Stop)) @@ -740,15 +576,6 @@ fn tool_native_name(tu: &ToolInvocation) -> String { tu.name.clone() } -/// Coerce a `Value` (expected to be a map) into Pi's `extra: -/// HashMap` shape. -fn extra_map_from(v: Option<&Value>) -> HashMap { - match v { - Some(Value::Object(m)) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), - _ => HashMap::new(), - } -} - // ── Tests ───────────────────────────────────────────────────────────── #[cfg(test)] @@ -981,8 +808,8 @@ mod tests { #[test] fn test_assistant_default_api_provider_for_non_pi_source() { - // Non-pi source has no Turn.extra["pi"]["api"] — defaults - // should kick in. + // The IR has no field to carry a source's original api/provider + // — defaults should kick in regardless of source. let session = PiProjector::default() .project(&view_with(vec![assistant_turn("a1", "hi")])) .unwrap(); @@ -1023,4 +850,142 @@ mod tests { ); } } + + fn event( + id: &str, + parent: Option<&str>, + event_type: &str, + data: &[(&str, serde_json::Value)], + ) -> toolpath_convo::ConversationEvent { + toolpath_convo::ConversationEvent { + id: id.into(), + timestamp: "2026-04-16T10:00:03Z".into(), + parent_id: parent.map(String::from), + event_type: event_type.into(), + data: data.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(), + } + } + + #[test] + fn test_model_change_event_re_emitted_as_entry() { + let mut view = view_with(vec![user_turn("u1", "hi")]); + view.events.push(event( + "mc-1", + Some("u1"), + "model_change", + &[ + ("provider", serde_json::json!("anthropic")), + ("modelId", serde_json::json!("claude-opus-4-7")), + ("note", serde_json::json!("switched")), + ], + )); + let session = PiProjector::default().project(&view).unwrap(); + let mc = session + .entries + .iter() + .find_map(|e| match e { + Entry::ModelChange { + base, + provider, + model_id, + extra, + } => Some((base.clone(), provider.clone(), model_id.clone(), extra.clone())), + _ => None, + }) + .expect("expected a ModelChange entry in projected session"); + assert_eq!(mc.0.id, "mc-1"); + assert_eq!(mc.0.parent_id.as_deref(), Some("u1")); + assert_eq!(mc.0.timestamp, "2026-04-16T10:00:03Z"); + assert_eq!(mc.1, "anthropic"); + assert_eq!(mc.2, "claude-opus-4-7"); + assert_eq!(mc.3.get("note"), Some(&serde_json::json!("switched"))); + } + + #[test] + fn test_thinking_level_change_event_re_emitted_as_entry() { + let mut view = view_with(vec![user_turn("u1", "hi")]); + view.events.push(event( + "tlc-1", + Some("u1"), + "thinking_level_change", + &[("thinkingLevel", serde_json::json!("high"))], + )); + let session = PiProjector::default().project(&view).unwrap(); + let found = session.entries.iter().any(|e| { + matches!(e, Entry::ThinkingLevelChange { base, thinking_level, .. } + if base.id == "tlc-1" && thinking_level == "high") + }); + assert!(found, "expected a ThinkingLevelChange entry"); + } + + #[test] + fn test_label_event_re_emitted_as_entry() { + let mut view = view_with(vec![user_turn("u1", "hi")]); + view.events.push(event( + "lbl-1", + Some("u1"), + "label", + &[("label", serde_json::json!("checkpoint"))], + )); + let session = PiProjector::default().project(&view).unwrap(); + let found = session.entries.iter().any(|e| { + matches!(e, Entry::Label { base, extra } + if base.id == "lbl-1" && extra.get("label") == Some(&serde_json::json!("checkpoint"))) + }); + assert!(found, "expected a Label entry"); + } + + #[test] + fn test_event_entry_inserted_after_its_parent() { + let mut view = view_with(vec![user_turn("u1", "hi"), assistant_turn("a1", "reply")]); + view.events.push(event( + "mc-1", + Some("u1"), + "model_change", + &[ + ("provider", serde_json::json!("anthropic")), + ("modelId", serde_json::json!("m")), + ], + )); + let session = PiProjector::default().project(&view).unwrap(); + let ids: Vec<&str> = session + .entries + .iter() + .filter_map(|e| match e { + Entry::Session(_) => None, + Entry::Message { base, .. } + | Entry::ModelChange { base, .. } + | Entry::ThinkingLevelChange { base, .. } + | Entry::Compaction { base, .. } + | Entry::BranchSummary { base, .. } + | Entry::Custom { base, .. } + | Entry::CustomMessage { base, .. } + | Entry::Label { base, .. } => Some(base.id.as_str()), + }) + .collect(); + let u1 = ids.iter().position(|i| *i == "u1").unwrap(); + let mc = ids.iter().position(|i| *i == "mc-1").unwrap(); + let a1 = ids.iter().position(|i| *i == "a1").unwrap(); + assert!( + u1 < mc && mc < a1, + "expected mc-1 between u1 and a1, got order {:?}", + ids + ); + } + + #[test] + fn test_foreign_event_types_are_not_emitted() { + // Events from other providers (e.g. a Claude attachment) have no + // Pi entry analog and must not produce garbage entries. + let mut view = view_with(vec![user_turn("u1", "hi")]); + view.events.push(event( + "att-1", + Some("u1"), + "attachment", + &[("path", serde_json::json!("/tmp/x"))], + )); + let session = PiProjector::default().project(&view).unwrap(); + // header + one user message only + assert_eq!(session.entries.len(), 2); + } } diff --git a/crates/toolpath-pi/src/provider.rs b/crates/toolpath-pi/src/provider.rs index 6f091c2d..00af8e1c 100644 --- a/crates/toolpath-pi/src/provider.rs +++ b/crates/toolpath-pi/src/provider.rs @@ -1,10 +1,16 @@ //! ConversationProvider bridge: map Pi sessions to `toolpath_convo::ConversationView`. //! //! Walks `PiSession.entries` in file order. Each `Entry::Message` becomes a -//! `Turn`; metadata-only entries like `ModelChange` / `ThinkingLevelChange` / -//! `Label` buffer and attach to the next message's `extra["pi"]`. `Compaction`, -//! `BranchSummary`, `Custom`, and `CustomMessage` emit synthetic turns with -//! appropriate roles. +//! `Turn`; metadata-only entries — `ModelChange` / `ThinkingLevelChange` / +//! `Label` — become `ConversationView.events` (`model_change` / +//! `thinking_level_change` / `label`), which the shared derive emits as +//! `conversation.event` steps and `PiProjector` re-materializes as real +//! Pi entries, so they survive a full Path round-trip. `Compaction`, +//! `BranchSummary`, `Custom`, and `CustomMessage` emit synthetic turns +//! with appropriate roles (`Role::System` / `Role::Other("custom")` / +//! etc.), but on a round-trip those turns are indistinguishable from +//! ordinary ones — the IR has no field to mark them with, so +//! `PiProjector` can't re-synthesize the original entry type. //! //! Tool-result correlation is a two-pass process: we record tool-call ids as //! assistant turns are built, then in a second pass populate matching tool @@ -20,9 +26,9 @@ use chrono::{DateTime, Utc}; use serde_json::{Value, json}; use std::collections::HashMap; use toolpath_convo::{ - ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork, - EnvironmentSnapshot, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, - Turn, + ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError, + DelegatedWork, EnvironmentSnapshot, Role, SessionBase, TokenUsage, ToolCategory, + ToolInvocation, ToolResult, Turn, }; // ── Classification helpers ─────────────────────────────────────────── @@ -226,6 +232,10 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { // invocation by id and populate `.result` (and any delegation // result). let mut turns: Vec = Vec::new(); + // Non-turn metadata entries land here; the shared derive emits them + // as `conversation.event` steps so they survive Path round-trips, + // and `PiProjector` re-materializes them as real Pi entries. + let mut events: Vec = Vec::new(); // Map tool-call id → (turn_idx, tool_idx). let mut tool_call_locs: HashMap = HashMap::new(); // Map tool-call id → delegation index within the turn (if any). @@ -237,9 +247,49 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { match entry { Entry::Session(_) => continue, - Entry::ModelChange { .. } | Entry::ThinkingLevelChange { .. } | Entry::Label { .. } => { - // Discarded — these influence rendering only and don't map onto - // a cross-harness IR field. + Entry::ModelChange { + base, + provider, + model_id, + extra, + } => { + let mut data: HashMap = extra.clone(); + data.insert("provider".to_string(), serde_json::json!(provider)); + data.insert("modelId".to_string(), serde_json::json!(model_id)); + events.push(ConversationEvent { + id: base.id.clone(), + timestamp: base.timestamp.clone(), + parent_id: base.parent_id.clone(), + event_type: "model_change".to_string(), + data, + }); + } + Entry::ThinkingLevelChange { + base, + thinking_level, + extra, + } => { + let mut data: HashMap = extra.clone(); + data.insert( + "thinkingLevel".to_string(), + serde_json::json!(thinking_level), + ); + events.push(ConversationEvent { + id: base.id.clone(), + timestamp: base.timestamp.clone(), + parent_id: base.parent_id.clone(), + event_type: "thinking_level_change".to_string(), + data, + }); + } + Entry::Label { base, extra } => { + events.push(ConversationEvent { + id: base.id.clone(), + timestamp: base.timestamp.clone(), + parent_id: base.parent_id.clone(), + event_type: "label".to_string(), + data: extra.clone(), + }); } Entry::Compaction { base, summary, .. } => { @@ -553,7 +603,7 @@ pub fn session_to_view(session: &PiSession) -> ConversationView { provider_id: Some("pi".to_string()), files_changed, session_ids, - events: vec![], + events, base, ..Default::default() } @@ -1285,4 +1335,61 @@ mod tests { assert_eq!(v.turns[0].role, Role::Other("custom:foo".to_string())); assert_eq!(v.turns[0].text, "body"); } + + #[test] + fn test_model_change_becomes_event() { + let mc = Entry::ModelChange { + base: base("mc-1", Some("u1"), "2026-04-16T00:00:03Z"), + provider: "anthropic".into(), + model_id: "claude-opus-4-7".into(), + extra: HashMap::from([("note".to_string(), serde_json::json!("switched"))]), + }; + let v = session_to_view(&session_from( + vec![user_text_entry("u1", None, "hi"), mc], + "/tmp/p", + )); + assert_eq!(v.events.len(), 1); + let e = &v.events[0]; + assert_eq!(e.id, "mc-1"); + assert_eq!(e.parent_id.as_deref(), Some("u1")); + assert_eq!(e.timestamp, "2026-04-16T00:00:03Z"); + assert_eq!(e.event_type, "model_change"); + assert_eq!(e.data.get("provider"), Some(&serde_json::json!("anthropic"))); + assert_eq!( + e.data.get("modelId"), + Some(&serde_json::json!("claude-opus-4-7")) + ); + assert_eq!(e.data.get("note"), Some(&serde_json::json!("switched"))); + } + + #[test] + fn test_thinking_level_change_becomes_event() { + let tlc = Entry::ThinkingLevelChange { + base: base("tlc-1", None, "2026-04-16T00:00:04Z"), + thinking_level: "high".into(), + extra: HashMap::new(), + }; + let v = session_to_view(&session_from(vec![tlc], "/tmp/p")); + assert_eq!(v.events.len(), 1); + let e = &v.events[0]; + assert_eq!(e.event_type, "thinking_level_change"); + assert_eq!(e.data.get("thinkingLevel"), Some(&serde_json::json!("high"))); + } + + #[test] + fn test_label_becomes_event() { + let label = Entry::Label { + base: base("lbl-1", Some("u1"), "2026-04-16T00:00:05Z"), + extra: HashMap::from([("label".to_string(), serde_json::json!("checkpoint"))]), + }; + let v = session_to_view(&session_from( + vec![user_text_entry("u1", None, "hi"), label], + "/tmp/p", + )); + assert_eq!(v.events.len(), 1); + let e = &v.events[0]; + assert_eq!(e.id, "lbl-1"); + assert_eq!(e.event_type, "label"); + assert_eq!(e.data.get("label"), Some(&serde_json::json!("checkpoint"))); + } } diff --git a/crates/toolpath-pi/tests/compaction_roundtrip.rs b/crates/toolpath-pi/tests/compaction_roundtrip.rs index ea5d006b..63167b9d 100644 --- a/crates/toolpath-pi/tests/compaction_roundtrip.rs +++ b/crates/toolpath-pi/tests/compaction_roundtrip.rs @@ -20,11 +20,13 @@ //! the Pi reader. //! //! Known limitation (documented, not asserted): the compaction marker -//! itself (with its `summary` text and `tokensBefore` metadata) lands -//! in `Turn.extra["pi"]["compaction"]` per the format docs, but the -//! full structural preservation through `derive → extract → project` -//! is not asserted here. Acceptable loss for "good UX" — the real -//! conversation content lives in the surrounding messages. +//! itself (with its `summary` text and `tokensBefore` metadata) has no +//! home in the provider-agnostic IR — the forward path folds it into +//! an ordinary `Role::System` turn, and the projector can't tell it +//! apart from any other system turn, so `Entry::Compaction` does not +//! survive a `derive → extract → project` round-trip. Acceptable loss +//! for "good UX" — the real conversation content lives in the +//! surrounding messages. use std::path::{Path, PathBuf}; diff --git a/crates/toolpath-pi/tests/metadata_entries_roundtrip.rs b/crates/toolpath-pi/tests/metadata_entries_roundtrip.rs new file mode 100644 index 00000000..ddc7a3c9 --- /dev/null +++ b/crates/toolpath-pi/tests/metadata_entries_roundtrip.rs @@ -0,0 +1,201 @@ +//! Metadata-entry round-trip: Pi's `ModelChange` / `ThinkingLevelChange` / +//! `Label` entries have no `Turn` mapping, so the forward path routes them +//! into `ConversationView.events`. The shared derive emits those as +//! `conversation.event` steps, `extract_conversation` restores them, and +//! `PiProjector` re-materializes them as real Pi entries — so a +//! pi → view → Path → view → pi chain preserves the entries (ids, +//! parentIds, payload fields) instead of dropping them. +//! +//! Synthetic fixture is justified per project policy: model changes and +//! labels are user-initiated UI actions that a capture prompt can't +//! reliably trigger mid-session. + +use std::collections::HashMap; + +use toolpath::v1::Graph; +use toolpath_convo::{ + ConversationProjector, ConversationView, DeriveConfig, derive_path, extract_conversation, +}; +use toolpath_pi::project::PiProjector; +use toolpath_pi::reader::PiSession; +use toolpath_pi::session_to_view; +use toolpath_pi::types::{ + AgentMessage, ContentBlock, CostBreakdown, Entry, EntryBase, KnownStopReason, MessageContent, + SessionHeader, StopReason, Usage, +}; + +fn base(id: &str, parent: Option<&str>, ts: &str) -> EntryBase { + EntryBase { + id: id.into(), + parent_id: parent.map(String::from), + timestamp: ts.into(), + } +} + +fn source_session() -> PiSession { + let header = SessionHeader { + version: 3, + id: "sess-meta".into(), + timestamp: "2026-04-16T00:00:00Z".into(), + cwd: "/tmp/proj".into(), + parent_session: None, + extra: HashMap::new(), + }; + let entries = vec![ + Entry::Session(header.clone()), + Entry::Message { + base: base("u1", None, "2026-04-16T00:00:01Z"), + message: AgentMessage::User { + content: MessageContent::Text("switch to opus please".into()), + timestamp: 1, + extra: HashMap::new(), + }, + extra: HashMap::new(), + }, + Entry::ModelChange { + base: base("mc-1", Some("u1"), "2026-04-16T00:00:02Z"), + provider: "anthropic".into(), + model_id: "claude-opus-4-7".into(), + extra: HashMap::new(), + }, + Entry::Message { + base: base("a1", Some("mc-1"), "2026-04-16T00:00:03Z"), + message: AgentMessage::Assistant { + content: vec![ContentBlock::Text { + text: "switched; carrying on".into(), + extra: HashMap::new(), + }], + api: "anthropic".into(), + provider: "anthropic".into(), + model: "claude-opus-4-7".into(), + usage: Usage { + input: 10, + output: 5, + cache_read: 0, + cache_write: 0, + total_tokens: 15, + cost: CostBreakdown::default(), + }, + stop_reason: StopReason::Known(KnownStopReason::Stop), + error_message: None, + timestamp: 3, + extra: HashMap::new(), + }, + extra: HashMap::new(), + }, + Entry::Label { + base: base("lbl-1", Some("a1"), "2026-04-16T00:00:04Z"), + extra: HashMap::from([("label".to_string(), serde_json::json!("checkpoint"))]), + }, + ]; + PiSession { + header, + entries, + file_path: std::path::PathBuf::from("/tmp/fake.jsonl"), + parent: None, + } +} + +/// Full chain with an on-disk-equivalent serialization in the middle. +fn full_roundtrip(source: &PiSession) -> PiSession { + let view_forward: ConversationView = session_to_view(source); + let path = derive_path(&view_forward, &DeriveConfig::default()); + let graph = Graph::from_path(path); + let json = graph.to_json().expect("serialize Graph"); + let back = Graph::from_json(&json).expect("parse Graph"); + let reparsed = back.into_single_path().expect("single path"); + let view_back = extract_conversation(&reparsed); + PiProjector::new() + .with_cwd(source.header.cwd.clone()) + .project(&view_back) + .expect("project") +} + +#[test] +fn model_change_survives_full_roundtrip() { + let source = source_session(); + let rebuilt = full_roundtrip(&source); + let mc = rebuilt + .entries + .iter() + .find_map(|e| match e { + Entry::ModelChange { + base, + provider, + model_id, + .. + } => Some((base.clone(), provider.clone(), model_id.clone())), + _ => None, + }) + .expect("ModelChange entry should survive the round-trip"); + assert_eq!(mc.0.id, "mc-1"); + assert_eq!(mc.0.parent_id.as_deref(), Some("u1")); + assert_eq!(mc.1, "anthropic"); + assert_eq!(mc.2, "claude-opus-4-7"); +} + +#[test] +fn label_survives_full_roundtrip() { + let source = source_session(); + let rebuilt = full_roundtrip(&source); + let lbl = rebuilt + .entries + .iter() + .find_map(|e| match e { + Entry::Label { base, extra } => Some((base.clone(), extra.clone())), + _ => None, + }) + .expect("Label entry should survive the round-trip"); + assert_eq!(lbl.0.id, "lbl-1"); + assert_eq!(lbl.0.parent_id.as_deref(), Some("a1")); + assert_eq!(lbl.1.get("label"), Some(&serde_json::json!("checkpoint"))); +} + +#[test] +fn metadata_entries_sit_after_their_parents_in_file_order() { + let source = source_session(); + let rebuilt = full_roundtrip(&source); + let ids: Vec = rebuilt + .entries + .iter() + .filter_map(|e| match e { + Entry::Session(_) => None, + Entry::Message { base, .. } + | Entry::ModelChange { base, .. } + | Entry::ThinkingLevelChange { base, .. } + | Entry::Compaction { base, .. } + | Entry::BranchSummary { base, .. } + | Entry::Custom { base, .. } + | Entry::CustomMessage { base, .. } + | Entry::Label { base, .. } => Some(base.id.clone()), + }) + .collect(); + let pos = |id: &str| ids.iter().position(|i| i == id).unwrap(); + assert!(pos("u1") < pos("mc-1"), "order: {:?}", ids); + assert!(pos("a1") < pos("lbl-1"), "order: {:?}", ids); +} + +#[test] +fn rebuilt_jsonl_reparses_through_pi_reader() { + let source = source_session(); + let rebuilt = full_roundtrip(&source); + let lines: Vec = rebuilt + .entries + .iter() + .map(|e| serde_json::to_string(e).expect("serialize entry")) + .collect(); + let tmp = tempfile::Builder::new() + .suffix(".jsonl") + .tempfile() + .expect("tempfile"); + std::fs::write(tmp.path(), lines.join("\n")).expect("write"); + let reread = + toolpath_pi::reader::read_session_from_file(tmp.path()).expect("re-read projected JSONL"); + assert!( + reread + .entries + .iter() + .any(|e| matches!(e, Entry::ModelChange { .. })), + "re-read session should still contain the ModelChange entry" + ); +} diff --git a/crates/toolpath/src/types.rs b/crates/toolpath/src/types.rs index d1be6137..c5c459ce 100644 --- a/crates/toolpath/src/types.rs +++ b/crates/toolpath/src/types.rs @@ -144,7 +144,7 @@ pub struct Base { /// Spec at . /// /// v1.1.0 specifies message-level token accounting: steps derived from one -/// provider message share a `message_id`, and the message's `token_usage` +/// provider message share a `group_id`, and the message's `token_usage` /// appears on exactly one of them (the group's last step in document /// order), so summing usage over a path's steps yields session totals. pub const PATH_KIND_AGENT_CODING_SESSION: &str = diff --git a/docs/agents/formats/codex.md b/docs/agents/formats/codex.md index 9734c359..8247b228 100644 --- a/docs/agents/formats/codex.md +++ b/docs/agents/formats/codex.md @@ -847,13 +847,14 @@ The mapping below is what the provider actually emits. Source: | `session_meta.id` | `ConversationView.id`, `path.id = path-codex-` | | `session_meta.cwd` | `Turn.environment.working_dir`, `path.base.uri` | | `session_meta.git.commit_hash` | `path.base.ref_str` | -| `session_meta` (full) | `path.meta.extra["codex"]` (originator, cli_version, model_provider, git block, forked_from_id) | +| `session_meta.originator` / `.cli_version` | `path.meta.extra["producer"]` (`name`, `version`) | +| `session_meta.model_provider`, `.source`, `.forked_from_id` | dropped — no cross-harness analog; the codex projector hard-codes defaults on the return path | | `turn_context.model` | `Turn.model` on subsequent assistant turns | | `turn_context` (full) | `ConversationEvent` (round-trip preservation) | | `message` role `user` | `Turn { role: User }` → Step with `actor: "human:user"` | | `message` role `assistant` | `Turn { role: Assistant, model }` → Step with `actor: "agent:"` | | `message` role `developer` | `Turn { role: System }` → Step with `actor: "tool:codex"` | -| `reasoning.encrypted_content` | `Turn.extra["codex"]["reasoning_encrypted"]` (**not** `Turn.thinking` — it would render as ciphertext) | +| `reasoning.encrypted_content` | dropped — the IR has no provider-namespaced extras field to preserve ciphertext in, so it never lands on `Turn` and does not survive a round-trip (**not** `Turn.thinking` — it would render as ciphertext) | | `reasoning.summary[].text` / `reasoning.content[].text` (plaintext) | `Turn.thinking` on the next assistant turn | | `function_call` / `function_call_output` paired by `call_id` | `Turn.tool_uses[].{input, result}` | | `custom_tool_call` / `_output` paired by `call_id` | same (raw `input` string preserved) | diff --git a/docs/agents/formats/gemini.md b/docs/agents/formats/gemini.md index 3fa8cfc2..b61e31a2 100644 --- a/docs/agents/formats/gemini.md +++ b/docs/agents/formats/gemini.md @@ -251,9 +251,9 @@ not concatenated into the visible text. All fields are optional. `input` → `input_tokens` and `cached` → `cache_read_tokens` map cleanly to the common `TokenUsage` schema. The -standalone `tool` and `total` counters are Gemini-specific and are -preserved raw in a provider-namespaced extras bucket -(`Turn.extra["gemini"]["tokens"]`). +standalone `tool` and `total` counters are Gemini-specific; the IR has +no provider-namespaced extras field to preserve them in, so they are +dropped. #### `thoughts` is additive reasoning — folded into `output_tokens` diff --git a/docs/agents/formats/opencode.md b/docs/agents/formats/opencode.md index f4c7f84d..02acd1ef 100644 --- a/docs/agents/formats/opencode.md +++ b/docs/agents/formats/opencode.md @@ -687,14 +687,14 @@ Minimum viable mapping, if we follow the Pi-style approach (build a | `session.id` | `ConversationView.id` | | `session.directory` + `project.worktree` | `Turn.environment.working_dir`, `path.base.uri` | | `project.id` (first-root-commit SHA) | `path.base.ref_str` (stable-enough) | -| User `message` | `Turn { role: User }` | +| User `message` | `Turn { role: User, model: modelID }` (bare modelID, same convention as assistant; `providerID` dropped) | | Assistant `message` | `Turn { role: Assistant, model: modelID }` | | `user.system` | `Turn { role: System }` or `ConversationEvent` | | `reasoning` part | `Turn.thinking` (plaintext — safe to render) | | `text` part | appended to `Turn.text` | | `tool` part (state: completed) | `Turn.tool_uses[] { input, result: state.output }` paired via `callID` | | `tool` part (state: error) | same, with `result.is_error = true`, `result.content = state.error` | -| `step-start` / `step-finish` | attach `snapshot` SHA to the turn for file-artifact reconstruction | +| `step-start` / `step-finish` | attach `snapshot` SHA to the turn for file-artifact reconstruction; the SHA also rides a `part.snapshot` `ConversationEvent` so the projector can restore it after a Path round-trip | | `patch` part | file-artifact sibling `ArtifactChange.raw` from `git diff ` | | `step-finish.tokens` | `Turn.token_usage` (delta) + summed into `ConversationView.total_usage` | | `subtask` part | `Turn.delegations[]`, with sub-session linked via `session.parent_id` | diff --git a/docs/agents/formats/pi.md b/docs/agents/formats/pi.md index ca1d77e7..a88cce11 100644 --- a/docs/agents/formats/pi.md +++ b/docs/agents/formats/pi.md @@ -204,10 +204,17 @@ constraint; Pi reads any `*.jsonl` file in the project directory. only the former (or duplicating the result inline) breaks correlation. 2. **`Compaction` / `BranchSummary` are first-class entry types**, not - roles. The forward path stashes structure markers under - `Turn.extra["pi"]["compaction"]` / `["branchSummary"]`; the - projector reads those to decide whether to emit `Entry::Compaction` - vs `Entry::Message`. + roles, but the IR has no field to mark a turn as having come from + one. The forward path emits them as ordinary `Role::System` turns + (`text: "Compacted (summary): …"` / `"Branch summary: …"`), and the + projector always maps `Role::System` back to a generic custom + message — the original entry type does not survive a round-trip. + By contrast, the metadata-only entries `model_change` / + `thinking_level_change` / `label` DO survive: the forward path + routes them into `ConversationView.events`, the shared derive + emits them as `conversation.event` steps, and the projector + re-materializes them as real Pi entries with their original + `id`/`parentId`. 3. **Inner message `timestamp` is u64 epoch milliseconds**, not an ISO-8601 string. The outer `EntryBase.timestamp` IS the ISO string. Two timestamp fields per message — keep them in sync on round-trip. diff --git a/site/_data/crates.json b/site/_data/crates.json index 6b730be1..325f41c1 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -57,7 +57,7 @@ }, { "name": "toolpath-opencode", - "version": "0.5.0", + "version": "0.5.2", "description": "Derive from opencode SQLite databases", "docs": "https://docs.rs/toolpath-opencode", "crate": "https://crates.io/crates/toolpath-opencode", @@ -65,7 +65,7 @@ }, { "name": "toolpath-pi", - "version": "0.6.0", + "version": "0.6.2", "description": "Derive Toolpath provenance documents from Pi (pi.dev) agent session logs", "docs": "https://docs.rs/toolpath-pi", "crate": "https://crates.io/crates/toolpath-pi",