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",