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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <media type>]` 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
Expand Down
39 changes: 37 additions & 2 deletions cli/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } => {
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions docs/formats/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <media type>]` (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
Expand Down
3 changes: 2 additions & 1 deletion docs/formats/grok.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <media type>]` 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
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Common>` 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<Common>` 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: <media type>]`; 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

Expand Down
157 changes: 157 additions & 0 deletions src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
//! `Vec<Message>`. 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;
Expand Down Expand Up @@ -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: <media type>]` (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::<Vec<_>>()
.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.
Expand Down Expand Up @@ -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`].
Expand Down
5 changes: 1 addition & 4 deletions src/harness/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 1 addition & 8 deletions src/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<DateTime<Utc>> {
s.parse::<DateTime<Utc>>().ok()
}
Expand Down
21 changes: 7 additions & 14 deletions src/harness/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ fn db_from_messages(meta: &Meta, messages: &[Message]) -> Result<CursorDb> {
"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,
Expand Down Expand Up @@ -721,7 +721,7 @@ fn orphan_tool_result_state_text(blocks: &[Block], known_tool_uses: &HashSet<Str
let status = if *is_error { "error" } else { "result" };
Some(format!(
"Tool {status} for {tool_use_id}:\n{}",
tool_output_text(content)
content.as_text()
))
}
// Other blocks are not tool results.
Expand Down Expand Up @@ -888,7 +888,7 @@ fn cursor_shell_result_proto(
workdir: &str,
result: &CursorStateToolResult,
) -> Vec<u8> {
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();
Expand Down Expand Up @@ -943,7 +943,7 @@ fn cursor_read_result_proto(
result: &CursorStateToolResult,
) -> Vec<u8> {
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);
Expand Down Expand Up @@ -998,7 +998,7 @@ fn cursor_edit_result_proto(
payload: &CursorEditPayload,
result: &CursorStateToolResult,
) -> Vec<u8> {
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();
Expand Down Expand Up @@ -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}"),
Expand Down Expand Up @@ -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(),
};
Expand Down Expand Up @@ -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::<Vec<_>>().join(" ");
Expand Down
Loading