From c73cdc94e31eec54636ecdc3a4f2de5555e53b3f Mon Sep 17 00:00:00 2001 From: Alfio Caprino Date: Tue, 27 Oct 2026 12:59:58 +0100 Subject: [PATCH] fix(opencode): keep the results of parallel calls written one per message A source can record parallel tool calls one call per assistant message, each result in its own later user message (call A, call B, result A, result B). The writer looked for a result only in the message right after its call, so both calls were written with an empty output. It now looks through the following user messages, up to the next prompt. Fixes #98 --- CHANGELOG.md | 1 + src/harness/opencode.rs | 46 ++++++++++++++++------ tests/regression/main.rs | 85 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4fae10..df044b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Each release is published to [crates.io](https://crates.io/crates/txcript), ### Fixed +- Keep the results of parallel tool calls when converting to OpenCode ([#98](https://github.com/skillsynchq/txcript/issues/98)). A source that records each call in its own assistant message, with each result in its own later user message, used to be written with every call's output empty, because the writer only looked for a result in the very next message; it now looks through the following user messages of the turn. - Resolve a session id that exists in several stores to the most recently modified copy. `view`, `export`, `continue` and `crop` used to load whichever copy the file system listed first; discovery now breaks start-time ties by file mtime. ### Added diff --git a/src/harness/opencode.rs b/src/harness/opencode.rs index 09da176..edb3bd2 100644 --- a/src/harness/opencode.rs +++ b/src/harness/opencode.rs @@ -575,17 +575,8 @@ fn assistant_part( "metadata": {}, "time": time, }); - // Fold matching ToolResult from the following user turn into state.output. - if let Some(next) = messages.get(*idx + 1) - && matches!(next.role, Role::User) - && let Some(result) = next.content.iter().find_map(|b| match b { - Block::ToolResult { - tool_use_id, - content, - is_error, - } if tool_use_id == id => Some((content, *is_error)), - _ => None, - }) + // Fold the matching ToolResult into state.output. + if let Some(result) = following_result(messages, *idx, id) && let Value::Object(obj) = &mut state { if result.1 { @@ -613,6 +604,39 @@ fn assistant_part( p } +/// The result of call `id` made in `messages[idx]`: the first matching +/// `ToolResult` in the messages after it, up to the next user message that is +/// more than tool results (the next prompt). Parallel calls can be recorded one +/// per assistant message, with each result in its own later user message, so +/// the result is not always in the very next message. +fn following_result<'m>( + messages: &'m [Message], + idx: usize, + id: &str, +) -> Option<(&'m ToolOutput, bool)> { + for message in messages.iter().skip(idx + 1) { + if message.role != Role::User { + continue; + } + let mut prompt = false; + for block in &message.content { + match block { + Block::ToolResult { + tool_use_id, + content, + is_error, + } if tool_use_id == id => return Some((content, *is_error)), + Block::ToolResult { .. } => {} + _ => prompt = true, + } + } + if prompt { + return None; + } + } + None +} + // ── tool normalization ───────────────────────────────────────────────── fn normalize_tool(tool: &str, input: Value) -> (String, Value) { diff --git a/tests/regression/main.rs b/tests/regression/main.rs index dc8cae5..a113b9d 100644 --- a/tests/regression/main.rs +++ b/tests/regression/main.rs @@ -13,7 +13,7 @@ mod codex_pairing; use chrono::{DateTime, Utc}; use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput}; -use txcript::harness::{claude_code, codex, grok}; +use txcript::harness::{claude_code, codex, grok, opencode}; use txcript::{Codec, HarnessId, Store, TextCodec, Transcript}; #[cfg(feature = "search")] @@ -349,3 +349,86 @@ fn search_substring_matches_flag_shaped_needles() { assert_eq!(hits[0].origin, Origin::ToolUse); } } + +/// Fixed for issue #98. A source can record parallel tool calls one call +/// per assistant message, each result in its own later user message +/// (call A, call B, result A, result B). The `OpenCode` writer looked for a +/// result only in the message right after its call, so both calls were +/// written with an empty output and their results were lost. +#[test] +fn opencode_keeps_the_results_of_parallel_calls_written_one_per_message() { + let call = |id: &str, path: &str| Block::ToolUse { + id: id.to_string(), + tool: Tool::Read { + file_path: path.to_string(), + offset: None, + limit: None, + }, + }; + let result = |id: &str, text: &str| Block::ToolResult { + tool_use_id: id.to_string(), + content: ToolOutput::Text(text.to_string()), + is_error: false, + }; + let common = Transcript::new( + meta("ses_parallel"), + vec![ + msg( + Role::User, + vec![Block::Text { + text: "read both".to_string(), + }], + 0, + ), + msg(Role::Assistant, vec![call("call_a", "/a")], 1), + msg(Role::Assistant, vec![call("call_b", "/b")], 1), + msg(Role::User, vec![result("call_a", "content a")], 2), + msg(Role::User, vec![result("call_b", "content b")], 2), + msg( + Role::Assistant, + vec![Block::Text { + text: "done".to_string(), + }], + 3, + ), + ], + ); + + let native = opencode::OpenCode::from_common(&common).unwrap(); + let outputs: Vec<(String, String)> = native + .body + .messages + .iter() + .flat_map(|m| m.parts.iter()) + .filter(|part| part["type"] == "tool") + .map(|part| { + ( + part["callID"].as_str().unwrap_or_default().to_string(), + part["state"]["output"] + .as_str() + .unwrap_or_default() + .to_string(), + ) + }) + .collect(); + let expected = vec![ + ("call_a".to_string(), "content a".to_string()), + ("call_b".to_string(), "content b".to_string()), + ]; + assert_eq!(outputs, expected); + + let back = opencode::OpenCode::to_common(&native).unwrap().body; + let results: Vec<(String, String)> = back + .iter() + .flat_map(|m| m.content.iter()) + .filter_map(|block| match block { + Block::ToolResult { + tool_use_id, + content: ToolOutput::Text(text), + .. + } => Some((tool_use_id.clone(), text.clone())), + _ => None, + }) + .collect(); + assert_eq!(results, expected); +}