Merge pull request #6766 from Hmbown/fix/tool-execution-identity

fix(engine): separate execution identity from provider tool IDs
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-29 15:24:36 -07:00
commit 1b975f16d1
70 files changed
+4102 -713

No files matched your search

+128
View File
@@ -163,6 +163,11 @@ pub enum ContentBlock {
id: String,
name: String,
input: serde_json::Value,
/// Host-owned execution correlation, distinct from the provider's id.
/// Persisted with history; provider adapters project only wire fields.
/// Missing identifies legacy/provider-only history, never a new grant.
#[serde(default, skip_serializing_if = "Option::is_none")]
execution_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
caller: Option<ToolCaller>,
/// Google thought signature captured from the OpenAI-compat route's
@@ -176,6 +181,9 @@ pub enum ContentBlock {
ToolResult {
tool_use_id: String,
content: String,
/// The exact host execution that produced this result, when recorded.
#[serde(default, skip_serializing_if = "Option::is_none")]
execution_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -199,7 +207,53 @@ pub enum ContentBlock {
},
}
/// Identity for pairing host history without conflating a provider's reused
/// string with a local execution. Legacy identity cannot establish a grant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolCallKey<'a> {
Execution(&'a str),
LegacyProvider(&'a str),
}
impl<'a> ToolCallKey<'a> {
#[must_use]
pub fn as_str(self) -> &'a str {
match self {
Self::Execution(id) | Self::LegacyProvider(id) => id,
}
}
}
impl ContentBlock {
/// Pairing identity in persisted host history. An explicit execution id
/// never falls back to provider correlation, even if malformed or mismatched.
/// Wire adapters must continue using the original id/tool_use_id instead.
#[must_use]
pub fn tool_call_key(&self) -> Option<ToolCallKey<'_>> {
match self {
Self::ToolUse {
id, execution_id, ..
}
| Self::ToolResult {
tool_use_id: id,
execution_id,
..
} => Some(
execution_id
.as_deref()
.map_or(ToolCallKey::LegacyProvider(id), ToolCallKey::Execution),
),
Self::ServerToolUse { id, .. }
| Self::ToolSearchToolResult {
tool_use_id: id, ..
}
| Self::CodeExecutionToolResult {
tool_use_id: id, ..
} => Some(ToolCallKey::LegacyProvider(id)),
_ => None,
}
}
/// Build readable reasoning with no provider-owned continuity state.
#[must_use]
pub fn thinking(thinking: impl Into<String>) -> Self {
@@ -321,6 +375,80 @@ mod tests {
);
}
#[test]
fn tool_execution_identity_round_trips_without_changing_legacy_bytes() {
let legacy = br#"[{"role":"assistant","content":[{"type":"tool_use","id":"wire","name":"read","input":{},"caller":{"type":"code_execution","tool_id":"parent-wire"},"thought_signature":"signature"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","content":"result"}]}]"#;
let mut messages: Vec<Message> = serde_json::from_slice(legacy).unwrap();
assert_eq!(serde_json::to_vec(&messages).unwrap(), legacy);
for block in messages.iter_mut().flat_map(|message| &mut message.content) {
match block {
ContentBlock::ToolUse { execution_id, .. }
| ContentBlock::ToolResult { execution_id, .. } => {
assert!(execution_id.is_none());
*execution_id = Some("local-execution".to_string());
}
_ => unreachable!(),
}
}
let persisted = serde_json::to_vec(&messages).unwrap();
let restored: Vec<Message> = serde_json::from_slice(&persisted).unwrap();
assert_eq!(restored, messages);
assert_eq!(
restored[0].content[0].tool_call_key(),
restored[1].content[0].tool_call_key()
);
let ContentBlock::ToolUse {
id,
caller,
thought_signature,
..
} = &restored[0].content[0]
else {
unreachable!()
};
assert_eq!(id, "wire");
assert_eq!(
caller.as_ref().unwrap().tool_id.as_deref(),
Some("parent-wire")
);
assert_eq!(thought_signature.as_deref(), Some("signature"));
}
#[test]
fn tool_history_keys_never_fall_back_or_cross_identity_domains() {
let call = |execution_id: Option<&str>| ContentBlock::ToolUse {
id: "same-string".to_string(),
name: "read".to_string(),
input: json!({}),
execution_id: execution_id.map(str::to_string),
caller: None,
thought_signature: None,
};
let legacy = call(None);
let local = call(Some("same-string"));
let different = call(Some("other-execution"));
let malformed = call(Some(""));
assert_eq!(
legacy.tool_call_key(),
Some(ToolCallKey::LegacyProvider("same-string"))
);
assert_eq!(
local.tool_call_key(),
Some(ToolCallKey::Execution("same-string"))
);
assert_ne!(legacy.tool_call_key(), local.tool_call_key());
assert_ne!(local.tool_call_key(), different.tool_call_key());
assert_eq!(malformed.tool_call_key(), Some(ToolCallKey::Execution("")));
assert_eq!(
std::collections::HashSet::from([
legacy.tool_call_key().unwrap(),
local.tool_call_key().unwrap(),
])
.len(),
2
);
}
#[test]
fn primary_turn_preparation_owns_shared_defaults() {
let request = prepare_primary_turn_request(primary_turn());
+1 -1
View File
@@ -71,7 +71,7 @@ const COMPACTION_THRESHOLD_PERCENT: u32 = 80;
pub use codewhale_core::request::{
CacheControl, ContentBlock, INTERRUPTED_ASSISTANT_CONTEXT_PREFIX, INTERRUPTED_ASSISTANT_ROLE,
ImageUrlContent, Message, MessageRequest, OpaqueReasoningState, SystemBlock, SystemPrompt,
Tool, ToolCaller,
Tool, ToolCallKey, ToolCaller,
};
#[allow(unused_imports)]
pub use codewhale_core::role::Role;
+104 -16
View File
@@ -21,6 +21,9 @@ pub struct ToolRepairReceipt {
pub repaired_call_ids: Vec<String>,
pub duplicate_result_ids: Vec<String>,
pub orphan_result_ids: Vec<String>,
/// Exact synthetic result positions in the repaired messages; callers must
/// not rediscover them by potentially reused provider IDs.
pub repaired_result_positions: Vec<(usize, usize)>,
}
impl ToolRepairReceipt {
@@ -69,7 +72,7 @@ fn repair_tool_call_pairs_inner(
let mut pending_call_message = None;
let mut pending_call_ids = Vec::new();
let mut retained_for_pending = HashSet::new();
let mut missing_by_message: HashMap<usize, Vec<String>> = HashMap::new();
let mut missing_by_message: HashMap<usize, Vec<(String, Option<String>)>> = HashMap::new();
let mut repaired_call_ids = Vec::new();
let mut duplicate_result_ids = Vec::new();
let mut orphan_result_ids = Vec::new();
@@ -90,10 +93,7 @@ fn repair_tool_call_pairs_inner(
pending_call_ids = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.clone()),
_ => None,
})
.filter(|block| matches!(block, ContentBlock::ToolUse { .. }))
.collect();
pending_call_message = (!pending_call_ids.is_empty()).then_some(message_index);
retained_for_pending.clear();
@@ -107,10 +107,22 @@ fn repair_tool_call_pairs_inner(
let follows_known_call = pending_call_message
.is_some_and(|call_index| call_index < message_index)
&& pending_call_ids.iter().any(|id| id == tool_use_id);
&& block
.tool_call_key()
.is_some_and(|key| !key.as_str().trim().is_empty())
&& pending_call_ids
.iter()
.filter(|call| call.tool_call_key() == block.tool_call_key())
.count()
== 1
&& pending_call_ids.iter().any(|call| {
call.tool_call_key() == block.tool_call_key()
&& matches!(call, ContentBlock::ToolUse { id, .. } if id == tool_use_id)
});
if !follows_known_call {
orphan_result_ids.push(tool_use_id.clone());
} else if !retained_for_pending.insert(tool_use_id.clone()) {
} else if !retained_for_pending.insert(block.tool_call_key().expect("tool result key"))
{
duplicate_result_ids.push(tool_use_id.clone());
} else {
keep_results.insert(ordinal);
@@ -125,10 +137,11 @@ fn repair_tool_call_pairs_inner(
&mut repaired_call_ids,
);
let receipt = ToolRepairReceipt {
let mut receipt = ToolRepairReceipt {
repaired_call_ids,
duplicate_result_ids,
orphan_result_ids,
repaired_result_positions: Vec::new(),
};
if receipt.is_empty() {
return receipt;
@@ -161,11 +174,15 @@ fn repair_tool_call_pairs_inner(
}
if !missing_after_message.is_empty() {
receipt.repaired_result_positions.extend(
(0..missing_after_message.len()).map(|block_index| (rebuilt.len(), block_index)),
);
rebuilt.push(Message {
role: Role::User,
content: missing_after_message
.into_iter()
.map(|tool_use_id| ContentBlock::ToolResult {
.map(|(tool_use_id, execution_id)| ContentBlock::ToolResult {
execution_id,
tool_use_id,
content: CRASH_REPAIR_CONTENT.to_string(),
is_error: Some(true),
@@ -191,23 +208,32 @@ fn repair_tool_call_pairs_inner(
fn record_missing_results(
call_message: Option<usize>,
call_ids: &[String],
retained_results: &HashSet<String>,
missing_by_message: &mut HashMap<usize, Vec<String>>,
calls: &[&ContentBlock],
retained_results: &HashSet<codewhale_models::ToolCallKey<'_>>,
missing_by_message: &mut HashMap<usize, Vec<(String, Option<String>)>>,
repaired_call_ids: &mut Vec<String>,
) {
let Some(message_index) = call_message else {
return;
};
let missing = call_ids
let missing = calls
.iter()
.filter(|id| !retained_results.contains(*id))
.cloned()
.filter(|call| {
!call
.tool_call_key()
.is_some_and(|key| retained_results.contains(&key))
})
.filter_map(|call| match call {
ContentBlock::ToolUse {
id, execution_id, ..
} => Some((id.clone(), execution_id.clone())),
_ => None,
})
.collect::<Vec<_>>();
if missing.is_empty() {
return;
}
repaired_call_ids.extend(missing.iter().cloned());
repaired_call_ids.extend(missing.iter().map(|(id, _)| id.clone()));
missing_by_message.insert(message_index, missing);
}
@@ -221,6 +247,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: "read_file".to_string(),
input: json!({"path": "README.md"}),
@@ -234,6 +261,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
@@ -252,6 +280,66 @@ mod tests {
}
}
#[test]
fn repair_preserves_execution_identity_and_refuses_mismatched_results() {
let mut messages: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"first","name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"kept"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"second","name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"stale"}]}
])).unwrap();
let first_pair = messages[..2].to_vec();
let receipt = repair_tool_call_pairs_for_provider(&mut messages);
assert_eq!(&messages[..2], first_pair.as_slice());
assert_eq!(receipt.orphan_result_ids, ["wire"]);
assert_eq!(receipt.repaired_result_positions, [(3, 0)]);
assert!(
matches!(&messages[3].content[0], ContentBlock::ToolResult { tool_use_id, execution_id: Some(id), content, is_error: Some(true), .. }
if tool_use_id == "wire" && id == "second" && content == CRASH_REPAIR_CONTENT)
);
assert!(repair_tool_call_pairs_for_provider(&mut messages).is_empty());
for (call_id, result_id, result_wire) in [
(Some("local"), None, "wire"),
(None, Some("wire"), "wire"),
(Some("local"), Some("local"), "wrong-wire"),
(Some(""), Some(""), "wire"),
] {
let mut malformed: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":call_id,"name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":result_wire,"execution_id":result_id,"content":"not a match"}]}
])).unwrap();
let receipt = repair_tool_call_pairs_for_provider(&mut malformed);
assert_eq!(receipt.orphan_result_ids.len(), 1);
assert_eq!(receipt.repaired_result_positions, [(1, 0)]);
assert!(
matches!(&malformed[1].content[0], ContentBlock::ToolResult { execution_id, .. } if execution_id.as_deref() == call_id)
);
}
}
#[test]
fn duplicate_execution_ids_cannot_lend_one_result_to_two_calls() {
let mut messages: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[
{"type":"tool_use","id":"wire-a","execution_id":"duplicate","name":"read","input":{}},
{"type":"tool_use","id":"wire-b","execution_id":"duplicate","name":"read","input":{}}
]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire-a","execution_id":"duplicate","content":"not attributable"}]}
])).unwrap();
let receipt = repair_tool_call_pairs_for_provider(&mut messages);
assert_eq!(receipt.orphan_result_ids, ["wire-a"]);
assert_eq!(receipt.repaired_result_positions, [(1, 0), (1, 1)]);
assert!(
messages[1]
.content
.iter()
.all(|block| matches!(block, ContentBlock::ToolResult {
execution_id: Some(id), content, is_error: Some(true), ..
} if id == "duplicate" && content == CRASH_REPAIR_CONTENT))
);
}
#[test]
fn well_formed_history_is_unchanged() {
let mut messages = vec![tool_call("call-1"), tool_result("call-1", "ok")];
+186 -37
View File
@@ -364,7 +364,11 @@ impl AgenticPromptError {
/// the turn hanging.
#[derive(Debug, Clone)]
struct PendingToolCall {
/// Original provider pairing, retained only for wire history.
id: String,
execution_id: String,
caller: Option<codewhale_models::ToolCaller>,
thought_signature: Option<String>,
name: String,
input: Value,
parse_error: Option<String>,
@@ -375,6 +379,8 @@ struct PendingToolCall {
#[derive(Debug, Default)]
struct ToolUseAccumulator {
id: String,
caller: Option<codewhale_models::ToolCaller>,
thought_signature: Option<String>,
name: String,
initial_input: Value,
buffer: String,
@@ -382,9 +388,13 @@ struct ToolUseAccumulator {
impl ToolUseAccumulator {
fn finalize(self) -> PendingToolCall {
let execution_id = uuid::Uuid::new_v4().to_string();
if self.buffer.trim().is_empty() {
return PendingToolCall {
id: self.id,
execution_id,
caller: self.caller,
thought_signature: self.thought_signature,
name: self.name,
input: self.initial_input,
parse_error: None,
@@ -393,12 +403,18 @@ impl ToolUseAccumulator {
match serde_json::from_str(&self.buffer) {
Ok(input) => PendingToolCall {
id: self.id,
execution_id,
caller: self.caller,
thought_signature: self.thought_signature,
name: self.name,
input,
parse_error: None,
},
Err(_) => PendingToolCall {
id: self.id,
execution_id,
caller: self.caller,
thought_signature: self.thought_signature,
name: self.name,
input: json!({}),
parse_error: Some(self.buffer),
@@ -474,12 +490,14 @@ where
match event {
StreamEvent::ContentBlockStart {
index,
content_block: ContentBlockStart::ToolUse { id, name, input, ..},
content_block: ContentBlockStart::ToolUse { id, name, input, caller, thought_signature },
} => {
pending_tool_uses.insert(
index,
ToolUseAccumulator {
id,
caller,
thought_signature,
name,
initial_input: input,
buffer: String::new(),
@@ -798,7 +816,7 @@ async fn prepare_acp_tool_with_hooks(
let hook_outcome = run_tool_call_before_hooks(
registry.context().runtime.hook_executor.as_ref(),
&call.name,
&call.id,
&call.execution_id,
&call.input,
acp_mode(config),
registry.context().workspace.as_path(),
@@ -850,7 +868,7 @@ where
"params": {
"sessionId": session_id,
"toolCall": {
"toolCallId": call.id,
"toolCallId": call.execution_id,
"title": tool_call_title(call),
"kind": tool_call_kind(call),
"status": "pending",
@@ -1032,7 +1050,7 @@ where
)
.await?;
Ok(tool_result_message_with_blocks(
&call.id,
call,
content,
is_error,
rich_blocks
@@ -1056,7 +1074,7 @@ where
write_tool_call_start(writer, session_id, &call).await?;
let content = "Cancelled before execution; the tool was not run.";
write_tool_call_update(writer, session_id, &call, "failed", Some(content)).await?;
messages.push(tool_result_message(&call.id, content.to_string(), true));
messages.push(tool_result_message(&call, content.to_string(), true));
}
Ok(messages)
}
@@ -1104,7 +1122,7 @@ where
if let Some(parse_error) = call.parse_error.clone() {
let content = format!("Error: tool arguments were not valid JSON: {parse_error}");
write_tool_call_update(writer, session_id, &call, "failed", Some(&content)).await?;
result_messages.push(tool_result_message(&call.id, content, true));
result_messages.push(tool_result_message(&call, content, true));
continue;
}
@@ -1113,7 +1131,7 @@ where
Err(err) => {
let content = format!("Error: {err}");
write_tool_call_update(writer, session_id, &call, "failed", Some(&content)).await?;
result_messages.push(tool_result_message(&call.id, content, true));
result_messages.push(tool_result_message(&call, content, true));
continue;
}
};
@@ -1124,7 +1142,7 @@ where
AcpToolAdmission::Block(reason) => {
let content = format!("Blocked by Codewhale policy: {reason}");
write_tool_call_update(writer, session_id, &call, "failed", Some(&content)).await?;
result_messages.push(tool_result_message(&call.id, content, true));
result_messages.push(tool_result_message(&call, content, true));
continue;
}
AcpToolAdmission::RequestPermission(reason) => {
@@ -1142,18 +1160,14 @@ where
AcpPermissionDecision::Reject(content) => {
write_tool_call_update(writer, session_id, &call, "failed", Some(&content))
.await?;
result_messages.push(tool_result_message(&call.id, content, true));
result_messages.push(tool_result_message(&call, content, true));
continue;
}
AcpPermissionDecision::Cancelled => {
let content = "Cancelled while awaiting permission; the tool was not run.";
write_tool_call_update(writer, session_id, &call, "failed", Some(content))
.await?;
result_messages.push(tool_result_message(
&call.id,
content.to_string(),
true,
));
result_messages.push(tool_result_message(&call, content.to_string(), true));
result_messages.extend(
record_unstarted_cancelled_calls(writer, session_id, calls).await?,
);
@@ -1166,7 +1180,10 @@ where
write_tool_call_update(writer, session_id, &call, "in_progress", None).await?;
let cancel_token = CancellationToken::new();
let mut turn_context = registry.context().clone();
let mut turn_context = registry
.context()
.clone()
.with_origin_tool_call_id(call.execution_id.clone());
turn_context.cancel_token = Some(cancel_token.clone());
let exec_fut = registry.execute_rich_full_with_context(
&call.name,
@@ -1255,12 +1272,12 @@ where
Ok(ToolBatchOutcome::Completed(result_messages))
}
fn tool_result_message(tool_use_id: &str, content: String, is_error: bool) -> Message {
tool_result_message_with_blocks(tool_use_id, content, is_error, Vec::new())
fn tool_result_message(call: &PendingToolCall, content: String, is_error: bool) -> Message {
tool_result_message_with_blocks(call, content, is_error, Vec::new())
}
fn tool_result_message_with_blocks(
tool_use_id: &str,
call: &PendingToolCall,
content: String,
is_error: bool,
content_blocks: Vec<Value>,
@@ -1268,7 +1285,8 @@ fn tool_result_message_with_blocks(
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: tool_use_id.to_string(),
execution_id: Some(call.execution_id.clone()),
tool_use_id: call.id.clone(),
content,
is_error: Some(is_error),
content_blocks: (!content_blocks.is_empty()).then_some(content_blocks),
@@ -1310,7 +1328,7 @@ where
R: AsyncBufRead + Unpin,
W: AsyncWrite + Unpin,
F: FnMut(Vec<Message>) -> Fut,
Fut: Future<Output = Result<StreamEventBox>>,
Fut: Future<Output = Result<(StreamEventBox, codewhale_config::provider::WireFormat)>>,
{
let AcpTurnContext {
session_id,
@@ -1323,7 +1341,7 @@ where
let mut empty_stop_retries: u32 = 0;
let mut empty_stop_nudge = false;
for _round in 0..MAX_ACP_TOOL_ROUNDS {
let (outcome, tool_calls) = loop {
let (outcome, tool_calls, protocol) = loop {
let mut outbound = messages.clone();
// Request-scoped: the nudge rides this one request and is never
// committed to the session history.
@@ -1337,7 +1355,7 @@ where
}],
});
}
let stream = open_stream(outbound)
let (stream, protocol) = open_stream(outbound)
.await
.map_err(|error| AgenticPromptError::new(error, &messages, has_tool_receipts))?;
let (outcome, tool_calls) =
@@ -1349,7 +1367,7 @@ where
let answerless = matches!(&outcome, PromptOutcome::Completed(text) if text.trim().is_empty())
&& tool_calls.is_empty();
if !answerless {
break (outcome, tool_calls);
break (outcome, tool_calls, protocol);
}
// Nothing was streamed to the client for this response, so a
// retry is invisible to it until the budget is spent.
@@ -1383,6 +1401,24 @@ where
PromptOutcome::MaxRounds(text) => text,
};
if let Err(error) = crate::client::validate_tool_call_ids_for_protocol(
protocol,
tool_calls.iter().map(|call| call.id.as_str()),
) {
// Text already shown remains real; ambiguous provider pairs never
// enter history or reach hooks, permission prompts, or dispatch.
if !text.is_empty() {
messages.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::Text {
text,
cache_control: None,
}],
});
}
return Err(AgenticPromptError::new(error, &messages, true));
}
let mut assistant_content = Vec::new();
if !text.is_empty() {
assistant_content.push(ContentBlock::Text {
@@ -1392,11 +1428,12 @@ where
}
for call in &tool_calls {
assistant_content.push(ContentBlock::ToolUse {
execution_id: Some(call.execution_id.clone()),
id: call.id.clone(),
name: call.name.clone(),
input: call.input.clone(),
caller: None,
thought_signature: None,
caller: call.caller.clone(),
thought_signature: call.thought_signature.clone(),
});
}
if !assistant_content.is_empty() {
@@ -2024,7 +2061,7 @@ impl AcpServer {
cwd: &PathBuf,
tool_registry: &ToolRegistry,
frozen_system_prompt: &std::sync::Mutex<Option<SystemPrompt>>,
) -> Result<StreamEventBox> {
) -> Result<(StreamEventBox, codewhale_config::provider::WireFormat)> {
let _cwd_guard = ScopedCurrentDir::new(cwd)?;
let last_user_text = messages
.iter()
@@ -2097,7 +2134,8 @@ impl AcpServer {
top_p: None,
};
client.create_message_stream(request).await
let protocol = client.wire_format();
Ok((client.create_message_stream(request).await?, protocol))
}
}
@@ -2425,7 +2463,7 @@ where
"sessionId": session_id,
"update": {
"sessionUpdate": "tool_call",
"toolCallId": call.id,
"toolCallId": call.execution_id,
"title": tool_call_title(call),
"kind": tool_call_kind(call),
"status": "pending",
@@ -2462,7 +2500,7 @@ where
{
let mut update = json!({
"sessionUpdate": "tool_call_update",
"toolCallId": call.id,
"toolCallId": call.execution_id,
"status": status,
});
if content.is_some() || !rich_blocks.is_empty() {
@@ -2742,6 +2780,9 @@ mod tests {
async fn tool_update_emits_typed_acp_image_content() {
let mut output = Vec::new();
let call = PendingToolCall {
execution_id: uuid::Uuid::new_v4().to_string(),
caller: None,
thought_signature: None,
id: "call_image_1".to_string(),
name: "read".to_string(),
input: json!({"path": "shot.png"}),
@@ -4305,6 +4346,9 @@ mod tests {
fn pending_call(name: &str, input: Value) -> PendingToolCall {
PendingToolCall {
execution_id: uuid::Uuid::new_v4().to_string(),
caller: None,
thought_signature: None,
id: "call_1".to_string(),
name: name.to_string(),
input,
@@ -4796,12 +4840,82 @@ mod tests {
Self(RefCell::new(VecDeque::from(streams)))
}
async fn next(&self) -> Result<StreamEventBox> {
Ok(self
.0
.borrow_mut()
.pop_front()
.expect("test provided enough scripted rounds"))
async fn next(&self) -> Result<(StreamEventBox, codewhale_config::provider::WireFormat)> {
self.next_for_protocol(codewhale_config::provider::WireFormat::ChatCompletions)
.await
}
async fn next_for_protocol(
&self,
protocol: codewhale_config::provider::WireFormat,
) -> Result<(StreamEventBox, codewhale_config::provider::WireFormat)> {
Ok((
self.0
.borrow_mut()
.pop_front()
.expect("test provided enough scripted rounds"),
protocol,
))
}
}
#[tokio::test]
async fn agentic_turn_refuses_ambiguous_pairing_before_observation_or_effects() {
use codewhale_config::provider::WireFormat;
for (protocol, ids) in [
(WireFormat::ChatCompletions, ["same", "same"]),
(WireFormat::AnthropicMessages, ["valid", ""]),
(WireFormat::Responses, ["same|item-a", "same|item-b"]),
] {
let (dir, registry) = workspace_registry();
let mut events = vec![text_delta("Kept response text")];
for (index, id) in ids.into_iter().enumerate() {
events.extend(tool_use_events(
index as u32,
id,
"File",
r#"{"action":"write","path":"must-not-exist","content":"no"}"#,
));
}
events.push(StreamEvent::MessageStop);
let scripted = ScriptedStreams::new(vec![ready_stream(events)]);
let mut reader = lines_from("");
let mut out = Vec::new();
let error = run_agentic_prompt_turn(
AcpTurnContext {
config: &Config::default(),
model: "test-model",
session_id: "pairing",
tool_registry: &registry,
response_id_policy: JsonRpcResponseIdPolicy::Preserve,
},
Vec::new(),
&mut reader,
&mut out,
|_| scripted.next_for_protocol(protocol),
)
.await
.expect_err("invalid whole batch is refused");
assert!(!dir.path().join("must-not-exist").exists());
let messages = error.partial_messages.expect("already shown text retained");
assert_eq!(messages.len(), 1);
assert!(
matches!(&messages[0].content[0], ContentBlock::Text { text, .. } if text == "Kept response text")
);
assert!(
messages
.iter()
.flat_map(|m| &m.content)
.all(|b| b.tool_call_key().is_none())
);
let updates = parse_lines(out);
assert!(
updates
.iter()
.all(|message| message["method"] != "session/request_permission"
&& message["params"]["update"]["sessionUpdate"] != "tool_call"
&& message["params"]["update"]["sessionUpdate"] != "tool_call_update")
);
}
}
@@ -4949,7 +5063,7 @@ mod tests {
// After seeing a.txt's contents, the model asks for b.txt too.
let round2 = ready_stream({
let mut events =
tool_use_events(0, "call_2", "File", r#"{"action":"read","path":"b.txt"}"#);
tool_use_events(0, "call_1", "File", r#"{"action":"read","path":"b.txt"}"#);
events.push(StreamEvent::MessageStop);
events
});
@@ -4959,6 +5073,7 @@ mod tests {
]);
let scripted = ScriptedStreams::new(vec![round1, round2, round3]);
let outbound = RefCell::new(Vec::new());
let mut reader = lines_from("");
let mut out = Vec::new();
@@ -4979,7 +5094,10 @@ mod tests {
}],
&mut reader,
&mut out,
|_msgs| scripted.next(),
|msgs| {
outbound.borrow_mut().push(msgs);
scripted.next()
},
)
.await
.expect("turn completes");
@@ -5005,6 +5123,37 @@ mod tests {
panic!("expected tool_result for b.txt");
};
assert!(b_content.contains("contents-of-b"));
let first = messages[1].content[0].tool_call_key().unwrap();
let second = messages[3].content[0].tool_call_key().unwrap();
assert!(matches!(first, codewhale_models::ToolCallKey::Execution(_)));
assert!(matches!(
second,
codewhale_models::ToolCallKey::Execution(_)
));
assert_ne!(
first, second,
"wire ID reuse must not reuse local observations"
);
assert_eq!(first, messages[2].content[0].tool_call_key().unwrap());
assert_eq!(second, messages[4].content[0].tool_call_key().unwrap());
for index in [1, 3] {
assert!(
matches!(&messages[index].content[0], ContentBlock::ToolUse { id, .. } if id == "call_1")
);
}
for index in [2, 4] {
assert!(
matches!(&messages[index].content[0], ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call_1")
);
}
assert_eq!(&outbound.borrow()[2], &messages[..5]);
let updates = parse_lines(out);
let started: Vec<_> = updates
.iter()
.filter(|message| message["params"]["update"]["sessionUpdate"] == "tool_call")
.map(|message| message["params"]["update"]["toolCallId"].as_str().unwrap())
.collect();
assert_eq!(started, [first.as_str(), second.as_str()]);
}
fn empty_stop_stream() -> StreamEventBox {
+216
View File
@@ -1445,7 +1445,41 @@ fn add_extra_root_certs(
builder
}
/// Admit a complete response's tool pairing ids before hooks, approvals or
/// replayable history. Use the protocol frozen with the actual request.
pub(crate) fn validate_tool_call_ids_for_protocol<'a>(
protocol: WireFormat,
ids: impl IntoIterator<Item = &'a str>,
) -> Result<()> {
let mut seen = std::collections::HashSet::new();
for id in ids {
let pairing_id = if protocol == WireFormat::Responses {
responses::parse_tool_use_id(id).0
} else {
id.to_string()
};
if pairing_id.trim().is_empty() {
anyhow::bail!("Provider returned a tool call without a pairing id");
}
if !seen.insert(pairing_id) {
anyhow::bail!("Provider returned duplicate tool call pairing ids in one response");
}
}
Ok(())
}
impl CodewhaleClient {
pub(crate) fn wire_format(&self) -> WireFormat {
self.wire_format
}
pub(crate) fn validate_tool_call_ids<'a>(
&self,
ids: impl IntoIterator<Item = &'a str>,
) -> Result<()> {
validate_tool_call_ids_for_protocol(self.wire_format, ids)
}
fn is_local_ds4_model(&self, model: &str) -> bool {
self.api_provider == ApiProvider::Custom
&& self.provider_identity.eq_ignore_ascii_case("ds4")
@@ -7319,6 +7353,7 @@ mod tests {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-k3-replay".to_string(),
name: "read_file".to_string(),
input: json!({"path": "src/lib.rs"}),
@@ -7330,6 +7365,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-k3-replay".to_string(),
content: "file contents".to_string(),
is_error: None,
@@ -8840,6 +8876,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-secret-test".to_string(),
name: "read_file".to_string(),
input: json!({"path": "config.toml"}),
@@ -8850,6 +8887,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-secret-test".to_string(),
content: content.into(),
is_error: None,
@@ -10657,6 +10695,7 @@ mod tests {
thinking: "Need to call a tool".to_string(),
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-1".to_string(),
name: "get_date".to_string(),
input: json!({}),
@@ -10668,6 +10707,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "2026-04-23".to_string(),
is_error: None,
@@ -10711,6 +10751,7 @@ mod tests {
thinking: "Need to call a tool".to_string(),
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-1".to_string(),
name: "get_date".to_string(),
input: json!({}),
@@ -10722,6 +10763,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "2026-04-23".to_string(),
is_error: None,
@@ -10883,6 +10925,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-no-thinking".to_string(),
name: "read_file".to_string(),
input: json!({"path": "Cargo.toml"}),
@@ -10893,6 +10936,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-no-thinking".to_string(),
content: "workspace manifest".to_string(),
is_error: None,
@@ -12049,6 +12093,7 @@ mod tests {
let messages = vec![Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "ok".to_string(),
is_error: None,
@@ -12186,6 +12231,7 @@ mod tests {
thinking: "Need to inspect the directory".to_string(),
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-1".to_string(),
name: "list_dir".to_string(),
input: json!({}),
@@ -12197,6 +12243,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "ok".to_string(),
is_error: None,
@@ -12229,6 +12276,7 @@ mod tests {
thinking: "Need to search".to_string(),
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-1".to_string(),
name: "web.run".to_string(),
input: json!({}),
@@ -12240,6 +12288,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "ok".to_string(),
is_error: None,
@@ -12275,6 +12324,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "tool-orphan".to_string(),
name: "read_file".to_string(),
input: json!({"path": "src/main.rs"}),
@@ -12322,6 +12372,7 @@ mod tests {
thinking: "Need to list files".to_string(),
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-ok".to_string(),
name: "list_dir".to_string(),
input: json!({}),
@@ -12333,6 +12384,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-ok".to_string(),
content: "files".to_string(),
is_error: None,
@@ -12364,6 +12416,7 @@ mod tests {
role: Role::Assistant,
content: vec![
ContentBlock::ToolUse {
execution_id: None,
id: "t1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "a.rs"}),
@@ -12371,6 +12424,7 @@ mod tests {
thought_signature: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "t2".to_string(),
name: "read_file".to_string(),
input: json!({"path": "b.rs"}),
@@ -12378,6 +12432,7 @@ mod tests {
thought_signature: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "t3".to_string(),
name: "shell".to_string(),
input: json!({"cmd": "ls"}),
@@ -12389,6 +12444,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "t1".to_string(),
content: "content a".to_string(),
is_error: None,
@@ -12398,6 +12454,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "t2".to_string(),
content: "content b".to_string(),
is_error: None,
@@ -13864,6 +13921,165 @@ mod tests {
.expect("route cap test client")
}
#[test]
fn tool_pairing_admission_uses_the_frozen_wire_protocol() {
for wire in [
WireFormat::ChatCompletions,
WireFormat::AnthropicMessages,
WireFormat::Responses,
] {
let client = route_cap_test_client(wire, RouteLimits::default());
assert!(
client
.validate_tool_call_ids(["unique-a", "unique-b"])
.is_ok()
);
for ids in [["", "valid"], [" ", "valid"], ["same", "same"]] {
assert!(
client.validate_tool_call_ids(ids).is_err(),
"{wire:?}: {ids:?}"
);
}
let result = client.validate_tool_call_ids(["call|item-a", "call|item-b"]);
assert_eq!(result.is_err(), wire == WireFormat::Responses);
assert_eq!(
client.validate_tool_call_ids(["|item"]).is_err(),
wire == WireFormat::Responses
);
// Each response is independent: provider reuse on another round is valid.
assert!(client.validate_tool_call_ids(["reused"]).is_ok());
assert!(client.validate_tool_call_ids(["reused"]).is_ok());
}
}
#[test]
fn outbound_seam_excludes_host_execution_identity_for_every_dialect() {
let _lock = crate::test_support::lock_test_env();
const IMAGE: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
for (wire, google_route) in [
(WireFormat::ChatCompletions, false),
(WireFormat::ChatCompletions, true),
(WireFormat::Responses, false),
(WireFormat::AnthropicMessages, false),
] {
let model = if google_route {
"gemini-2.5-flash"
} else {
"DeepSeek-V4-Flash"
};
let client = if google_route {
let base_url = "https://generativelanguage.googleapis.com/v1beta/openai";
let config = Config {
provider: Some("custom".to_string()),
default_text_model: Some(model.to_string()),
..Config::default()
}
.with_legacy_root(Some("fixture".to_string()), Some(base_url.to_string()));
CodewhaleClient::from_parts(
base_url.to_string(),
model.to_string(),
wire,
Some(RouteLimits::default()),
&config,
)
.unwrap()
} else {
route_cap_test_client(wire, RouteLimits::default())
};
let provider_id = if wire == WireFormat::Responses {
"wire-call|provider-item"
} else {
"wire-call"
};
let mut request =
translation_message_request("inspect", model.to_string(), "English", 1024);
request.messages.extend([
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
id: provider_id.to_string(),
execution_id: Some("local-execution-sentinel".to_string()),
name: "read".to_string(),
input: json!({"path": "shot.png"}),
caller: Some(codewhale_models::ToolCaller {
caller_type: "code_execution".to_string(),
tool_id: Some("parent-wire".to_string()),
}),
thought_signature: Some("provider-signature".to_string()),
}],
},
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: provider_id.to_string(),
execution_id: Some("local-execution-sentinel".to_string()),
content: "captured image".to_string(),
is_error: Some(false),
content_blocks: Some(vec![
json!({"type": "image", "mime_type": "image/png", "data": IMAGE}),
]),
}],
},
]);
let mut legacy = request.clone();
for block in legacy
.messages
.iter_mut()
.flat_map(|message| &mut message.content)
{
match block {
ContentBlock::ToolUse { execution_id, .. }
| ContentBlock::ToolResult { execution_id, .. } => *execution_id = None,
_ => {}
}
}
for streaming in [false, true] {
let prepared = client
.prepare_outbound_request(request.clone(), streaming)
.unwrap();
let without_local = client
.prepare_outbound_request(legacy.clone(), streaming)
.unwrap();
assert_eq!(
prepared.body, without_local.body,
"{wire:?}, stream={streaming}"
);
let bytes = prepared.body.to_string();
assert!(
!bytes.contains("execution_id") && !bytes.contains("local-execution-sentinel")
);
assert!(bytes.contains("wire-call") && bytes.contains(IMAGE));
if wire == WireFormat::ChatCompletions {
assert!(bytes.contains("parent-wire"));
assert_eq!(
bytes.contains("provider-signature"),
google_route,
"Google signatures remain restricted to Google's route"
);
}
if wire == WireFormat::Responses {
let input = prepared.body["input"].as_array().unwrap();
assert!(
input.iter().any(|item| item["type"] == "function_call"
&& item["call_id"] == "wire-call")
);
assert!(
input
.iter()
.any(|item| item["type"] == "function_call_output"
&& item["call_id"] == "wire-call")
);
}
}
assert_eq!(
request.messages[1].content[0].tool_call_key(),
Some(codewhale_models::ToolCallKey::Execution(
"local-execution-sentinel"
))
);
}
}
#[test]
fn unresolved_ollama_client_waits_for_catalog_but_probe_can_bootstrap() {
use codewhale_config::catalog::CatalogOffering;
+98 -10
View File
@@ -407,17 +407,35 @@ impl CodewhaleClient {
let response = self
.send_anthropic_request(&prepared.endpoint.url, &prepared.body)
.await?;
let mut value: Value = response
let value: Value = response
.json()
.await
.context("Failed to parse Anthropic Messages response")?;
if let Some(usage) = value.get_mut("usage") {
*usage = json!(parse_anthropic_usage(usage));
}
serde_json::from_value(value).context("Failed to decode Anthropic Messages response")
decode_anthropic_message(value)
}
}
fn discard_provider_execution_ids(value: &mut Value) {
// Shared history retains execution ids on disk. Incoming provider content
// cannot choose one, including a value that would not deserialize as the
// host field. Live execution must mint its own correlation instead.
if let Some(blocks) = value.get_mut("content").and_then(Value::as_array_mut) {
for block in blocks {
if let Some(block) = block.as_object_mut() {
block.remove("execution_id");
}
}
}
}
fn decode_anthropic_message(mut value: Value) -> Result<MessageResponse> {
discard_provider_execution_ids(&mut value);
if let Some(usage) = value.get_mut("usage") {
*usage = json!(parse_anthropic_usage(usage));
}
serde_json::from_value(value).context("Failed to decode Anthropic Messages response")
}
/// Build the `/v1/messages` endpoint URL, tolerating base URLs that already
/// carry a `/v1` suffix.
pub(super) fn anthropic_messages_url(base_url: &str) -> String {
@@ -745,6 +763,7 @@ fn content_block_to_anthropic(block: &ContentBlock) -> Option<Value> {
content,
is_error,
content_blocks,
..
} => {
let mut value = json!({
"type": "tool_result",
@@ -923,11 +942,11 @@ fn convert_anthropic_sse_usage_event(trimmed: &str) -> Option<Result<StreamEvent
match value.get("type").and_then(Value::as_str) {
Some("message_start") => {
if let Some(usage) = value
.get_mut("message")
.and_then(|message| message.get_mut("usage"))
{
*usage = json!(parse_anthropic_usage(usage));
if let Some(message) = value.get_mut("message") {
discard_provider_execution_ids(message);
if let Some(usage) = message.get_mut("usage") {
*usage = json!(parse_anthropic_usage(usage));
}
}
}
Some("message_delta") => {
@@ -1006,6 +1025,67 @@ mod tests {
use codewhale_models::Role;
use codewhale_models::{CacheControl, Message, SystemBlock, SystemPrompt, Tool};
#[test]
fn provider_messages_cannot_supply_host_execution_identity() {
for supplied in [json!("forged-local"), json!({"invalid": "host id"})] {
let value = json!({
"id": "provider-message", "type": "message", "role": "assistant",
"model": "claude-sonnet-4-6", "stop_reason": "tool_use",
"content": [
{"type": "tool_use", "id": "wire-call", "name": "read",
"input": {"execution_id": "ordinary argument"},
"execution_id": supplied,
"caller": {"type": "code_execution", "tool_id": "parent-wire"},
"thought_signature": "provider-signature"},
{"type": "tool_result", "tool_use_id": "wire-call", "content": "result",
"execution_id": supplied},
],
"usage": {"input_tokens": 3, "output_tokens": 2},
});
let decoded = decode_anthropic_message(value.clone()).unwrap();
let event = convert_anthropic_sse_data(
&json!({
"type": "message_start", "message": value,
})
.to_string(),
)
.unwrap()
.unwrap();
let StreamEvent::MessageStart { message: streamed } = event else {
panic!("expected message start")
};
for message in [decoded, streamed] {
let ContentBlock::ToolUse {
id,
input,
execution_id,
caller,
thought_signature,
..
} = &message.content[0]
else {
panic!("expected tool use")
};
assert!(execution_id.is_none());
assert_eq!(id, "wire-call");
assert_eq!(input["execution_id"], "ordinary argument");
assert_eq!(
caller.as_ref().unwrap().tool_id.as_deref(),
Some("parent-wire")
);
assert_eq!(thought_signature.as_deref(), Some("provider-signature"));
assert!(matches!(
&message.content[1],
ContentBlock::ToolResult {
execution_id: None,
..
}
));
assert_eq!(message.usage.input_tokens, 3);
}
}
}
fn request_with(
model: &str,
reasoning_effort: Option<&str>,
@@ -1053,6 +1133,7 @@ mod tests {
let client = test_client();
let mut request = request_with("claude-sonnet-4-6", None, None, None);
let tool_use = |id: &str, path: &str| ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: "read".to_string(),
input: json!({ "path": path }),
@@ -1060,6 +1141,7 @@ mod tests {
thought_signature: None,
};
let tool_result = |id: &str, content: &str| ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
@@ -1381,6 +1463,7 @@ mod tests {
role: Role::Assistant,
content: vec![
ContentBlock::ToolUse {
execution_id: None,
id: "toolu_ok".to_string(),
name: "read_file".to_string(),
input: json!({"path": "a.txt"}),
@@ -1388,6 +1471,7 @@ mod tests {
thought_signature: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "toolu_orphan".to_string(),
name: "task".to_string(),
input: json!({}),
@@ -1400,6 +1484,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "toolu_ok".to_string(),
content: "contents".to_string(),
is_error: None,
@@ -1410,6 +1495,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "toolu_tail".to_string(),
name: "task".to_string(),
input: json!({}),
@@ -1709,6 +1795,7 @@ mod tests {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "toolu_1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "a.txt"}),
@@ -1720,6 +1807,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "toolu_1".to_string(),
content: "contents".to_string(),
is_error: None,
+23
View File
@@ -2785,6 +2785,7 @@ fn build_chat_messages_with_reasoning(
input,
caller,
thought_signature,
..
} => {
let args = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
let mut call = json!({
@@ -3818,6 +3819,7 @@ fn parse_chat_message_for_route(
.and_then(Value::as_str)
.map(str::to_string);
content_blocks.push(ContentBlock::ToolUse {
execution_id: None,
id,
name: from_api_tool_name(&name),
input: arguments,
@@ -4779,6 +4781,7 @@ mod minimax_reasoning_replay_tests {
cache_control: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call_qwen38_001".to_string(),
name: "read".to_string(),
input: serde_json::json!({ "path": "widget.rs" }),
@@ -4790,6 +4793,7 @@ mod minimax_reasoning_replay_tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_qwen38_001".to_string(),
content: "widget.rs: struct Widget { .. }".to_string(),
is_error: None,
@@ -6787,6 +6791,7 @@ mod image_block_wire_tests {
fn fixture_tool_use(id: &str) -> ContentBlock {
ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": format!("{id}.txt")}),
@@ -6799,6 +6804,7 @@ mod image_block_wire_tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: format!("result for {id}"),
is_error: Some(false),
@@ -6947,6 +6953,7 @@ mod image_block_wire_tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_image_1".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "shot.png"}),
@@ -6957,6 +6964,7 @@ mod image_block_wire_tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_image_1".to_string(),
content: "screenshot captured".to_string(),
is_error: Some(false),
@@ -7008,6 +7016,7 @@ mod image_block_wire_tests {
role: Role::Assistant,
content: vec![
ContentBlock::ToolUse {
execution_id: None,
id: "call_image_1".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "first.png"}),
@@ -7015,6 +7024,7 @@ mod image_block_wire_tests {
thought_signature: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call_image_2".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "second.png"}),
@@ -7026,6 +7036,7 @@ mod image_block_wire_tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_image_1".to_string(),
content: "first screenshot captured".to_string(),
is_error: Some(false),
@@ -7039,6 +7050,7 @@ mod image_block_wire_tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_image_2".to_string(),
content: "second screenshot captured".to_string(),
is_error: Some(false),
@@ -7133,6 +7145,7 @@ mod image_block_wire_tests {
role: Role::Assistant,
content: vec![
ContentBlock::ToolUse {
execution_id: None,
id: "call_one".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "first.png"}),
@@ -7140,6 +7153,7 @@ mod image_block_wire_tests {
thought_signature: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call_two".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "second.png"}),
@@ -7151,6 +7165,7 @@ mod image_block_wire_tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_one".to_string(),
content: "first screenshot captured".to_string(),
is_error: Some(false),
@@ -7169,6 +7184,7 @@ mod image_block_wire_tests {
cache_control: None,
},
ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_two".to_string(),
content: "second screenshot captured".to_string(),
is_error: Some(false),
@@ -7214,6 +7230,7 @@ mod image_block_wire_tests {
role: Role::Assistant,
content: vec![
ContentBlock::ToolUse {
execution_id: None,
id: "duplicate".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "first.png"}),
@@ -7221,6 +7238,7 @@ mod image_block_wire_tests {
thought_signature: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "duplicate".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "second.png"}),
@@ -7232,6 +7250,7 @@ mod image_block_wire_tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "duplicate".to_string(),
content: "one result for two calls".to_string(),
is_error: Some(false),
@@ -7292,6 +7311,7 @@ mod mistral_reasoning_tests {
cache_control: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "README.md"}),
@@ -7303,6 +7323,7 @@ mod mistral_reasoning_tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "contents".to_string(),
is_error: None,
@@ -7714,6 +7735,7 @@ mod google_thought_signature_tests {
cache_control: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-g-1".to_string(),
name: "read".to_string(),
input: json!({"path": "config.toml"}),
@@ -7727,6 +7749,7 @@ mod google_thought_signature_tests {
messages.push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-g-1".to_string(),
content: "key = \"value\"".to_string(),
is_error: None,
@@ -998,6 +998,7 @@ fn tool_use_message(id: &str, name: &str, input: Value) -> Message {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: name.to_string(),
input,
@@ -1011,6 +1012,7 @@ fn tool_result_message(id: &str, content: &str) -> Message {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
+2 -1
View File
@@ -625,6 +625,7 @@ impl CodewhaleClient {
caller,
thought_signature,
} => ContentBlock::ToolUse {
execution_id: None,
id,
name,
input,
@@ -1021,7 +1022,7 @@ fn string_at<'a>(value: &'a Value, path: &str) -> Option<&'a str> {
/// Parse a composite tool_use_id back to (call_id, item_id).
/// Composite format: "call_id|item_id"
fn parse_tool_use_id(id: &str) -> (String, String) {
pub(super) fn parse_tool_use_id(id: &str) -> (String, String) {
if let Some(pipe_pos) = id.find('|') {
(id[..pipe_pos].to_string(), id[pipe_pos + 1..].to_string())
} else {
+5
View File
@@ -1156,6 +1156,7 @@ fn responses_input_includes_user_role_tool_results() {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_abc|fc_123".to_string(),
name: "checklist_write".to_string(),
input: json!({"items": []}),
@@ -1166,6 +1167,7 @@ fn responses_input_includes_user_role_tool_results() {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_abc|fc_123".to_string(),
content: "<6 items>".to_string(),
is_error: None,
@@ -1202,6 +1204,7 @@ fn responses_input_encodes_tool_call_names() {
messages: vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_abc|fc_123".to_string(),
name: "web.run".to_string(),
input: json!({}),
@@ -1378,6 +1381,7 @@ fn tool_result_image_becomes_native_function_output_content() {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_image_1".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "shot.png"}),
@@ -1388,6 +1392,7 @@ fn tool_result_image_becomes_native_function_output_content() {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_image_1".to_string(),
content: "screenshot captured".to_string(),
is_error: Some(false),
+1
View File
@@ -1641,6 +1641,7 @@ fn project_block(block: &ContentBlock) -> ExportBlock {
content,
is_error,
content_blocks,
..
} => ExportBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: content.clone(),
@@ -151,61 +151,67 @@ pub(in crate::commands) fn undo_conversation(app: &mut App) -> usize {
}
pub(crate) fn prune_undone_tool_context(app: &mut App, tool_id: &str) {
// A display/control id alone must not choose between duplicated or mixed
// legacy/local history. Refuse to prune when the source is ambiguous.
let mut matches = app
.api_messages
.iter()
.enumerate()
.flat_map(|(msg_idx, msg)| {
msg.content
.iter()
.enumerate()
.filter_map(move |(block_idx, block)| {
(matches!(block, ContentBlock::ToolUse { .. })
&& block.tool_call_key().is_some_and(|key| {
!key.as_str().trim().is_empty() && key.as_str() == tool_id
}))
.then_some((msg_idx, block_idx))
})
});
let Some((msg_idx, block_idx)) = matches.next() else {
return;
};
if matches.next().is_some() {
return;
}
drop(matches);
if let Some(history_idx) = app.tool_cells.get(tool_id).copied() {
app.truncate_history_to(history_idx);
}
let Some((msg_idx, block_idx)) =
app.api_messages
.iter()
.enumerate()
.find_map(|(msg_idx, msg)| {
msg.content
.iter()
.position(
|block| matches!(block, ContentBlock::ToolUse { id, ..} if id == tool_id),
)
.map(|block_idx| (msg_idx, block_idx))
})
else {
return;
};
let kept_blocks = app.api_messages[msg_idx].content[..block_idx].to_vec();
let kept_tool_ids: std::collections::HashSet<String> = kept_blocks
let kept_tool_ids: std::collections::HashSet<_> = kept_blocks
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.clone()),
ContentBlock::ToolUse { id, .. } => block.tool_call_key().map(|key| (key, id.as_str())),
_ => None,
})
.collect();
if kept_blocks.is_empty() {
app.truncate_api_messages(msg_idx);
return;
}
// Re-inserted tool results keep the stamp they earned, so the journal's
// timeline survives the prune.
let preserved_tool_results: Vec<_> =
app.api_messages_stamped()
.skip(msg_idx + 1)
.take_while(|(msg, _)| {
msg.role == "user"
&& !msg.content.is_empty()
&& msg
.content
.iter()
.all(|block| tool_result_id(block).is_some())
})
.filter(|(msg, _)| {
msg.role == "user"
&& !msg.content.is_empty()
&& msg.content.iter().all(|block| {
tool_result_id(block).is_some_and(|id| kept_tool_ids.contains(id))
})
})
.map(|(msg, stamp)| (msg.clone(), stamp))
.collect();
// Preserve surviving result blocks even when a message also contains the
// undone result; retain the stamp of the original message.
let preserved_tool_results: Vec<_> = app
.api_messages_stamped()
.skip(msg_idx + 1)
.take_while(|(msg, _)| {
msg.role == "user"
&& !msg.content.is_empty()
&& msg
.content
.iter()
.all(|block| tool_result_id(block).is_some())
})
.filter_map(|(msg, stamp)| {
let mut retained = msg.clone();
retained.content.retain(|block| {
tool_result_id(block).is_some_and(|key| kept_tool_ids.contains(&key))
});
(!retained.content.is_empty()).then_some((retained, stamp))
})
.collect();
app.truncate_api_messages(msg_idx + 1);
app.api_messages_mut()[msg_idx].content = kept_blocks;
for (message, stamp) in preserved_tool_results {
@@ -227,11 +233,13 @@ fn prune_undone_turn_context(app: &mut App) {
}
}
fn tool_result_id(block: &ContentBlock) -> Option<&String> {
fn tool_result_id(block: &ContentBlock) -> Option<(codewhale_models::ToolCallKey<'_>, &str)> {
match block {
ContentBlock::ToolResult { tool_use_id, .. }
| ContentBlock::ToolSearchToolResult { tool_use_id, .. }
| ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => Some(tool_use_id),
| ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => {
block.tool_call_key().map(|key| (key, tool_use_id.as_str()))
}
_ => None,
}
}
@@ -744,6 +744,7 @@ fn push_repeated_shell_results(app: &mut App, output: &str) {
app.api_messages_mut().push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: "shell_command".to_string(),
input: serde_json::json!({"command": "cargo test"}),
@@ -754,6 +755,7 @@ fn push_repeated_shell_results(app: &mut App, output: &str) {
app.api_messages_mut().push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: output.to_string(),
is_error: None,
@@ -448,6 +448,7 @@ fn test_patch_undo_prunes_tool_turn_context() {
cache_control: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-1".to_string(),
name: "write_file".to_string(),
input: serde_json::json!({"path": "a.txt"}),
@@ -459,6 +460,7 @@ fn test_patch_undo_prunes_tool_turn_context() {
app.api_messages_mut().push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "updated".to_string(),
is_error: None,
@@ -631,6 +633,7 @@ fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
cache_control: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-a".to_string(),
name: "write_file".to_string(),
input: serde_json::json!({"path": "a.txt"}),
@@ -638,6 +641,7 @@ fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
thought_signature: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-b".to_string(),
name: "write_file".to_string(),
input: serde_json::json!({"path": "b.txt"}),
@@ -649,6 +653,7 @@ fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
app.api_messages_mut().push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-a".to_string(),
content: "updated a".to_string(),
is_error: None,
@@ -658,6 +663,7 @@ fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
app.api_messages_mut().push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-b".to_string(),
content: "updated b".to_string(),
is_error: None,
@@ -689,6 +695,52 @@ fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
));
}
#[test]
fn undo_uses_execution_identity_and_preserves_coalesced_result_stamp() {
let mut app = create_test_app();
let stamp = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z")
.unwrap()
.with_timezone(&chrono::Utc);
let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"assistant","content":[
{"type":"tool_use","id":"wire","execution_id":"first","name":"write_file","input":{"path":"a.txt"}},
{"type":"tool_use","id":"wire","execution_id":"second","name":"write_file","input":{"path":"b.txt"}}
]},
{"role":"user","content":[
{"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"kept"},
{"type":"tool_result","tool_use_id":"wire","execution_id":"second","content":"undone"}
]}
])).unwrap();
for message in messages {
app.push_api_message_stamped(message, stamp);
}
prune_undone_tool_context(&mut app, "second");
assert_eq!(app.api_messages.len(), 2);
assert_eq!(app.api_messages[0].content.len(), 1);
assert!(
matches!(&app.api_messages[1].content[..], [ContentBlock::ToolResult {
execution_id: Some(id), tool_use_id, content, ..
}] if id == "first" && tool_use_id == "wire" && content == "kept")
);
assert_eq!(app.api_messages_stamped().nth(1).unwrap().1, stamp);
// A legacy provider ID that happens to spell a local ID is not another
// spelling for that execution. With both present, the raw undo request is
// ambiguous and must leave every message intact.
app.push_api_message_stamped(
serde_json::from_value(serde_json::json!({
"role":"assistant","content":[
{"type":"tool_use","id":"first","name":"write_file","input":{}}
]
}))
.unwrap(),
stamp,
);
let before = serde_json::to_value(&*app.api_messages).unwrap();
prune_undone_tool_context(&mut app, "first");
assert_eq!(serde_json::to_value(&*app.api_messages).unwrap(), before);
}
// ── /cache stats tests ──────────────────────────────────────────────
#[test]
@@ -1228,6 +1280,7 @@ fn receipts_command_is_registered_and_reads_the_transcript() {
app.api_messages_mut().push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-1".to_string(),
name: "bash".to_string(),
input: serde_json::json!({"command": "cargo test"}),
@@ -1238,6 +1291,7 @@ fn receipts_command_is_registered_and_reads_the_transcript() {
app.api_messages_mut().push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "ok".to_string(),
is_error: None,
@@ -1255,6 +1309,7 @@ fn receipts_command_is_registered_and_reads_the_transcript() {
app.api_messages_mut().push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-2".to_string(),
name: "bash".to_string(),
input: serde_json::json!({ "command": shell }),
@@ -1265,6 +1320,7 @@ fn receipts_command_is_registered_and_reads_the_transcript() {
app.api_messages_mut().push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-2".to_string(),
content: "ok".to_string(),
is_error: None,
@@ -1091,6 +1091,7 @@ mod tests {
app.api_messages_mut().push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-big".to_string(),
content: raw,
is_error: None,
@@ -293,6 +293,7 @@ fn block_payload(block: &ContentBlock) -> Value {
content,
is_error,
content_blocks,
..
} => json!({
"type": "tool_result",
"tool_use_id": tool_use_id,
@@ -339,36 +340,51 @@ fn block_payload(block: &ContentBlock) -> Value {
}
fn tool_payload(app: &App, call_id: &str) -> Result<(&'static str, Value, Value), String> {
let mut found_call: Option<(String, Value)> = None;
let mut found_result: Option<(Option<bool>, String, Option<Vec<Value>>)> = None;
for message in app.api_messages.iter() {
for block in &message.content {
match block {
ContentBlock::ToolUse {
id, name, input, ..
} => {
if id.as_str() == call_id {
found_call = Some((name.clone(), input.clone()));
}
}
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
content_blocks,
} if tool_use_id.as_str() == call_id => {
found_result = Some((*is_error, content.clone(), content_blocks.clone()));
}
_ => {}
let unavailable = || unavailable_message(app, &CopyKind::Tool(call_id.to_string()));
if call_id.trim().is_empty() {
return Err(unavailable());
}
// The selector has no namespace tag, so even a local/legacy string
// collision is ambiguous. Never fall back from an explicit local ID.
let mut calls = app
.api_messages
.iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::ToolUse {
id, name, input, ..
} => block
.tool_call_key()
.filter(|key| key.as_str() == call_id)
.map(|key| (key, id, name, input)),
_ => None,
});
let Some((key, provider_id, name, input)) = calls.next() else {
return Err(unavailable());
};
if calls.next().is_some() || provider_id.trim().is_empty() {
return Err(unavailable());
}
let mut found_result = None;
for block in app.api_messages.iter().flat_map(|message| &message.content) {
if block.tool_call_key() != Some(key) {
continue;
}
if let ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
content_blocks,
..
} = block
{
if tool_use_id != provider_id || found_result.is_some() {
return Err(unavailable());
}
found_result = Some((*is_error, content, content_blocks));
}
}
let Some((name, input)) = found_call else {
return Err(unavailable_message(
app,
&CopyKind::Tool(call_id.to_string()),
));
};
let result = match found_result {
Some((is_error, content, content_blocks)) => json!({
"found": true,
@@ -1290,6 +1306,7 @@ mod tests {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-7".to_string(),
name: "fetch_url".to_string(),
input: json!({
@@ -1307,6 +1324,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-7".to_string(),
content: "Authorization: Bearer result-secret-token\nfetch ok".to_string(),
is_error: Some(false),
@@ -1433,6 +1451,7 @@ mod tests {
app.api_messages_mut()[1]
.content
.push(ContentBlock::ToolUse {
execution_id: None,
id: "call-lonely".to_string(),
name: "view_image".to_string(),
input: json!({}),
@@ -1447,6 +1466,154 @@ mod tests {
assert_eq!(value["object"]["result"]["found"], json!(false));
}
#[test]
fn tool_copy_selects_execution_when_provider_reuses_wire_id() {
let tmpdir = TempDir::new().expect("tempdir");
let mut app = test_app(&tmpdir);
app.api_messages = std::sync::Arc::new(
serde_json::from_value(json!([
{"role":"assistant", "content":[{"type":"tool_use", "id":"wire",
"execution_id":"first", "name":"read_file", "input":{"path":"first.txt"}}]},
{"role":"user", "content":[{"type":"tool_result", "tool_use_id":"wire",
"execution_id":"first", "content":"first output"}]},
{"role":"assistant", "content":[{"type":"tool_use", "id":"wire",
"execution_id":"second", "name":"read_file", "input":{"path":"second.txt"}}]},
{"role":"user", "content":[{"type":"tool_result", "tool_use_id":"wire",
"execution_id":"second", "content":"second output"}]}
]))
.expect("transcript"),
);
let before = serde_json::to_value(app.api_messages.as_ref()).expect("transcript");
for (selector, path, output) in [
("first", "first.txt", "first output"),
("second", "second.txt", "second output"),
] {
let value = parsed(&stdout_json(&execute_structcopy(
&mut app,
Some(&format!("tool {selector} stdout")),
)));
assert_eq!(value["receipt"]["selector"], selector);
assert_eq!(value["object"]["call_id"], selector);
assert_eq!(value["object"]["input"]["path"], path);
assert_eq!(value["object"]["result"]["content"], output);
}
assert!(execute_structcopy(&mut app, Some("tool wire stdout")).is_error);
assert_eq!(
serde_json::to_value(app.api_messages.as_ref()).unwrap(),
before
);
}
#[test]
fn tool_copy_refuses_ambiguous_or_inconsistent_identity() {
let tmpdir = TempDir::new().expect("tempdir");
let mut app = test_app(&tmpdir);
let call = |execution_id: Option<&str>, provider: &str| {
json!({
"type":"tool_use", "id":provider, "execution_id":execution_id,
"name":"read_file", "input":{"path":"selected.txt"}
})
};
let result = |execution_id: Option<&str>, provider: &str| {
json!({
"type":"tool_result", "tool_use_id":provider, "execution_id":execution_id,
"content":"must not borrow this output"
})
};
for (label, selector, calls, results, unavailable) in [
(
"duplicate local calls",
"exec",
vec![call(Some("exec"), "wire"), call(Some("exec"), "wire")],
vec![result(Some("exec"), "wire")],
true,
),
(
"duplicate legacy calls",
"wire",
vec![call(None, "wire"), call(None, "wire")],
vec![result(None, "wire")],
true,
),
(
"duplicate local results",
"exec",
vec![call(Some("exec"), "wire")],
vec![result(Some("exec"), "wire"), result(Some("exec"), "wire")],
true,
),
(
"duplicate legacy results",
"wire",
vec![call(None, "wire")],
vec![result(None, "wire"), result(None, "wire")],
true,
),
(
"wrong provider",
"exec",
vec![call(Some("exec"), "wire")],
vec![result(Some("exec"), "other")],
true,
),
(
"empty local identity",
"wire",
vec![call(Some(""), "wire")],
vec![result(Some(""), "wire")],
true,
),
(
"colliding domains",
"exec",
vec![call(Some("exec"), "wire"), call(None, "exec")],
vec![result(Some("exec"), "wire"), result(None, "exec")],
true,
),
(
"no local fallback",
"exec",
vec![call(Some("exec"), "wire")],
vec![result(Some("wrong"), "wire"), result(None, "wire")],
false,
),
(
"no legacy fallback",
"wire",
vec![call(None, "wire")],
vec![result(Some("wire"), "wire")],
false,
),
] {
app.api_messages = std::sync::Arc::new(
serde_json::from_value(json!([
{"role":"assistant", "content":calls}, {"role":"user", "content":results}
]))
.expect("transcript"),
);
let before = serde_json::to_value(app.api_messages.as_ref()).unwrap();
let copied = execute_structcopy(&mut app, Some(&format!("tool {selector} stdout")));
assert_eq!(copied.is_error, unavailable, "{label}");
if !unavailable {
let value = parsed(&stdout_json(&copied));
assert_eq!(value["object"]["result"]["found"], false, "{label}");
}
assert!(
!copied
.message
.as_deref()
.unwrap_or_default()
.contains("must not borrow"),
"{label}"
);
assert_eq!(
serde_json::to_value(app.api_messages.as_ref()).unwrap(),
before,
"{label}"
);
}
}
/// An unknown `Option<bool>` must serialize as JSON `null`. Collapsing it
/// to `false` would assert an outcome nothing observed.
#[test]
@@ -1461,6 +1628,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-unknown".to_string(),
name: "exec_command".to_string(),
input: json!({}),
@@ -1472,6 +1640,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-unknown".to_string(),
content: "no error flag was recorded".to_string(),
is_error: None,
@@ -1753,6 +1922,7 @@ mod tests {
app.api_messages_mut()[1]
.content
.push(ContentBlock::ToolUse {
execution_id: None,
id: long_id.clone(),
name: "exec_command".to_string(),
input: json!({}),
@@ -1781,6 +1951,7 @@ mod tests {
app.api_messages_mut()[1]
.content
.push(ContentBlock::ToolUse {
execution_id: None,
id: bearer_id.to_string(),
name: "exec_command".to_string(),
input: json!({}),
@@ -1821,6 +1992,7 @@ mod tests {
app.api_messages = std::sync::Arc::new(vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-keys".to_string(),
name: "exec_command".to_string(),
input,
@@ -1890,6 +2062,7 @@ mod tests {
app.api_messages = std::sync::Arc::new(vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-obfuscated-keys".to_string(),
name: "exec_command".to_string(),
input: json!({
@@ -1959,6 +2132,7 @@ mod tests {
app.api_messages = std::sync::Arc::new(vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-reserve".to_string(),
name: "exec_command".to_string(),
input: json!({exact: 1, same_after_flatten: 2}),
@@ -2271,6 +2445,7 @@ mod tests {
app.api_messages = std::sync::Arc::new(vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: call_id.to_string(),
name: "exec_command".to_string(),
input: json!({
@@ -2432,6 +2607,7 @@ mod tests {
app.api_messages = std::sync::Arc::new(vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-deep".to_string(),
name: "exec_command".to_string(),
input: json!({"a": {"b": {"c": {"d": {"e": "too deep"}}}}}),
@@ -2472,6 +2648,7 @@ mod tests {
app.api_messages = std::sync::Arc::new(vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-counts".to_string(),
name: "exec_command".to_string(),
input: json!({
@@ -2544,6 +2721,7 @@ mod tests {
app.api_messages = std::sync::Arc::new(vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-deep-keys".to_string(),
name: "exec_command".to_string(),
input: json!({"deep": {"one": {long_a: 1, long_b: 2}}}),
@@ -118,6 +118,7 @@ fn adapter_projects_authoritative_metadata_and_omits_hidden_payloads() {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-1".to_string(),
name: "fetch_url".to_string(),
input: serde_json::json!({"url": "https://example.com/a"}),
@@ -703,6 +704,7 @@ fn baseline_golden_messages() -> Vec<Message> {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-1".to_string(),
name: "fetch_url".to_string(),
input: serde_json::json!({
@@ -721,6 +723,7 @@ fn baseline_golden_messages() -> Vec<Message> {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "Authorization: Bearer another-secret-token\nresult ok".to_string(),
is_error: Some(false),
@@ -997,6 +1000,7 @@ fn command_clipboard_export_preserves_structure_and_redacts_secrets() {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-1".to_string(),
name: "fetch_url".to_string(),
input: serde_json::json!({
@@ -1015,6 +1019,7 @@ fn command_clipboard_export_preserves_structure_and_redacts_secrets() {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "Authorization: Bearer another-secret-token\nresult ok".to_string(),
is_error: Some(false),
+33 -10
View File
@@ -918,22 +918,33 @@ fn tool_args_preview(input: &serde_json::Value) -> String {
truncate_chars(&raw, 120).to_string()
}
fn collect_tool_uses(messages: &[Message]) -> HashMap<String, ToolUseInfo> {
fn collect_tool_uses(
messages: &[Message],
) -> HashMap<codewhale_models::ToolCallKey<'_>, Option<(&str, ToolUseInfo)>> {
let mut tool_uses = HashMap::new();
for message in messages {
for block in &message.content {
if let ContentBlock::ToolUse {
id, name, input, ..
} = block
&& let Some(key) = block.tool_call_key()
&& !key.as_str().trim().is_empty()
{
tool_uses.insert(
id.clone(),
ToolUseInfo {
name: name.clone(),
key: tool_use_key(name, input),
args_preview: tool_args_preview(input),
},
);
// Ambiguous old or malformed new history is left intact; a
// later call must not supply the earlier result's metadata.
tool_uses
.entry(key)
.and_modify(|entry| *entry = None)
.or_insert_with(|| {
Some((
id.as_str(),
ToolUseInfo {
name: name.clone(),
key: tool_use_key(name, input),
args_preview: tool_args_preview(input),
},
))
});
}
}
}
@@ -1054,9 +1065,16 @@ fn plan_tool_result_prunes(messages: &[Message], protected_window: usize) -> Vec
else {
continue;
};
let Some(info) = tool_uses.get(tool_use_id) else {
let Some((provider_id, info)) = block
.tool_call_key()
.and_then(|key| tool_uses.get(&key))
.and_then(Option::as_ref)
else {
continue;
};
if provider_id != &tool_use_id.as_str() {
continue;
}
latest_by_key.insert(info.key.clone(), message_idx);
*count_by_key.entry(info.key.clone()).or_insert(0) += 1;
candidates.push(ToolResultPruneCandidate {
@@ -2294,6 +2312,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: name.to_string(),
input,
@@ -2307,6 +2326,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
@@ -3396,6 +3416,7 @@ mod tests {
// the retained copy must keep the text and drop the orphaned result.
let mut mixed = msg("user", "Please keep this context.");
mixed.content.push(ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "toolu_orphan_1".to_string(),
content: "{\"ok\":true}".to_string(),
is_error: None,
@@ -3836,6 +3857,7 @@ mod tests {
thinking: thinking.clone(),
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "Cargo.toml"}),
@@ -3847,6 +3869,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "manifest".to_string(),
is_error: None,
+94 -21
View File
@@ -292,46 +292,63 @@ pub(super) fn bound_last_round(messages: &[Message]) -> Vec<Message> {
round
}
fn tool_result_ids(message: &Message) -> Vec<String> {
fn tool_result_ids(message: &Message) -> Vec<(codewhale_models::ToolCallKey<'_>, &str)> {
message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.clone()),
ContentBlock::ToolResult { tool_use_id, .. } => {
block.tool_call_key().map(|key| (key, tool_use_id.as_str()))
}
_ => None,
})
.collect()
}
fn has_tool_result_id(message: &Message, id: &str) -> bool {
fn has_tool_result_id(message: &Message, id: &(codewhale_models::ToolCallKey<'_>, &str)) -> bool {
if id.0.as_str().trim().is_empty() {
return false;
}
message.content.iter().any(|block| {
matches!(
block,
ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == id
ContentBlock::ToolResult { tool_use_id, .. } if block.tool_call_key() == Some(id.0) && tool_use_id == id.1
)
})
}
fn tool_use_ids(message: &Message) -> Vec<String> {
fn tool_use_ids(message: &Message) -> Vec<(codewhale_models::ToolCallKey<'_>, &str)> {
message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.clone()),
ContentBlock::ToolUse { id, .. } => block.tool_call_key().map(|key| (key, id.as_str())),
_ => None,
})
.collect()
}
fn has_tool_use_id(message: &Message, id: &str) -> bool {
fn has_tool_use_id(message: &Message, id: &(codewhale_models::ToolCallKey<'_>, &str)) -> bool {
if id.0.as_str().trim().is_empty() {
return false;
}
message.content.iter().any(|block| {
matches!(
block,
ContentBlock::ToolUse { id: seen, .. } if seen == id
ContentBlock::ToolUse { id: provider, .. } if block.tool_call_key() == Some(id.0) && provider == id.1
)
})
}
fn tool_call_identity_label(id: &(codewhale_models::ToolCallKey<'_>, &str)) -> String {
match id.0 {
codewhale_models::ToolCallKey::Execution(execution_id) => {
format!("execution {execution_id} (provider call {})", id.1)
}
codewhale_models::ToolCallKey::LegacyProvider(provider_id) => provider_id.to_string(),
}
}
fn assistant_text_of(message: &Message) -> Option<String> {
if !message.role.is_assistant_like() {
return None;
@@ -383,8 +400,9 @@ pub(crate) fn validate_last_round_coverage(
.iter()
.any(|message| has_tool_result_id(message, &id))
{
let label = tool_call_identity_label(&id);
anyhow::bail!(
"Making room stopped: last-round tool result {id} was dropped; history was not replaced."
"Making room stopped: last-round tool result {label} was dropped; history was not replaced."
);
}
}
@@ -395,8 +413,9 @@ pub(crate) fn validate_last_round_coverage(
.iter()
.any(|message| has_tool_use_id(message, &id))
{
let label = tool_call_identity_label(&id);
anyhow::bail!(
"Making room stopped: last-round tool call {id} was dropped; history was not replaced."
"Making room stopped: last-round tool call {label} was dropped; history was not replaced."
);
}
}
@@ -520,6 +539,50 @@ mod tests {
use codewhale_models::{ContentBlock, Role};
use serde_json::json;
#[test]
fn coverage_requires_the_original_execution_and_provider_pair() {
let original: Vec<Message> = serde_json::from_value(json!([
{"role":"user","content":[{"type":"text","text":"keep this exchange"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"local","name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"local","content":"kept"}]}
])).unwrap();
assert!(validate_last_round_coverage(&original, &original).is_ok());
for identity in [None, Some("different")] {
let mut replacement = original.clone();
for block in replacement
.iter_mut()
.flat_map(|message| &mut message.content)
{
match block {
ContentBlock::ToolUse { execution_id, .. }
| ContentBlock::ToolResult { execution_id, .. } => {
*execution_id = identity.map(str::to_string)
}
_ => {}
}
}
assert!(validate_last_round_coverage(&original, &replacement).is_err());
}
let mut replacement = original.clone();
if let ContentBlock::ToolResult { tool_use_id, .. } = &mut replacement[2].content[0] {
*tool_use_id = "wrong-wire".to_string();
}
let error = validate_last_round_coverage(&original, &replacement).unwrap_err();
assert_eq!(
error.to_string(),
"Making room stopped: last-round tool result execution local (provider call wire) was dropped; history was not replaced."
);
let mut replacement = original.clone();
if let ContentBlock::ToolUse { id, .. } = &mut replacement[1].content[0] {
*id = "wrong-wire".to_string();
}
let error = validate_last_round_coverage(&original, &replacement).unwrap_err();
assert_eq!(
error.to_string(),
"Making room stopped: last-round tool call execution local (provider call wire) was dropped; history was not replaced."
);
}
#[test]
fn confined_pinned_anchors_require_workspace_trust() {
use crate::test_support::{EnvVarGuard, lock_test_env};
@@ -601,6 +664,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: name.to_string(),
input,
@@ -614,6 +678,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
@@ -644,7 +709,11 @@ mod tests {
tool_result("third", &long_output),
];
let kept = replacement_messages(&original, 20_000);
let kept_ids: Vec<String> = kept.iter().flat_map(tool_result_ids).collect();
let kept_ids: Vec<&str> = kept
.iter()
.flat_map(tool_result_ids)
.map(|(_, wire)| wire)
.collect();
assert_eq!(kept_ids, ["second", "third"], "earlier steps are dropped");
assert!(
kept.iter()
@@ -876,16 +945,20 @@ mod tests {
user_text_of(message).as_deref() == Some(long_question.as_str())
})
);
assert!(
second
.iter()
.any(|message| has_tool_use_id(message, "call_1"))
);
assert!(
second
.iter()
.any(|message| has_tool_result_id(message, "call_1"))
);
assert!(second.iter().any(|message| has_tool_use_id(
message,
&(
codewhale_models::ToolCallKey::LegacyProvider("call_1"),
"call_1"
)
)));
assert!(second.iter().any(|message| has_tool_result_id(
message,
&(
codewhale_models::ToolCallKey::LegacyProvider("call_1"),
"call_1"
)
)));
let without_question = second
.iter()
.filter(|message| user_text_of(message).as_deref() != Some(long_question.as_str()))
+26
View File
@@ -202,6 +202,7 @@ fn oversized_tool_pair(id: &str, content: String) -> Vec<Message> {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "src/compaction.rs"}),
@@ -212,6 +213,7 @@ fn oversized_tool_pair(id: &str, content: String) -> Vec<Message> {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content,
is_error: None,
@@ -608,6 +610,7 @@ fn tool_call(id: &str) -> Message {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: "Bash".to_string(),
input: serde_json::json!({"command": "ls"}),
@@ -621,6 +624,7 @@ fn tool_output(id: &str) -> Message {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: "ok".to_string(),
is_error: None,
@@ -681,3 +685,25 @@ fn overflow_retry_keeps_the_previous_note_and_the_instruction() {
assert!(drop_oldest_history_messages(&mut quoted));
assert_eq!(quoted[0], text_message(Role::User, "later"));
}
#[test]
fn pruning_metadata_belongs_to_the_exact_execution() {
let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"assistant","content":[{"type":"tool_use","id":"reused","execution_id":"first","name":"read","input":{"path":"first.txt"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"reused","execution_id":"first","content":"A".repeat(20000)}]},
{"role":"assistant","content":[{"type":"tool_use","id":"reused","execution_id":"second","name":"search","input":{"pattern":"second"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"reused","execution_id":"second","content":"B".repeat(20000)}]}
])).unwrap();
let plan = plan_tool_result_prunes(&messages, 0);
assert_eq!(plan.len(), 2);
let first = plan.iter().find(|item| item.message_idx == 1).unwrap();
assert!(first.summary.contains("first.txt"), "{}", first.summary);
assert!(!first.summary.contains("second"), "{}", first.summary);
let mut ambiguous = messages.clone();
ambiguous.push(messages[0].clone());
assert!(
!plan_tool_result_prunes(&ambiguous, 0)
.iter()
.any(|item| item.message_idx == 1)
);
}
+1
View File
@@ -904,6 +904,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_1".to_string(),
content: "large tool output".repeat(40),
is_error: None,
+2
View File
@@ -2094,6 +2094,7 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallStarted {
model_call: None,
id: tool_id.clone(),
name: tool_name.clone(),
input: tool_input.clone(),
@@ -2184,6 +2185,7 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: None,
id: tool_id,
name: tool_name,
result,
+60 -26
View File
@@ -490,18 +490,37 @@ mod tests {
]
}
fn fixture_execution_id(events: &[Event], provider_id: &str) -> String {
let ids = events
.iter()
.filter_map(|event| match event {
Event::ToolCallStarted {
id,
model_call: Some(model_call),
..
} if model_call.provider_id == provider_id => Some(id),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(ids.len(), 1, "one execution starts for {provider_id}");
let id = ids[0];
assert_ne!(id, provider_id, "provider IDs cannot authorize executions");
uuid::Uuid::parse_str(id).expect("host-generated execution UUID");
id.clone()
}
async fn wait_for_fixture_approval(
events: &Arc<tokio::sync::RwLock<tokio::sync::mpsc::Receiver<Event>>>,
expected_id: &str,
) -> Vec<Event> {
provider_id: &str,
) -> (String, Vec<Event>) {
tokio::time::timeout(Duration::from_secs(5), async {
let mut seen = Vec::new();
let mut events = events.write().await;
while let Some(event) = events.recv().await {
if let Event::ApprovalRequired { id, tool_name, .. } = &event {
assert_eq!(id, expected_id);
assert_eq!(id, &fixture_execution_id(&seen, provider_id));
assert_eq!(tool_name, COUNTER_TOOL);
return seen;
return (id.clone(), seen);
}
seen.push(event);
}
@@ -567,14 +586,14 @@ mod tests {
});
// Reach the gate and answer nothing: this is the park.
let _ = wait_for_fixture_approval(&events, CURRENT_CALL).await;
let (execution_id, _) = wait_for_fixture_approval(&events, CURRENT_CALL).await;
let announced = tokio::time::timeout(Duration::from_secs(5), async {
let mut rx = events.write().await;
while let Some(event) = rx.recv().await {
if let Event::Status { message } = &event
&& message.contains("Still waiting for tool approval")
&& message.contains(CURRENT_CALL)
&& message.contains(&execution_id)
{
return true;
}
@@ -733,8 +752,10 @@ mod tests {
.await
});
let mut current_execution_id = None;
if !full_access {
let seen = wait_for_fixture_approval(&events, CURRENT_CALL).await;
let (execution_id, seen) = wait_for_fixture_approval(&events, CURRENT_CALL).await;
current_execution_id = Some(execution_id.clone());
match source {
ClaimSource::Assistant => assert!(seen.iter().any(|event| matches!(event, Event::MessageDelta { content, .. } if content.contains(INVENTED_APPROVAL)))),
ClaimSource::ToolOutput => {
@@ -754,18 +775,20 @@ mod tests {
let pending = store.replay(&session_id).expect("pending receipt");
assert!(pending.completed.is_empty());
assert!(
matches!(pending.unmatched_asks.as_slice(), [ApprovalReceipt::Asked { approval_id, tool_call_id, tool_name, .. }] if approval_id == CURRENT_CALL && tool_call_id == CURRENT_CALL && tool_name == COUNTER_TOOL)
matches!(pending.unmatched_asks.as_slice(), [ApprovalReceipt::Asked { approval_id, tool_call_id, tool_name, .. }] if approval_id == &execution_id && tool_call_id == &execution_id && tool_name == COUNTER_TOOL)
);
match action {
HostAction::AllowOnce => {
let host = handle.as_ref().unwrap();
host.approve_tool_call(CURRENT_CALL)
host.approve_tool_call(&execution_id)
.await
.expect("matching typed allow");
host.approve_tool_call(CURRENT_CALL)
host.approve_tool_call(&execution_id)
.await
.expect("duplicate old decision");
wait_for_fixture_approval(&events, NEXT_CALL).await;
let (next_execution_id, _) =
wait_for_fixture_approval(&events, NEXT_CALL).await;
assert_ne!(next_execution_id, execution_id);
assert!(
tokio::time::timeout(Duration::from_millis(25), &mut task)
.await
@@ -773,14 +796,14 @@ mod tests {
"old approval cannot authorize the next call"
);
assert_eq!(executions.load(Ordering::SeqCst), 1);
host.deny_tool_call(NEXT_CALL)
host.deny_tool_call(&next_execution_id)
.await
.expect("deny next call");
}
HostAction::Deny => handle
.as_ref()
.unwrap()
.deny_tool_call(CURRENT_CALL)
.deny_tool_call(&execution_id)
.await
.expect("typed deny"),
HostAction::StaleThenDeny => {
@@ -788,6 +811,9 @@ mod tests {
host.approve_tool_call("counter-stale")
.await
.expect("stale typed allow");
host.approve_tool_call(CURRENT_CALL)
.await
.expect("provider ID is not host approval authority");
assert!(
tokio::time::timeout(Duration::from_millis(25), &mut task)
.await
@@ -798,7 +824,7 @@ mod tests {
store.replay(&session_id).unwrap().unmatched_asks,
pending.unmatched_asks
);
host.deny_tool_call(CURRENT_CALL)
host.deny_tool_call(&execution_id)
.await
.expect("close pending call");
}
@@ -832,6 +858,7 @@ mod tests {
assert!(!matches!(event, Event::ApprovalRequired { .. }));
}
} else {
let execution_id = current_execution_id.expect("observed approval execution");
let expected = match action {
HostAction::AllowOnce => {
vec![ApprovalOutcome::ApprovedOnce, ApprovalOutcome::Denied]
@@ -850,7 +877,7 @@ mod tests {
expected
);
assert!(
matches!(&replay.completed[0].ask, ApprovalReceipt::Asked { approval_id, tool_call_id, tool_name, .. } if approval_id == CURRENT_CALL && tool_call_id == CURRENT_CALL && tool_name == COUNTER_TOOL)
matches!(&replay.completed[0].ask, ApprovalReceipt::Asked { approval_id, tool_call_id, tool_name, .. } if approval_id == &execution_id && tool_call_id == &execution_id && tool_name == COUNTER_TOOL)
);
}
}
@@ -1096,7 +1123,12 @@ mod tests {
let mut seen = Vec::new();
let (id, tool_name, description) = next_approval(&events, &mut seen).await;
assert_eq!(id, "exec-1.1", "the program itself is not a prompt");
let execution_id = fixture_execution_id(&seen, "exec-1");
assert_eq!(
id,
format!("{execution_id}.1"),
"the program itself is not a prompt"
);
assert_eq!(tool_name, COUNTER_TOOL);
assert!(
description.contains("execute_tools program call"),
@@ -1109,17 +1141,17 @@ mod tests {
"the program is suspended on its nested call"
);
assert_eq!(executions.load(Ordering::SeqCst), 0);
handle.approve_tool_call("exec-1.1").await.expect("allow");
handle.approve_tool_call(&id).await.expect("allow");
let (id, tool_name, _) = next_approval(&events, &mut seen).await;
assert_eq!(id, "exec-1.2");
assert_eq!(id, format!("{execution_id}.2"));
assert_eq!(tool_name, COUNTER_TOOL);
assert_eq!(
executions.load(Ordering::SeqCst),
1,
"allow resumed the program"
);
handle.deny_tool_call("exec-1.2").await.expect("deny");
handle.deny_tool_call(&id).await.expect("deny");
let store = turn.store.clone();
let session_id = turn.session_id.clone();
@@ -1192,7 +1224,8 @@ mod tests {
let mut seen = Vec::new();
let (id, tool_name, description) = next_approval(&events, &mut seen).await;
assert_eq!(id, "exec-1.1");
let execution_id = fixture_execution_id(&seen, "exec-1");
assert_eq!(id, format!("{execution_id}.1"));
assert_eq!(tool_name, "slow_wait");
assert!(
description.contains("execute_tools program call")
@@ -1210,16 +1243,16 @@ mod tests {
Some(sent_before),
"no tool/call before approval"
);
handle.approve_tool_call("exec-1.1").await.expect("allow");
handle.approve_tool_call(&id).await.expect("allow");
let (id, _, _) = next_approval(&events, &mut seen).await;
assert_eq!(id, "exec-1.2");
assert_eq!(id, format!("{execution_id}.2"));
assert_eq!(
manager.host_requests_started(),
Some(sent_before + 1),
"allow sent exactly one tool/call"
);
handle.deny_tool_call("exec-1.2").await.expect("deny");
handle.deny_tool_call(&id).await.expect("deny");
let receipt = finish_nested_program_turn(&mut turn, &mut seen).await;
assert_eq!(receipt["success"], true, "{receipt}");
@@ -1267,7 +1300,8 @@ mod tests {
let mut seen = Vec::new();
let (id, _, _) = next_approval(&events, &mut seen).await;
assert_eq!(id, "exec-1.1");
let execution_id = fixture_execution_id(&seen, "exec-1");
assert_eq!(id, format!("{execution_id}.1"));
// The user narrows Work/Ask to Plan while the card is open, then
// approves the card.
handle.publish_turn_authority(
@@ -1278,7 +1312,7 @@ mod tests {
ApprovalMode::Suggest,
None,
);
handle.approve_tool_call("exec-1.1").await.expect("allow");
handle.approve_tool_call(&id).await.expect("allow");
let receipt = finish_nested_program_turn(&mut turn, &mut seen).await;
assert_eq!(executions.load(Ordering::SeqCst), 0, "nothing ran");
@@ -1303,7 +1337,7 @@ mod tests {
assert!(
!seen.iter().any(|event| matches!(
event,
Event::ApprovalRequired { id, .. } if id == "exec-1.2"
Event::ApprovalRequired { id, .. } if id == &format!("{execution_id}.2")
)),
"the second call is refused without a prompt"
);
+2
View File
@@ -32,6 +32,7 @@ const MAX_SCHEMA_CONTAINER_REPAIR_BYTES: usize = 64 * 1024;
pub(super) struct ToolExecOutcome {
pub(super) index: usize,
pub(super) id: String,
pub(super) model_call: Option<crate::core::events::ModelToolCall>,
pub(super) name: String,
pub(super) input: serde_json::Value,
pub(super) started_at: std::time::Instant,
@@ -276,6 +277,7 @@ impl FleetDenialGuard {
pub(super) struct ToolExecutionPlan {
pub(super) index: usize,
pub(super) id: String,
pub(super) model_call: Option<crate::core::events::ModelToolCall>,
pub(super) name: String,
pub(super) input: serde_json::Value,
pub(super) caller: Option<ToolCaller>,
@@ -385,6 +385,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call_1".to_string(),
name: "bash".to_string(),
input: json!({"command": "echo hello"}),
@@ -402,6 +403,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call_2".to_string(),
name: "read".to_string(),
input: json!({"path": "x"}), // 13-byte JSON -> 3
+11
View File
@@ -17,6 +17,7 @@ pub(super) enum ContentBlockKind {
#[derive(Debug, Clone)]
pub(super) struct ToolUseState {
pub(super) id: String,
pub(super) execution_id: String,
pub(super) name: String,
pub(super) input: serde_json::Value,
pub(super) caller: Option<ToolCaller>,
@@ -27,6 +28,16 @@ pub(super) struct ToolUseState {
pub(super) input_parse_error: Option<String>,
}
impl ToolUseState {
pub(super) fn model_call(&self) -> crate::core::events::ModelToolCall {
crate::core::events::ModelToolCall {
provider_id: self.id.clone(),
caller: self.caller.clone(),
thought_signature: self.thought_signature.clone(),
}
}
}
/// Maximum total bytes of text/thinking content before aborting the stream.
pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB
/// Sanity backstop for total stream wall-clock duration. **Not** a routine
+505 -45
View File
@@ -201,6 +201,7 @@ fn recorded_snapshot_receipts_bracket_the_turn_and_its_file_writes() {
handle.send(Op::SendMessage(spec)).await.unwrap();
let mut completions = HashMap::new();
let mut local_ids = HashMap::new();
let mut receipts = Vec::new();
let mut rx = handle.rx_event.write().await;
while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
@@ -208,8 +209,23 @@ fn recorded_snapshot_receipts_bracket_the_turn_and_its_file_writes() {
.expect("turn events")
{
match event {
Event::ToolCallComplete { id, result, .. } => {
completions.insert(id, result.expect("tool result"));
Event::ToolCallStarted {
id,
model_call: Some(model_call),
..
} => {
uuid::Uuid::parse_str(&id).expect("host execution id");
assert_ne!(id, model_call.provider_id);
assert!(local_ids.insert(model_call.provider_id, id).is_none());
}
Event::ToolCallComplete {
id,
result,
model_call: Some(model_call),
..
} => {
assert_eq!(local_ids.get(&model_call.provider_id), Some(&id));
completions.insert(model_call.provider_id, result.expect("tool result"));
}
Event::WorkspaceSnapshotTaken { snapshot } => receipts.push(snapshot),
Event::TurnComplete { status, error, .. } => {
@@ -223,6 +239,8 @@ fn recorded_snapshot_receipts_bracket_the_turn_and_its_file_writes() {
assert!(completions.get("call-write").expect("write ran").success);
assert!(completions.get("call-read").expect("read ran").success);
assert_eq!(local_ids.len(), 2);
assert_ne!(local_ids["call-write"], local_ids["call-read"]);
// Every receipt, post-turn included, arrived before TurnComplete.
assert_eq!(
receipts
@@ -231,8 +249,14 @@ fn recorded_snapshot_receipts_bracket_the_turn_and_its_file_writes() {
.collect::<Vec<_>>(),
[
(WorkspaceSnapshotKind::PreTurn, None),
(WorkspaceSnapshotKind::Tool, Some("call-write")),
(WorkspaceSnapshotKind::PostTool, Some("call-write")),
(
WorkspaceSnapshotKind::Tool,
Some(local_ids["call-write"].as_str())
),
(
WorkspaceSnapshotKind::PostTool,
Some(local_ids["call-write"].as_str())
),
(WorkspaceSnapshotKind::PostTurn, None),
],
"a read-only call takes no restore point: {receipts:?}"
@@ -256,9 +280,8 @@ fn recorded_snapshot_receipts_bracket_the_turn_and_its_file_writes() {
.expect("snapshot repo");
let listed = repo.list(usize::MAX).unwrap();
assert!(
listed.iter().any(
|snapshot| receipts[1].matches(snapshot) && snapshot.label == "tool:call-write"
),
listed.iter().any(|snapshot| receipts[1].matches(snapshot)
&& snapshot.label == format!("tool:{}", local_ids["call-write"])),
"the tool receipt names a live snapshot"
);
let delta = repo
@@ -5879,13 +5902,187 @@ async fn run_budgeted_read_turn(
let mut events = handle.rx_event.write().await;
let completions = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
Event::ToolCallComplete { id, result, .. } => Some((id, result)),
Event::ToolCallComplete {
model_call: Some(model_call),
result,
..
} => Some((model_call.provider_id, result)),
_ => None,
})
.collect::<Vec<_>>();
(status, error, completions)
}
#[tokio::test]
async fn provider_id_reuse_mints_distinct_execution_identity_in_all_dispatch_modes() {
use crate::llm_client::mock::{MockLlmClient, canned};
use crate::tools::spec::{ToolCapability, ToolSpec};
struct IdentityTool {
parallel: bool,
observed: Arc<StdMutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl ToolSpec for IdentityTool {
fn name(&self) -> &str {
"fixture_identity"
}
fn description(&self) -> &str {
"Record the admitted execution identity."
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn supports_parallel(&self) -> bool {
self.parallel
}
async fn execute(&self, _: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let id = context
.origin_tool_call_id
.as_ref()
.expect("host origin")
.clone();
self.observed.lock().unwrap().push(id.clone());
Ok(ToolResult::success(id))
}
}
for parallel in [false, true] {
let workspace = tempdir().unwrap();
let calls = [
("reused", "fixture_identity", "{}"),
("peer", "fixture_identity", "{}"),
];
let mock = Arc::new(MockLlmClient::new(vec![
tool_batch_turn(&calls),
tool_batch_turn(&calls),
canned::simple_text_turn("done"),
]));
let (mut engine, handle) = Engine::new_with_model_client(
deterministic_engine_config(workspace.path()),
&Config::default(),
mock.clone(),
);
let observed = Arc::new(StdMutex::new(Vec::new()));
let mut registry =
crate::tools::ToolRegistry::new(ToolContext::new(workspace.path().to_path_buf()));
registry.register(Arc::new(IdentityTool {
parallel,
observed: observed.clone(),
}));
let tools = Some(registry.to_api_tools_with_cache(true));
let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(4);
let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
let ids: HashSet<_> = observed.lock().unwrap().iter().cloned().collect();
assert_eq!(ids.len(), 4);
assert!(ids.iter().all(|id| uuid::Uuid::parse_str(id).is_ok()));
let mut starts = HashMap::new();
let mut completes = HashMap::new();
let mut events = handle.rx_event.write().await;
while let Ok(event) = events.try_recv() {
match event {
Event::ToolCallStarted {
id,
model_call: Some(correlation),
..
} => {
assert!(starts.insert(id, correlation).is_none());
}
Event::ToolCallComplete {
id,
model_call: Some(correlation),
result,
..
} => {
assert_eq!(result.unwrap().content, id);
assert!(completes.insert(id, correlation).is_none());
}
_ => {}
}
}
assert_eq!(starts, completes);
assert_eq!(starts.keys().cloned().collect::<HashSet<_>>(), ids);
let requests = mock.captured_requests();
let history = &requests[2].messages;
let mut pairs = HashMap::<String, (usize, usize)>::new();
for block in history.iter().flat_map(|message| &message.content) {
match block {
ContentBlock::ToolUse {
id,
execution_id: Some(local),
..
} => {
assert_eq!(starts[local].provider_id, *id);
pairs.entry(local.clone()).or_default().0 += 1;
}
ContentBlock::ToolResult {
tool_use_id,
execution_id: Some(local),
..
} => {
assert_eq!(starts[local].provider_id, *tool_use_id);
pairs.entry(local.clone()).or_default().1 += 1;
}
_ => {}
}
}
assert_eq!(pairs.len(), 4);
assert!(pairs.values().all(|pair| *pair == (1, 1)));
}
}
#[tokio::test]
async fn invalid_provider_tool_batch_is_rejected_before_observation_or_history() {
use crate::llm_client::mock::MockLlmClient;
for ids in [["duplicate", "duplicate"], ["valid", ""], ["valid", " "]] {
let workspace = tempdir().unwrap();
let mock = Arc::new(MockLlmClient::new(vec![tool_batch_turn(&[
(ids[0], "read_file", r#"{"path":"never-read"}"#),
(ids[1], "read_file", r#"{"path":"never-read"}"#),
])]));
let (mut engine, handle) = Engine::new_with_model_client(
deterministic_engine_config(workspace.path()),
&Config::default(),
mock.clone(),
);
let mut registry =
crate::tools::ToolRegistry::new(ToolContext::new(workspace.path().to_path_buf()));
registry.register(Arc::new(crate::tools::file::ReadFileTool));
let tools = Some(registry.to_api_tools_with_cache(true));
let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(4);
let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_eq!(status, TurnOutcomeStatus::Failed);
assert!(error.unwrap().contains("pairing id"));
assert_eq!(mock.call_count(), 1);
let mut events = handle.rx_event.write().await;
while let Ok(event) = events.try_recv() {
assert!(!matches!(
event,
Event::ToolCallStarted { .. }
| Event::ToolCallComplete { .. }
| Event::ApprovalRequired { .. }
| Event::ToolGateDecision { .. }
));
}
assert!(
!engine
.session
.messages
.iter()
.flat_map(|message| &message.content)
.any(|block| matches!(
block,
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
))
);
}
}
/// B1: tool output is redacted once, as it enters the transcript, so the
/// session messages (and the session JSON built from them) never hold a live
/// credential a tool printed.
@@ -6079,7 +6276,9 @@ async fn approval_timeout_is_reported_as_timeout_and_refunds_the_budget() {
.expect("timed out waiting for the turn")
.expect("engine event stream closed");
match event {
Event::ApprovalRequired { id, .. } if id == "call-timeout" => {
Event::ApprovalRequired { id, tool_name, .. }
if matches!(tool_name.as_str(), "bash" | "Bash") =>
{
handle
.deny_tool_call_timed_out(&id)
.await
@@ -6088,10 +6287,18 @@ async fn approval_timeout_is_reported_as_timeout_and_refunds_the_budget() {
Event::ApprovalRequired { id, .. } => {
handle.approve_tool_call(&id).await.expect("approve");
}
Event::ToolCallComplete { id, result, .. } if id == "call-timeout" => {
Event::ToolCallComplete {
model_call: Some(model_call),
result,
..
} if model_call.provider_id == "call-timeout" => {
timed_out = Some(result);
}
Event::ToolCallComplete { id, result, .. } if id == "call-after" => {
Event::ToolCallComplete {
model_call: Some(model_call),
result,
..
} if model_call.provider_id == "call-after" => {
after = Some(result);
}
Event::TurnComplete { status, error, .. } => {
@@ -6121,6 +6328,185 @@ async fn approval_timeout_is_reported_as_timeout_and_refunds_the_budget() {
task.await.expect("engine task");
}
/// A repeated provider ID is a different host execution each time. The FIFO
/// child waiter proves the stale answer was consumed while the second Ask was
/// pending, without relying on a sleep or a second execution racing an assert.
#[cfg(unix)]
#[test]
fn repeated_provider_id_ask_rejects_stale_answer_and_keeps_local_artifact_origin() {
use crate::approval_log::{ApprovalOutcome, ApprovalReceipt};
use crate::llm_client::mock::{MockLlmClient, canned};
use crate::tools::subagent::ChildApprovalOutcome;
with_artifact_home(|home| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let command = "printf 'identity:%10000s:done\\n' x";
let arguments = json!({"command": command}).to_string();
let mock = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn("reused-ask", "bash", &arguments),
canned::tool_call_turn("reused-ask", "bash", &arguments),
canned::simple_text_turn("done"),
]));
let mut config = Config::default();
let mut op = external_user_message_op(
"Run the same output fixture twice.",
AppMode::Agent,
&config,
);
let Op::SendMessage(turn) = &mut op else {
unreachable!("fixture creates a model turn");
};
// Declare both limits on the actual route. A 64K window alone
// still reserves the model's 64K output cap, leaving 1K input.
config.custom_models = Some(vec![
serde_json::from_value(json!({
"provider": turn.route.identity.key,
"base_url": turn.route.candidate.endpoint().base_url,
"id": turn.route.model,
"tool_call": true,
"limit": {"context": 64_000, "output": 4_096},
}))
.expect("fixture model limits"),
]);
turn.route = resolved_route_for_test(&config, &turn.route.model);
let limits = turn.route.candidate.limits();
let provider = turn.route.identity.provider;
assert_eq!(limits.context_tokens, Some(64_000));
assert_eq!(limits.output_tokens, Some(4_096));
assert!(
context_input_budget_for_route(provider, &turn.route.model, Some(limits), 0)
.unwrap()
>= 58_880,
"the resolved input budget must fit the real instructions and two calls"
);
assert!(
crate::route_budget::route_inline_char_budget_for_route(
provider,
&turn.route.model,
Some(limits),
) < 10_000,
"the unchanged output fixture must still require an artifact"
);
let mut engine_config = deterministic_engine_config(home);
engine_config.exec_policy_engine = ask_rule_engine(command);
let (engine, handle) =
Engine::new_with_model_client(engine_config, &config, mock.clone());
let shell_manager = engine.shell_manager.clone();
let child_manager = engine.subagent_manager.clone();
let session_id = engine.session.id.clone();
let receipt_store = engine.approval_receipt_store.clone().unwrap();
let task = tokio::spawn(engine.run());
handle.send(op).await.unwrap();
let mut starts = Vec::new();
let mut approvals = Vec::new();
let mut completions = Vec::new();
let mut artifact_paths = Vec::new();
let mut rx = handle.rx_event.write().await;
loop {
match tokio::time::timeout(model_turn_event_timeout(), rx.recv())
.await
.expect("the fixture turn must settle")
.expect("engine event stream")
{
Event::ToolCallStarted { id, model_call, .. } => {
assert_eq!(model_call.unwrap().provider_id, "reused-ask");
assert!(uuid::Uuid::parse_str(&id).is_ok());
assert!(!starts.contains(&id));
starts.push(id);
}
Event::ApprovalRequired { id, .. } => {
assert_eq!(starts.last(), Some(&id));
if let Some(first) = approvals.first() {
assert_ne!(first, &id);
assert_eq!(completions, approvals);
let (barrier_id, barrier) = child_manager
.write()
.await
.register_child_approval(
"agent_barrier",
&format!("agent:agent_barrier:approval:{}", uuid::Uuid::new_v4()),
"bash",
"approval-channel barrier",
)
.unwrap();
handle.approve_tool_call(first).await.unwrap();
handle.approve_tool_call(&barrier_id).await.unwrap();
assert_eq!(
tokio::time::timeout(model_turn_event_timeout(), barrier)
.await
.expect("queued decisions are consumed")
.unwrap(),
ChildApprovalOutcome::Approved
);
let receipts = receipt_store.load(&session_id).unwrap();
assert_eq!(receipts.len(), 3, "stale answer cannot decide the second Ask");
assert!(matches!(receipts.last(), Some(ApprovalReceipt::Asked { tool_call_id, .. }) if tool_call_id == &id));
let jobs = shell_manager.lock().unwrap().list_jobs_for_session(&session_id);
assert_eq!(jobs.len(), 1, "the second command is still unexecuted");
assert_eq!(jobs[0].origin_tool_call_id.as_ref(), Some(first));
}
handle.approve_tool_call(&id).await.unwrap();
approvals.push(id);
}
Event::ToolCallComplete { id, model_call, result, .. } => {
assert_eq!(model_call.unwrap().provider_id, "reused-ask");
assert_eq!(approvals.last(), Some(&id));
let output = result.expect("approved shell fixture succeeds");
assert!(output.success, "{output:?}");
let metadata = output.metadata.as_ref().expect("over-budget output is archived");
assert_eq!(metadata["artifact_id"], crate::artifacts::artifact_id_for_tool_call(&id));
let path = metadata["artifact_path"].as_str().unwrap().to_string();
assert_eq!(fs::read_to_string(&path).unwrap(), output.content);
assert!(!artifact_paths.contains(&path));
artifact_paths.push(path);
completions.push(id);
}
Event::TurnComplete { status, error, .. } => {
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
break;
}
_ => {}
}
}
drop(rx);
assert_eq!(starts.len(), 2);
assert_eq!(starts, approvals);
assert_eq!(starts, completions);
let receipts = receipt_store.load(&session_id).unwrap();
assert_eq!(receipts.len(), 4);
for (pair, id) in receipts.as_chunks::<2>().0.iter().zip(&starts) {
assert!(matches!(&pair[0], ApprovalReceipt::Asked { approval_id, tool_call_id, .. } if approval_id == id && tool_call_id == id));
assert!(matches!(&pair[1], ApprovalReceipt::Decided { approval_id, tool_call_id, outcome: ApprovalOutcome::ApprovedOnce, .. } if approval_id == id && tool_call_id == id));
}
let origins = shell_manager
.lock()
.unwrap()
.list_jobs_for_session(&session_id)
.into_iter()
.map(|job| job.origin_tool_call_id.unwrap())
.collect::<HashSet<_>>();
assert_eq!(origins, starts.iter().cloned().collect());
let requests = mock.captured_requests();
assert_eq!(requests.len(), 3);
for id in &starts {
let pairs = requests[2].messages.iter().flat_map(|message| &message.content)
.filter(|block| block.tool_call_key() == Some(codewhale_models::ToolCallKey::Execution(id)))
.collect::<Vec<_>>();
assert_eq!(pairs.len(), 2);
assert!(matches!(pairs[0], ContentBlock::ToolUse { id, .. } if id == "reused-ask"));
assert!(matches!(pairs[1], ContentBlock::ToolResult { tool_use_id, content, .. } if tool_use_id == "reused-ask" && content.contains(&crate::artifacts::artifact_id_for_tool_call(id))));
}
handle.send(Op::Shutdown).await.unwrap();
task.await.unwrap();
});
});
}
/// #4415 AC(b): a 4-call parallel batch proposed with 2 calls remaining is
/// truncated to the first 2 calls in proposal order; the excess 2 are
/// rejected with the same typed reason, and the batch is counted in full.
@@ -6523,6 +6909,7 @@ fn make_plan_at(
interactive: bool,
) -> ToolExecutionPlan {
ToolExecutionPlan {
model_call: None,
index,
id: format!("tool-{index}"),
name: "grep_files".to_string(),
@@ -7640,8 +8027,8 @@ async fn tool_result_followed_by_terminal_empty_assistant_fails_turn() {
assert!(
events.iter().any(|event| matches!(
event,
Event::ToolCallComplete { id, result, .. }
if id == "call-read" && result.is_ok()
Event::ToolCallComplete { model_call: Some(model_call), result, .. }
if model_call.provider_id == "call-read" && result.is_ok()
)),
"the successful tool result must remain durable: {events:?}"
);
@@ -8454,7 +8841,7 @@ async fn injected_model_sandbox_escalation_applies_only_after_exact_call_approva
input,
description,
..
} if id == "call-escalated-bash" => {
} => {
assert_eq!(input["sandbox_permissions"], "workspace-write");
assert!(
description.contains("the exact command writes the requested workspace proof"),
@@ -8477,7 +8864,11 @@ async fn injected_model_sandbox_escalation_applies_only_after_exact_call_approva
.await
.expect("approve exact escalated call");
}
Event::ToolCallComplete { id, result, .. } if id == "call-escalated-bash" => {
Event::ToolCallComplete {
model_call: Some(model_call),
result,
..
} if model_call.provider_id == "call-escalated-bash" => {
approved_result = Some(result.expect("approved escalation result"));
}
Event::TurnComplete { status, error, .. } => {
@@ -8578,9 +8969,11 @@ async fn sandbox_escalation_fails_closed_when_the_posture_cannot_prompt() {
Event::ApprovalRequired { .. } => {
panic!("{posture} must not open an escalation prompt")
}
Event::ToolCallComplete { id, result, .. }
if id == "call-unattended-escalation" =>
{
Event::ToolCallComplete {
model_call: Some(model_call),
result,
..
} if model_call.provider_id == "call-unattended-escalation" => {
let error = result.expect_err("unattended escalation must be denied");
assert!(error.to_string().contains(expected_denial), "{error}");
assert!(error.to_string().contains(posture), "{error}");
@@ -9026,9 +9419,12 @@ async fn injected_model_duplicate_reads_both_execute_and_close_both_tool_ids() {
{
match event {
Event::ToolCallComplete {
id, name, result, ..
model_call: Some(model_call),
name,
result,
..
} if name == "File" => {
results.insert(id, result.expect("read result"));
results.insert(model_call.provider_id, result.expect("read result"));
}
Event::TurnComplete { status, error, .. } => {
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
@@ -9364,15 +9760,25 @@ async fn injected_model_complete_tool_block_at_max_output_tokens_executes() {
{
match event {
Event::TurnUsage { .. } => saw_turn_usage = true,
Event::ToolCallStarted { id, name, .. } => {
assert_eq!(id, "call-truncated");
Event::ToolCallStarted {
id,
name,
model_call,
..
} => {
assert_ne!(id, "call-truncated");
assert_eq!(model_call.unwrap().provider_id, "call-truncated");
assert_eq!(name, "File");
saw_tool_start = true;
}
Event::ToolCallComplete {
id, name, result, ..
id,
name,
result,
model_call,
} => {
assert_eq!(id, "call-truncated");
assert_ne!(id, "call-truncated");
assert_eq!(model_call.unwrap().provider_id, "call-truncated");
assert_eq!(name, "File");
let result = result.expect("complete tool call closes with a tool result");
assert!(
@@ -9590,6 +9996,7 @@ async fn collect_guardian_journey_with_receipts(
Vec<GateReceipt>,
) {
let mut completion = None;
let mut execution_id = None;
let mut usage_events = Vec::new();
let mut receipts = Vec::new();
let mut rx = handle.rx_event.write().await;
@@ -9599,7 +10006,18 @@ async fn collect_guardian_journey_with_receipts(
.expect("timed out waiting for Auto-Review journey")
.expect("engine event stream closed");
match event {
Event::ToolCallComplete { id, result, .. } if id == call_id => {
Event::ToolCallStarted {
id,
model_call: Some(model_call),
..
} if model_call.provider_id == call_id => {
execution_id = Some(id);
}
Event::ToolCallComplete {
model_call: Some(model_call),
result,
..
} if model_call.provider_id == call_id => {
assert!(
completion.replace(result).is_none(),
"duplicate tool result"
@@ -9618,7 +10036,7 @@ async fn collect_guardian_journey_with_receipts(
risk,
reason,
..
} if tool_id == call_id => receipts.push(GateReceipt {
} if execution_id.as_deref() == Some(tool_id.as_str()) => receipts.push(GateReceipt {
gate,
decision,
risk,
@@ -11792,6 +12210,7 @@ fn execution_failures_are_returned_without_strategy_coaching() {
#[test]
fn tool_exec_outcome_tracks_duration() {
let outcome = ToolExecOutcome {
model_call: None,
index: 0,
id: "tool-1".to_string(),
name: "grep_files".to_string(),
@@ -13606,8 +14025,14 @@ async fn run_shell_command_op_executes_without_approval_modal() {
assert!(turn_id.starts_with(USER_SHELL_TOOL_ID_PREFIX));
assert!(route.is_none());
}
Event::ToolCallStarted { id, name, input } => {
Event::ToolCallStarted {
id,
name,
input,
model_call,
} => {
saw_started = true;
assert!(model_call.is_none());
assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX));
assert_eq!(name, "Bash");
assert_eq!(input["action"], json!("run"));
@@ -13617,8 +14042,14 @@ async fn run_shell_command_op_executes_without_approval_modal() {
Event::ApprovalRequired { .. } => {
saw_approval = true;
}
Event::ToolCallComplete { id, name, result } => {
Event::ToolCallComplete {
id,
name,
result,
model_call,
} => {
saw_complete = true;
assert!(model_call.is_none());
assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX));
assert_eq!(name, "Bash");
let result = result.expect("shell result");
@@ -16643,6 +17074,7 @@ async fn session_update_preserves_reasoning_tool_only_turn() {
thinking: "Need a tool before answering.".to_string(),
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "Cargo.toml"}),
@@ -18294,6 +18726,7 @@ async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_1".to_string(),
name: "Bash".to_string(),
input: serde_json::json!({"command": "printf hi"}),
@@ -18304,6 +18737,7 @@ async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_1".to_string(),
content: "unique-tool-output-marker".to_string(),
is_error: None,
@@ -19324,7 +19758,10 @@ fn oversized_tool_output_is_recoverable_before_serial_and_parallel_fanout() {
let mut events = handle.rx_event.write().await;
let mut completed = 0;
while let Ok(event) = events.try_recv() {
let Event::ToolCallComplete { id, result, .. } = event else {
let Event::ToolCallComplete {
result, model_call, ..
} = event
else {
continue;
};
let output = result.expect("tool succeeded");
@@ -19335,7 +19772,8 @@ fn oversized_tool_output_is_recoverable_before_serial_and_parallel_fanout() {
let path = metadata["artifact_path"].as_str().expect("artifact path");
assert_eq!(fs::read_to_string(path).unwrap(), raw);
let reference = metadata["artifact_id"].as_str().unwrap();
let results = guardian_tool_results(&requests[1], &id);
let results =
guardian_tool_results(&requests[1], &model_call.unwrap().provider_id);
assert_eq!(results.len(), 1);
let (text, _) = results[0];
assert!(text.len() <= 7_680, "64K route inline budget");
@@ -20970,6 +21408,7 @@ fn turn_metadata_skips_tool_result_messages() {
engine.session.add_message(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_42".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "src/lib.rs"}),
@@ -20981,6 +21420,7 @@ fn turn_metadata_skips_tool_result_messages() {
engine.session.add_message(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_42".to_string(),
content: "pub fn sample() {}".to_string(),
is_error: None,
@@ -21082,6 +21522,7 @@ fn turn_metadata_skips_when_only_tool_results_trail() {
engine.session.add_message(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_42".to_string(),
content: "pub fn sample() {}".to_string(),
is_error: None,
@@ -22551,11 +22992,12 @@ fn fake_wrapper_notice_is_compact_and_actionable() {
// chunks that build up `input_buffer`. The TUI used to fire `ToolCallStarted`
// from `ContentBlockStart` with the empty initial input and never re-emit
// once args were known, so cells rendered the literal text `<command>` /
// `<file>` placeholders. The fix relocates the emission to `ContentBlockStop`
// and routes the input through `final_tool_input`, which prefers the parsed
// `<file>` placeholders. The input is finalized at `ContentBlockStop`, then published after batch admission
// through `final_tool_input`, which prefers the parsed
// buffer over a stale empty placeholder.
fn tool_state(initial: serde_json::Value, buffer: &str) -> ToolUseState {
ToolUseState {
execution_id: uuid::Uuid::new_v4().to_string(),
id: "t1".into(),
name: "exec_shell".into(),
input: initial,
@@ -26686,11 +27128,16 @@ async fn idle_engine_routes_child_approval_decisions_to_the_waiting_child() {
let manager = engine.subagent_manager.clone();
let run = tokio::spawn(engine.run());
let (approval_id, receiver) =
manager
.write()
.await
.register_child_approval("agent_child", "bash", "fixture");
let (approval_id, receiver) = manager
.write()
.await
.register_child_approval(
"agent_child",
&format!("agent:agent_child:approval:{}", uuid::Uuid::new_v4()),
"bash",
"fixture",
)
.unwrap();
handle
.approve_tool_call(approval_id.clone())
.await
@@ -26703,11 +27150,16 @@ async fn idle_engine_routes_child_approval_decisions_to_the_waiting_child() {
assert_eq!(manager.read().await.pending_child_approvals(), 0);
// A denial for a second prompt routes the same way.
let (approval_id, receiver) =
manager
.write()
.await
.register_child_approval("agent_child", "bash", "fixture");
let (approval_id, receiver) = manager
.write()
.await
.register_child_approval(
"agent_child",
&format!("agent:agent_child:approval:{}", uuid::Uuid::new_v4()),
"bash",
"fixture",
)
.unwrap();
handle
.deny_tool_call(approval_id)
.await
@@ -27748,17 +28200,25 @@ async fn extension_tool_is_deferred_gated_and_attributed_on_the_model_path() {
.expect("timed out waiting for the extension tool turn")
.expect("engine event stream closed");
match event {
Event::ToolCallComplete { id, result: r, .. } if id == "call-search" => {
Event::ToolCallComplete {
model_call: Some(model_call),
result: r,
..
} if model_call.provider_id == "call-search" => {
search = Some(r.expect("tool_search result"));
}
Event::ApprovalRequired {
id, description, ..
} if id == "call-ext" => {
} => {
assert!(result.is_none(), "approval must precede execution");
approval = Some(description);
handle.approve_tool_call(&id).await.expect("approve");
}
Event::ToolCallComplete { id, result: r, .. } if id == "call-ext" => {
Event::ToolCallComplete {
model_call: Some(model_call),
result: r,
..
} if model_call.provider_id == "call-ext" => {
result = Some(r.expect("extension tool result"));
}
Event::TurnComplete { status, error, .. } => {
@@ -82,15 +82,26 @@ async fn engine_cancel_stops_started_foreground_descendants_and_preserves_backgr
handle.cancel();
let mut receipts = Vec::new();
let mut foreground_execution_id = None;
let mut skipped = false;
tokio::time::timeout(Duration::from_secs(10), async {
let mut events = handle.rx_event.write().await;
loop {
match events.recv().await.expect("engine remains alive") {
Event::ToolCallComplete { id, result, .. } if id == "call-foreground" => {
Event::ToolCallComplete {
id,
result,
model_call: Some(model_call),
..
} if model_call.provider_id == "call-foreground" => {
foreground_execution_id = Some(id);
receipts.push(result.expect("model-visible cancellation receipt"));
}
Event::ToolCallComplete { id, result, .. } if id == "call-skipped" => {
Event::ToolCallComplete {
model_call: Some(model_call),
result,
..
} if model_call.provider_id == "call-skipped" => {
let result = result.unwrap();
assert_eq!(result.metadata.unwrap()["executed"], false);
assert!(result.content.contains("before this tool ran"));
@@ -135,7 +146,7 @@ async fn engine_cancel_stops_started_foreground_descendants_and_preserves_backgr
let jobs = manager.list_jobs_for_session(&session_id);
let foreground = jobs
.iter()
.find(|job| job.origin_tool_call_id.as_deref() == Some("call-foreground"))
.find(|job| job.origin_tool_call_id.as_deref() == foreground_execution_id.as_deref())
.expect("the exact foreground owner remains inspectable");
assert_eq!(foreground.status, crate::tools::shell::ShellStatus::Killed);
assert!(foreground.stdout_tail.contains("foreground-started"));
@@ -263,7 +274,11 @@ async fn returned_tool_failure_reaches_next_model_request_as_error() {
let mut events = handle.rx_event.write().await;
let results = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
Event::ToolCallComplete { id, result, .. } => Some((id, result)),
Event::ToolCallComplete {
model_call: Some(model_call),
result,
..
} => Some((model_call.provider_id, result)),
_ => None,
})
.collect::<Vec<_>>();
+137 -73
View File
@@ -488,6 +488,7 @@ impl Engine {
.collect::<Vec<_>>()
.join("\n\n");
let mut uses = [ToolUseState {
execution_id: approval_id.to_string(),
id: approval_id.to_string(),
name: tool_name.to_string(),
input: json!({ "code": code }),
@@ -2070,6 +2071,21 @@ impl Engine {
.await;
}
let protocol = self.active_route_endpoint.as_ref().map_or(
codewhale_config::provider::WireFormat::ChatCompletions,
|endpoint| endpoint.protocol,
);
// No tool observation or replayable tool history is published before
// this whole-response admission. Usage and visible text remain real.
if let Err(error) = crate::client::validate_tool_call_ids_for_protocol(
protocol,
tool_uses.iter().map(|tool| tool.id.as_str()),
) {
self.add_interrupted_assistant_text(&current_text_visible)
.await;
return (TurnOutcomeStatus::Failed, Some(error.to_string()));
}
if self.cancel_token.is_cancelled() {
let _ = self.tx_event.send(Event::status("Request cancelled")).await;
self.add_interrupted_assistant_text(&current_text_visible)
@@ -2099,10 +2115,20 @@ impl Engine {
// Fall through to the normal content/tool dispatch below.
} else {
for tool in &tool_uses {
let _ = self
.tx_event
.send(Event::ToolCallStarted {
id: tool.execution_id.clone(),
model_call: Some(tool.model_call()),
name: tool.name.clone(),
input: final_tool_input(tool),
})
.await;
let _ = self
.tx_event
.send(Event::ToolCallComplete {
id: tool.id.clone(),
id: tool.execution_id.clone(),
model_call: Some(tool.model_call()),
name: tool.name.clone(),
result: Ok(incomplete_tool_result(reason)),
})
@@ -2207,6 +2233,7 @@ impl Engine {
}
for tool in &tool_uses {
resume_blocks.push(ContentBlock::ToolUse {
execution_id: Some(tool.execution_id.clone()),
id: tool.id.clone(),
name: tool.name.clone(),
input: tool.input.clone(),
@@ -2280,6 +2307,44 @@ impl Engine {
stream_retry_budget.reset();
}
let mut final_text = current_text_visible.clone();
if tool_uses.is_empty() && tool_parser::has_tool_call_markers(&current_text_raw) {
let parsed = tool_parser::parse_tool_calls(&current_text_raw);
final_text = parsed.clean_text;
for call in parsed.tool_calls {
tool_uses.push(ToolUseState {
execution_id: uuid::Uuid::new_v4().to_string(),
id: call.id,
name: call.name,
input: call.args,
caller: None,
thought_signature: None,
input_buffer: String::new(),
input_parse_error: None,
});
}
if let Err(error) = crate::client::validate_tool_call_ids_for_protocol(
protocol,
tool_uses.iter().map(|tool| tool.id.as_str()),
) {
self.add_interrupted_assistant_text(&current_text_visible)
.await;
return (TurnOutcomeStatus::Failed, Some(error.to_string()));
}
}
for tool in &tool_uses {
let _ = self
.tx_event
.send(Event::ToolCallStarted {
id: tool.execution_id.clone(),
model_call: Some(tool.model_call()),
name: tool.name.clone(),
input: final_tool_input(tool),
})
.await;
}
// Persist only reasoning the provider actually emitted. Some chat
// wires require a non-empty `reasoning_content` field when an
// assistant message carries tool calls; the route serializer adds
@@ -2297,30 +2362,6 @@ impl Engine {
state: current_thinking_state.clone(),
});
}
let mut final_text = current_text_visible.clone();
if tool_uses.is_empty() && tool_parser::has_tool_call_markers(&current_text_raw) {
let parsed = tool_parser::parse_tool_calls(&current_text_raw);
final_text = parsed.clean_text;
for call in parsed.tool_calls {
let _ = self
.tx_event
.send(Event::ToolCallStarted {
id: call.id.clone(),
name: call.name.clone(),
input: call.args.clone(),
})
.await;
tool_uses.push(ToolUseState {
id: call.id,
name: call.name,
input: call.args,
caller: None,
thought_signature: None,
input_buffer: String::new(),
input_parse_error: None,
});
}
}
// A worker may cooperate with the strategy notice by immediately
// reporting its blocker. No intervening useful work means that
@@ -2385,6 +2426,7 @@ impl Engine {
}
for tool in &tool_uses {
content_blocks.push(ContentBlock::ToolUse {
execution_id: Some(tool.execution_id.clone()),
id: tool.id.clone(),
name: tool.name.clone(),
input: tool.input.clone(),
@@ -3430,7 +3472,7 @@ impl Engine {
crate::sandbox::SandboxPolicy::ReadOnly
);
for (index, tool) in tool_uses.iter_mut().enumerate() {
let tool_id = tool.id.clone();
let tool_id = tool.execution_id.clone();
let mut tool_name = tool.name.clone();
let mut tool_input = tool.input.clone();
let tool_caller = tool.caller.clone();
@@ -3984,6 +4026,7 @@ impl Engine {
}
plans.push(ToolExecutionPlan {
model_call: (source == ToolCallSource::Model).then(|| tool.model_call()),
index,
id: tool_id,
name: tool_name,
@@ -4136,12 +4179,14 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: plan.id.clone(),
name: plan.name.clone(),
result: result.clone(),
})
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: plan.id,
name: plan.name,
@@ -4174,12 +4219,14 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: plan.id.clone(),
name: plan.name.clone(),
result: result.clone(),
})
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: plan.id,
name: plan.name,
@@ -4203,6 +4250,7 @@ impl Engine {
.map(|plan| {
(
plan.index,
plan.model_call.clone(),
plan.id.clone(),
plan.name.clone(),
plan.input.clone(),
@@ -4217,12 +4265,14 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: plan.id.clone(),
name: plan.name.clone(),
result: result.clone(),
})
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: plan.id,
name: plan.name,
@@ -4235,7 +4285,17 @@ impl Engine {
continue;
}
if let Some(err) = plan.blocked_error.clone() {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
id: plan.id.clone(),
model_call: plan.model_call.clone(),
name: plan.name.clone(),
result: Err(err.clone()),
})
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: plan.id,
name: plan.name,
@@ -4325,6 +4385,7 @@ impl Engine {
let legacy_result = result.map(RichToolResult::into_result);
let _ = tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: plan.id.clone(),
name: plan.name.clone(),
result: legacy_result.clone(),
@@ -4332,6 +4393,7 @@ impl Engine {
.await;
ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: plan.id,
name: plan.name,
@@ -4364,7 +4426,7 @@ impl Engine {
// waiting for cooperative cancellation inside each tool.
drop(tool_tasks);
if parallel_cancelled {
for (index, id, name, input) in parallel_plan_receipts {
for (index, model_call, id, name, input) in parallel_plan_receipts {
if outcomes[index].is_some() {
continue;
}
@@ -4375,12 +4437,14 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: model_call.clone(),
id: id.clone(),
name: name.clone(),
result: result.clone(),
})
.await;
outcomes[index] = Some(ToolExecOutcome {
model_call: model_call.clone(),
index,
id,
name,
@@ -4404,12 +4468,14 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: tool_id.clone(),
name: tool_name.clone(),
result: result.clone(),
})
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: tool_id,
name: tool_name,
@@ -4427,12 +4493,14 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: tool_id.clone(),
name: tool_name.clone(),
result: result.clone(),
})
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: tool_id,
name: tool_name,
@@ -4484,6 +4552,7 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: tool_id.clone(),
name: tool_name.clone(),
result: result.clone(),
@@ -4491,6 +4560,7 @@ impl Engine {
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: tool_id,
name: tool_name,
@@ -4525,6 +4595,7 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: tool_id.clone(),
name: tool_name.clone(),
result: result.clone(),
@@ -4532,6 +4603,7 @@ impl Engine {
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: tool_id,
name: tool_name,
@@ -4562,6 +4634,7 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: tool_id.clone(),
name: tool_name.clone(),
result: result.clone(),
@@ -4569,6 +4642,7 @@ impl Engine {
.await;
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: tool_id,
name: tool_name,
@@ -4918,6 +4992,7 @@ impl Engine {
let _ = self
.tx_event
.send(Event::ToolCallComplete {
model_call: plan.model_call.clone(),
id: tool_id.clone(),
name: tool_name.clone(),
result: legacy_result.clone(),
@@ -4932,6 +5007,7 @@ impl Engine {
ToolExecutionOutcome::from_legacy(legacy_result)
};
outcomes[plan.index] = Some(ToolExecOutcome {
model_call: plan.model_call.clone(),
index: plan.index,
id: tool_id,
name: tool_name,
@@ -5063,6 +5139,7 @@ impl Engine {
let nested_id = format!("{parent_id}.{seq}");
let mut uses = [ToolUseState {
execution_id: nested_id.clone(),
id: nested_id.clone(),
name,
input,
@@ -5488,16 +5565,20 @@ impl Engine {
.iter()
.filter_map(|block| serde_json::to_value(block).ok())
.collect::<Vec<_>>();
self.add_session_message(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: outcome.id,
content: output_for_context,
is_error: (!output.success).then_some(true),
content_blocks: (!content_blocks.is_empty()).then_some(content_blocks),
}],
})
.await;
if let Some(model_call) = outcome.model_call {
self.add_session_message(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: Some(outcome.id),
tool_use_id: model_call.provider_id,
content: output_for_context,
is_error: (!output.success).then_some(true),
content_blocks: (!content_blocks.is_empty())
.then_some(content_blocks),
}],
})
.await;
}
}
Err(e) => {
let envelope: ErrorEnvelope = e.clone().into();
@@ -5522,16 +5603,19 @@ impl Engine {
Some(&error),
&self.session.workspace,
);
self.add_session_message(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: outcome.id,
content: format!("Error: {error}"),
is_error: Some(true),
content_blocks: None,
}],
})
.await;
if let Some(model_call) = outcome.model_call {
self.add_session_message(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: Some(outcome.id),
tool_use_id: model_call.provider_id,
content: format!("Error: {error}"),
is_error: Some(true),
content_blocks: None,
}],
})
.await;
}
}
}
}
@@ -5601,7 +5685,7 @@ impl Engine {
// all Stops are flushed together at `finish_reason`. A single
// Option<usize> gets overwritten by each new Start; the first
// Stop then takes the last index, and every subsequent Stop
// takes `None`, dropping ToolCallStarted events for every
// takes `None`, dropping input finalization for every
// tool call except the last one in the batch.
let mut current_tool_indices: std::collections::HashMap<u32, usize> =
std::collections::HashMap::new();
@@ -5997,11 +6081,12 @@ impl Engine {
));
current_block_kind = Some(ContentBlockKind::ToolUse);
current_tool_indices.insert(index, tool_uses.len());
// ToolCallStarted is deferred to ContentBlockStop —
// see `final_tool_input`. Emitting here would ship
// ToolCallStarted is deferred until whole-batch admission.
// See `final_tool_input`: emitting here would ship
// the placeholder `{}` and the cell would render
// `<command>` / `<file>` literals to the user.
tool_uses.push(ToolUseState {
execution_id: uuid::Uuid::new_v4().to_string(),
id,
name,
input,
@@ -6018,6 +6103,7 @@ impl Engine {
current_block_kind = Some(ContentBlockKind::ToolUse);
current_tool_indices.insert(index, tool_uses.len());
tool_uses.push(ToolUseState {
execution_id: uuid::Uuid::new_v4().to_string(),
id,
name,
input,
@@ -6144,20 +6230,6 @@ impl Engine {
tool_state.name, tool_state.input_buffer
));
self.finalize_streamed_tool_input(tool_state).await;
// Now that the input is finalized, announce the
// tool call to the UI. Deferring to here is what
// keeps the cell from rendering `<command>` /
// `<file>` placeholders during the brief window
// between block start and the last InputJsonDelta.
let _ = self
.tx_event
.send(Event::ToolCallStarted {
id: tool_state.id.clone(),
name: tool_state.name.clone(),
input: final_tool_input(tool_state),
})
.await;
}
}
StreamEvent::MessageDelta {
@@ -6202,21 +6274,13 @@ impl Engine {
// this drain existed a truncated tool call reached dispatch through
// `tool.input` and executed (#5986). Every block that never stopped
// goes through the same finalization gate a normal ContentBlockStop
// applies, and is announced with the same finalized input — which is
// applies, and is later announced with the same finalized input — which is
// also why no mid-stream parse is needed (#6213 T4).
for tool_idx in std::mem::take(&mut current_tool_indices).into_values() {
let Some(tool_state) = tool_uses.get_mut(tool_idx) else {
continue;
};
self.finalize_streamed_tool_input(tool_state).await;
let _ = self
.tx_event
.send(Event::ToolCallStarted {
id: tool_state.id.clone(),
name: tool_state.name.clone(),
input: final_tool_input(tool_state),
})
.await;
}
StreamOutcome {
current_text_raw,
+11
View File
@@ -16,6 +16,15 @@ use crate::tools::subagent::{AgentWorkerStatus, CoordinationDetailProjection, Su
use crate::tools::user_input::UserInputRequest;
use codewhale_models::{Message, SystemPrompt, Tool, Usage};
/// Provider correlation retained only for model-history reconstruction.
/// Event ids remain host execution ids; this is not approval authority.
#[derive(Debug, Clone, PartialEq)]
pub struct ModelToolCall {
pub provider_id: String,
pub caller: Option<codewhale_models::ToolCaller>,
pub thought_signature: Option<String>,
}
/// Final status for a turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -234,6 +243,7 @@ pub enum Event {
/// Tool call initiated
ToolCallStarted {
id: String,
model_call: Option<ModelToolCall>,
name: String,
input: Value,
},
@@ -248,6 +258,7 @@ pub enum Event {
/// Tool call completed
ToolCallComplete {
id: String,
model_call: Option<ModelToolCall>,
name: String,
result: Result<ToolResult, ToolError>,
},
+9 -2
View File
@@ -486,7 +486,9 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
session_id,
index: count(*index),
},
Event::ToolCallStarted { id, name, input } => wire::EventMsg::ToolCallStarted {
Event::ToolCallStarted {
id, name, input, ..
} => wire::EventMsg::ToolCallStarted {
thread_id,
session_id,
tool_call_id: id.clone(),
@@ -497,7 +499,9 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
thread_id,
session_id,
},
Event::ToolCallComplete { id, name, result } => wire::EventMsg::ToolCallComplete {
Event::ToolCallComplete {
id, name, result, ..
} => wire::EventMsg::ToolCallComplete {
thread_id,
session_id,
tool_call_id: id.clone(),
@@ -1417,17 +1421,20 @@ mod tests {
content: "hmm".into(),
},
Event::ToolCallStarted {
model_call: None,
id: "c1".into(),
name: "read_file".into(),
input: json!({"path": "x"}),
},
Event::ToolCallHeartbeat,
Event::ToolCallComplete {
model_call: None,
id: "c1".into(),
name: "read_file".into(),
result: Ok(ToolResult::success("ok")),
},
Event::ToolCallComplete {
model_call: None,
id: "c2".into(),
name: "bash".into(),
result: Err(ToolError::Timeout { seconds: 9 }),
+44 -4
View File
@@ -149,6 +149,7 @@ async fn approving_the_first_of_three_queued_calls_cancels_none_of_them() {
.expect("send model turn");
let mut approvals = 0usize;
let mut starts = Vec::new();
let mut results = Vec::new();
let mut rx = handle.rx_event.write().await;
while let Some(event) = tokio::time::timeout(event_timeout(), rx.recv())
@@ -156,7 +157,21 @@ async fn approving_the_first_of_three_queued_calls_cancels_none_of_them() {
.expect("timed out waiting for turn event")
{
match event {
Event::ToolCallStarted {
id,
name,
model_call,
..
} if name == "Bash" => {
let model_call = model_call.expect("model call correlation");
uuid::Uuid::parse_str(&id).expect("host execution id");
assert!(!CALLS.contains(&id.as_str()));
assert!(!starts.iter().any(|(started, _)| started == &id));
starts.push((id, model_call.provider_id));
}
Event::ApprovalRequired { id, .. } => {
assert!(starts.iter().any(|(started, _)| started == &id));
assert!(!CALLS.contains(&id.as_str()));
approvals += 1;
if approvals == 1 {
// The desktop client republishes its unchanged posture
@@ -175,8 +190,17 @@ async fn approving_the_first_of_three_queued_calls_cancels_none_of_them() {
}
handle.approve_tool_call(id).await.expect("approve call");
}
Event::ToolCallComplete { id, name, result } if name == "Bash" => {
results.push((id, result));
Event::ToolCallComplete {
id,
name,
result,
model_call,
} if name == "Bash" => {
let model_call = model_call.expect("model call correlation");
assert!(starts.iter().any(
|(started, provider)| started == &id && provider == &model_call.provider_id
));
results.push((id, model_call.provider_id, result));
}
Event::TurnComplete { .. } => break,
_ => {}
@@ -195,7 +219,7 @@ async fn approving_the_first_of_three_queued_calls_cancels_none_of_them() {
CALLS.len(),
"every queued call reported a result: {results:?}"
);
for (id, result) in &results {
for (id, _, result) in &results {
let result = result
.as_ref()
.unwrap_or_else(|err| panic!("{id} failed after an approval: {err}"));
@@ -207,8 +231,24 @@ async fn approving_the_first_of_three_queued_calls_cancels_none_of_them() {
result.content
);
}
let order: Vec<&str> = results.iter().map(|(id, _)| id.as_str()).collect();
let order: Vec<&str> = results
.iter()
.map(|(_, provider, _)| provider.as_str())
.collect();
assert_eq!(order, CALLS, "queued calls run in the order the model gave");
assert_eq!(
starts
.iter()
.map(|(_, provider)| provider.as_str())
.collect::<Vec<_>>(),
CALLS,
"admitted calls retain the model's order and provider identities"
);
assert_eq!(
results.iter().map(|(id, _, _)| id).collect::<Vec<_>>(),
starts.iter().map(|(id, _)| id).collect::<Vec<_>>(),
"each admitted execution completes exactly once in queue order"
);
for id in CALLS {
assert!(
workspace.path().join(format!("{id}.txt")).exists(),
+1
View File
@@ -490,6 +490,7 @@ mod usage_tests {
with_tool.push(codewhale_models::Message {
role: codewhale_models::Role::User,
content: vec![codewhale_models::ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "x".repeat(80_000),
is_error: None,
+3 -1
View File
@@ -860,7 +860,9 @@ pub(crate) async fn run_exec_agent(
)
);
}
Event::ToolCallStarted { id, name, input } => {
Event::ToolCallStarted {
id, name, input, ..
} => {
let started_at = chrono::Utc::now().to_rfc3339();
tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
if output_format == ExecOutputFormat::StreamJson {
+3
View File
@@ -73,6 +73,7 @@ fn image_blocks_fixture() -> Vec<codewhale_models::Message> {
codewhale_models::Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_1".to_string(),
content: "Read image file [image/png]".to_string(),
is_error: None,
@@ -228,6 +229,7 @@ fn blind_route_removes_nested_tool_result_image() {
let mut messages = vec![codewhale_models::Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-image".to_string(),
content: "Read image file [image/png]".to_string(),
is_error: None,
@@ -860,6 +862,7 @@ fn only_images_since_the_latest_prompt_count_as_this_turns() {
let tool_image = codewhale_models::Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call".to_string(),
content: "Read image".to_string(),
is_error: None,
+4
View File
@@ -19362,6 +19362,7 @@ api_key = "test-only-key"
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "listed files".to_string(),
is_error: Some(false),
@@ -19428,6 +19429,7 @@ api_key = "test-only-key"
messages.push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-current".into(),
content: "result".into(),
is_error: None,
@@ -19481,6 +19483,7 @@ api_key = "test-only-key"
cache_control: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "call-1".to_string(),
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "cargo test"}),
@@ -19492,6 +19495,7 @@ api_key = "test-only-key"
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "stdout line\nstderr line".to_string(),
is_error: Some(false),
+66 -14
View File
@@ -499,31 +499,40 @@ fn publish_offloaded_context(
}
/// When a message containing a ToolUse or ToolResult is marked for removal,
/// cascade that removal to its counterpart so the API never sees orphaned
/// blocks. Runs a fixpoint loop until the remove set is closed under pairing.
/// cascade that removal to its unambiguous counterpart. Corrupt or ambiguous
/// identities cannot select another exchange for deletion. Runs a fixpoint
/// loop until the remove set is closed under proven pairing.
fn cascade_tool_pair_removals(messages: &[Message], remove_set: &mut FastHashSet<usize>) {
if remove_set.is_empty() {
return;
}
// Internal transcript IDs and message indices are assigned by the engine,
// so this per-purge pairing pass can use the faster non-cryptographic hasher.
let mut call_id_to_idx: FastHashMap<String, usize> = FastHashMap::default();
let mut result_id_to_idx: FastHashMap<String, usize> = FastHashMap::default();
// A reused provider ID is not a unique host identity. Ambiguous entries
// cannot select another exchange's counterpart for deletion.
let mut call_id_to_idx = FastHashMap::default();
let mut result_id_to_idx = FastHashMap::default();
for (idx, msg) in messages.iter().enumerate() {
for block in &msg.content {
match block {
let Some(key) = block
.tool_call_key()
.filter(|key| !key.as_str().trim().is_empty())
else {
continue;
};
let (map, provider_id) = match block {
ContentBlock::ToolUse { id, .. } | ContentBlock::ServerToolUse { id, .. } => {
call_id_to_idx.insert(id.clone(), idx);
(&mut call_id_to_idx, id.as_str())
}
ContentBlock::ToolResult { tool_use_id, .. }
| ContentBlock::ToolSearchToolResult { tool_use_id, .. }
| ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => {
result_id_to_idx.insert(tool_use_id.clone(), idx);
(&mut result_id_to_idx, tool_use_id.as_str())
}
_ => {}
}
_ => continue,
};
map.entry(key)
.and_modify(|entry| *entry = None)
.or_insert(Some((idx, provider_id)));
}
}
@@ -538,7 +547,16 @@ fn cascade_tool_pair_removals(messages: &[Message], remove_set: &mut FastHashSet
for block in &msg.content {
match block {
ContentBlock::ToolUse { id, .. } | ContentBlock::ServerToolUse { id, .. } => {
if let Some(&result_idx) = result_id_to_idx.get(id)
if let Some(&(result_idx, provider_id)) = block
.tool_call_key()
.and_then(|key| result_id_to_idx.get(&key))
.and_then(Option::as_ref)
&& provider_id == id.as_str()
&& block
.tool_call_key()
.and_then(|key| call_id_to_idx.get(&key))
.and_then(Option::as_ref)
.is_some_and(|(call_idx, _)| *call_idx == idx)
&& remove_set.insert(result_idx)
{
changed = true;
@@ -547,7 +565,16 @@ fn cascade_tool_pair_removals(messages: &[Message], remove_set: &mut FastHashSet
ContentBlock::ToolResult { tool_use_id, .. }
| ContentBlock::ToolSearchToolResult { tool_use_id, .. }
| ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => {
if let Some(&call_idx) = call_id_to_idx.get(tool_use_id)
if let Some(&(call_idx, provider_id)) = block
.tool_call_key()
.and_then(|key| call_id_to_idx.get(&key))
.and_then(Option::as_ref)
&& provider_id == tool_use_id.as_str()
&& block
.tool_call_key()
.and_then(|key| result_id_to_idx.get(&key))
.and_then(Option::as_ref)
.is_some_and(|(result_idx, _)| *result_idx == idx)
&& remove_set.insert(call_idx)
{
changed = true;
@@ -753,6 +780,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: name.to_string(),
input,
@@ -766,6 +794,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
@@ -774,6 +803,28 @@ mod tests {
}
}
#[test]
fn purge_counterparts_use_execution_identity_without_legacy_fallback() {
let messages: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"first","name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"first"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"second","name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"second","content":"second"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"first","name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"first","content":"legacy"}]}
])).unwrap();
for (selected, expected) in [(0, vec![0, 1]), (3, vec![2, 3]), (4, vec![4, 5])] {
let mut remove = FastHashSet::from_iter([selected]);
cascade_tool_pair_removals(&messages, &mut remove);
assert_eq!(remove, FastHashSet::from_iter(expected));
}
let mut ambiguous = messages.clone();
ambiguous.push(messages[0].clone());
let mut remove = FastHashSet::from_iter([0]);
cascade_tool_pair_removals(&ambiguous, &mut remove);
assert_eq!(remove, FastHashSet::from_iter([0]));
}
#[test]
fn offload_round_trips_full_paired_context_through_the_existing_retrieval_tool() {
use crate::test_support::{EnvVarGuard, lock_test_env};
@@ -1101,6 +1152,7 @@ mod tests {
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_purge".to_string(),
name: "purge_context".to_string(),
input: json!({"operations": operations}),
+65 -9
View File
@@ -432,7 +432,28 @@ pub(crate) fn run_receipts_command(
/// block) comes back too: it labels the turn's workspace snapshots.
fn steps_from_messages(messages: &[Message]) -> (Vec<ToolStep>, Vec<TurnPosture>, Vec<TurnPrompt>) {
let mut steps: Vec<ToolStep> = Vec::new();
let mut by_id: HashMap<String, usize> = HashMap::new();
let mut by_id = HashMap::new();
let mut call_counts = HashMap::<codewhale_models::ToolCallKey<'_>, usize>::new();
let mut raw_counts = HashMap::<&str, usize>::new();
let mut results = HashMap::new();
for block in messages.iter().flat_map(|message| &message.content) {
let Some(key) = block.tool_call_key() else {
continue;
};
match block {
ContentBlock::ToolUse { .. } | ContentBlock::ServerToolUse { .. } => {
*call_counts.entry(key).or_default() += 1;
*raw_counts.entry(key.as_str()).or_default() += 1;
}
ContentBlock::ToolResult { tool_use_id, .. } => {
results
.entry(key)
.and_modify(|entry| *entry = None)
.or_insert(Some(tool_use_id.as_str()));
}
_ => {}
}
}
let mut turn_postures: Vec<TurnPosture> = Vec::new();
let mut turn_prompts: Vec<TurnPrompt> = Vec::new();
let mut turn = 0usize;
@@ -450,10 +471,19 @@ fn steps_from_messages(messages: &[Message]) -> (Vec<ToolStep>, Vec<TurnPosture>
id, name, input, ..
}
| ContentBlock::ServerToolUse { id, name, input } => {
by_id.insert(id.clone(), steps.len());
let key = block.tool_call_key().expect("tool use key");
let unambiguous = !key.as_str().trim().is_empty()
&& call_counts.get(&key) == Some(&1)
&& raw_counts.get(key.as_str()) == Some(&1)
&& results
.get(&key)
.is_none_or(|result| result.as_deref() == Some(id.as_str()));
if unambiguous {
by_id.insert(key, (steps.len(), id.as_str()));
}
steps.push(ToolStep {
turn: (turn > 0).then(|| turn.to_string()),
call_id: Some(id.clone()),
call_id: unambiguous.then(|| key.as_str().to_string()),
name: name.clone(),
input: input.clone(),
outcome: StepOutcome::Unknown,
@@ -469,7 +499,10 @@ fn steps_from_messages(messages: &[Message]) -> (Vec<ToolStep>, Vec<TurnPosture>
is_error,
..
} => {
if let Some(&index) = by_id.get(tool_use_id) {
if let Some(&(index, provider_id)) =
block.tool_call_key().and_then(|key| by_id.get(&key))
&& provider_id == tool_use_id
{
let step = &mut steps[index];
step.outcome = if is_error.unwrap_or(false) {
StepOutcome::Failed
@@ -1656,12 +1689,27 @@ fn assemble(
turn_postures,
approvals_recorded,
} = scope;
let mut approvals_by_call: HashMap<String, usize> = HashMap::new();
let mut approvals_by_call = HashMap::new();
let mut calls_by_id = HashMap::<&str, usize>::new();
for step in &steps {
if let Some(id) = step.call_id.as_deref() {
*calls_by_id.entry(id).or_default() += 1;
}
}
for (index, approval) in approvals.iter().enumerate() {
if let Some(call_id) = &approval.call_id {
approvals_by_call.insert(call_id.clone(), index);
approvals_by_call
.entry(call_id.as_str())
.and_modify(|entry| *entry = None)
.or_insert(Some(index));
}
}
if steps.iter().any(|step| step.call_id.is_none())
|| calls_by_id.values().any(|count| *count > 1)
|| approvals_by_call.values().any(Option::is_none)
{
notes.insert("Tool identity: missing, mismatched or repeated call identities cannot establish a unique approval association.".to_string());
}
let mut approval_used = vec![false; approvals.len()];
let mut totals = ReceiptTotals {
line_counts_complete: true,
@@ -1674,8 +1722,9 @@ fn assemble(
for step in &steps {
let approval_index = step
.call_id
.as_ref()
.and_then(|id| approvals_by_call.get(id).copied());
.as_deref()
.filter(|id| !id.trim().is_empty() && calls_by_id.get(id) == Some(&1))
.and_then(|id| approvals_by_call.get(id).copied().flatten());
if let Some(index) = approval_index {
approval_used[index] = true;
}
@@ -1871,7 +1920,14 @@ fn assemble(
if approvals_recorded {
totals.ran_without_asking = actions
.iter()
.filter(|action| action.ran_without_asking())
.filter(|action| {
action.ran_without_asking()
&& action.call_id.as_deref().is_some_and(|id| {
!id.trim().is_empty()
&& calls_by_id.get(id) == Some(&1)
&& approvals_by_call.get(id).is_none_or(Option::is_some)
})
})
.count();
}
let mut postures: Vec<&'static str> = Vec::new();
+75
View File
@@ -262,6 +262,7 @@ fn tool_use(id: &str, name: &str, input: Value) -> Message {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.into(),
name: name.into(),
input,
@@ -275,6 +276,7 @@ fn tool_result(id: &str, content: &str, is_error: bool) -> Message {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.into(),
content: content.into(),
is_error: is_error.then_some(true),
@@ -1068,3 +1070,76 @@ fn a_repeated_prompt_does_not_take_another_turns_snapshots() {
receipt.not_recorded
);
}
#[test]
fn session_receipts_join_only_unambiguous_execution_identities() {
let messages: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"first","name":"bash","input":{"command":"echo first"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"first"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"second","name":"bash","input":{"command":"echo second"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"second","content":"second"}]}
])).unwrap();
let approvals = vec![
ApprovalReceipt::asked("first", "bash"),
ApprovalReceipt::decided("first", ApprovalOutcome::ApprovedOnce),
];
let receipt = session_receipt(session_source_fixture(), &messages, &approvals, None).unwrap();
let commands = receipt
.actions
.iter()
.filter(|action| matches!(action.what, ActionKind::Command { .. }))
.collect::<Vec<_>>();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].call_id.as_deref(), Some("first"));
assert!(commands[0].approval.is_some());
assert_eq!(commands[1].call_id.as_deref(), Some("second"));
assert!(commands[1].approval.is_none());
assert_eq!(commands[1].status, ActionStatus::Ok);
// Duplicate local IDs, repeated legacy IDs, and mixed domains with the
// same string cannot lend one stored approval to multiple calls.
for (first, second, wire) in [
(Some("wire"), Some("wire"), "provider"),
(None, None, "wire"),
(Some("wire"), None, "wire"),
] {
let ambiguous: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[{"type":"tool_use","id":wire,"execution_id":first,"name":"bash","input":{"command":"echo a"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":wire,"execution_id":first,"content":"a"}]},
{"role":"assistant","content":[{"type":"tool_use","id":wire,"execution_id":second,"name":"bash","input":{"command":"echo b"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":wire,"execution_id":second,"content":"b"}]}
])).unwrap();
let approvals = vec![
ApprovalReceipt::asked("wire", "bash"),
ApprovalReceipt::decided("wire", ApprovalOutcome::ApprovedOnce),
];
let receipt =
session_receipt(session_source_fixture(), &ambiguous, &approvals, None).unwrap();
assert!(
receipt
.actions
.iter()
.filter(|action| matches!(action.what, ActionKind::Command { .. }))
.all(|action| action.approval.is_none() && action.call_id.is_none())
);
assert!(
receipt
.not_recorded
.iter()
.any(|note| note.contains("unique approval association"))
);
}
for (call, result, wire) in [
(Some(""), Some(""), "wire"),
(Some("first"), None, "wire"),
(Some("first"), Some("first"), "other"),
] {
let invalid: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":call,"name":"bash","input":{"command":"echo a"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":wire,"execution_id":result,"content":"not this execution"}]}
])).unwrap();
let (steps, _, _) = steps_from_messages(&invalid);
assert_eq!(steps[0].outcome, StepOutcome::Unknown);
assert!(steps[0].output.is_none());
}
}
+2
View File
@@ -6421,6 +6421,7 @@ mod tests {
content: "existing turn output".to_string(),
});
controller.observe_engine_event(&EngineEvent::ToolCallStarted {
model_call: None,
id: "tool_existing".to_string(),
name: "shell".to_string(),
input: json!({ "never": "relayed" }),
@@ -8800,6 +8801,7 @@ mod tests {
);
controller.observe_engine_event(&EngineEvent::ToolCallStarted {
model_call: None,
id: "tool_fixture".to_string(),
name: "shell".to_string(),
input: json!({}),
+2
View File
@@ -370,6 +370,7 @@ fn compact_content_block(block: &ContentBlock) -> Value {
content,
is_error,
content_blocks,
..
} => {
let chars = content.chars().count();
let large = chars > 2_000;
@@ -525,6 +526,7 @@ mod tests {
vec![Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_1".to_string(),
content: large.clone(),
is_error: None,
+13 -127
View File
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::runtime_threads::{
CreateThreadRequest, RuntimeTurnStatus, ThreadDetail, ThreadListFilter, TurnItemKind,
CreateThreadRequest, RuntimeThreadManager, RuntimeTurnStatus, ThreadDetail, ThreadListFilter,
TurnItemLifecycleStatus,
};
use crate::session_manager::{
@@ -17,10 +17,9 @@ use crate::session_manager::{
};
use crate::session_peek::{MAX_PEEK_ENTRIES, SessionPeek, build_peek};
use crate::session_projection::{SessionQuery, SessionSortMode, SessionSummary, project_sessions};
use codewhale_models::{ContentBlock, Message};
use codewhale_models::{Message, Role};
use super::{ApiError, RuntimeApiState, map_thread_err, truncate_text};
use codewhale_models::Role;
#[derive(Debug, Serialize)]
pub(super) struct SessionsResponse {
@@ -444,7 +443,9 @@ pub(super) async fn create_session_from_thread(
});
}
let messages = messages_from_thread_detail(&detail);
let messages = messages_from_thread_detail(&detail).map_err(|error| {
ApiError::internal(format!("Failed to reconstruct thread history: {error}"))
})?;
if messages.is_empty() {
return Err(ApiError::bad_request(format!(
"Thread {thread_id} has no user or assistant messages to save"
@@ -606,130 +607,15 @@ fn thread_detail_has_live_work(detail: &ThreadDetail) -> bool {
})
}
pub(super) fn messages_from_thread_detail(detail: &ThreadDetail) -> Vec<Message> {
let items_by_id: HashMap<&str, _> = detail
.items
.iter()
.map(|item| (item.id.as_str(), item))
.collect();
let mut messages = Vec::new();
for turn in &detail.turns {
let mut assistant_blocks: Vec<ContentBlock> = Vec::new();
let mut user_blocks: Vec<ContentBlock> = Vec::new();
let flush_assistant = |blocks: &mut Vec<ContentBlock>, msgs: &mut Vec<Message>| {
if !blocks.is_empty() {
msgs.push(Message {
role: Role::Assistant,
content: std::mem::take(blocks),
});
}
};
let flush_user = |blocks: &mut Vec<ContentBlock>, msgs: &mut Vec<Message>| {
if !blocks.is_empty() {
msgs.push(Message {
role: Role::User,
content: std::mem::take(blocks),
});
}
};
for item_id in &turn.item_ids {
let Some(item) = items_by_id.get(item_id.as_str()) else {
continue;
};
match item.kind {
TurnItemKind::UserMessage => {
flush_assistant(&mut assistant_blocks, &mut messages);
let text = item.detail.as_deref().map(str::trim).unwrap_or("");
if !text.is_empty() {
user_blocks.push(ContentBlock::Text {
text: text.to_string(),
cache_control: None,
});
}
}
TurnItemKind::AgentMessage => {
flush_user(&mut user_blocks, &mut messages);
let text = item.detail.as_deref().map(str::trim).unwrap_or("");
if !text.is_empty() {
assistant_blocks.push(ContentBlock::Text {
text: text.to_string(),
cache_control: None,
});
}
}
TurnItemKind::AgentReasoning => {
flush_user(&mut user_blocks, &mut messages);
let thinking = item.detail.as_deref().map(str::trim).unwrap_or("");
if !thinking.is_empty() {
assistant_blocks.push(ContentBlock::Thinking {
thinking: thinking.to_string(),
signature: None,
state: None,
});
}
}
TurnItemKind::ToolCall => {
// Check metadata to distinguish tool_use from tool_result.
let meta = item.metadata.as_ref();
let is_tool_result = meta.and_then(|m| m.get("tool_result_for")).is_some();
if is_tool_result {
flush_assistant(&mut assistant_blocks, &mut messages);
let tool_use_id = meta
.and_then(|m| m.get("tool_result_for"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let content = item.detail.as_deref().unwrap_or("").to_string();
let is_error = meta
.and_then(|m| m.get("is_error"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let content_blocks = meta
.and_then(|m| m.get("content_blocks"))
.and_then(|v| v.as_array())
.cloned();
user_blocks.push(ContentBlock::ToolResult {
tool_use_id,
content,
is_error: if is_error { Some(true) } else { None },
content_blocks,
});
} else {
flush_user(&mut user_blocks, &mut messages);
let tool_use_id = meta
.and_then(|m| m.get("tool_use_id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let tool_name = meta
.and_then(|m| m.get("tool_name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let input_str = item.detail.as_deref().unwrap_or("{}");
let input: Value = serde_json::from_str(input_str).unwrap_or(Value::Null);
assistant_blocks.push(ContentBlock::ToolUse {
id: tool_use_id,
name: tool_name,
input,
caller: None,
thought_signature: None,
});
}
}
// Skip other item kinds (file_change, command_execution, etc.)
_ => {}
}
}
flush_assistant(&mut assistant_blocks, &mut messages);
flush_user(&mut user_blocks, &mut messages);
pub(super) fn messages_from_thread_detail(detail: &ThreadDetail) -> anyhow::Result<Vec<Message>> {
let mut items_by_turn = HashMap::new();
for item in &detail.items {
items_by_turn
.entry(item.turn_id.clone())
.or_insert_with(Vec::new)
.push(item.clone());
}
messages
RuntimeThreadManager::reconstruct_messages_from_turns_with(&detail.turns, &items_by_turn)
}
/// Merge the thread's authoritative cost into a session about to be saved.
+143 -1
View File
@@ -372,6 +372,7 @@ fn session_detail_scenario() {
{
let detail = session_to_detail(saved_session_with_blocks(vec![
codewhale_models::ContentBlock::ToolUse {
execution_id: Some("host-display-only".into()),
id: "tool-1".to_string(),
name: "task_shell_start".to_string(),
input: json!({ "cmd": "cargo test" }),
@@ -385,6 +386,10 @@ fn session_detail_scenario() {
let block = &detail.messages[0]["content"][0];
assert_eq!(block["type"].as_str(), Some("tool_use"));
assert!(
block.get("execution_id").is_none(),
"detail remains a display projection"
);
assert_eq!(block["caller"]["type"].as_str(), Some("subagent"));
assert_eq!(block["caller"]["tool_id"].as_str(), Some("parent-tool"));
}
@@ -392,6 +397,7 @@ fn session_detail_scenario() {
{
let detail = session_to_detail(saved_session_with_blocks(vec![
codewhale_models::ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "fallback text".to_string(),
is_error: Some(false),
@@ -574,7 +580,7 @@ fn messages_from_thread_detail_batches_tool_results() {
approval_grants: Vec::new(),
};
let messages = messages_from_thread_detail(&detail);
let messages = messages_from_thread_detail(&detail).expect("rebuild thread history");
let roles = messages
.iter()
.map(|message| message.role.as_str())
@@ -587,6 +593,7 @@ fn messages_from_thread_detail_batches_tool_results() {
content,
is_error,
content_blocks,
..
} => {
assert_eq!(tool_use_id, "tool-1");
assert_eq!(content, "one");
@@ -606,6 +613,7 @@ fn messages_from_thread_detail_batches_tool_results() {
content,
is_error,
content_blocks,
..
} => {
assert_eq!(tool_use_id, "tool-2");
assert_eq!(content, "two");
@@ -3425,6 +3433,11 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
model_call: Some(crate::core::events::ModelToolCall {
provider_id: "input_compat".to_string(),
caller: None,
thought_signature: None,
}),
id: "input_compat".to_string(),
name: "request_user_input".to_string(),
input: serde_json::to_value(&request)?,
@@ -3450,6 +3463,7 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "input_compat".to_string(),
name: "request_user_input".to_string(),
result: Ok(tool_result),
@@ -23091,6 +23105,7 @@ mod thread_snapshot_ownership {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call_legacy".to_string(),
name: "write_file".to_string(),
input: json!({ "path": "legacy.txt", "content": "x" }),
@@ -23535,3 +23550,130 @@ api_key = "sk-or-test"
handle.abort();
Ok(())
}
#[tokio::test]
async fn session_export_keeps_execution_identity_and_rejects_invalid_item_correlation() -> Result<()>
{
let _env = lock_test_env();
let dir = tempfile::tempdir()?;
let _home = EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let root = dir.path().join("server");
let sessions = dir.path().join("sessions");
let (addr, runtime, handle) = spawn_test_server_with_root(root.clone(), sessions.clone())
.await?
.context("execution identity export requires the local API fixture")?;
let thread = runtime.create_thread(Default::default()).await?;
let original: Vec<Message> = serde_json::from_value(json!([
{"role":"user","content":[{"type":"text","text":"export exact executions"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"same-wire","execution_id":"host-first","name":"read_file","input":{"path":"one"},"caller":{"type":"subagent","tool_id":"parent"},"thought_signature":"signature"}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"same-wire","execution_id":"host-first","content":"one","content_blocks":[{"type":"text","text":"rich"}]}]},
{"role":"assistant","content":[{"type":"tool_use","id":"same-wire","execution_id":"host-second","name":"read_file","input":{"path":"two"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"same-wire","execution_id":"host-second","content":"two"}]}
]))?;
runtime
.seed_thread_from_messages(&thread.id, &original)
.await?;
let detail = runtime.get_thread_detail(&thread.id).await?;
assert_eq!(messages_from_thread_detail(&detail)?, original);
// A completed live item contains both sides. It must not become only a
// result when the same thread is saved through the API's projection.
let mut coalesced = detail.clone();
let result_index = coalesced
.items
.iter()
.position(|item| {
item.metadata
.as_ref()
.is_some_and(|m| m["tool_result_for"] == "host-first")
})
.context("result item")?;
let result_item = coalesced.items.remove(result_index);
let call = coalesced
.items
.iter_mut()
.find(|item| {
item.metadata
.as_ref()
.is_some_and(|m| m["tool_use_id"] == "host-first")
})
.context("call item")?;
let metadata = call.metadata.as_mut().unwrap();
metadata["tool_input"] = json!(call.detail.clone().unwrap());
metadata["tool_result_for"] = json!("host-first");
metadata["content_blocks"] = result_item.metadata.as_ref().unwrap()["content_blocks"].clone();
call.detail = result_item.detail;
for turn in &mut coalesced.turns {
turn.item_ids.retain(|id| id != &result_item.id);
}
assert_eq!(messages_from_thread_detail(&coalesced)?, original);
let client = crate::tls::reqwest_client();
let exported = client
.post(format!("http://{addr}/v1/sessions"))
.json(&json!({"thread_id":thread.id}))
.send()
.await?;
assert_eq!(exported.status(), StatusCode::CREATED);
let exported: Value = exported.json().await?;
let id = exported["session_id"].as_str().context("session id")?;
let manager = crate::session_manager::SessionManager::new(sessions.clone())?;
let saved = manager.load_session(id)?;
assert_eq!(saved.messages, original);
assert_eq!(
saved.journal.as_ref().context("journal")?.to_messages(),
original
);
let display: Value = client
.get(format!("http://{addr}/v1/sessions/{id}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert!(
display["messages"]
.as_array()
.unwrap()
.iter()
.flat_map(|m| m["content"].as_array().unwrap())
.all(|block| block.get("execution_id").is_none()),
"detail stays display-only"
);
// Corrupt an identity in the existing durable record. The export must
// report the error, without overwriting its prior valid saved document.
let mut broken = detail
.items
.iter()
.find(|item| {
item.metadata
.as_ref()
.is_some_and(|m| m["tool_use_id"] == "host-first")
})
.context("first call")?
.clone();
broken.metadata.as_mut().unwrap()["execution_id"] = json!("");
fs::write(
root.join("runtime/runtime/items")
.join(format!("{}.json", broken.id)),
serde_json::to_vec(&broken)?,
)?;
let saved_path = sessions.join(format!("{id}.json"));
let before = fs::read(&saved_path)?;
let refused = client
.post(format!("http://{addr}/v1/sessions"))
.json(&json!({"thread_id":thread.id}))
.send()
.await?;
assert_eq!(refused.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(
refused
.text()
.await?
.contains("Invalid stored tool execution identity")
);
assert_eq!(fs::read(saved_path)?, before);
handle.abort();
Ok(())
}
+1
View File
@@ -1497,6 +1497,7 @@ mod tests {
let tool_result = Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_1".to_string(),
content: "tool output".to_string(),
is_error: None,
+296 -79
View File
@@ -58,7 +58,7 @@ use crate::tools::todo::new_shared_todo_list;
use codewhale_config::AppMode;
use codewhale_execpolicy::ApprovalMode;
use codewhale_models::Role;
use codewhale_models::{ContentBlock, Message, SystemPrompt, Usage};
use codewhale_models::{ContentBlock, Message, SystemPrompt, ToolCallKey, ToolCaller, Usage};
use codewhale_protocol::agent_mail::{
AGENT_MAIL_EVENT_CANCELED, AGENT_MAIL_EVENT_DELIVERED, AGENT_MAIL_EVENT_DELIVERING,
AGENT_MAIL_EVENT_DELIVERY_FAILED, AGENT_MAIL_EVENT_QUEUED, AGENT_MAIL_EVENT_READ,
@@ -1049,9 +1049,19 @@ fn session_recovery_projection(messages: &[Message]) -> Vec<Value> {
projection.push(json!(["thinking", thinking]))
}
ContentBlock::ToolUse {
id, name, input, ..
id,
name,
input,
execution_id,
..
} if role == "assistant" => {
let mut row = json!(["tool_use", id, name, input]);
if let Some(id) = execution_id {
row.as_array_mut().expect("projection row").push(json!(id));
}
projection.push(row);
}
| ContentBlock::ServerToolUse {
ContentBlock::ServerToolUse {
id, name, input, ..
} if role == "assistant" => projection.push(json!(["tool_use", id, name, input])),
ContentBlock::ToolResult {
@@ -1059,13 +1069,20 @@ fn session_recovery_projection(messages: &[Message]) -> Vec<Value> {
content,
is_error,
content_blocks,
} if role == "user" => projection.push(json!([
"tool_result",
tool_use_id,
content,
is_error.unwrap_or(false),
content_blocks
])),
execution_id,
} if role == "user" => {
let mut row = json!([
"tool_result",
tool_use_id,
content,
is_error.unwrap_or(false),
content_blocks
]);
if let Some(id) = execution_id {
row.as_array_mut().expect("projection row").push(json!(id));
}
projection.push(row);
}
_ => {}
}
}
@@ -1109,6 +1126,124 @@ fn saved_transcript_is_compacted(
})
}
/// Stored item correlation is a projection of the existing history identity.
/// Explicit executions never fall back to legacy provider ids. New non-model
/// events carry an execution id but no provider id and do not enter history.
#[derive(Clone, Copy)]
struct RuntimeToolIdentity<'a> {
execution_id: Option<&'a str>,
provider_id: &'a str,
}
impl<'a> RuntimeToolIdentity<'a> {
fn read(metadata: Option<&'a Value>, side: &str) -> Result<Option<Self>> {
let Some(meta) = metadata else {
return Ok(None);
};
let Some(local) = meta.get(side) else {
return Ok(None);
};
let local = local.as_str().context("Invalid stored tool correlation")?;
let Some(execution) = meta.get("execution_id") else {
return Ok((!local.is_empty()).then_some(Self {
execution_id: None,
provider_id: local,
}));
};
let execution = execution
.as_str()
.filter(|id| !id.is_empty())
.context("Invalid stored tool execution identity")?;
anyhow::ensure!(
local == execution,
"Stored tool execution identity mismatch"
);
let Some(provider) = meta.get("provider_tool_use_id") else {
return Ok(None);
};
let provider = provider
.as_str()
.filter(|id| !id.is_empty())
.context("Invalid stored provider tool identity")?;
Ok(Some(Self {
execution_id: Some(execution),
provider_id: provider,
}))
}
fn key(self) -> (ToolCallKey<'a>, &'a str) {
(
self.execution_id.map_or(
ToolCallKey::LegacyProvider(self.provider_id),
ToolCallKey::Execution,
),
self.provider_id,
)
}
}
/// A persisted execution cannot acquire a different provider pairing on a
/// later result, turn, or import block. This is a transient validation index,
/// never a grant or another history store; legacy wire-only keys stay unchanged.
fn check_execution_provider(
bindings: &mut HashMap<String, String>,
execution: Option<&str>,
provider: &str,
) -> Result<()> {
if let Some(execution) = execution {
anyhow::ensure!(
!execution.is_empty() && !provider.is_empty(),
"Invalid stored tool execution identity"
);
if let Some(previous) = bindings.get(execution) {
anyhow::ensure!(
previous == provider,
"Stored tool execution has inconsistent provider identity"
);
} else {
bindings.insert(execution.to_string(), provider.to_string());
}
}
Ok(())
}
/// Only the admitted start supplies correlation, even when an optional value
/// was absent. Tool output cannot manufacture those missing fields.
fn retain_started_tool_metadata(
output: &mut serde_json::Map<String, Value>,
started: Option<&Value>,
) {
for key in [
"tool_use_id",
"tool_name",
"tool_input",
"visibility",
"execution_id",
"provider_tool_use_id",
"tool_caller",
"tool_thought_signature",
] {
output.remove(key);
if let Some(value) = started.and_then(|meta| meta.get(key)) {
output.insert(key.to_string(), value.clone());
}
}
}
/// Legacy item reconstruction covered generic tool rows only. New model
/// correlation survives the UI's shell/file classification as well; a local
/// non-model record is still excluded by RuntimeToolIdentity::read.
fn projects_tool_history(item: &TurnItemRecord) -> bool {
item.kind == TurnItemKind::ToolCall
|| (matches!(
item.kind,
TurnItemKind::FileChange | TurnItemKind::CommandExecution
) && item
.metadata
.as_ref()
.is_some_and(|meta| meta.get("execution_id").is_some()))
}
/// The result a rebuilt history must give a tool call whose outcome the turn
/// store never recorded, or `None` when nothing is missing.
///
@@ -1136,11 +1271,14 @@ fn saved_transcript_is_compacted(
/// coming — so this answers only the terminal failures.
fn unanswered_call_result(
item: &TurnItemRecord,
call_id: &str,
identity: RuntimeToolIdentity<'_>,
call_name: &str,
recorded_results: &HashSet<String>,
recorded_results: &HashSet<(ToolCallKey<'_>, &str)>,
) -> Option<String> {
if call_id.is_empty() || call_name.is_empty() || recorded_results.contains(call_id) {
if identity.provider_id.is_empty()
|| call_name.is_empty()
|| recorded_results.contains(&identity.key())
{
return None;
}
match item.status {
@@ -6076,11 +6214,15 @@ enum SeedItem {
Thinking(String),
ToolUse {
id: String,
execution_id: Option<String>,
name: String,
input: serde_json::Value,
caller: Option<ToolCaller>,
thought_signature: Option<String>,
},
ToolResult {
tool_use_id: String,
execution_id: Option<String>,
content: String,
is_error: bool,
content_blocks: Option<Vec<serde_json::Value>>,
@@ -10972,12 +11114,14 @@ impl RuntimeThreadManager {
}
ContentBlock::ToolResult {
tool_use_id,
execution_id,
content,
is_error,
content_blocks,
} => {
tool_results.push(SeedItem::ToolResult {
tool_use_id: tool_use_id.clone(),
execution_id: execution_id.clone(),
content: content.clone(),
is_error: is_error.unwrap_or(false),
content_blocks: content_blocks.clone(),
@@ -11037,12 +11181,20 @@ impl RuntimeThreadManager {
turn.items.push(SeedItem::Thinking(thinking.clone()));
}
ContentBlock::ToolUse {
id, name, input, ..
id,
execution_id,
name,
input,
caller,
thought_signature,
} => {
turn.items.push(SeedItem::ToolUse {
id: id.clone(),
execution_id: execution_id.clone(),
name: name.clone(),
input: input.clone(),
caller: caller.clone(),
thought_signature: thought_signature.clone(),
});
}
ContentBlock::ServerToolUse {
@@ -11050,8 +11202,11 @@ impl RuntimeThreadManager {
} => {
turn.items.push(SeedItem::ToolUse {
id: id.clone(),
execution_id: None,
name: name.clone(),
input: input.clone(),
caller: None,
thought_signature: None,
});
}
// Skip other block types (image_url, etc.)
@@ -11070,10 +11225,30 @@ impl RuntimeThreadManager {
// Validate the entire import before the first durable write. Saved local
// images keep their 5 MiB ceiling; no path or remote URL is dereferenced.
let mut execution_providers = HashMap::new();
for turn_seed in &turns {
if !turn_seed.image_content.is_empty() {
crate::image_attach::validate_stored_image_content(&turn_seed.image_content)?;
}
for item in &turn_seed.items {
if let SeedItem::ToolUse {
execution_id,
id: provider,
..
}
| SeedItem::ToolResult {
execution_id,
tool_use_id: provider,
..
} = item
{
check_execution_provider(
&mut execution_providers,
execution_id.as_deref(),
provider,
)?;
}
}
}
for turn_seed in turns {
@@ -11158,8 +11333,11 @@ impl RuntimeThreadManager {
}
SeedItem::ToolUse {
id: tool_id,
execution_id,
name,
input,
caller,
thought_signature,
} => {
let input_str =
serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
@@ -11179,15 +11357,23 @@ impl RuntimeThreadManager {
status: TurnItemLifecycleStatus::Completed,
summary: tool_summary,
detail: Some(input_str),
metadata: Some(serde_json::Value::Object(
serde_json::json!({
"tool_use_id": tool_id,
metadata: Some({
let mut meta = json!({
"tool_use_id": execution_id.as_ref().unwrap_or(tool_id),
"tool_name": name,
})
.as_object()
.unwrap()
.clone(),
)),
});
if let Some(id) = execution_id {
meta["execution_id"] = json!(id);
meta["provider_tool_use_id"] = json!(tool_id);
}
if let Some(caller) = caller {
meta["tool_caller"] = json!(caller);
}
if let Some(signature) = thought_signature {
meta["tool_thought_signature"] = json!(signature);
}
meta
}),
artifact_refs: Vec::new(),
artifacts: Vec::new(),
started_at: Some(item_at),
@@ -11196,6 +11382,7 @@ impl RuntimeThreadManager {
}
SeedItem::ToolResult {
tool_use_id,
execution_id,
content,
is_error,
content_blocks,
@@ -11206,7 +11393,14 @@ impl RuntimeThreadManager {
content.clone()
};
let mut metadata = serde_json::Map::new();
metadata.insert("tool_result_for".to_string(), json!(tool_use_id));
metadata.insert(
"tool_result_for".to_string(),
json!(execution_id.as_ref().unwrap_or(tool_use_id)),
);
if let Some(id) = execution_id {
metadata.insert("execution_id".to_string(), json!(id));
metadata.insert("provider_tool_use_id".to_string(), json!(tool_use_id));
}
metadata.insert("is_error".to_string(), json!(is_error));
if let Some(blocks) = content_blocks {
metadata
@@ -13863,11 +14057,12 @@ impl RuntimeThreadManager {
Self::reconstruct_messages_from_turns_with(turns, &items_by_turn)
}
fn reconstruct_messages_from_turns_with(
pub(crate) fn reconstruct_messages_from_turns_with(
turns: &[TurnRecord],
items_by_turn: &HashMap<String, Vec<TurnItemRecord>>,
) -> Result<Vec<Message>> {
let mut messages = Vec::new();
let mut execution_providers = HashMap::new();
for turn in turns {
let stored_items = items_by_turn.get(&turn.id).cloned().unwrap_or_default();
let items = if turn.item_ids.is_empty() {
@@ -13892,24 +14087,30 @@ impl RuntimeThreadManager {
ordered
};
// The calls this turn already answers with a result item of their
// own. Only a `tool_call` item is rebuilt into a result — a
// `file_change` item records its own call and result, and the
// rebuild reads neither — so this set names exactly the answers a
// rebuild will emit, and a call in it must not be answered twice.
// A call item may carry its result (the completed live shape) or a
// sibling `tool_result_for` item may (seeded history, and the
// repair's own notice); both are `tool_call` items.
let recorded_results: HashSet<String> = items
for item in items.iter().filter(|item| projects_tool_history(item)) {
for side in ["tool_use_id", "tool_result_for"] {
if let Some(identity) = RuntimeToolIdentity::read(item.metadata.as_ref(), side)?
{
check_execution_provider(
&mut execution_providers,
identity.execution_id,
identity.provider_id,
)?;
}
}
}
// Only results this same projection will emit can settle a call.
// New execution and legacy provider key domains remain disjoint;
// provider identity must also agree with an explicit execution.
let recorded_results: HashSet<_> = items
.iter()
.filter(|item| item.kind == TurnItemKind::ToolCall)
.filter_map(|item| {
item.metadata
.as_ref()?
.get("tool_result_for")?
.as_str()
.map(str::to_string)
})
.filter(|item| projects_tool_history(item))
.map(|item| RuntimeToolIdentity::read(item.metadata.as_ref(), "tool_result_for"))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.map(RuntimeToolIdentity::key)
.collect();
let mut assistant_blocks: Vec<ContentBlock> = Vec::new();
@@ -13930,7 +14131,7 @@ impl RuntimeThreadManager {
});
}
};
for item in items {
for item in &items {
match item.kind {
TurnItemKind::UserMessage => {
// A steer the engine never committed is recorded
@@ -13949,7 +14150,7 @@ impl RuntimeThreadManager {
}
TurnItemKind::AgentMessage => {
flush_user(&mut user_blocks, &mut messages);
let text = item.detail.unwrap_or(item.summary);
let text = item.detail.clone().unwrap_or_else(|| item.summary.clone());
if !text.trim().is_empty() {
assistant_blocks.push(ContentBlock::Text {
text,
@@ -13959,7 +14160,7 @@ impl RuntimeThreadManager {
}
TurnItemKind::AgentReasoning => {
flush_user(&mut user_blocks, &mut messages);
let thinking = item.detail.unwrap_or(item.summary);
let thinking = item.detail.clone().unwrap_or_else(|| item.summary.clone());
if !thinking.trim().is_empty() {
assistant_blocks.push(ContentBlock::Thinking {
thinking,
@@ -13968,7 +14169,7 @@ impl RuntimeThreadManager {
});
}
}
TurnItemKind::ToolCall => {
_ if projects_tool_history(item) => {
let meta = item.metadata.as_ref();
let meta_str = |key: &str| {
meta.and_then(|m| m.get(key))
@@ -13976,20 +14177,17 @@ impl RuntimeThreadManager {
.unwrap_or_default()
.to_string()
};
let tool_use_id = meta_str("tool_use_id");
let call = RuntimeToolIdentity::read(meta, "tool_use_id")?;
let result = RuntimeToolIdentity::read(meta, "tool_result_for")?;
let tool_name = meta_str("tool_name");
let tool_result_for = meta_str("tool_result_for");
// A call whose own outcome the store never recorded is
// answered with the outcome it does hold, decided while
// the id is still borrowed — see
// `unanswered_call_result`.
let unanswered = unanswered_call_result(
&item,
&tool_use_id,
&tool_name,
&recorded_results,
)
.map(|content| (tool_use_id.clone(), content));
let unanswered = call.and_then(|identity| {
unanswered_call_result(item, identity, &tool_name, &recorded_results)
.map(|content| (identity, content))
});
// Completed live turns persist the call and its result
// on one item; seeded history persists them as two.
// Both shapes must rebuild the paired tool_call /
@@ -13997,7 +14195,7 @@ impl RuntimeThreadManager {
// was durable carry neither side: skip them rather than
// replay an empty tool_call shell that strict
// OpenAI-compatible endpoints reject (#5823).
if !tool_use_id.is_empty() && !tool_name.is_empty() {
if let Some(identity) = call.filter(|_| !tool_name.is_empty()) {
flush_user(&mut user_blocks, &mut messages);
let input_str = meta
.and_then(|m| m.get("tool_input"))
@@ -14008,16 +14206,29 @@ impl RuntimeThreadManager {
let input: serde_json::Value =
serde_json::from_str(&input_str).unwrap_or(serde_json::Value::Null);
assistant_blocks.push(ContentBlock::ToolUse {
id: tool_use_id,
execution_id: identity.execution_id.map(str::to_string),
id: identity.provider_id.to_string(),
name: tool_name,
input,
caller: None,
thought_signature: None,
caller: meta
.and_then(|m| m.get("tool_caller"))
.map(|value| serde_json::from_value(value.clone()))
.transpose()
.context("Invalid stored tool caller")?,
thought_signature: meta
.and_then(|m| m.get("tool_thought_signature"))
.map(|value| {
value
.as_str()
.map(str::to_string)
.context("Invalid stored tool thought signature")
})
.transpose()?,
});
}
if !tool_result_for.is_empty() {
if let Some(identity) = result {
flush_assistant(&mut assistant_blocks, &mut messages);
let content = item.detail.unwrap_or_default();
let content = item.detail.clone().unwrap_or_default();
let is_error = meta
.and_then(|m| m.get("is_error"))
.and_then(Value::as_bool)
@@ -14027,18 +14238,20 @@ impl RuntimeThreadManager {
.and_then(Value::as_array)
.cloned();
user_blocks.push(ContentBlock::ToolResult {
tool_use_id: tool_result_for,
execution_id: identity.execution_id.map(str::to_string),
tool_use_id: identity.provider_id.to_string(),
content,
is_error: if is_error { Some(true) } else { None },
content_blocks,
});
} else if let Some((call_id, content)) = unanswered {
} else if let Some((identity, content)) = unanswered {
// The call above had no recorded outcome: pair it
// here, in the position the live transcript held
// its result, so the rebuilt turn is complete.
flush_assistant(&mut assistant_blocks, &mut messages);
user_blocks.push(ContentBlock::ToolResult {
tool_use_id: call_id,
execution_id: identity.execution_id.map(str::to_string),
tool_use_id: identity.provider_id.to_string(),
content,
is_error: Some(true),
content_blocks: None,
@@ -14507,7 +14720,12 @@ impl RuntimeThreadManager {
.await?;
}
}
EngineEvent::ToolCallStarted { id, name, input } => {
EngineEvent::ToolCallStarted {
id,
name,
input,
model_call,
} => {
let item_id = runtime_record_id("item");
tool_items.insert(id.clone(), item_id.clone());
let kind = tool_kind_for_name(&name);
@@ -14531,7 +14749,17 @@ impl RuntimeThreadManager {
"tool_use_id": id.clone(),
"tool_name": name.clone(),
"tool_input": input_str,
"execution_id": id.clone(),
});
if let Some(model_call) = model_call {
meta["provider_tool_use_id"] = json!(model_call.provider_id);
if let Some(caller) = model_call.caller {
meta["tool_caller"] = json!(caller);
}
if let Some(signature) = model_call.thought_signature {
meta["tool_thought_signature"] = json!(signature);
}
}
// Tool discovery is engine plumbing, not work the
// user asked for: clients collapse it by default.
if crate::core::engine::tool_catalog::is_tool_search_tool(&name) {
@@ -14555,7 +14783,9 @@ impl RuntimeThreadManager {
)
.await?;
}
EngineEvent::ToolCallComplete { id, name, result } => {
EngineEvent::ToolCallComplete {
id, name, result, ..
} => {
if let Some(hooks) = thread_hooks.as_deref() {
fire_runtime_tool_completion_hooks(hooks, &thread_id, &id, &name, &result);
}
@@ -14671,20 +14901,7 @@ impl RuntimeThreadManager {
_ => json!({}),
};
if let Some(obj) = meta.as_object_mut() {
if let Some(started) =
item.metadata.as_ref().and_then(Value::as_object)
{
for key in [
"tool_use_id",
"tool_name",
"tool_input",
"visibility",
] {
if let Some(value) = started.get(key) {
obj.insert(key.to_string(), value.clone());
}
}
}
retain_started_tool_metadata(obj, item.metadata.as_ref());
// A first call to a deferred tool only
// loads its schema; the model retries.
// That hand-off is not a user-facing step.
+439 -8
View File
@@ -7787,6 +7787,7 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-1".to_string(),
name: "shell".to_string(),
input: json!({ "cmd": "one" }),
@@ -7794,6 +7795,7 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> {
thought_signature: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-2".to_string(),
name: "shell".to_string(),
input: json!({ "cmd": "two" }),
@@ -7805,6 +7807,7 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "one".to_string(),
is_error: None,
@@ -7817,6 +7820,7 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-2".to_string(),
content: "two".to_string(),
is_error: Some(true),
@@ -7852,6 +7856,7 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> {
content,
is_error,
content_blocks,
..
} => {
assert_eq!(tool_use_id, "tool-1");
assert_eq!(content, "one");
@@ -7871,6 +7876,7 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> {
content,
is_error,
content_blocks,
..
} => {
assert_eq!(tool_use_id, "tool-2");
assert_eq!(content, "two");
@@ -7914,6 +7920,7 @@ async fn seeded_session_records_carry_a_total_order() -> Result<()> {
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: "tool-1".to_string(),
name: "shell".to_string(),
input: json!({ "cmd": "ls" }),
@@ -7925,6 +7932,7 @@ async fn seeded_session_records_carry_a_total_order() -> Result<()> {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool-1".to_string(),
content: "listing".to_string(),
is_error: None,
@@ -9098,6 +9106,7 @@ async fn monitor_deduplicates_sink_and_metadata_and_persists_metadata_only_missi
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "tool-child-usage".to_string(),
name: "rlm".to_string(),
result: Ok(
@@ -9233,6 +9242,11 @@ async fn monitor_separates_lifecycle_start_from_billing_dispatch_and_child_usage
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
model_call: Some(crate::core::events::ModelToolCall {
provider_id: "tool-routed-coverage".to_string(),
caller: None,
thought_signature: None,
}),
id: "tool-routed-coverage".to_string(),
name: "rlm".to_string(),
input: json!({"action": "eval"}),
@@ -9241,6 +9255,7 @@ async fn monitor_separates_lifecycle_start_from_billing_dispatch_and_child_usage
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "tool-routed-coverage".to_string(),
name: "rlm".to_string(),
result: Ok(
@@ -18989,6 +19004,7 @@ async fn notices_raise_from_engine_events_and_clear_on_settle_or_ack() -> Result
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "tool_notify_1".to_string(),
name: "notify".to_string(),
result: Ok(crate::tools::spec::ToolResult::success("pinged")),
@@ -19029,6 +19045,7 @@ async fn notices_raise_from_engine_events_and_clear_on_settle_or_ack() -> Result
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "tool_needs_elev".to_string(),
name: "exec_command".to_string(),
result: Ok(crate::tools::spec::ToolResult::success("elevated ok")),
@@ -20000,6 +20017,11 @@ async fn engine_plumbing_items_are_tagged_internal_and_retry_hints_are_dropped()
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
model_call: Some(crate::core::events::ModelToolCall {
provider_id: "tool-search-1".to_string(),
caller: None,
thought_signature: None,
}),
id: "tool-search-1".to_string(),
name: "tool_search".to_string(),
input: json!({"query": "skill"}),
@@ -20008,6 +20030,7 @@ async fn engine_plumbing_items_are_tagged_internal_and_retry_hints_are_dropped()
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "tool-search-1".to_string(),
name: "tool_search".to_string(),
result: Ok(crate::tools::spec::ToolResult::success("found load_skill")),
@@ -20016,6 +20039,11 @@ async fn engine_plumbing_items_are_tagged_internal_and_retry_hints_are_dropped()
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
model_call: Some(crate::core::events::ModelToolCall {
provider_id: "hydrate-1".to_string(),
caller: None,
thought_signature: None,
}),
id: "hydrate-1".to_string(),
name: "load_skill".to_string(),
input: json!({}),
@@ -20024,6 +20052,7 @@ async fn engine_plumbing_items_are_tagged_internal_and_retry_hints_are_dropped()
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "hydrate-1".to_string(),
name: "load_skill".to_string(),
result: Ok(
@@ -20494,6 +20523,10 @@ fn a_lost_call_is_answered_only_where_its_own_outcome_is_known() {
item
};
let none = HashSet::new();
let identity = |provider_id| RuntimeToolIdentity {
execution_id: None,
provider_id,
};
// A failure keeps the text the tool reported, and falls back to the
// item's summary when the record holds no detail of its own.
@@ -20503,7 +20536,7 @@ fn a_lost_call_is_answered_only_where_its_own_outcome_is_known() {
TurnItemLifecycleStatus::Failed,
Some("Failed to execute tool: boom")
),
"call-1",
identity("call-1"),
"bash",
&none
),
@@ -20512,7 +20545,7 @@ fn a_lost_call_is_answered_only_where_its_own_outcome_is_known() {
assert_eq!(
unanswered_call_result(
&item(TurnItemLifecycleStatus::Failed, None),
"call-1",
identity("call-1"),
"bash",
&none
),
@@ -20526,7 +20559,7 @@ fn a_lost_call_is_answered_only_where_its_own_outcome_is_known() {
TurnItemLifecycleStatus::Canceled,
] {
assert_eq!(
unanswered_call_result(&item(status, None), "call-1", "bash", &none),
unanswered_call_result(&item(status, None), identity("call-1"), "bash", &none),
Some(crate::tool_history_repair::CRASH_REPAIR_CONTENT.to_string()),
"{status:?} is terminal: no result is coming"
);
@@ -20542,7 +20575,7 @@ fn a_lost_call_is_answered_only_where_its_own_outcome_is_known() {
TurnItemLifecycleStatus::Completed,
] {
assert_eq!(
unanswered_call_result(&item(status, None), "call-1", "bash", &none),
unanswered_call_result(&item(status, None), identity("call-1"), "bash", &none),
None,
"{status:?} is not a lost outcome"
);
@@ -20550,7 +20583,7 @@ fn a_lost_call_is_answered_only_where_its_own_outcome_is_known() {
assert_eq!(
unanswered_call_result(
&item(TurnItemLifecycleStatus::Failed, None),
"call-1",
identity("call-1"),
"",
&none
),
@@ -20560,7 +20593,7 @@ fn a_lost_call_is_answered_only_where_its_own_outcome_is_known() {
assert_eq!(
unanswered_call_result(
&item(TurnItemLifecycleStatus::Failed, None),
"",
identity(""),
"bash",
&none
),
@@ -20568,11 +20601,11 @@ fn a_lost_call_is_answered_only_where_its_own_outcome_is_known() {
);
// A result the turn records on its own item is that call's answer.
let recorded: HashSet<String> = ["call-1".to_string()].into_iter().collect();
let recorded = [identity("call-1").key()].into_iter().collect();
assert_eq!(
unanswered_call_result(
&item(TurnItemLifecycleStatus::Failed, None),
"call-1",
identity("call-1"),
"bash",
&recorded
),
@@ -20645,6 +20678,11 @@ async fn runtime_receipts_mask_configured_secrets() -> Result<()> {
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
model_call: Some(crate::core::events::ModelToolCall {
provider_id: "tool-cat-auth".to_string(),
caller: None,
thought_signature: None,
}),
id: "tool-cat-auth".to_string(),
name: "exec_command".to_string(),
input: json!({"cmd": "cat auth.json"}),
@@ -20653,6 +20691,7 @@ async fn runtime_receipts_mask_configured_secrets() -> Result<()> {
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "tool-cat-auth".to_string(),
name: "exec_command".to_string(),
// Real `exec_shell` metadata shape: the summaries carry the
@@ -20674,6 +20713,11 @@ async fn runtime_receipts_mask_configured_secrets() -> Result<()> {
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
model_call: Some(crate::core::events::ModelToolCall {
provider_id: "tool-error".into(),
caller: None,
thought_signature: None,
}),
id: "tool-error".into(),
name: "exec_command".into(),
input: json!({"cmd": "false"}),
@@ -20682,6 +20726,7 @@ async fn runtime_receipts_mask_configured_secrets() -> Result<()> {
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "tool-error".into(),
name: "exec_command".into(),
result: Err(crate::tools::spec::ToolError::execution_failed(format!(
@@ -20881,6 +20926,11 @@ async fn tool_completion_items_carry_typed_artifact_refs() -> Result<()> {
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
model_call: Some(crate::core::events::ModelToolCall {
provider_id: call.to_string(),
caller: None,
thought_signature: None,
}),
id: call.to_string(),
name: name.to_string(),
input: json!({}),
@@ -20890,6 +20940,7 @@ async fn tool_completion_items_carry_typed_artifact_refs() -> Result<()> {
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "call_write".to_string(),
name: "apply_patch".to_string(),
result: Ok(
@@ -20909,6 +20960,7 @@ async fn tool_completion_items_carry_typed_artifact_refs() -> Result<()> {
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "call_shell".to_string(),
name: "exec_shell".to_string(),
// A failed call's large output spills too.
@@ -21124,6 +21176,11 @@ async fn run_workspace_turn(
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
model_call: Some(crate::core::events::ModelToolCall {
provider_id: "call_patch".to_string(),
caller: None,
thought_signature: None,
}),
id: "call_patch".to_string(),
name: "apply_patch".to_string(),
input: json!({}),
@@ -21148,6 +21205,7 @@ async fn run_workspace_turn(
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
model_call: None,
id: "call_patch".to_string(),
name: "apply_patch".to_string(),
result: Ok(
@@ -21531,3 +21589,376 @@ async fn runtime_shell_completion_delivers_exit_code_and_status_to_hooks() -> Re
);
Ok(())
}
mod execution_identity {
use super::*;
use crate::core::events::ModelToolCall;
#[tokio::test]
async fn seed_restart_snapshot_import_and_fork_preserve_execution_pairs() -> Result<()> {
let _env = crate::test_support::lock_test_env();
let dir = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let runtime = dir.path().join("runtime");
let manager = test_manager(runtime.clone())?;
let thread = manager.create_thread(Default::default()).await?;
let original: Vec<Message> = serde_json::from_value(json!([
{"role":"user","content":[{"type":"text","text":"retain both executions"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"wire-reused","execution_id":"host-one","name":"read_file","input":{"path":"one"},"caller":{"type":"subagent","tool_id":"parent-wire"},"thought_signature":"signed-one"}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire-reused","execution_id":"host-one","content":"one","content_blocks":[{"type":"text","text":"rich-one"}]}]},
{"role":"assistant","content":[{"type":"tool_use","id":"wire-reused","execution_id":"host-two","name":"read_file","input":{"path":"two"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire-reused","execution_id":"host-two","content":"two"}]}
]))?;
manager
.seed_thread_from_messages(&thread.id, &original)
.await?;
drop(manager);
let manager = test_manager(runtime)?;
let reopened = manager.get_thread(&thread.id).await?;
assert_eq!(manager.restore_thread_messages(&reopened)?, original);
let detail = manager.get_thread_detail(&thread.id).await?;
let calls: Vec<_> = detail
.items
.iter()
.filter_map(|item| item.metadata.as_ref())
.filter(|meta| meta.get("tool_use_id").is_some())
.collect();
assert_eq!(calls.len(), 2);
assert_ne!(calls[0]["tool_use_id"], calls[1]["tool_use_id"]);
assert!(
calls
.iter()
.all(|meta| meta["provider_tool_use_id"] == "wire-reused")
);
// The existing snapshot/journal and foreign-import owner carry the
// metadata; importing does not create authority or a fresh execution.
let saved = crate::session_manager::create_saved_session_with_id_and_mode(
Uuid::new_v4().to_string(),
&original,
&thread.model,
&thread.workspace,
0,
None,
Some("agent"),
);
let sessions = crate::session_manager::SessionManager::new(
crate::session_manager::default_sessions_dir()?,
)?;
sessions.save_session(&saved)?;
let loaded = sessions.load_session(&saved.metadata.id)?;
assert_eq!(loaded.messages, original);
let imported = crate::session_manager::SavedSession::import_foreign(
loaded.export_container("identity-fixture"),
thread.workspace.clone(),
thread.model.clone(),
)
.map_err(anyhow::Error::msg)?;
assert_eq!(imported.messages, original);
assert!(imported.approval_receipts.is_empty());
manager
.set_thread_session_checkpoint(&thread.id, &loaded)
.await?;
let fork = manager.fork_thread(&thread.id).await?;
assert_eq!(manager.restore_thread_messages(&fork)?, original);
assert_eq!(
manager
.get_thread_detail(&fork.id)
.await?
.pending_approvals
.len(),
0
);
let mut other_identity = original.clone();
if let ContentBlock::ToolUse { execution_id, .. } = &mut other_identity[1].content[0] {
*execution_id = Some("host-other".into());
}
assert_ne!(
session_recovery_projection(&original),
session_recovery_projection(&other_identity)
);
if let ContentBlock::ToolUse { execution_id, .. } = &mut other_identity[1].content[0] {
*execution_id = Some(String::new());
}
let before = serde_json::to_value(manager.get_thread_detail(&thread.id).await?)?;
assert!(
manager
.seed_thread_from_messages(&thread.id, &other_identity)
.await
.is_err()
);
assert_eq!(
serde_json::to_value(manager.get_thread_detail(&thread.id).await?)?,
before,
"reject the whole malformed import before writing any turn or item"
);
let mut wrong_provider = original.clone();
if let ContentBlock::ToolResult { tool_use_id, .. } = &mut wrong_provider[4].content[0] {
*tool_use_id = "different-wire".into();
}
assert!(
manager
.seed_thread_from_messages(&thread.id, &wrong_provider)
.await
.unwrap_err()
.to_string()
.contains("inconsistent provider identity")
);
assert_eq!(
serde_json::to_value(manager.get_thread_detail(&thread.id).await?)?,
before
);
// Also refuse already-corrupted separate result records on a real
// item-only rebuild; never return a repaired call plus an orphan result.
let turns = manager.store.list_turns_for_thread(&thread.id)?;
let mut result = manager
.store
.list_items_for_turn(&turns[0].id)?
.into_iter()
.find(|item| {
item.metadata
.as_ref()
.is_some_and(|meta| meta["tool_result_for"] == "host-two")
})
.context("second result")?;
result.metadata.as_mut().unwrap()["provider_tool_use_id"] = json!("different-wire");
manager.store.save_item(&result)?;
assert!(
manager
.reconstruct_messages_from_turns(&turns)
.unwrap_err()
.to_string()
.contains("inconsistent provider identity")
);
Ok(())
}
#[tokio::test]
async fn monitor_preserves_admitted_correlation_and_redacts_private_answers() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
let thread = manager.create_thread(Default::default()).await?;
let mut harness = install_mock_engine(&manager, &thread.id).await;
let turn = manager
.start_turn(
&thread.id,
StartTurnRequest {
prompt: "project exact tool events".into(),
..Default::default()
},
)
.await?;
assert!(matches!(
harness.rx_op.recv().await,
Some(Op::SendMessage(_))
));
harness
.tx_event
.send(EngineEvent::TurnStarted {
turn_id: turn.id.clone(),
created_at: Utc::now(),
route: None,
})
.await?;
for (id, name, model, fails) in [
("host-one", "read_file", true, false),
("host-two", "exec_shell", true, false),
("host-three", "write_file", true, true),
("host-local", "read_file", false, false),
("host-private", REQUEST_USER_INPUT_TOOL_NAME, true, false),
] {
let caller = (id == "host-one").then(|| ToolCaller {
caller_type: "subagent".into(),
tool_id: Some("parent-wire".into()),
});
let model_call = model.then(|| ModelToolCall {
provider_id: "wire-reused".into(),
caller,
thought_signature: (id == "host-one").then(|| "trusted-signature".into()),
});
harness
.tx_event
.send(EngineEvent::ToolCallStarted {
id: id.into(),
name: name.into(),
input: json!({"source":id}),
model_call,
})
.await?;
let result = if fails {
Err(crate::tools::spec::ToolError::execution_failed(
"interrupted fixture",
))
} else {
Ok(crate::tools::spec::ToolResult::success(if id == "host-private" {
"private-answer-do-not-persist"
} else { "output" }).with_metadata(json!({
"tool_use_id":"forged", "tool_result_for":"forged", "tool_name":"forged",
"tool_input":"forged", "execution_id":"forged", "provider_tool_use_id":"forged",
"tool_caller":{"type":"forged"}, "tool_thought_signature":"forged",
"visibility":"forged", "ordinary_result_metadata":42,
"content_blocks":[{"type":"text","text":"rich-output"}]
})))
};
harness
.tx_event
.send(EngineEvent::ToolCallComplete {
id: id.into(),
name: name.into(),
result,
// Completion cannot replace the admitted start's identity.
model_call: Some(ModelToolCall {
provider_id: "forged-completion".into(),
caller: None,
thought_signature: None,
}),
})
.await?;
}
harness
.tx_event
.send(EngineEvent::TurnComplete {
usage: Usage::default(),
parent_route_usage: Usage::default(),
routed_usage_dropped_records: 0,
status: TurnOutcomeStatus::Completed,
error: None,
tool_catalog: None,
base_url: None,
})
.await?;
wait_for_terminal_turn(&manager, &turn.id).await?;
let detail = manager.get_thread_detail(&thread.id).await?;
let bytes = serde_json::to_string(&detail.items)?;
assert!(!bytes.contains("private-answer-do-not-persist"));
assert!(!bytes.contains("forged"));
let second = detail
.items
.iter()
.find(|item| {
item.metadata
.as_ref()
.is_some_and(|meta| meta["tool_use_id"] == "host-two")
})
.context("second call")?;
assert_eq!(second.kind, TurnItemKind::CommandExecution);
let meta = second.metadata.as_ref().unwrap();
assert!(meta.get("tool_caller").is_none());
assert!(meta.get("tool_thought_signature").is_none());
assert!(meta.get("visibility").is_none());
assert_eq!(meta["ordinary_result_metadata"], 42);
let messages = manager.reconstruct_messages_from_turns(&detail.turns)?;
let uses: Vec<_> = messages
.iter()
.flat_map(|m| &m.content)
.filter(|block| matches!(block, ContentBlock::ToolUse { .. }))
.collect();
let results: Vec<_> = messages
.iter()
.flat_map(|m| &m.content)
.filter(|block| matches!(block, ContentBlock::ToolResult { .. }))
.collect();
assert_eq!(
uses.len(),
3,
"non-model and redacted private items are not replayed"
);
assert_eq!(
results.len(),
3,
"an earlier same-wire result cannot hide a failed later call"
);
for (index, (call, result)) in uses.iter().zip(results.iter()).enumerate() {
let expected = format!("host-{}", ["one", "two", "three"][index]);
assert_eq!(
call.tool_call_key(),
Some(ToolCallKey::Execution(&expected))
);
assert_eq!(call.tool_call_key(), result.tool_call_key());
match call {
ContentBlock::ToolUse {
id,
caller,
thought_signature,
..
} => {
assert_eq!(id, "wire-reused");
assert_eq!(caller.is_some(), index == 0);
assert_eq!(thought_signature.is_some(), index == 0);
}
_ => unreachable!(),
}
}
assert!(matches!(
results[2],
ContentBlock::ToolResult {
is_error: Some(true),
..
}
));
assert!(
!serde_json::to_string(&manager.events_since(&thread.id, None)?)?
.contains("private-answer-do-not-persist")
);
Ok(())
}
#[test]
fn explicit_and_legacy_result_domains_cannot_settle_each_other() -> Result<()> {
let mut item = sample_item(
"turn_identity",
"call",
TurnItemLifecycleStatus::Interrupted,
);
item.kind = TurnItemKind::ToolCall;
let new = RuntimeToolIdentity {
execution_id: Some("same-text"),
provider_id: "wire",
};
let legacy = RuntimeToolIdentity {
execution_id: None,
provider_id: "same-text",
};
for (call, result) in [
(new, legacy),
(legacy, new),
(
new,
RuntimeToolIdentity {
execution_id: Some("same-text"),
provider_id: "different-wire",
},
),
] {
assert!(
unanswered_call_result(
&item,
call,
"read_file",
&[result.key()].into_iter().collect()
)
.is_some()
);
}
assert!(
unanswered_call_result(&item, new, "read_file", &[new.key()].into_iter().collect())
.is_none()
);
for meta in [
json!({"tool_use_id":"same-text","execution_id":"","provider_tool_use_id":"wire"}),
json!({"tool_use_id":"other","execution_id":"same-text","provider_tool_use_id":"wire"}),
json!({"tool_use_id":"same-text","execution_id":"same-text","provider_tool_use_id":false}),
] {
assert!(RuntimeToolIdentity::read(Some(&meta), "tool_use_id").is_err());
}
let non_model = json!({"tool_use_id":"same-text","execution_id":"same-text"});
assert!(RuntimeToolIdentity::read(Some(&non_model), "tool_use_id")?.is_none());
let old = json!({"tool_use_id":"same-text"});
assert_eq!(
RuntimeToolIdentity::read(Some(&old), "tool_use_id")?
.unwrap()
.key(),
legacy.key()
);
Ok(())
}
}
+2
View File
@@ -478,6 +478,7 @@ mod tests {
content: vec![
ContentBlock::thinking("I should run ls first"),
ContentBlock::ToolUse {
execution_id: None,
id: "toolu_01".to_string(),
name: "shell".to_string(),
input: serde_json::json!({ "command": "ls src" }),
@@ -489,6 +490,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "toolu_01".to_string(),
content: "main.rs\nlib.rs".to_string(),
is_error: None,
+63
View File
@@ -5616,6 +5616,59 @@ mod tests {
);
}
#[test]
fn saved_history_keeps_execution_identity_in_snapshots_and_journal() {
let tmp = tempdir().unwrap();
let manager = SessionManager::new(tmp.path().join("sessions")).unwrap();
let mut messages = vec![make_test_message("user", "inspect")];
for execution in ["execution-a", "execution-b"] {
messages.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
id: "reused-provider-id".to_string(),
execution_id: Some(execution.to_string()),
name: "read".to_string(),
input: serde_json::json!({"path": "README.md"}),
caller: Some(codewhale_models::ToolCaller {
caller_type: "code_execution".to_string(),
tool_id: Some("provider-parent".to_string()),
}),
thought_signature: Some("provider-signature".to_string()),
}],
});
messages.push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: "reused-provider-id".to_string(),
execution_id: Some(execution.to_string()),
content: format!("result for {execution}"),
is_error: None,
content_blocks: None,
}],
});
}
let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
manager.save_session(&session).unwrap();
manager.save_checkpoint(&session).unwrap();
let snapshot = manager.load_session_snapshot(&session.metadata.id).unwrap();
let checkpoint = manager
.load_session_checkpoint(&session.metadata.id)
.unwrap()
.unwrap();
for restored in [snapshot, checkpoint] {
assert_eq!(restored.messages, messages);
assert_eq!(
restored.journal.as_ref().unwrap().active_messages(false),
messages
);
let first = restored.messages[1].content[0].tool_call_key();
let second = restored.messages[3].content[0].tool_call_key();
assert_ne!(first, second);
assert_eq!(first, restored.messages[2].content[0].tool_call_key());
assert_eq!(second, restored.messages[4].content[0].tool_call_key());
}
}
#[test]
fn approval_hydration_distinguishes_missing_and_present_logs_on_disk() {
let ask = ApprovalReceipt::asked("receipt-hydration", "exec_shell");
@@ -5949,6 +6002,7 @@ mod tests {
let messages = vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-in-flight".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "README.md"}),
@@ -5984,6 +6038,7 @@ mod tests {
let messages = vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-crashed".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "README.md"}),
@@ -6021,6 +6076,7 @@ mod tests {
let messages = vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-crashed".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "README.md"}),
@@ -6051,6 +6107,7 @@ mod tests {
let messages = vec![Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-crashed".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "README.md"}),
@@ -6105,6 +6162,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "plan-1".to_string(),
name: "update_plan".to_string(),
input: serde_json::json!({
@@ -6125,6 +6183,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "plan-1".to_string(),
content: "Plan updated".to_string(),
is_error: None,
@@ -6163,6 +6222,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-big".to_string(),
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "cargo test -p codewhale-tui"}),
@@ -6173,6 +6233,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-big".to_string(),
content: raw.clone(),
is_error: None,
@@ -6216,6 +6277,7 @@ mod tests {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "call-legacy".to_string(),
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "cargo check"}),
@@ -6226,6 +6288,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-legacy".to_string(),
content: raw.clone(),
is_error: None,
+2
View File
@@ -402,6 +402,7 @@ mod tests {
role: Role::Assistant,
content: vec![
ContentBlock::ToolUse {
execution_id: None,
id: "call-1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({ "path": "/etc/shadow" }),
@@ -409,6 +410,7 @@ mod tests {
thought_signature: None,
},
ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call-1".to_string(),
content: "root:$6$verysecrethash".to_string(),
is_error: None,
+1 -1
View File
@@ -1177,7 +1177,7 @@ async fn ingest_runtime_event(
let kind = item.get("kind").and_then(Value::as_str).unwrap_or_default();
if kind == "tool_call" || kind == "file_change" || kind == "command_execution" {
let metadata = item.get("metadata");
// Starts carry the provider call ID; item.id is Runtime's
// Starts carry the call execution ID; item.id is Runtime's
// separate receipt ID. Runtime preserves the call identity
// in terminal metadata, including errors and redacted input.
let id = metadata
+1
View File
@@ -105,6 +105,7 @@ mod tests {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
+2
View File
@@ -1492,6 +1492,7 @@ mod tests {
codewhale_models::Message {
role: Role::Assistant,
content: vec![codewhale_models::ContentBlock::ToolUse {
execution_id: None,
id: "call_read_media".to_string(),
name: "read_media".to_string(),
input: json!({ "path": "wire.png" }),
@@ -1502,6 +1503,7 @@ mod tests {
codewhale_models::Message {
role: Role::User,
content: vec![codewhale_models::ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "call_read_media".to_string(),
content: rich.content.clone(),
is_error: None,
+154 -20
View File
@@ -224,17 +224,20 @@ impl Default for EmissionGuard {
/// Extract bounded tool-call/result pairs from a session message slice.
///
/// Scans `messages` in reverse (newest first), collects up to `max_pairs`
/// `ToolUse`/`ToolResult` pairs, then returns them oldest-first.
/// Takes the newest `max_pairs` tool calls, then returns their unambiguous
/// pairs oldest-first. Missing results remain pending; corrupt or ambiguous
/// identities are omitted rather than borrowing another execution's output.
#[must_use]
pub fn extract_tool_call_pairs(messages: &[Message], max_pairs: usize) -> Vec<ToolCallPair> {
// Collect ToolUse names+inputs (from assistant messages) and
// ToolResult texts (from user messages) into a pairing structure.
let mut uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
let mut results: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut uses = Vec::new();
let mut use_counts = std::collections::HashMap::new();
let mut results = std::collections::HashMap::new();
for msg in messages {
for block in &msg.content {
let Some(key) = block.tool_call_key() else {
continue;
};
match block {
ContentBlock::ToolUse {
id, name, input, ..
@@ -244,37 +247,51 @@ pub fn extract_tool_call_pairs(messages: &[Message], max_pairs: usize) -> Vec<To
MAX_CHARS_PER_PAIR / 2,
"…",
);
uses.push((id.clone(), name.clone(), input_str));
*use_counts.entry(key).or_insert(0usize) += 1;
uses.push((key, id.as_str(), name.as_str(), input_str));
}
ContentBlock::ToolResult {
tool_use_id,
content,
..
} => {
results.insert(
tool_use_id.clone(),
truncate_with_ellipsis(content, MAX_CHARS_PER_PAIR / 2, "…"),
);
results
.entry(key)
.and_modify(|result| *result = None)
.or_insert_with(|| {
Some((
tool_use_id.as_str(),
truncate_with_ellipsis(content, MAX_CHARS_PER_PAIR / 2, "…"),
))
});
}
_ => {}
}
}
}
// Match uses with results and take the last `max_pairs`.
let start = uses.len().saturating_sub(max_pairs);
uses[start..]
.iter()
.map(|(id, name, input)| {
let result = results
.get(id.as_str())
.cloned()
.unwrap_or_else(|| "(pending)".to_string());
ToolCallPair {
name: name.clone(),
.filter_map(|(key, provider_id, name, input)| {
if key.as_str().trim().is_empty()
|| provider_id.trim().is_empty()
|| use_counts.get(key) != Some(&1)
{
return None;
}
let result = match results.get(key) {
Some(Some((result_provider, content))) if result_provider == provider_id => {
content.clone()
}
Some(_) => return None,
None => "(pending)".to_string(),
};
Some(ToolCallPair {
name: (*name).to_string(),
input_preview: input.clone(),
result_preview: result,
}
})
})
.collect()
}
@@ -585,6 +602,7 @@ mod tests {
messages.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.clone(),
name: "exec_shell".to_string(),
input: serde_json::json!({"command": format!("echo {i}")}),
@@ -596,6 +614,7 @@ mod tests {
messages.push(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: id,
content: format!("{i}"),
is_error: None,
@@ -635,6 +654,121 @@ mod tests {
assert_eq!(pairs.len(), 3);
}
#[test]
fn extract_tool_call_pairs_keep_execution_and_legacy_domains_distinct() {
let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"assistant", "content":[{"type":"tool_use", "id":"wire",
"execution_id":"first", "name":"read_file", "input":{"path":"first.txt"}}]},
{"role":"user", "content":[{"type":"tool_result", "tool_use_id":"wire",
"execution_id":"first", "content":"first output"}]},
{"role":"assistant", "content":[{"type":"tool_use", "id":"wire",
"execution_id":"second", "name":"read_file", "input":{"path":"second.txt"}}]},
{"role":"user", "content":[{"type":"tool_result", "tool_use_id":"wire",
"execution_id":"second", "content":"second output"}]},
{"role":"assistant", "content":[{"type":"tool_use", "id":"first",
"name":"read_file", "input":{"path":"legacy.txt"}}]},
{"role":"user", "content":[{"type":"tool_result", "tool_use_id":"first",
"content":"legacy output"}]}
]))
.expect("transcript");
let pairs = extract_tool_call_pairs(&messages, 3);
assert_eq!(pairs.len(), 3);
for (pair, path, output) in [
(&pairs[0], "first.txt", "first output"),
(&pairs[1], "second.txt", "second output"),
(&pairs[2], "legacy.txt", "legacy output"),
] {
assert!(pair.input_preview.contains(path));
assert_eq!(pair.result_preview, output);
}
let bounded = extract_tool_call_pairs(&messages, 2);
assert_eq!(bounded.len(), 2);
assert_eq!(bounded[0].result_preview, "second output");
assert_eq!(bounded[1].result_preview, "legacy output");
assert!(extract_tool_call_pairs(&messages, 0).is_empty());
}
#[test]
fn extract_tool_call_pairs_refuse_corrupt_identity_without_fallback() {
let call = |execution_id: Option<&str>| {
serde_json::json!({
"type":"tool_use", "id":"wire", "execution_id":execution_id,
"name":"read_file", "input":{"path":"selected.txt"}
})
};
let result = |execution_id: Option<&str>, provider: &str| {
serde_json::json!({
"type":"tool_result", "tool_use_id":provider, "execution_id":execution_id,
"content":"must not borrow this output"
})
};
for (label, calls, results, pending) in [
(
"duplicate local calls",
vec![call(Some("exec")), call(Some("exec"))],
vec![result(Some("exec"), "wire")],
false,
),
(
"duplicate legacy calls",
vec![call(None), call(None)],
vec![result(None, "wire")],
false,
),
(
"duplicate local results",
vec![call(Some("exec"))],
vec![result(Some("exec"), "wire"), result(Some("exec"), "wire")],
false,
),
(
"duplicate legacy results",
vec![call(None)],
vec![result(None, "wire"), result(None, "wire")],
false,
),
(
"wrong provider",
vec![call(Some("exec"))],
vec![result(Some("exec"), "other")],
false,
),
(
"empty local identity",
vec![call(Some(""))],
vec![result(Some(""), "wire")],
false,
),
(
"no local fallback",
vec![call(Some("exec"))],
vec![result(Some("wrong"), "wire"), result(None, "wire")],
true,
),
(
"no legacy fallback",
vec![call(None)],
vec![result(Some("wire"), "wire")],
true,
),
("missing result", vec![call(Some("exec"))], vec![], true),
] {
let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"assistant", "content":calls}, {"role":"user", "content":results}
]))
.expect("transcript");
let pairs = extract_tool_call_pairs(&messages, 5);
assert_eq!(pairs.len(), usize::from(pending), "{label}");
if pending {
assert_eq!(pairs[0].result_preview, "(pending)", "{label}");
}
assert!(
!build_advisor_prompt(&pairs).contains("must not borrow"),
"{label}"
);
}
}
// ── rate limiting ─────────────────────────────────────────────────────
#[test]
@@ -63,31 +63,29 @@ pub(super) enum Outcome {
}
pub(super) fn repair_stopped_tool_calls(messages: &mut Vec<Message>, cause: &str) {
// Keep the final call blocks, so keys remain borrowed from their original
// identities while repair changes the message vector.
let final_calls = messages
.iter()
.rev()
.find(|message| message.role == Role::Assistant)
.into_iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.clone()),
_ => None,
})
.collect::<HashSet<_>>();
.map(|message| message.content.clone())
.unwrap_or_default();
let repair = crate::tool_history_repair::repair_tool_call_pairs_for_provider(messages);
let stopped = repair
.repaired_call_ids
.into_iter()
.filter(|id| final_calls.contains(id))
.collect::<HashSet<_>>();
for block in messages.iter_mut().flat_map(|message| &mut message.content) {
if let ContentBlock::ToolResult {
tool_use_id,
content,
..
} = block
&& stopped.contains(tool_use_id.as_str())
{
let final_message = messages
.iter()
.rposition(|message| message.role == Role::Assistant);
for (message_index, block_index) in repair.repaired_result_positions {
if !final_message.is_some_and(|index| message_index > index) {
continue;
}
let block = &mut messages[message_index].content[block_index];
let key = block.tool_call_key();
let is_final_call = key.is_some_and(|key| !key.as_str().trim().is_empty())
&& final_calls.iter().any(|call| {
matches!(call, ContentBlock::ToolUse { .. }) && call.tool_call_key() == key
});
if is_final_call && let ContentBlock::ToolResult { content, .. } = block {
*content = format!(
"Tool call not executed: task execution stopped at its budget boundary. Terminal status: budget_exhausted. {cause}"
);
@@ -1048,6 +1048,7 @@ fn assistant_message(content: Vec<ContentBlock>) -> Message {
fn tool_use(name: &str, input: Value) -> ContentBlock {
ContentBlock::ToolUse {
execution_id: None,
id: format!("call_{name}"),
name: name.to_string(),
input,
@@ -1134,3 +1135,21 @@ fn fallback_partial_text_is_silent_only_when_nothing_was_recorded() {
.contains("No assistant text was recorded")
);
}
#[test]
fn budget_repair_only_rewrites_the_newly_synthesized_final_execution() {
for (first, last) in [(Some("first"), Some("last")), (None, None)] {
let mut messages: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[{"type":"tool_use","id":"reused","execution_id":first,"name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"reused","execution_id":first,"content":"earlier completed output"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"reused","execution_id":last,"name":"read","input":{}}]}
])).unwrap();
let earlier = messages[..2].to_vec();
budget_handback::repair_stopped_tool_calls(&mut messages, "fixture deadline");
assert_eq!(&messages[..2], earlier.as_slice());
assert!(
matches!(&messages[3].content[0], ContentBlock::ToolResult { execution_id, content, .. }
if execution_id.as_deref() == last && content.contains("budget_exhausted"))
);
}
}
+101 -37
View File
@@ -1368,6 +1368,10 @@ async fn record_provider_response_usage(
);
}
fn new_child_execution_id(agent_id: &str) -> String {
format!("agent:{agent_id}:approval:{}", Uuid::new_v4())
}
/// One logical held child-tool call gets one guardian usage identity even if a
/// mailbox/monitor replays its receipt. Raw agent/tool ids can be model-owned,
/// so only this fixed-length digest crosses telemetry or persistence seams.
@@ -3744,12 +3748,11 @@ pub struct SubAgentManager {
/// without the manager lock, even when the awaiting task was aborted.
worktree_cleanups: Arc<std::sync::Mutex<HashSet<PathBuf>>>,
/// Approval prompts raised on a child's behalf under Ask: the approval id
/// the host sees (`agent:<agent_id>:approval:<boot_id>:<n>`) → the
/// the host sees (`agent:<agent_id>:approval:<execution UUID>`) → the
/// waiting child. The engine
/// routes the person's decision here; a decision for an id nobody is
/// waiting on is dropped, never applied to a different call.
child_approvals: HashMap<String, ChildPendingRequest>,
child_approval_seq: u64,
/// Wake cursor for `agent wait` (approvals C2): approval ids already
/// reported to the parent model as `needs_person`. A reported id keeps a
/// later wait blocking instead of re-waking on the same request.
@@ -4027,31 +4030,33 @@ impl SubAgentManager {
pub fn register_child_approval(
&mut self,
agent_id: &str,
tool_id: &str,
tool_name: &str,
reason: &str,
) -> (String, tokio::sync::oneshot::Receiver<ChildApprovalOutcome>) {
self.child_approval_seq = self.child_approval_seq.wrapping_add(1);
// Namespace with the manager's boot id (#5615): the sequence restarts
// with every manager, and a resumed agent under a new manager would
// otherwise reuse ids from an earlier lifecycle. Durable approval
// receipts (#5584) make a stale id a live hazard — the old receipt
// could auto-answer the new prompt.
let id = format!(
"agent:{agent_id}:approval:{}:{}",
self.current_session_boot_id, self.child_approval_seq
) -> Result<(String, tokio::sync::oneshot::Receiver<ChildApprovalOutcome>)> {
// The host mints this before checkpointing the call. Approval, history,
// audit, and artifacts share it; registration never allocates another.
let prefix = format!("agent:{agent_id}:approval:");
anyhow::ensure!(
tool_id
.strip_prefix(&prefix)
.is_some_and(|id| Uuid::parse_str(id).is_ok()),
"invalid child execution identity"
);
let std::collections::hash_map::Entry::Vacant(entry) =
self.child_approvals.entry(tool_id.to_string())
else {
anyhow::bail!("child execution already has a pending approval");
};
let (tx, rx) = tokio::sync::oneshot::channel();
self.child_approvals.insert(
id.clone(),
ChildPendingRequest {
tx,
agent_id: agent_id.to_string(),
tool_name: tool_name.to_string(),
summary: one_line_pending_summary(reason),
requested_at: std::time::Instant::now(),
},
);
(id, rx)
entry.insert(ChildPendingRequest {
tx,
agent_id: agent_id.to_string(),
tool_name: tool_name.to_string(),
summary: one_line_pending_summary(reason),
requested_at: std::time::Instant::now(),
});
Ok((tool_id.to_string(), rx))
}
/// Requests from `agent_id` still waiting on a person, oldest first.
@@ -4236,7 +4241,6 @@ impl SubAgentManager {
resume_targets: HashMap::new(),
worktree_cleanups: Arc::default(),
child_approvals: HashMap::new(),
child_approval_seq: 0,
reported_pending: HashSet::new(),
}
}
@@ -14600,7 +14604,7 @@ async fn run_subagent(
// cancel during a long thinking turn doesn't have to wait for the
// step timeout.
let request_attempted = std::sync::atomic::AtomicBool::new(false);
let (response, usage_route) = tokio::select! {
let (mut response, usage_route) = tokio::select! {
biased;
() = runtime.cancel_token.cancelled() => {
if request_attempted.load(std::sync::atomic::Ordering::Relaxed) {
@@ -14866,6 +14870,30 @@ async fn run_subagent(
// boundary weighs it for compaction exactly as the parent does.
last_billed_input_tokens = Some(u64::from(response.usage.input_tokens));
// Admission precedes checkpointing any executable calls. A provider's
// optional host field cannot select local correlation, even on resume.
let invalid_pairing = route_runtime
.client
.validate_tool_call_ids(response.content.iter().filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
_ => None,
}))
.err();
if invalid_pairing.is_some() {
response.content.retain(|block| {
!matches!(
block,
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
)
});
} else {
for block in &mut response.content {
if let ContentBlock::ToolUse { execution_id, .. } = block {
*execution_id = Some(new_child_execution_id(&agent_id));
}
}
}
let mut current_response_text = None;
for block in &response.content {
match block {
@@ -14874,9 +14902,18 @@ async fn run_subagent(
final_result = Some(text.clone());
}
ContentBlock::ToolUse {
id, name, input, ..
id,
execution_id,
name,
input,
..
} => {
tool_uses.push((id.clone(), name.clone(), input.clone()));
tool_uses.push((
id.clone(),
execution_id.clone().expect("admitted host identity"),
name.clone(),
input.clone(),
));
}
_ => {}
}
@@ -14912,6 +14949,18 @@ async fn run_subagent(
)
.await;
if let Some(error) = invalid_pairing {
let reason = error.to_string();
record_agent_progress(
runtime,
&agent_id,
AgentProgressEventMeta::new(AgentWorkerStatus::Failed).with_step(steps),
format!("{}: {reason}", format_step_counter(steps, max_steps)),
);
terminal_failure_reason = Some(reason);
break;
}
if is_incomplete_stop_reason(response.stop_reason.as_deref()) {
final_result = current_response_text;
let failure = incomplete_subagent_response_failure(&response);
@@ -15010,7 +15059,7 @@ async fn run_subagent(
);
let mut tool_results: Vec<ContentBlock> = Vec::new();
let mut denial_batch = FleetDenialBatch::default();
for (tool_id, tool_name, tool_input) in tool_uses {
for (provider_id, tool_id, tool_name, tool_input) in tool_uses {
if work_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
budget_failure_reason = Some("child wall-time budget exhausted during task execution; remaining time is reserved for hand-back. Narrow the task or have the operator raise the inherited wall-time limit".to_string());
break;
@@ -15036,7 +15085,8 @@ async fn run_subagent(
format!("Error: {blocked}"),
);
tool_results.push(ContentBlock::ToolResult {
tool_use_id: tool_id,
execution_id: Some(tool_id),
tool_use_id: provider_id,
content: result,
// Refusals reach the provider as errors, matching the
// parent turn loop (#6015).
@@ -15201,7 +15251,8 @@ async fn run_subagent(
}
tool_results.push(ContentBlock::ToolResult {
tool_use_id: tool_id,
execution_id: Some(tool_id),
tool_use_id: provider_id,
content: result,
// A refused or failed call is marked as an error for the
// provider, matching the parent turn loop (#6015).
@@ -18624,14 +18675,21 @@ impl SubAgentToolRegistry {
unreachable!("force_prompt implies ForcePrompt");
};
return self
.prompt_parent_for_child_call(agent_id, name, input, &reason, true)
.prompt_parent_for_child_call(agent_id, tool_id, name, input, &reason, true)
.await;
};
if approval_mode == ApprovalMode::Never {
return ChildGateVerdict::Deny(refusal);
}
self.prompt_parent_for_child_call(agent_id, name, input, &refusal, force_prompt)
.await
self.prompt_parent_for_child_call(
agent_id,
tool_id,
name,
input,
&refusal,
force_prompt,
)
.await
}
}
}
@@ -18723,6 +18781,7 @@ impl SubAgentToolRegistry {
async fn prompt_parent_for_child_call(
&self,
agent_id: &str,
tool_id: &str,
name: &str,
input: &Value,
reason: &str,
@@ -18738,12 +18797,16 @@ impl SubAgentToolRegistry {
"{reason} (this host cannot raise a prompt for an agent; run the call in the main conversation, or switch the session to Auto-Review or Full Access)"
));
};
let (approval_id, receiver) = self
let (approval_id, receiver) = match self
.gate_runtime
.manager
.write()
.await
.register_child_approval(agent_id, name, reason);
.register_child_approval(agent_id, tool_id, name, reason)
{
Ok(pending) => pending,
Err(error) => return ChildGateVerdict::Deny(error.to_string()),
};
if let Err(error) = self
.commit_child_approval_receipt(crate::approval_log::ApprovalReceipt::asked(
approval_id.clone(),
@@ -19597,7 +19660,8 @@ impl SubAgentToolRegistry {
.registry
.context()
.clone()
.with_owner_agent(self.owner_agent_id.clone(), self.owner_agent_name.clone());
.with_owner_agent(self.owner_agent_id.clone(), self.owner_agent_name.clone())
.with_origin_tool_call_id(tool_id.to_string());
let observed_paths = if scope_aware_write {
mutation_paths(name, &input)?
} else {
@@ -19624,7 +19688,7 @@ impl SubAgentToolRegistry {
#[cfg(test)]
async fn execute(&self, agent_id: &str, name: &str, input: Value) -> Result<String> {
self.execute_full(agent_id, "", name, input)
self.execute_full(agent_id, &new_child_execution_id(agent_id), name, input)
.await
.map(|result| result.result.content)
}
+239 -18
View File
@@ -258,7 +258,14 @@ fn child_approval_ids_stay_unique_within_and_across_manager_boots() {
let mut first_ids = Vec::new();
for _ in 0..3 {
let (id, _rx) = first.register_child_approval("resumed-agent-7", "bash", "fixture");
let (id, _rx) = first
.register_child_approval(
"resumed-agent-7",
&new_child_execution_id("resumed-agent-7"),
"bash",
"fixture",
)
.unwrap();
assert!(
SubAgentManager::is_child_approval_id(&id),
"routing hint must still recognize {id}"
@@ -277,7 +284,14 @@ fn child_approval_ids_stay_unique_within_and_across_manager_boots() {
let mut second = SubAgentManager::new(tmp.path().to_path_buf(), 4);
let mut second_ids = Vec::new();
for _ in 0..3 {
let (id, _rx) = second.register_child_approval("resumed-agent-7", "bash", "fixture");
let (id, _rx) = second
.register_child_approval(
"resumed-agent-7",
&new_child_execution_id("resumed-agent-7"),
"bash",
"fixture",
)
.unwrap();
assert!(SubAgentManager::is_child_approval_id(&id));
second_ids.push(id);
}
@@ -289,6 +303,46 @@ fn child_approval_ids_stay_unique_within_and_across_manager_boots() {
}
}
#[tokio::test]
async fn child_execution_identity_cannot_replace_a_waiter_or_answer_a_later_call() {
let tmp = tempdir().unwrap();
let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2);
let first = new_child_execution_id("child");
let second = new_child_execution_id("child");
let (registered, receiver) = manager
.register_child_approval("child", &first, "bash", "one")
.unwrap();
assert_eq!(registered, first);
assert!(
manager
.register_child_approval("child", &first, "bash", "replacement")
.is_err()
);
assert!(
manager
.register_child_approval("other", &first, "bash", "wrong owner")
.is_err()
);
assert!(manager.resolve_child_approval(&first, ChildApprovalOutcome::Denied));
assert!(matches!(
receiver.await.unwrap(),
ChildApprovalOutcome::Denied
));
let (_, mut receiver) = manager
.register_child_approval("child", &second, "bash", "two")
.unwrap();
assert!(!manager.resolve_child_approval(&first, ChildApprovalOutcome::Approved));
assert!(matches!(
receiver.try_recv(),
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
));
assert!(manager.resolve_child_approval(&second, ChildApprovalOutcome::Approved));
assert!(matches!(
receiver.await.unwrap(),
ChildApprovalOutcome::Approved
));
}
#[test]
fn active_worker_records_are_never_pruned_by_history_retention() {
let tmp = tempdir().expect("tempdir");
@@ -8078,7 +8132,14 @@ async fn execute_surface_tool(
) -> Result<String> {
let request_active = surface.active_names.clone();
registry
.execute_from_surface("agent_test", "", surface, &request_active, name, input)
.execute_from_surface(
"agent_test",
&new_child_execution_id("agent_test"),
surface,
&request_active,
name,
input,
)
.await
.map(|result| result.result.content)
}
@@ -8240,6 +8301,7 @@ async fn small_surface_fork_context_survives_fresh_child_discovery() {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "search-1".to_string(),
name: TOOL_SEARCH_NAME.to_string(),
input: json!({"query": "web"}),
@@ -8250,6 +8312,7 @@ async fn small_surface_fork_context_survives_fresh_child_discovery() {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "search-1".to_string(),
content: json!({
"type": "tool_search_tool_search_result",
@@ -19442,8 +19505,14 @@ async fn agent_wait_returns_early_with_needs_approval() {
let mut inner = SubAgentManager::new(PathBuf::from("."), 1);
let agent_id = insert_running_agent(&mut inner, "test_agent_wait_needs_person");
// The child is blocked on the Ask prompt for a shell call.
let (_approval_id, _rx) =
inner.register_child_approval(&agent_id, "bash", "Tool bash requires approval\nand more");
let (_approval_id, _rx) = inner
.register_child_approval(
&agent_id,
&new_child_execution_id(&agent_id),
"bash",
"Tool bash requires approval\nand more",
)
.unwrap();
let manager = Arc::new(RwLock::new(inner));
let context = ToolContext::new(".");
@@ -19484,7 +19553,14 @@ async fn agent_wait_returns_early_with_needs_approval() {
async fn agent_wait_does_not_rewake_on_reported_request() {
let mut inner = SubAgentManager::new(PathBuf::from("."), 1);
let agent_id = insert_running_agent(&mut inner, "test_agent_wait_no_rewake");
let (_approval_id, _rx) = inner.register_child_approval(&agent_id, "bash", "held");
let (_approval_id, _rx) = inner
.register_child_approval(
&agent_id,
&new_child_execution_id(&agent_id),
"bash",
"held",
)
.unwrap();
let manager = Arc::new(RwLock::new(inner));
let context = ToolContext::new(".");
@@ -19521,11 +19597,16 @@ async fn agent_wait_does_not_rewake_on_reported_request() {
assert!(joined.get("needs_person").is_none(), "{joined}");
// A new request wakes the join.
let (_next_id, _next_rx) =
manager
.write()
.await
.register_child_approval(&agent_id, "write_file", "held again");
let (_next_id, _next_rx) = manager
.write()
.await
.register_child_approval(
&agent_id,
&new_child_execution_id(&agent_id),
"write_file",
"held again",
)
.unwrap();
let joined = coord::dispatch_wait(
&json!({"until": "all", "timeout_secs": 30}),
Arc::clone(&manager),
@@ -19550,7 +19631,14 @@ async fn status_waiting_is_derived_from_pending_store() {
"workspace",
None,
);
let (approval_id, _rx) = inner.register_child_approval(&agent_id, "bash", "held");
let (approval_id, _rx) = inner
.register_child_approval(
&agent_id,
&new_child_execution_id(&agent_id),
"bash",
"held",
)
.unwrap();
// The WaitingForUser progress write was skipped under contention; the
// store still makes the record waiting, with the request and the action.
let record = inner.get_worker_record(&agent_id).expect("worker record");
@@ -23722,6 +23810,8 @@ mod child_permission_gate {
let receipt_store_for_answer = receipt_store.clone();
let session_id_for_answer = session_id.clone();
let manager_for_answer = Arc::clone(&manager);
let execution_id = new_child_execution_id("agent_gate");
let expected_id = execution_id.clone();
let answerer = tokio::spawn(async move {
// Wait for the prompt, then answer it exactly like the engine
// does when the person decides in the parent's UI.
@@ -23744,6 +23834,7 @@ mod child_permission_gate {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let approval_id = approval_id.expect("child prompt must reach the host");
assert_eq!(approval_id, expected_id);
let replay_before_decision = receipt_store_for_answer
.replay(&session_id_for_answer)
.expect("the durable ask replays before the prompt is answered");
@@ -23764,8 +23855,14 @@ mod child_permission_gate {
replay_before_decision
});
let result = registry
.execute("agent_gate", "bash", json!({"command": "echo gated"}))
.await;
.execute_full(
"agent_gate",
&execution_id,
"bash",
json!({"command": "echo gated"}),
)
.await
.map(|output| output.result.content);
let replay_before_decision = answerer.await.expect("answerer task");
assert_eq!(replay_before_decision.unmatched_asks.len(), 1);
assert!(replay_before_decision.completed.is_empty());
@@ -24056,7 +24153,14 @@ mod child_permission_gate {
let (registry, _rx, manager) = worker_registry(ApprovalMode::Suggest, false, true, None);
let agent_id = running_gate_agent(&registry, &manager, "old_session_child").await;
let mut manager = manager.write().await;
let (_id, receiver) = manager.register_child_approval(&agent_id, "bash", "held");
let (_id, receiver) = manager
.register_child_approval(
&agent_id,
&new_child_execution_id(&agent_id),
"bash",
"held",
)
.unwrap();
assert!(manager.finalize_session_close_for_session("workspace") > 0);
assert_eq!(
manager.pending_child_approvals(),
@@ -25079,23 +25183,39 @@ mod readonly_shell_6015 {
async fn scripted_bash_calls_client(
commands: Vec<&'static str>,
report: &'static str,
) -> (CodewhaleClient, Arc<AtomicUsize>) {
scripted_bash_calls_with_pairing(commands, report, None).await
}
async fn scripted_bash_calls_with_pairing(
commands: Vec<&'static str>,
report: &'static str,
invalid_ids: Option<[&'static str; 2]>,
) -> (CodewhaleClient, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let app = Router::new().route(
"/{*path}",
post({
let calls = Arc::clone(&calls);
move |Json(_body): Json<Value>| {
move |Json(request): Json<Value>| {
let calls = Arc::clone(&calls);
let commands = commands.clone();
async move {
let attempt = calls.fetch_add(1, Ordering::SeqCst);
let serialized = request.to_string();
assert!(!serialized.contains("execution_id"));
assert!(!serialized.contains(":approval:"));
for message in request["messages"].as_array().unwrap() {
if message["role"] == "tool" {
assert_eq!(message["tool_call_id"], "call_ro_reused");
}
}
let usage = json!({
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
});
let body = match commands.get(attempt) {
let mut body = match commands.get(attempt) {
Some(command) => json!({
"id": format!("chatcmpl-ro-{attempt}"),
"model": "deepseek-v4-flash",
@@ -25105,7 +25225,8 @@ mod readonly_shell_6015 {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": format!("call_ro_{attempt}"),
"id": "call_ro_reused",
"execution_id": "provider-forged-local",
"type": "function",
"function": {
"name": "bash",
@@ -25128,6 +25249,16 @@ mod readonly_shell_6015 {
"usage": usage
}),
};
if let Some(ids) = invalid_ids {
body["choices"][0]["message"]["content"] =
json!("Text retained from the refused tool response.");
let prototype = body["choices"][0]["message"]["tool_calls"][0].clone();
body["choices"][0]["message"]["tool_calls"] = json!(ids.map(|id| {
let mut call = prototype.clone();
call["id"] = json!(id);
call
}));
}
Json(body).into_response()
}
}
@@ -25238,6 +25369,96 @@ mod readonly_shell_6015 {
.collect()
}
#[tokio::test]
async fn ambiguous_child_batch_keeps_text_and_usage_without_executing_calls() {
for ids in [["same", "same"], ["valid", ""]] {
let tmp = tempdir().unwrap();
let (client, calls) =
scripted_bash_calls_with_pairing(vec!["pwd"], "no further round", Some(ids)).await;
let result = run_scout(tmp.path(), client, "agent_bad_identity").await;
assert!(
matches!(&result.status, SubAgentStatus::Failed(error) if error.contains("pairing id")),
"{:?}",
result.status
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"invalid batch is terminal before dispatch"
);
assert!(
result.usage.as_ref().and_then(|usage| usage.total_tokens) == Some(15),
"billed usage must survive refusal"
);
let checkpoint = result
.checkpoint
.as_ref()
.expect("refused response checkpoint");
assert!(checkpoint.messages.iter().flat_map(|m| &m.content).any(
|block| matches!(block, ContentBlock::Text { text, .. } if text == "Text retained from the refused tool response.")
));
assert!(
checkpoint
.messages
.iter()
.flat_map(|m| &m.content)
.all(|block| !matches!(
block,
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
))
);
}
}
#[tokio::test]
async fn reused_provider_ids_keep_child_execution_history_distinct() {
let tmp = tempdir().unwrap();
let (client, calls) = scripted_bash_calls_client(vec!["pwd", "pwd"], "read twice").await;
let result = run_scout(tmp.path(), client, "agent_identity").await;
assert_eq!(
result.status,
SubAgentStatus::Completed,
"{:?}",
result.result
);
assert_eq!(calls.load(Ordering::SeqCst), 3);
let checkpoint = result.checkpoint.as_ref().expect("real child checkpoint");
let mut ids = Vec::new();
let mut results = Vec::new();
for block in checkpoint.messages.iter().flat_map(|m| &m.content) {
match block {
ContentBlock::ToolUse {
id, execution_id, ..
} => {
assert_eq!(id, "call_ro_reused");
let local = execution_id.clone().expect("host identity");
assert!(SubAgentManager::is_child_approval_id(&local));
assert_ne!(local, "provider-forged-local");
ids.push(local);
}
ContentBlock::ToolResult {
tool_use_id,
execution_id,
..
} => {
assert_eq!(tool_use_id, "call_ro_reused");
results.push(execution_id.clone().expect("result identity"));
}
_ => {}
}
}
assert_eq!(ids.len(), 2);
assert_ne!(ids[0], ids[1]);
assert_eq!(ids, results);
assert_ne!(
child_guardian_usage_source_id("agent_identity", &ids[0]),
child_guardian_usage_source_id("agent_identity", &ids[1])
);
let restored: SubAgentCheckpoint =
serde_json::from_slice(&serde_json::to_vec(checkpoint).unwrap()).unwrap();
assert_eq!(restored.messages, checkpoint.messages);
}
// Only the unix-gated tests build a git fixture; ungated, Windows
// `-D warnings` rejects this helper as dead code.
#[cfg(unix)]
@@ -273,3 +273,139 @@ replacements = ["fixture-backup-model"]
0
);
}
/// The approved replacement owns the response grammar. Its distinct Responses
/// item IDs do not make a repeated call_id safe to dispatch under Chat rules.
#[tokio::test]
async fn responses_replacement_rejects_pairing_collisions_using_its_actual_route() {
let _env = crate::test_support::lock_test_env();
let root = tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "deepseek");
let _model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "deepseek-v4-flash");
let (pin_url, pin_calls) = refusing_chat_server().await;
let backup = wiremock::MockServer::start().await;
let mut frames = vec![
json!({"type":"response.output_item.added", "item":{"type":"message", "id":"message"}}),
json!({"type":"response.output_text.delta", "delta":"Retained replacement response text."}),
json!({"type":"response.output_item.done"}),
];
for item in ["item_first", "item_second"] {
frames.extend([
json!({"type":"response.output_item.added", "item":{
"type":"function_call", "id":item, "call_id":"same_call", "name":"bash"
}}),
json!({"type":"response.function_call_arguments.delta", "delta":r#"{"command":"pwd"}"#}),
json!({"type":"response.output_item.done"}),
]);
}
frames.push(json!({"type":"response.completed", "response":{
"status":"completed", "usage":{"input_tokens":10, "output_tokens":5}
}}));
let body = frames
.into_iter()
.map(|frame| format!("data: {frame}\n\n"))
.collect::<String>();
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/v1/responses"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.insert_header("Content-Type", "text/event-stream")
.set_body_string(body),
)
.expect(1)
.mount(&backup)
.await;
let config_path = root.path().join("config.toml");
write_replacement_config(
&config_path,
&pin_url,
&format!("{}/v1", backup.uri()),
r#"
wire = "responses"
[subagents.roles.reviewer]
model = "PinRoute/fixture-pin-model"
replacements = ["BackupRoute/fixture-backup-model"]
"#,
)
.await;
let config = crate::config::Config::load(Some(config_path), None).unwrap();
let client = CodewhaleClient::new(&config).unwrap();
assert_eq!(
client.wire_format(),
codewhale_config::provider::WireFormat::ChatCompletions
);
let manager = new_shared_subagent_manager(root.path().to_path_buf(), 2);
let context = ToolContext::new(root.path()).with_state_namespace("route-identity");
let runtime = SubAgentRuntime::new(
client,
"deepseek-v4-flash".into(),
context.clone(),
false,
None,
manager.clone(),
)
.with_api_config(config);
let tool = AgentTool::new(manager.clone(), runtime);
let started = tool
.execute(
json!({"type":"reviewer", "prompt":"Inspect the workspace."}),
&context,
)
.await
.unwrap();
let metadata = started.metadata.as_ref().unwrap();
let id = metadata["agent_id"].as_str().unwrap();
assert_eq!(metadata["child_route"]["provider_id"], "PinRoute");
let result = tokio::time::timeout(Duration::from_secs(10), async {
loop {
let result = manager.read().await.get_result(id).unwrap();
if result.status != SubAgentStatus::Running {
return result;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("replacement pairing refusal settles");
let SubAgentStatus::Failed(error) = &result.status else {
panic!("ambiguous replacement must fail: {:?}", result.status);
};
assert!(error.contains("pairing id"), "{error}");
assert_eq!(pin_calls.load(Ordering::SeqCst), 1);
let requests = backup.received_requests().await.unwrap();
assert_eq!(
requests.len(),
1,
"no tool result round follows refused admission"
);
let request: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert!(request.get("input").is_some());
assert!(request.get("messages").is_none());
assert_eq!(request["model"], "fixture-backup-model");
assert!(
result.usage.as_ref().and_then(|usage| usage.total_tokens) == Some(15),
"the refused response remains billed"
);
let checkpoint = result
.checkpoint
.as_ref()
.expect("durable refused response");
assert!(checkpoint.messages.iter().flat_map(|m| &m.content).any(
|block| matches!(block, ContentBlock::Text { text, .. } if text == "Retained replacement response text.")
));
assert!(
checkpoint
.messages
.iter()
.flat_map(|m| &m.content)
.all(|block| !matches!(
block,
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
))
);
let route = result.child_route.unwrap();
assert_eq!(route.provider_id, "BackupRoute");
assert_eq!(route.route_source, "role.replacement");
}
+76 -14
View File
@@ -156,28 +156,55 @@ pub(crate) fn agent_display_label(app: &App, agent_id: &str) -> String {
/// running, after the tool-use block itself), so a decision reads in place.
fn cells_for_messages(messages: &[Message], receipts: &[(String, String)]) -> Vec<HistoryCell> {
use codewhale_models::ContentBlock;
let mut resulted: std::collections::HashSet<&str> = std::collections::HashSet::new();
for message in messages {
for block in &message.content {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
resulted.insert(tool_use_id.as_str());
let mut calls = std::collections::HashMap::new();
let mut results = std::collections::HashMap::new();
let mut raw_ids = std::collections::HashMap::<&str, usize>::new();
for block in messages.iter().flat_map(|message| &message.content) {
let Some(key) = block
.tool_call_key()
.filter(|key| !key.as_str().trim().is_empty())
else {
continue;
};
match block {
ContentBlock::ToolUse { id, .. } => {
*raw_ids.entry(key.as_str()).or_default() += 1;
calls
.entry(key)
.and_modify(|entry| *entry = None)
.or_insert(Some(id.as_str()));
}
ContentBlock::ToolResult { tool_use_id, .. } => {
results
.entry(key)
.and_modify(|entry| *entry = None)
.or_insert(Some(tool_use_id.as_str()));
}
_ => {}
}
}
let mut cells = Vec::new();
for message in messages {
cells.extend(history_cells_from_message(message));
for block in &message.content {
let anchor = match block {
ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
ContentBlock::ToolUse { id, .. } if !resulted.contains(id.as_str()) => {
Some(id.as_str())
}
_ => None,
let Some(key) = block.tool_call_key() else {
continue;
};
let Some(anchor) = anchor else { continue };
for (tool_id, text) in receipts {
if tool_id == anchor {
let Some(Some(provider)) = calls.get(&key) else {
continue;
};
if raw_ids.get(key.as_str()) != Some(&1) {
continue;
}
let anchor = match block {
ContentBlock::ToolResult { tool_use_id, .. } => {
results.get(&key) == Some(&Some(*provider)) && tool_use_id.as_str() == *provider
}
ContentBlock::ToolUse { .. } => !results.contains_key(&key),
_ => false,
};
if anchor {
for (_, text) in receipts.iter().filter(|(id, _)| id == key.as_str()) {
cells.push(HistoryCell::System {
content: text.clone(),
});
@@ -760,6 +787,41 @@ mod tests {
)
}
#[test]
fn child_receipts_anchor_only_to_unique_execution_pairs() {
let messages: Vec<Message> = serde_json::from_value(json!([
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"first","name":"read_file","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"one"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"second","name":"read_file","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"second","content":"two"}]}
])).unwrap();
let receipts = vec![
("first".into(), "receipt one".into()),
("second".into(), "receipt two".into()),
];
let rendered = cells_for_messages(&messages, &receipts);
let anchored: Vec<_> = rendered
.iter()
.filter_map(|cell| match cell {
HistoryCell::System { content } if content.starts_with("receipt ") => {
Some(content.as_str())
}
_ => None,
})
.collect();
assert_eq!(anchored, ["receipt one", "receipt two"]);
for duplicate in [messages[0].clone(), serde_json::from_value(json!({
"role":"assistant","content":[{"type":"tool_use","id":"first","name":"read_file","input":{}}]
})).unwrap()] {
let mut ambiguous = messages.clone();
ambiguous.push(duplicate);
let rendered = cells_for_messages(&ambiguous, &receipts);
assert!(!rendered.iter().any(|cell| matches!(cell, HistoryCell::System { content } if content == "receipt one")));
assert_eq!(rendered.iter().filter(|cell| matches!(cell, HistoryCell::System { content } if content == "receipt two")).count(), 1);
}
}
#[test]
fn agent_shell_shortcuts_only_claim_an_unowned_empty_composer() {
let tmp = tempdir().expect("tempdir");
+2 -2
View File
@@ -49,7 +49,7 @@ use crate::tui::streaming::StreamingState;
use crate::tui::transcript::TranscriptViewCache;
use crate::tui::views::ViewStack;
use codewhale_localization::{Locale, MessageId, resolve_locale, tr};
use codewhale_models::{Message, SystemPrompt, Tool, Usage};
use codewhale_models::{ContentBlock, Message, SystemPrompt, Tool, Usage};
use codewhale_palette::{self as palette, UiTheme};
mod composer;
@@ -2316,7 +2316,7 @@ pub struct App {
/// Last completed reasoning block
pub last_reasoning: Option<String>,
/// Tool calls captured for the pending assistant message
pub pending_tool_uses: Vec<(String, String, Value)>,
pub pending_tool_uses: Vec<ContentBlock>,
/// One-line permission receipts (`tool_id`, text) for decisions nobody
/// was prompted for, held until that tool's card completes so the note
/// lands directly under the card instead of splitting a running tool run.
+1
View File
@@ -2111,6 +2111,7 @@ fn replay_routes_repair_receipts_and_plan_calls_to_typed_cells() {
let plan = Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "plan-1".to_string(),
name: "update_plan".to_string(),
input: serde_json::json!({
+1
View File
@@ -999,6 +999,7 @@ mod tests {
fn foreground_projection_forwards_only_typed_owner_metadata() {
let call = metadata(
&Event::ToolCallStarted {
model_call: None,
id: "call-a".into(),
name: "exec_command".into(),
input: json!({"command":"PRIVATE TOOL INPUT"}),
+2 -6
View File
@@ -62,7 +62,7 @@ use crate::config::{
};
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::Event as EngineEvent;
use crate::core::ops::{Op, ProviderRuntimeStatus, USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance};
use crate::core::ops::{Op, ProviderRuntimeStatus, UserInputProvenance};
use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals};
use crate::llm_client::LlmClient;
use crate::prompts;
@@ -262,7 +262,7 @@ const REQUIRED_RELEASE_ASSETS: &[&str] = &[
type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
type PendingToolUses = Vec<(String, String, serde_json::Value)>;
type PendingToolUses = Vec<ContentBlock>;
#[derive(Debug)]
enum TranslationEvent {
@@ -769,10 +769,6 @@ pub(crate) struct UserDispatchOutcome {
auto_selection: Option<crate::model_routing::AutoRouteSelection>,
}
fn is_model_visible_tool_call(id: &str) -> bool {
!id.starts_with(USER_SHELL_TOOL_ID_PREFIX)
}
/// Tell the operator that an explicit "make this my default" request did not
/// take effect, instead of leaving a normal apply summary that reads like
/// success. Silence here is what made the sticky-default bug so confusing.
+1 -9
View File
@@ -132,15 +132,7 @@ pub(crate) fn push_assistant_message(
cache_control: None,
});
}
for (id, name, input) in tool_uses {
blocks.push(ContentBlock::ToolUse {
id,
name,
input,
caller: None,
thought_signature: None,
});
}
blocks.extend(tool_uses);
let has_sendable_content = blocks.iter().any(|block| {
matches!(
+29 -8
View File
@@ -2374,10 +2374,23 @@ pub(crate) async fn run_event_loop(
streaming_thinking::stash_reasoning_buffer_into_last_reasoning(app);
stream_display_clock.reset();
}
EngineEvent::ToolCallStarted { id, name, input } => {
EngineEvent::ToolCallStarted {
id,
name,
input,
model_call,
} => {
app.session_metrics.record_tool_started(&id);
app.pending_tool_uses
.push((id.clone(), name.clone(), input.clone()));
if let Some(model_call) = model_call {
app.pending_tool_uses.push(ContentBlock::ToolUse {
id: model_call.provider_id,
execution_id: Some(id.clone()),
name: name.clone(),
input: input.clone(),
caller: model_call.caller,
thought_signature: model_call.thought_signature,
});
}
// Note this dispatch so the next sub-agent `Started`
// mailbox envelope routes into the right card kind
// (delegate vs fanout).
@@ -2403,7 +2416,12 @@ pub(crate) async fn run_event_loop(
// transcript renders from the ToolCall* events.
EngineEvent::OperationActivityStarted { .. }
| EngineEvent::OperationActivityCompleted { .. } => {}
EngineEvent::ToolCallComplete { id, name, result } => {
EngineEvent::ToolCallComplete {
id,
name,
result,
model_call,
} => {
if crate::tui::tool_routing::evidence_completion_should_be_ignored(
app, &id, &result,
) {
@@ -2411,7 +2429,7 @@ pub(crate) async fn run_event_loop(
continue;
}
app.session_metrics.record_tool_completed(&id);
if is_model_visible_tool_call(&id) {
if let Some(model_call) = model_call {
let tool_content = match &result {
Ok(output) => sanitize_stream_chunk(
&tool_result_content_for_api_message(app, &name, output),
@@ -2421,15 +2439,18 @@ pub(crate) async fn run_event_loop(
app.push_api_message(Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: id.clone(),
execution_id: Some(id.clone()),
tool_use_id: model_call.provider_id,
content: tool_content,
is_error: None,
content_blocks: None,
}],
});
} else {
app.pending_tool_uses
.retain(|(tool_id, _, _)| tool_id != &id);
app.pending_tool_uses.retain(|block| {
block.tool_call_key()
!= Some(codewhale_models::ToolCallKey::Execution(&id))
});
}
handle_tool_call_complete(app, &id, &name, &result);
if name
+30 -11
View File
@@ -9097,6 +9097,7 @@ fn apply_loaded_session_never_restores_background_shell_event_as_composer_draft(
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "plan-complete".to_string(),
name: "update_plan".to_string(),
input: serde_json::json!({"plan": [{"step": "Check the output", "status": "completed"}]}),
@@ -9107,6 +9108,7 @@ fn apply_loaded_session_never_restores_background_shell_event_as_composer_draft(
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "plan-complete".to_string(),
content: "Plan updated".to_string(),
is_error: None,
@@ -16963,12 +16965,6 @@ async fn empty_bang_shell_input_is_consumed_with_usage_error() {
);
}
#[test]
fn local_bang_shell_tool_ids_are_not_model_visible() {
assert!(!is_model_visible_tool_call("user_shell_1"));
assert!(is_model_visible_tool_call("toolu_01abc"));
}
fn complete_release_json(tag: &str) -> serde_json::Value {
let assets = REQUIRED_RELEASE_ASSETS
.iter()
@@ -25326,11 +25322,14 @@ fn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {
#[test]
fn approval_prompt_uses_event_input_after_message_complete_drain() {
let mut app = create_test_app();
app.pending_tool_uses.push((
"tool-1".to_string(),
"exec_shell".to_string(),
serde_json::json!({"command": "stale value from drained list"}),
));
app.pending_tool_uses.push(ContentBlock::ToolUse {
execution_id: Some("tool-1".to_string()),
id: "provider-tool-1".to_string(),
name: "exec_shell".to_string(),
input: serde_json::json!({"command": "stale value from drained list"}),
caller: None,
thought_signature: None,
});
// Mirror the old race: MessageComplete drains pending tool uses before
// ApprovalRequired is handled. The approval modal must still show the
@@ -29795,6 +29794,7 @@ fn backtrack_cut_index_skips_tool_result_user_messages() {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "t1".into(),
name: "read_file".into(),
input: serde_json::json!({"path":"x"}),
@@ -29805,6 +29805,7 @@ fn backtrack_cut_index_skips_tool_result_user_messages() {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "t1".into(),
content: "data".into(),
is_error: None,
@@ -32222,3 +32223,21 @@ async fn provider_switch_back_lands_on_root_default_owned_by_that_provider() {
assert_eq!(app.api_provider, ApiProvider::Openai);
assert_eq!(app.model, "gpui-fixture");
}
#[test]
fn transient_assistant_history_preserves_provider_and_local_tool_identity() {
let mut app = create_test_app();
let block = ContentBlock::ToolUse {
id: "wire-reused".to_string(),
execution_id: Some("local-fresh".to_string()),
name: "read".to_string(),
input: serde_json::json!({"path":"README.md"}),
caller: Some(codewhale_models::ToolCaller {
caller_type: "code_execution".to_string(),
tool_id: Some("provider-parent".to_string()),
}),
thought_signature: Some("provider-signature".to_string()),
};
push_assistant_message(&mut app, String::new(), None, vec![block.clone()]);
assert_eq!(app.api_messages.last().unwrap().content, vec![block]);
}
+1
View File
@@ -1910,6 +1910,7 @@ mod tests {
let msg = Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: "tool_1".to_string(),
content: "Changed src/compaction.rs".to_string(),
is_error: None,
@@ -41,6 +41,12 @@ async fn headless_bash_success_and_failure_are_distinct_bounded_exact_evidence()
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let events: Vec<Value> = std::str::from_utf8(&output.stdout)
.expect("UTF-8 stream events")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("valid stream event"))
.collect();
let requests = server.received_requests().await.expect("recorded requests");
let success_receipt =
@@ -113,8 +119,27 @@ async fn headless_bash_success_and_failure_are_distinct_bounded_exact_evidence()
.count();
assert_eq!(payloads, 2, "exactly one evidence payload per result");
let success = assert_exact_artifact(&artifact_dir, SUCCESS_CALL_ID, SUCCESS_SENTINEL, "Bash");
let failure = assert_exact_artifact(&artifact_dir, FAILURE_CALL_ID, FAILURE_SENTINEL, "Bash");
assert_ne!(
quoted_after(&success_receipt, "ref=\""),
quoted_after(&failure_receipt, "ref=\""),
"separate executions retain separate evidence handles"
);
let success = assert_exact_artifact(
&artifact_dir,
SUCCESS_CALL_ID,
&success_receipt,
&events,
SUCCESS_SENTINEL,
"success",
);
let failure = assert_exact_artifact(
&artifact_dir,
FAILURE_CALL_ID,
&failure_receipt,
&events,
FAILURE_SENTINEL,
"error",
);
assert_ne!(
success, failure,
"success and failure bytes must stay distinct"
@@ -123,11 +148,40 @@ async fn headless_bash_success_and_failure_are_distinct_bounded_exact_evidence()
fn assert_exact_artifact(
artifact_dir: &Path,
call_id: &str,
provider_id: &str,
receipt: &str,
events: &[Value],
sentinel: &str,
tool_name: &str,
status: &str,
) -> Vec<u8> {
let handle = format!("art_{call_id}");
let handle = quoted_after(receipt, "ref=\"").expect("model-visible artifact handle");
let call_id = handle.strip_prefix("art_").expect("tool artifact handle");
uuid::Uuid::parse_str(call_id).expect("artifact belongs to a host execution");
assert_ne!(call_id, provider_id);
for kind in ["tool_use", "tool_result"] {
let matching = events
.iter()
.filter(|event| event["type"] == kind && event["id"] == call_id)
.collect::<Vec<_>>();
assert_eq!(matching.len(), 1, "one {kind} for this exact execution");
let event = matching[0];
assert_eq!(event["name"], "Bash");
if kind == "tool_use" {
assert!(
event["input"]["command"]
.as_str()
.unwrap()
.contains(sentinel)
);
} else {
// Model history trims the preview; stream events preserve it verbatim.
assert_eq!(
event["output"].as_str().expect("stream output").trim(),
receipt
);
assert_eq!(event["status"], status);
}
}
let exact =
std::fs::read(artifact_dir.join(format!("{handle}.txt"))).expect("exact evidence bytes");
assert!(
@@ -145,7 +199,7 @@ fn assert_exact_artifact(
.collect::<String>();
assert_eq!(metadata["handle"], handle);
assert_eq!(metadata["call_id"], call_id);
assert_eq!(metadata["tool_name"], tool_name);
assert_eq!(metadata["tool_name"], "Bash");
assert_eq!(metadata["digest"], digest);
assert_eq!(metadata["size_bytes"], exact.len() as u64);
assert_eq!(metadata["generation"], 1);
@@ -216,7 +270,7 @@ impl Respond for EvidenceScenario {
}
/// Read the recovery ref the truncation footer hands the model, e.g. the
/// `art_call_bash_failure` inside `… call retrieve_tool_result with ref="…"`.
/// `art_<execution-id>` inside `… call retrieve_tool_result with ref="…"`.
fn quoted_after<'a>(receipt: &'a str, marker: &str) -> Option<&'a str> {
let rest = &receipt[receipt.find(marker)? + marker.len()..];
rest.get(..rest.find('"')?)
@@ -90,6 +90,7 @@ fn assistant_tool_call(id: &str, name: &str, input: serde_json::Value) -> Messag
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: name.to_string(),
input,
@@ -103,6 +104,7 @@ fn tool_result_message(tool_use_id: &str, content: &str) -> Message {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: tool_use_id.to_string(),
content: content.to_string(),
is_error: None,
@@ -30,6 +30,7 @@ fn assistant_thinking_tool_call(
state: None,
},
ContentBlock::ToolUse {
execution_id: None,
id: id.to_string(),
name: name.to_string(),
input,
@@ -44,6 +45,7 @@ fn tool_result_message(tool_use_id: &str, content: &str) -> Message {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
execution_id: None,
tool_use_id: tool_use_id.to_string(),
content: content.to_string(),
is_error: None,