From ffa1985ece0e18bdb2c28a2c43f2190ceafe6a69 Mon Sep 17 00:00:00 2001 From: Muhammad Ali Hashar Date: Sat, 17 Oct 2026 12:59:24 -0400 Subject: [PATCH] fix(common): render tool-result content blocks as text, not escaped JSON or base64 --- CHANGELOG.md | 7 + cli/src/view.rs | 39 +++++- docs/formats/claude-code.md | 8 ++ docs/formats/grok.md | 3 +- docs/usage.md | 2 +- src/common.rs | 157 ++++++++++++++++++++++ src/harness/claude_code.rs | 5 +- src/harness/codex.rs | 9 +- src/harness/cursor.rs | 21 +-- src/harness/fx.rs | 25 +--- src/harness/grok.rs | 28 +--- src/harness/opencode.rs | 13 +- src/harness/pi.rs | 9 +- src/search.rs | 11 +- src/text.rs | 9 +- tests/regression/main.rs | 1 + tests/regression/tool_result_blocks.rs | 179 +++++++++++++++++++++++++ 17 files changed, 415 insertions(+), 111 deletions(-) create mode 100644 tests/regression/tool_result_blocks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 107c4d4..a86138f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ Each release is published to [crates.io](https://crates.io/crates/txcript), teammates' `teamName`/`agentName`. Filled at discovery time, not only on load. Writing lineage into native formats is not implemented yet. +### Fixed + +- Show tool results that are content-block arrays (every MCP result, screenshots) as their text, not + escaped JSON, in `view`, MCP `read_session`, search, and exports to Codex, pi, Campfire, Cursor, + OpenCode, Grok, and fx. Images in a tool result render as `[image: ]` instead of their + whole base64 payload, which could push a session past the MCP read budget. + ## [0.14.4](https://github.com/skillsynchq/txcript/compare/v0.14.3...v0.14.4) - 2026-09-13 ### Fixed diff --git a/cli/src/view.rs b/cli/src/view.rs index 68f5c6a..deeed32 100644 --- a/cli/src/view.rs +++ b/cli/src/view.rs @@ -615,13 +615,16 @@ impl<'m> Blocks<'_, 'm> { if *is_error { "1;31" } else { "1;32" }, color, ); + let text = content.as_text(); match content { - ToolOutput::Text(text) => human_body(out, text), - ToolOutput::Json(value) => { + // Other JSON stays pretty-printed; `as_text` is its + // compact form only when it is not a block array. + ToolOutput::Json(value) if text == value.to_string() => { let body = serde_json::to_string_pretty(value) .unwrap_or_else(|_| value.to_string()); human_body(out, &body); } + ToolOutput::Text(_) | ToolOutput::Json(_) => human_body(out, &text), } } Block::Image { source } => { @@ -862,6 +865,38 @@ mod tests { assert!(pipeline.contains("── #1 ──\n[user]\nPlease fix it.")); } + #[test] + fn human_tool_results_show_block_arrays_as_text_and_keep_other_json_pretty() { + let mut common = transcript(); + let outputs = [ + ToolOutput::Json(serde_json::json!([ + {"type": "text", "text": "row1: alpha\nrow2: \"beta\""}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "QUJD".repeat(5000)}}, + ])), + ToolOutput::Json(serde_json::json!({"count": 3})), + ]; + common.body = outputs + .into_iter() + .enumerate() + .map(|(n, content)| Message { + content: vec![Block::ToolResult { + tool_use_id: format!("call-{n}"), + content, + is_error: false, + }], + ..common.body[0].clone() + }) + .collect(); + let rendered = Document::new(common, Span(0..2), false, None) + .render(60, Filters::default()) + .unwrap() + .text; + + assert!(rendered.contains("row1: alpha\nrow2: \"beta\"\n\n[image: image/png]")); + assert!(!rendered.contains("QUJD")); + assert!(rendered.contains("{\n \"count\": 3\n}")); + } + #[test] fn human_ranges_keep_full_session_ordinals() { let mut common = transcript(); diff --git a/docs/formats/claude-code.md b/docs/formats/claude-code.md index 4dfc0f3..4713630 100644 --- a/docs/formats/claude-code.md +++ b/docs/formats/claude-code.md @@ -116,10 +116,18 @@ that relation here. `from_common` never writes `teamName`/`agentName` back. types are gone after a cross-model trip (same-harness native trips keep everything). Rewritten `tool_result.content` must be a string or block array — anything else fails a `claude --resume` load — so bare JSON is flattened to its compact text. + Block arrays are written back unchanged. Common → Claude Code removes Claude.ai's `uuid` metadata from tool-result content blocks, including nested search-result and document content. The Messages API rejects those extra fields. Text, supported block fields, and UUIDs inside ordinary tool data are preserved; native load/save is unchanged. +- **Block-array tool results read as text.** MCP and image tool results arrive as a block + array (`text`, `image`, ...). `view`, the MCP `read_session`, search, and every export to + a harness that stores results as plain strings (Codex, pi, Campfire, Cursor, OpenCode, + Grok, fx) render it with `ToolOutput::as_text`: text blocks as their text, joined by a + blank line; image blocks as `[image: ]` (never the base64); other blocks as + compact JSON. Any other structured result stays compact JSON. Antigravity and Cursor + desktop keep compact JSON text, because they parse it back into structured output. - **Tool inputs.** The Messages API requires `tool_use.input` to be an object, including calls already in the conversation history. Common → Claude Code wraps non-object inputs, such as Codex freeform tool text, as diff --git a/docs/formats/grok.md b/docs/formats/grok.md index 86c7077..375de50 100644 --- a/docs/formats/grok.md +++ b/docs/formats/grok.md @@ -92,7 +92,8 @@ A synthetic `chat_history.jsonl` assistant line, shaped like the real thing: - Token usage is never extracted (the display log's `_meta.totalTokens` is ignored), so `usage` is always absent. - Regenerating from Common is lossy: per-turn usage, non-turn stop reasons, - reasoning record ids, structured tool-result JSON (flattened to strings), + reasoning record ids, structured tool-result JSON (flattened to strings: block arrays + read as their text and `[image: ]` placeholders, other JSON as compact text), and the system prompt record are not reproduced; sidecars are left empty for Grok to regenerate. Native load → save round-trips everything except `terminal/` output scratch and `*.lock` files, which are deliberately diff --git a/docs/usage.md b/docs/usage.md index e9ad6c8..1c161d6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -206,7 +206,7 @@ An empty pattern returns documents newest-first. Tool outputs are excluded by de ### Text projection -`txcript::text::to_text(&common)` is the projection behind [`txcript view`](#cli): a one-way, token-conscious rendering of `Transcript` for use as LLM context. It keeps messages, reasoning text, and compact tool calls/results; replay-only payloads (encrypted reasoning, usage accounting, inline image bytes) are omitted. `to_text_fragment(&common, &span)` renders a `Span` of the body, keeping each message's ordinal in the full session. +`txcript::text::to_text(&common)` is the projection behind [`txcript view`](#cli): a one-way, token-conscious rendering of `Transcript` for use as LLM context. It keeps messages, reasoning text, and compact tool calls/results; replay-only payloads (encrypted reasoning, usage accounting, inline image bytes) are omitted. A tool result that is a content-block array (every MCP result) reads as its text, blocks separated by a blank line, with an image shown as `[image: ]`; other structured results stay compact JSON. `to_text_fragment(&common, &span)` renders a `Span` of the body, keeping each message's ordinal in the full session. ## npm package diff --git a/src/common.rs b/src/common.rs index e804876..a558f60 100644 --- a/src/common.rs +++ b/src/common.rs @@ -10,6 +10,8 @@ //! `Vec`. The model is harness-agnostic: it does not record where it //! came from. +use std::borrow::Cow; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -232,6 +234,63 @@ pub enum ToolOutput { Json(Value), } +impl ToolOutput { + /// The output as readable text, for harnesses and views that carry tool + /// results as plain strings. + /// + /// An Anthropic-style content-block array (the shape of MCP and image + /// tool results) renders block by block, joined by a blank line: text + /// blocks as their text, image blocks as `[image: ]` (never + /// the encoded bytes), any other block as its compact JSON. Every other + /// JSON value, including an array of only unrecognized blocks, is its + /// compact JSON. + #[must_use] + pub fn as_text(&self) -> Cow<'_, str> { + match self { + Self::Text(text) => Cow::Borrowed(text), + Self::Json(value) => match value.as_array() { + Some(blocks) if is_readable_block_array(blocks) => Cow::Owned( + blocks + .iter() + .map(block_text) + .collect::>() + .join("\n\n"), + ), + _ => Cow::Owned(value.to_string()), + }, + } + } +} + +/// Whether every element is a typed block object and at least one is a text +/// or image block, so rendering block by block beats dumping the array. +fn is_readable_block_array(blocks: &[Value]) -> bool { + blocks + .iter() + .all(|b| b.is_object() && block_type(b).is_some()) + && blocks + .iter() + .any(|b| matches!(block_type(b), Some("text" | "image"))) +} + +fn block_type(block: &Value) -> Option<&str> { + block.get("type").and_then(Value::as_str) +} + +fn block_text(block: &Value) -> String { + match block_type(block) { + Some("text") => block.get("text").and_then(Value::as_str), + Some("image") => { + return match block.pointer("/source/media_type").and_then(Value::as_str) { + Some(media_type) => format!("[image: {media_type}]"), + None => "[image]".to_owned(), + }; + } + _ => None, + } + .map_or_else(|| block.to_string(), str::to_owned) +} + /// A canonical tool invocation. /// /// Known tools are normalized to Claude-canonical names with typed arguments. @@ -603,6 +662,104 @@ mod tests { assert!(matches!(tool, Tool::Bash { .. })); assert_eq!(tool.to_canonical().1, input); } + + fn as_text(value: Value) -> String { + ToolOutput::Json(value).as_text().into_owned() + } + + #[test] + fn text_output_is_returned_as_is() { + let out = ToolOutput::Text("a \"b\"\nc".into()); + assert!(matches!(out.as_text(), Cow::Borrowed("a \"b\"\nc"))); + } + + /// Text blocks render as their text, not as escaped JSON. + #[test] + fn text_blocks_join_with_a_blank_line() { + let text = as_text(json!([ + {"type": "text", "text": "row1: alpha\nrow2: \"beta\""}, + {"type": "text", "text": "second \u{1f389} naïve 日本語"}, + ])); + assert_eq!( + text, + "row1: alpha\nrow2: \"beta\"\n\nsecond \u{1f389} naïve 日本語" + ); + assert_eq!(as_text(json!([{"type": "text", "text": "only"}])), "only"); + } + + /// An image block becomes a placeholder; its bytes never reach the text. + #[test] + fn image_blocks_render_a_placeholder_without_the_payload() { + let payload = "A".repeat(150_000); + let text = as_text(json!([ + {"type": "text", "text": "shot"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": payload}}, + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": payload}}, + ])); + assert_eq!(text, "shot\n\n[image: image/png]\n\n[image: image/jpeg]"); + } + + #[test] + fn image_without_a_media_type_renders_a_bare_placeholder() { + assert_eq!(as_text(json!([{"type": "image"}])), "[image]"); + assert_eq!( + as_text( + json!([{"type": "image", "source": {"type": "url", "url": "https://x/y.png"}}]) + ), + "[image]" + ); + } + + /// Other block types keep their compact JSON, in place among readable ones. + #[test] + fn unrecognized_blocks_render_as_compact_json() { + let text = as_text(json!([ + {"type": "text", "text": "found"}, + {"type": "tool_reference", "tool_name": "Read"}, + {"type": "search_result", "title": "T", "content": [{"type": "text", "text": "nested"}]}, + ])); + assert_eq!( + text, + "found\n\n{\"tool_name\":\"Read\",\"type\":\"tool_reference\"}\n\n\ + {\"content\":[{\"text\":\"nested\",\"type\":\"text\"}],\"title\":\"T\",\"type\":\"search_result\"}" + ); + // A text block without a string `text` is not readable text either. + assert_eq!( + as_text(json!([{"type": "text", "text": 5}, {"type": "text", "text": "ok"}])), + "{\"text\":5,\"type\":\"text\"}\n\nok" + ); + } + + /// Everything that is not a readable block array stays compact JSON. + #[test] + fn other_json_is_compact_json() { + let cases = [ + json!([]), + json!({"count": 3, "rows": ["a", "b"]}), + json!({"type": "text", "text": "an object, not an array"}), + json!(["a", "b"]), + json!([1, 2, 3]), + json!([{"no": "type"}, {"type": "text", "text": "x"}]), + json!([{"type": 7}, {"type": "text", "text": "x"}]), + json!([{"type": "text", "text": "x"}, "stray"]), + // Only unrecognized blocks: the array stays whole. + json!([{"tool_name": "WebFetch", "type": "tool_reference"}]), + json!(42), + json!(null), + json!(true), + json!("a json string"), + ]; + for value in cases { + assert_eq!(as_text(value.clone()), value.to_string(), "{value}"); + } + } + + #[test] + fn very_long_text_blocks_are_not_truncated() { + let long = "x".repeat(1_000_000); + let text = as_text(json!([{"type": "text", "text": long.clone()}])); + assert_eq!(text, long); + } } /// One find/replace within a [`Tool::MultiEdit`]. diff --git a/src/harness/claude_code.rs b/src/harness/claude_code.rs index 5466689..0b18202 100644 --- a/src/harness/claude_code.rs +++ b/src/harness/claude_code.rs @@ -884,10 +884,7 @@ fn local_command_record<'a>( ] if command_ids.contains(tool_use_id.as_str()) || !called.contains(tool_use_id.as_str()) => { - let text = match content { - ToolOutput::Text(text) => text.clone(), - ToolOutput::Json(value) => value.to_string(), - }; + let text = content.as_text().into_owned(); let mut line = Map::new(); let mut put = |key: &str, value: Value| { line.insert(key.to_string(), value); diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 092f675..efb5d52 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -657,7 +657,7 @@ fn push_message_lines<'a>( custom_tool_output(content, *is_error), ) } else { - ("function_call_output", tool_output_text(content)) + ("function_call_output", content.as_text().into_owned()) }; lines.push(meta_line_str( ts, @@ -1676,13 +1676,6 @@ fn parse_optional_json_string(raw: Option<&str>) -> Value { raw.map_or(Value::Object(Map::new()), parse_json_string_or_raw) } -fn tool_output_text(out: &ToolOutput) -> String { - match out { - ToolOutput::Text(s) => s.clone(), - ToolOutput::Json(v) => v.to_string(), - } -} - fn parse_ts(s: &str) -> Option> { s.parse::>().ok() } diff --git a/src/harness/cursor.rs b/src/harness/cursor.rs index 66e5533..19b706e 100644 --- a/src/harness/cursor.rs +++ b/src/harness/cursor.rs @@ -433,7 +433,7 @@ fn db_from_messages(meta: &Meta, messages: &[Message]) -> Result { "type": "tool-result", "toolCallId": tool_use_id, "toolName": tool_name, - "result": tool_output_text(content), + "result": content.as_text(), }], "createdAt": message.timestamp.timestamp_millis(), "isError": is_error, @@ -721,7 +721,7 @@ fn orphan_tool_result_state_text(blocks: &[Block], known_tool_uses: &HashSet Vec { - let output = tool_output_text(&result.content); + let output = result.content.as_text(); let mut shell_result = Vec::new(); if result.is_error { let mut failure = Vec::new(); @@ -943,7 +943,7 @@ fn cursor_read_result_proto( result: &CursorStateToolResult, ) -> Vec { let mut read_result = Vec::new(); - let text = tool_output_text(&result.content); + let text = result.content.as_text(); if result.is_error { let mut error = Vec::new(); pb_string(&mut error, 1, &text); @@ -998,7 +998,7 @@ fn cursor_edit_result_proto( payload: &CursorEditPayload, result: &CursorStateToolResult, ) -> Vec { - let output = tool_output_text(&result.content); + let output = result.content.as_text(); let mut edit_result = Vec::new(); if result.is_error { let mut error = Vec::new(); @@ -1120,7 +1120,7 @@ fn edit_success_message( ) -> String { result .filter(|result| !result.is_error) - .map(|result| tool_output_text(&result.content)) + .map(|result| result.content.as_text().into_owned()) .filter(|text| !text.trim().is_empty()) .unwrap_or_else(|| match fallback_action { "written" => format!("Wrote contents to {path}"), @@ -1358,7 +1358,7 @@ fn serialize_user_block(block: &Block) -> Value { let text = match other { Block::Thinking { text, .. } => text.clone(), Block::ToolUse { tool, .. } => format!("{tool:?}"), - Block::ToolResult { content, .. } => tool_output_text(content), + Block::ToolResult { content, .. } => content.as_text().into_owned(), Block::Artifact { artifact } => artifact.display_text(), Block::Text { .. } | Block::Image { .. } => String::new(), }; @@ -1807,13 +1807,6 @@ fn looks_error(text: &str) -> bool { text.starts_with("Error:") || text.contains(" exited with code ") } -fn tool_output_text(out: &ToolOutput) -> String { - match out { - ToolOutput::Text(s) => s.clone(), - ToolOutput::Json(v) => v.to_string(), - } -} - fn truncate_title(text: &str) -> String { const MAX: usize = 80; let one_line = text.split_whitespace().collect::>().join(" "); diff --git a/src/harness/fx.rs b/src/harness/fx.rs index 47d0660..e8298d0 100644 --- a/src/harness/fx.rs +++ b/src/harness/fx.rs @@ -878,7 +878,7 @@ impl TurnBuilder { content, is_error, } => { - let output = tool_output_string(content); + let output = content.as_text(); let bytes = output.len(); let tool_name = self .tool_names @@ -909,29 +909,6 @@ impl TurnBuilder { } } -/// fx stores tool output as a plain string. Anthropic-style block arrays -/// flatten to their text; anything else structured becomes compact JSON. -fn tool_output_string(content: &ToolOutput) -> String { - match content { - ToolOutput::Text(s) => s.clone(), - ToolOutput::Json(v) => { - let block_texts: Option> = v.as_array().and_then(|arr| { - arr.iter() - .map(|b| { - (b.get("type").and_then(Value::as_str) == Some("text")) - .then(|| b.get("text").and_then(Value::as_str)) - .flatten() - }) - .collect() - }); - match block_texts { - Some(texts) if !texts.is_empty() => texts.join("\n\n"), - Some(_) | None => v.to_string(), - } - } - } -} - fn is_prompt(msg: &Message) -> bool { msg.role == Role::User && msg.content.iter().any(|b| { diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 23407ee..4066548 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -798,7 +798,7 @@ fn body_from_messages(meta: &Meta, messages: &[Message]) -> GrokSession { tool_call_id: tool_use_id.clone(), // Grok's tool_result content is a plain // string; structured outputs are flattened. - content: Value::String(tool_output_string(content)), + content: Value::String(content.as_text().into_owned()), extra: Map::new(), })); updates.tool_result(msg.timestamp, tool_use_id, content, *is_error); @@ -1036,7 +1036,7 @@ impl UpdateLog { } fn tool_result(&mut self, ts: DateTime, id: &str, content: &ToolOutput, is_error: bool) { - let text = tool_output_string(content); + let text = content.as_text(); self.push( ts, "session/update", @@ -1069,30 +1069,6 @@ impl UpdateLog { } } -/// Grok stores tool results as plain strings. Anthropic-style block arrays -/// flatten to their text; anything else structured becomes compact JSON. -fn tool_output_string(content: &ToolOutput) -> String { - match content { - ToolOutput::Text(s) => s.clone(), - ToolOutput::Json(v) => { - let block_texts: Option> = v.as_array().and_then(|arr| { - arr.iter() - .map(|b| { - (b.get("type").and_then(Value::as_str) == Some("text")) - .then(|| b.get("text").and_then(Value::as_str)) - .flatten() - }) - .collect() - }); - match block_texts { - Some(texts) if !texts.is_empty() => texts.join("\n\n"), - // Not a text-block array (or an empty one): compact JSON. - Some(_) | None => v.to_string(), - } - } - } -} - /// Display kind for a native tool name, mirroring Grok's own labels. fn tool_kind(name: &str) -> &'static str { match name { diff --git a/src/harness/opencode.rs b/src/harness/opencode.rs index 09da176..0b0dd4d 100644 --- a/src/harness/opencode.rs +++ b/src/harness/opencode.rs @@ -590,16 +590,16 @@ fn assistant_part( { if result.1 { obj.insert("status".into(), json!("error")); - obj.insert("error".into(), json!(output_to_string(result.0))); + obj.insert("error".into(), json!(result.0.as_text())); obj.remove("output"); } else { - obj.insert("output".into(), json!(output_to_string(result.0))); + obj.insert("output".into(), json!(result.0.as_text())); } } json!({ "type": "tool", "tool": oc_name, "callID": id, "state": state }) } Block::ToolResult { content, .. } => { - json!({ "type": "text", "text": output_to_string(content), "time": time }) + json!({ "type": "text", "text": content.as_text(), "time": time }) } }; if let Value::Object(obj) = &mut p { @@ -783,13 +783,6 @@ fn value_to_output(v: Value) -> ToolOutput { } } -fn output_to_string(out: &ToolOutput) -> String { - match out { - ToolOutput::Text(s) => s.clone(), - ToolOutput::Json(v) => v.to_string(), - } -} - fn rename_keys(input: Value, renames: &[(&str, &str)]) -> Value { match input { Value::Object(mut obj) => { diff --git a/src/harness/pi.rs b/src/harness/pi.rs index 5105966..712be15 100644 --- a/src/harness/pi.rs +++ b/src/harness/pi.rs @@ -348,7 +348,7 @@ fn pi_payloads_for( "role": "toolResult", "toolCallId": tool_use_id, "toolName": tool_name, - "content": [{"type": "text", "text": tool_output_text(result)}], + "content": [{"type": "text", "text": result.as_text()}], "isError": is_error, "timestamp": ts_ms, })); @@ -1096,13 +1096,6 @@ fn provider_api(model: &str) -> (&'static str, &'static str) { } } -fn tool_output_text(out: &ToolOutput) -> String { - match out { - ToolOutput::Text(s) => s.clone(), - ToolOutput::Json(v) => v.to_string(), - } -} - fn rename_keys(input: Value, renames: &[(&str, &str)]) -> Value { match input { Value::Object(mut obj) => { diff --git a/src/search.rs b/src/search.rs index eaa7200..b3fe44f 100644 --- a/src/search.rs +++ b/src/search.rs @@ -28,7 +28,7 @@ use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String}; use serde::{Deserialize, Serialize}; -use crate::common::{Block, Message, Meta, Role, Tool, ToolOutput}; +use crate::common::{Block, Message, Meta, Role, Tool}; use crate::{Common, HarnessId, Span, Transcript}; // ── query ────────────────────────────────────────────────────────────── @@ -1053,12 +1053,9 @@ fn extract(meta: &Meta, messages: &[Message]) -> Vec { Block::ToolUse { tool, .. } => { extract_tool(tool, |text| push(m, b, Origin::ToolUse, text)); } - Block::ToolResult { content, .. } => match content { - ToolOutput::Text(text) => push(m, b, Origin::ToolResult, text), - ToolOutput::Json(value) => { - push(m, b, Origin::ToolResult, &value.to_string()); - } - }, + Block::ToolResult { content, .. } => { + push(m, b, Origin::ToolResult, &content.as_text()); + } // Images carry no searchable text. Block::Image { .. } => {} Block::Artifact { artifact } => { diff --git a/src/text.rs b/src/text.rs index 8807b4d..68bb0ab 100644 --- a/src/text.rs +++ b/src/text.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use std::fmt::Write as _; -use crate::common::{Block, Message, Meta, Role, ToolOutput}; +use crate::common::{Block, Message, Meta, Role}; use crate::{Common, Span, Transcript}; /// Render a canonical transcript as compact, LLM-oriented text. @@ -118,10 +118,7 @@ fn blocks<'a>( let short_id = short_tool_id(tool_ids, next_tool_id, tool_use_id); let error = if *is_error { " error" } else { "" }; let label = format!("result {short_id}{error}"); - match content { - ToolOutput::Text(text) => section(out, &label, text), - ToolOutput::Json(value) => section(out, &label, &value.to_string()), - } + section(out, &label, &content.as_text()); } Block::Image { source } => section( out, @@ -187,7 +184,7 @@ mod tests { use serde_json::json; use super::*; - use crate::common::{ImageSource, Message, Meta, StopReason, Tool, Usage}; + use crate::common::{ImageSource, Message, Meta, StopReason, Tool, ToolOutput, Usage}; fn transcript(body: Vec) -> Transcript { Transcript::new( diff --git a/tests/regression/main.rs b/tests/regression/main.rs index dc8cae5..d5eec09 100644 --- a/tests/regression/main.rs +++ b/tests/regression/main.rs @@ -10,6 +10,7 @@ mod claude_tool_result; #[cfg(feature = "codex_zstd")] mod codex_compressed; mod codex_pairing; +mod tool_result_blocks; use chrono::{DateTime, Utc}; use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput}; diff --git a/tests/regression/tool_result_blocks.rs b/tests/regression/tool_result_blocks.rs new file mode 100644 index 0000000..7ec15d9 --- /dev/null +++ b/tests/regression/tool_result_blocks.rs @@ -0,0 +1,179 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +//! Regressions for Claude tool results that arrive as content-block arrays. + +use serde_json::{Value, json}; +use txcript::harness::{claude_code::ClaudeCode, codex::Codex, grok::Grok, pi::Pi}; +use txcript::{Codec, Common, TextCodec, Transcript}; + +const TEXT: &str = "row1: alpha\nrow2: \"beta\""; +const PLAIN: &str = "plain line one\nplain \"two\""; +const OBJECT: &str = r#"{"count":3,"rows":["a","b"]}"#; + +fn image_data() -> String { + "iVBORw0KGgo".repeat(15_000) +} + +/// A Claude Code session whose tool results cover every shape: a text-block +/// array (every MCP tool), a text + image array (screenshots), a plain +/// string, and a bare JSON object. +fn session() -> Transcript { + let base = json!({"sessionId": "blocks-1", "cwd": "/work/repo", "version": "2.0.0"}); + let line = |mut value: Value, second: u32| { + value["timestamp"] = json!(format!("2026-01-02T03:04:{second:02}.000Z")); + for (key, field) in base.as_object().unwrap() { + value[key] = field.clone(); + } + value.to_string() + }; + let call = + |id: &str, name: &str| json!({"type": "tool_use", "id": id, "name": name, "input": {}}); + let text = [ + line( + json!({"type": "user", "uuid": "u1", "message": {"role": "user", "content": "run them"}}), + 1, + ), + line( + json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1", "message": { + "id": "m1", "role": "assistant", "model": "claude-opus-4-8", "stop_reason": "tool_use", + "content": [ + call("t1", "mcp__db__query"), + call("t2", "mcp__shot__capture"), + call("t3", "Bash"), + call("t4", "mcp__db__stats"), + ], + }}), + 2, + ), + line( + json!({"type": "user", "uuid": "u2", "parentUuid": "a1", "message": { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": [ + {"type": "text", "text": TEXT}, + ]}, + {"type": "tool_result", "tool_use_id": "t2", "content": [ + {"type": "text", "text": "screenshot taken"}, + {"type": "image", "source": { + "type": "base64", "media_type": "image/png", "data": image_data(), + }}, + ]}, + {"type": "tool_result", "tool_use_id": "t3", "content": PLAIN}, + {"type": "tool_result", "tool_use_id": "t4", "content": {"count": 3, "rows": ["a", "b"]}}, + ], + }}), + 3, + ), + ] + .join("\n"); + ClaudeCode::to_common(&ClaudeCode::from_text(&text).unwrap()).unwrap() +} + +/// The text each of the four tool results was written as, in call order. +fn codex_outputs(common: &Transcript) -> Vec { + let text = Codex::to_text(&Codex::from_common(common).unwrap()).unwrap(); + text.lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|line| line["payload"]["type"] == "function_call_output") + .map(|line| line["payload"]["output"].as_str().unwrap().to_owned()) + .collect() +} + +fn pi_outputs(common: &Transcript) -> Vec { + let text = Pi::to_text(&Pi::from_common(common).unwrap()).unwrap(); + text.lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|line| line["message"]["role"] == "toolResult") + .map(|line| { + line["message"]["content"][0]["text"] + .as_str() + .unwrap() + .to_owned() + }) + .collect() +} + +fn grok_outputs(common: &Transcript) -> Vec { + let native = Grok::from_common(common).unwrap(); + let log = serde_json::to_value(&native.body.chat_history).unwrap(); + log.as_array() + .unwrap() + .iter() + .filter(|record| record["type"] == "tool_result") + .map(|record| record["content"].as_str().unwrap().to_owned()) + .collect() +} + +/// Every Claude tool result that is a content-block array (all of MCP's, and +/// every screenshot or image read) was stringified as compact JSON. Readers +/// saw escaped `\n` and `\"` and reordered keys instead of the text, and an +/// image in the result was dumped as its entire base64 payload: a 150 KB +/// screenshot made a 200 KB `view` line, a 200 KB line in each exported +/// rollout, and the MCP server refused to read the session as over its +/// 100000-byte budget. Block arrays now render as their text, with images as +/// an `[image: ]` placeholder; strings and other JSON are +/// unchanged. +#[test] +fn tool_result_block_arrays_render_as_text_not_json() { + let common = session(); + let image = image_data(); + let screenshot = "screenshot taken\n\n[image: image/png]"; + + // `view` and the MCP `read_session` text. + let view = txcript::text::to_text(&common); + assert!(view.contains(&format!("[result 1]\n{TEXT}\n")), "{view}"); + assert!( + view.contains(&format!("[result 2]\n{screenshot}\n")), + "{view}" + ); + assert!(view.contains(&format!("[result 3]\n{PLAIN}\n"))); + assert!(view.contains(&format!("[result 4]\n{OBJECT}\n"))); + assert!(!view.contains(&image[..64])); + assert!(view.len() < 2_000, "view is {} bytes", view.len()); + + // Every exporter that writes plain-string results. + for outputs in [ + codex_outputs(&common), + pi_outputs(&common), + grok_outputs(&common), + ] { + assert_eq!(outputs, [TEXT, screenshot, PLAIN, OBJECT]); + } +} + +#[cfg(feature = "search")] +#[test] +fn tool_result_block_arrays_search_as_text_not_json() { + use txcript::search::{Origin, Query, search}; + + let common = session(); + let image = image_data(); + let mut query = Query::substring("alpha"); + query.origins = Origin::ALL.to_vec(); + let hits = search(&common, &query); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].origin, Origin::ToolResult); + assert_eq!(hits[0].line, "row1: alpha"); + + let mut query = Query::substring("screenshot"); + query.origins = Origin::ALL.to_vec(); + let lines: Vec<_> = search(&common, &query) + .into_iter() + .map(|hit| hit.line) + .collect(); + assert_eq!(lines, ["screenshot taken"]); + + for pattern in ["image/png", "iVBOR"] { + let mut query = Query::substring(pattern); + query.origins = Origin::ALL.to_vec(); + let lines: Vec<_> = search(&common, &query) + .into_iter() + .map(|hit| hit.line) + .collect(); + assert!( + lines + .iter() + .all(|line| line.len() < 100 && !line.contains(&image[..64])) + ); + } +}