From 97676eb45fee12fe2df7f4047c0c1c82b4d75611 Mon Sep 17 00:00:00 2001 From: Drishtant Ghosh Date: Thu, 24 Sep 2026 08:33:25 +0530 Subject: [PATCH 1/2] fix(claude): close dangling tool calls with aborted results --- docs/formats/claude-code.md | 4 + src/harness/claude_code.rs | 71 +++++++++- tests/integration/claude_code.rs | 222 ++++++++++++++++++++++++++++++- 3 files changed, 295 insertions(+), 2 deletions(-) diff --git a/docs/formats/claude-code.md b/docs/formats/claude-code.md index 7792c37..c2394d8 100644 --- a/docs/formats/claude-code.md +++ b/docs/formats/claude-code.md @@ -111,6 +111,10 @@ A synthetic assistant line (real files add more envelope keys — `isSidechain`, `{"input": }`. Existing objects keep their shape. Reading that export into Common retains the wrapper; native load/save keeps the original records untouched. +- **Dangling tool calls.** A history whose `tool_use` has no `tool_result` + (Ctrl+C mid-tool) fails a `claude --resume` load with HTTP 400, so export + appends one aborted error result per unanswered call as a final user turn. + Slash-command calls are excluded; their output rides `local_command` lines. - **Resume anchoring.** A leading `summary` line's `leafUuid` must name a real user/assistant line in the file, or Claude Code reports the whole session missing; txcript anchors generated summaries to the last real turn. diff --git a/src/harness/claude_code.rs b/src/harness/claude_code.rs index 15417e7..3f4110c 100644 --- a/src/harness/claude_code.rs +++ b/src/harness/claude_code.rs @@ -223,8 +223,11 @@ pub(crate) fn records_to_messages(records: &[Record], fallback_ts: DateTime /// the `sessionId` stamped on every line (a fresh UUID when empty). Shared /// with harnesses that embed Claude Code's JSONL (Cowork). pub(crate) fn messages_to_records(meta: &Meta, messages: &[Message]) -> Vec { + // Lower first: artifact calls arrive with their results, so the dangling + // pass below must not close them a second time. let lowered = lower_artifact_messages(messages); - let messages = lowered.as_slice(); + let messages = close_dangling_calls(lowered); + let messages = messages.as_slice(); let session_id = if meta.id.is_empty() { Uuid::new_v4().to_string() } else { @@ -321,6 +324,72 @@ pub(crate) fn messages_to_records(meta: &Meta, messages: &[Message]) -> Vec) -> Vec { + // Ids still open, in first-opened order, with per-id counts: a second + // call reuses the entry, and each result closes one opening. + let mut open: Vec<&str> = Vec::new(); + let mut awaiting: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + let mut credit: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for msg in &messages { + for block in &msg.content { + match block { + Block::ToolUse { id, tool } if !matches!(tool, Tool::Command { .. }) => { + let id = id.as_str(); + if let Some(n) = credit.get_mut(id).filter(|n| **n > 0) { + *n -= 1; + } else { + if awaiting.get(id).is_none_or(|&n| n == 0) { + open.push(id); + } + *awaiting.entry(id).or_insert(0) += 1; + } + } + Block::ToolResult { tool_use_id, .. } => { + let id = tool_use_id.as_str(); + if let Some(n) = awaiting.get_mut(id).filter(|n| **n > 0) { + *n -= 1; + if *n == 0 { + open.retain(|o| *o != id); + } + } else { + *credit.entry(id).or_insert(0) += 1; + } + } + _ => {} + } + } + } + if open.is_empty() { + return messages; + } + // Non-empty `open` implies at least one message exists. + let Some(timestamp) = messages.last().map(|msg| msg.timestamp) else { + return messages; + }; + messages.push(Message { + role: Role::User, + content: open + .into_iter() + .map(|id| Block::ToolResult { + tool_use_id: id.to_string(), + content: ToolOutput::Text("Tool execution was interrupted or cancelled.".into()), + is_error: true, + }) + .collect(), + timestamp, + model: None, + stop_reason: None, + usage: None, + }); + messages +} + /// Claude Code's native artifact is a normal `Artifact` tool call followed /// by a tool result. Lower path-backed Common artifacts into that observed /// shape while leaving the rest of the serializer unchanged. diff --git a/tests/integration/claude_code.rs b/tests/integration/claude_code.rs index ceb835a..a3ce5be 100644 --- a/tests/integration/claude_code.rs +++ b/tests/integration/claude_code.rs @@ -4,7 +4,7 @@ //! extraction. use chrono::{DateTime, Utc}; -use serde_json::json; +use serde_json::{Value, json}; use txcript::common; use txcript::harness::claude_code; use txcript::{Codec, Common, Store, TextCodec, Transcript}; @@ -572,3 +572,223 @@ fn commands_are_searchable_by_name() { "the command name should be searchable" ); } + +/// Ctrl+C mid-tool leaves an assistant turn ending in an unanswered `ToolUse`. +/// Every exported `tool_use` needs its `tool_result`, or the Anthropic API +/// rejects the resumed session with HTTP 400. +#[test] +fn from_common_closes_dangling_tool_call_with_aborted_result() { + let mut common = sample_common(); + common.body.push(common::Message { + role: common::Role::Assistant, + content: vec![common::Block::ToolUse { + id: "dangling".into(), + tool: common::Tool::Bash { + command: "sleep 60".into(), + workdir: None, + timeout_ms: None, + description: None, + run_in_background: false, + }, + }], + timestamp: ts("2026-01-02T03:04:09.000Z"), + model: Some("claude-opus-4-8".into()), + stop_reason: Some(common::StopReason::Aborted), + usage: None, + }); + let native = claude_code::ClaudeCode::from_common(&common).unwrap(); + let results = exported_results(&native); + assert!( + results.contains(&("dangling".to_string(), true)), + "an unanswered call must export an error result, got {results:?}" + ); + // Paired calls must not gain a duplicate result. + assert_eq!( + results.iter().filter(|(id, _)| id == "t1").count(), + 1, + "paired calls keep exactly one result, got {results:?}" + ); +} + +fn exported_results(native: &Transcript) -> Vec<(String, bool)> { + let body = serde_json::to_value(native.body.clone()).unwrap(); + let mut results = Vec::new(); + if let Value::Array(records) = &body { + for record in records { + if let Some(blocks) = record + .get("message") + .and_then(|m| m.get("content")) + .and_then(Value::as_array) + { + for block in blocks { + if block.get("type").and_then(Value::as_str) == Some("tool_result") { + results.push(( + block["tool_use_id"].as_str().unwrap_or("").to_string(), + block + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false), + )); + } + } + } + } + } + results +} + +/// An id answered once then reused by a dangling call closes only the open +/// occurrence; the settled pair is untouched. +#[test] +fn from_common_closes_only_the_reused_call_left_open() { + let bash = || common::Tool::Bash { + command: "ls".into(), + workdir: None, + timeout_ms: None, + description: None, + run_in_background: false, + }; + let mut common = sample_common(); + let call = |id: &str, tool| common::Message { + role: common::Role::Assistant, + content: vec![common::Block::ToolUse { + id: id.into(), + tool, + }], + timestamp: ts("2026-01-02T03:04:09.000Z"), + model: None, + stop_reason: None, + usage: None, + }; + let result = |text: &str| common::Message { + role: common::Role::User, + content: vec![common::Block::ToolResult { + tool_use_id: "reused".into(), + content: common::ToolOutput::Text(text.into()), + is_error: false, + }], + timestamp: ts("2026-01-02T03:04:10.000Z"), + model: None, + stop_reason: None, + usage: None, + }; + common.body.push(call("reused", bash())); + common.body.push(result("first")); + common.body.push(call("reused", bash())); + let native = claude_code::ClaudeCode::from_common(&common).unwrap(); + let results = exported_results(&native); + assert_eq!( + results.iter().filter(|(id, _)| id == "reused").count(), + 2, + "one settled result plus one synthesized, got {results:?}" + ); + assert!( + results.contains(&("reused".to_string(), true)), + "the open reuse must close as an error, got {results:?}" + ); +} + +/// Two open calls in one turn close together: the following message must +/// begin with a matching number of results. +#[test] +fn from_common_closes_every_dangling_call_in_one_turn() { + let bash = |command: &str| common::Tool::Bash { + command: command.into(), + workdir: None, + timeout_ms: None, + description: None, + run_in_background: false, + }; + let mut common = sample_common(); + common.body.push(common::Message { + role: common::Role::Assistant, + content: vec![ + common::Block::ToolUse { + id: "open-1".into(), + tool: bash("sleep 60"), + }, + common::Block::ToolUse { + id: "open-2".into(), + tool: bash("sleep 61"), + }, + ], + timestamp: ts("2026-01-02T03:04:09.000Z"), + model: None, + stop_reason: Some(common::StopReason::Aborted), + usage: None, + }); + let native = claude_code::ClaudeCode::from_common(&common).unwrap(); + let results = exported_results(&native); + assert!( + results.contains(&("open-1".to_string(), true)) + && results.contains(&("open-2".to_string(), true)), + "both open calls must close as errors, got {results:?}" + ); +} + +/// A result recorded before its call (Codex web-search order) already +/// answers it: export must not synthesize a duplicate. +#[test] +fn from_common_keeps_result_before_call_without_duplicate() { + let mut common = sample_common(); + common.body.push(common::Message { + role: common::Role::User, + content: vec![common::Block::ToolResult { + tool_use_id: "early".into(), + content: common::ToolOutput::Text("found".into()), + is_error: false, + }], + timestamp: ts("2026-01-02T03:04:09.000Z"), + model: None, + stop_reason: None, + usage: None, + }); + common.body.push(common::Message { + role: common::Role::Assistant, + content: vec![common::Block::ToolUse { + id: "early".into(), + tool: common::Tool::Raw { + tool_name: "WebSearch".into(), + input: serde_json::json!({"query": "pairing"}), + }, + }], + timestamp: ts("2026-01-02T03:04:10.000Z"), + model: None, + stop_reason: None, + usage: None, + }); + let native = claude_code::ClaudeCode::from_common(&common).unwrap(); + let results = exported_results(&native); + assert_eq!( + results.iter().filter(|(id, _)| id == "early").count(), + 1, + "the early result answers the call, got {results:?}" + ); +} + +/// Slash-command calls are answered by `local_command` lines, never by +/// `tool_result` — synthesizing one for them would itself be rejected. +#[test] +fn from_common_leaves_command_calls_without_tool_result() { + let mut common = sample_common(); + common.body.push(common::Message { + role: common::Role::User, + content: vec![common::Block::ToolUse { + id: "cmd-1".into(), + tool: common::Tool::Command { + command: "/release".into(), + args: None, + }, + }], + timestamp: ts("2026-01-02T03:04:09.000Z"), + model: None, + stop_reason: None, + usage: None, + }); + let native = claude_code::ClaudeCode::from_common(&common).unwrap(); + let results = exported_results(&native); + assert!( + results.iter().all(|(id, _)| id != "cmd-1"), + "command calls must not gain a tool_result, got {results:?}" + ); +} From feae307bbf33af68860870f5f12207e7155f4e13 Mon Sep 17 00:00:00 2001 From: Drishtant Ghosh Date: Sat, 26 Sep 2026 03:16:13 +0530 Subject: [PATCH 2/2] fix(claude): insert aborted results after the call, keep export deterministic --- docs/formats/claude-code.md | 5 +- src/harness/claude_code.rs | 99 +++++++++++++++++++------------- tests/integration/claude_code.rs | 75 ++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 43 deletions(-) diff --git a/docs/formats/claude-code.md b/docs/formats/claude-code.md index c2394d8..e3e70c2 100644 --- a/docs/formats/claude-code.md +++ b/docs/formats/claude-code.md @@ -113,8 +113,9 @@ A synthetic assistant line (real files add more envelope keys — `isSidechain`, records untouched. - **Dangling tool calls.** A history whose `tool_use` has no `tool_result` (Ctrl+C mid-tool) fails a `claude --resume` load with HTTP 400, so export - appends one aborted error result per unanswered call as a final user turn. - Slash-command calls are excluded; their output rides `local_command` lines. + inserts one aborted error result per unanswered call directly after the + turn that made it. Slash-command calls are excluded; their output rides + `local_command` lines. - **Resume anchoring.** A leading `summary` line's `leafUuid` must name a real user/assistant line in the file, or Claude Code reports the whole session missing; txcript anchors generated summaries to the last real turn. diff --git a/src/harness/claude_code.rs b/src/harness/claude_code.rs index 3f4110c..f19a844 100644 --- a/src/harness/claude_code.rs +++ b/src/harness/claude_code.rs @@ -325,18 +325,21 @@ pub(crate) fn messages_to_records(meta: &Meta, messages: &[Message]) -> Vec) -> Vec { - // Ids still open, in first-opened order, with per-id counts: a second - // call reuses the entry, and each result closes one opening. - let mut open: Vec<&str> = Vec::new(); - let mut awaiting: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); +/// each, inserted directly after the turn that made them: the Anthropic API +/// rejects a resume whose `tool_use` lacks its `tool_result` in the next +/// message (HTTP 400), e.g. after Ctrl+C mid-tool — appending at the end +/// would leave a mid-transcript interruption just as broken. Slash commands +/// are excluded — their output rides `local_command` lines, where a stray +/// `tool_result` is itself rejected. A result recorded before its call +/// (Codex web-search order) already answers it, so it holds as credit +/// instead of drawing a duplicate. +fn close_dangling_calls(messages: Vec) -> Vec { + // Message indices of still-open calls per id, in opened order; each + // result answers the earliest opening, and anything recorded early + // holds as credit for a call that arrives later. + let mut open: std::collections::HashMap<&str, Vec> = std::collections::HashMap::new(); let mut credit: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); - for msg in &messages { + for (i, msg) in messages.iter().enumerate() { for block in &msg.content { match block { Block::ToolUse { id, tool } if !matches!(tool, Tool::Command { .. }) => { @@ -344,19 +347,13 @@ fn close_dangling_calls(mut messages: Vec) -> Vec { if let Some(n) = credit.get_mut(id).filter(|n| **n > 0) { *n -= 1; } else { - if awaiting.get(id).is_none_or(|&n| n == 0) { - open.push(id); - } - *awaiting.entry(id).or_insert(0) += 1; + open.entry(id).or_default().push(i); } } Block::ToolResult { tool_use_id, .. } => { let id = tool_use_id.as_str(); - if let Some(n) = awaiting.get_mut(id).filter(|n| **n > 0) { - *n -= 1; - if *n == 0 { - open.retain(|o| *o != id); - } + if let Some(v) = open.get_mut(id).filter(|v| !v.is_empty()) { + v.remove(0); } else { *credit.entry(id).or_insert(0) += 1; } @@ -365,29 +362,49 @@ fn close_dangling_calls(mut messages: Vec) -> Vec { } } } - if open.is_empty() { - return messages; + // Unmatched openings per message, in opened order, so each turn's + // results land directly after it. Owned ids: the rebuild below moves + // `messages`, which borrowed keys would not survive. + let mut inserts: Vec> = vec![Vec::new(); messages.len()]; + for (id, idxs) in &open { + for &i in idxs { + inserts[i].push((*id).to_string()); + } + } + // HashMap iteration is randomly ordered: sort each turn's ids so the + // export is deterministic for the same input. + for ids in &mut inserts { + ids.sort(); } - // Non-empty `open` implies at least one message exists. - let Some(timestamp) = messages.last().map(|msg| msg.timestamp) else { + let turns = inserts.iter().filter(|v| !v.is_empty()).count(); + if turns == 0 { return messages; - }; - messages.push(Message { - role: Role::User, - content: open - .into_iter() - .map(|id| Block::ToolResult { - tool_use_id: id.to_string(), - content: ToolOutput::Text("Tool execution was interrupted or cancelled.".into()), - is_error: true, - }) - .collect(), - timestamp, - model: None, - stop_reason: None, - usage: None, - }); - messages + } + let mut closed = Vec::with_capacity(messages.len() + turns); + for (msg, ids) in messages.into_iter().zip(inserts) { + let timestamp = msg.timestamp; + closed.push(msg); + if !ids.is_empty() { + closed.push(Message { + role: Role::User, + content: ids + .into_iter() + .map(|tool_use_id| Block::ToolResult { + tool_use_id, + content: ToolOutput::Text( + "Tool execution was interrupted or cancelled.".into(), + ), + is_error: true, + }) + .collect(), + timestamp, + model: None, + stop_reason: None, + usage: None, + }); + } + } + closed } /// Claude Code's native artifact is a normal `Artifact` tool call followed diff --git a/tests/integration/claude_code.rs b/tests/integration/claude_code.rs index a3ce5be..1461c13 100644 --- a/tests/integration/claude_code.rs +++ b/tests/integration/claude_code.rs @@ -724,6 +724,13 @@ fn from_common_closes_every_dangling_call_in_one_turn() { && results.contains(&("open-2".to_string(), true)), "both open calls must close as errors, got {results:?}" ); + // Same input, identical output: insert order must not depend on hash + // iteration order. + let again = claude_code::ClaudeCode::from_common(&common).unwrap(); + assert_eq!( + serde_json::to_value(native.body).unwrap(), + serde_json::to_value(again.body).unwrap() + ); } /// A result recorded before its call (Codex web-search order) already @@ -766,6 +773,74 @@ fn from_common_keeps_result_before_call_without_duplicate() { ); } +/// An interruption followed by more conversation: the synthetic result is +/// inserted directly after the call, where the API requires it — not at +/// the end. +#[test] +fn from_common_inserts_aborted_result_immediately_after_the_call() { + let bash = || common::Tool::Bash { + command: "sleep 60".into(), + workdir: None, + timeout_ms: None, + description: None, + run_in_background: false, + }; + let mut common = sample_common(); + common.body.push(common::Message { + role: common::Role::Assistant, + content: vec![common::Block::ToolUse { + id: "mid".into(), + tool: bash(), + }], + timestamp: ts("2026-01-02T03:04:09.000Z"), + model: None, + stop_reason: Some(common::StopReason::Aborted), + usage: None, + }); + common.body.push(common::Message { + role: common::Role::User, + content: vec![common::Block::Text { + text: "never mind".into(), + }], + timestamp: ts("2026-01-02T03:04:10.000Z"), + model: None, + stop_reason: None, + usage: None, + }); + let native = claude_code::ClaudeCode::from_common(&common).unwrap(); + let body = serde_json::to_value(native.body).unwrap(); + let records = body.as_array().unwrap(); + let at = records + .iter() + .position(|r| { + r.get("message") + .and_then(|m| m.get("content")) + .and_then(Value::as_array) + .is_some_and(|blocks| { + blocks.iter().any(|b| { + b.get("type").and_then(Value::as_str) == Some("tool_use") + && b.get("id").and_then(Value::as_str) == Some("mid") + }) + }) + }) + .unwrap(); + let next = &records[at + 1]; + let first = next + .get("message") + .and_then(|m| m.get("content")) + .and_then(Value::as_array) + .and_then(|blocks| blocks.first()); + assert!( + next.get("type").and_then(Value::as_str) == Some("user") + && first.and_then(|b| b.get("type").and_then(Value::as_str)) == Some("tool_result") + && first.and_then(|b| b.get("tool_use_id").and_then(Value::as_str)) == Some("mid") + && first + .and_then(|b| b.get("is_error").and_then(Value::as_bool)) + .unwrap_or(false), + "the call must be followed at once by its error result, got {next}" + ); +} + /// Slash-command calls are answered by `local_command` lines, never by /// `tool_result` — synthesizing one for them would itself be rejected. #[test]