Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 35 additions & 11 deletions src/harness/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
85 changes: 84 additions & 1 deletion tests/regression/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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);
}