fix(ai): sanitize empty Read pages for Claude tools

This commit is contained in:
hemo94931
2026-05-25 21:01:10 +08:00
parent 686311eabf
commit 495795327c
6 changed files with 463 additions and 148 deletions
@@ -410,6 +410,7 @@ enum ClaudeOpenBlock {
struct ClaudeClientToolState {
call_id: String,
name: String,
buffered_arguments: String,
}
#[derive(Default)]
@@ -460,18 +461,47 @@ impl ClaudeClientEmitter {
let Some(open_block) = self.open_block.take() else {
return Ok(Vec::new());
};
let mut out = Vec::new();
let block_index = match open_block {
ClaudeOpenBlock::Text { block_index } => block_index,
ClaudeOpenBlock::Thinking { block_index } => block_index,
ClaudeOpenBlock::Tool { block_index, .. } => block_index,
ClaudeOpenBlock::Tool {
tool_index,
block_index,
} => {
if let Some(state) = self.tool_states.get_mut(&tool_index) {
if state.name == "Read" && !state.buffered_arguments.is_empty() {
let arguments = remove_empty_pages_from_tool_arguments(
&state.name,
&state.buffered_arguments,
);
state.buffered_arguments.clear();
if !arguments.is_empty() {
out.extend(encode_json_sse(
Some("content_block_delta"),
&json!({
"type": "content_block_delta",
"index": block_index,
"delta": {
"type": "input_json_delta",
"partial_json": arguments,
}
}),
)?);
}
}
}
block_index
}
};
encode_json_sse(
out.extend(encode_json_sse(
Some("content_block_stop"),
&json!({
"type": "content_block_stop",
"index": block_index,
}),
)
)?);
Ok(out)
}
fn ensure_text_block(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
@@ -688,23 +718,33 @@ impl ClaudeClientEmitter {
Ok(out)
}
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let arguments = remove_empty_pages_from_tool_arguments(&arguments);
let (call_id, name) = {
let state = self.tool_states.entry(index).or_default();
let call_id = if state.call_id.is_empty() {
format!("tool_{index}")
} else {
state.call_id.clone()
};
let name = if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
};
(call_id, name)
};
if arguments.is_empty() {
return Ok(Vec::new());
}
let mut out = self.ensure_started()?;
let state = self.tool_states.entry(index).or_default();
let call_id = if state.call_id.is_empty() {
format!("tool_{index}")
} else {
state.call_id.clone()
};
let name = if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
};
out.extend(self.ensure_tool_block(index, &call_id, &name)?);
if name == "Read" {
self.tool_states
.entry(index)
.or_default()
.buffered_arguments
.push_str(&arguments);
return Ok(out);
}
let block_index = match self.open_block {
Some(ClaudeOpenBlock::Tool { block_index, .. }) => block_index,
_ => return Ok(out),
@@ -1230,7 +1270,7 @@ mod tests {
}
#[test]
fn claude_client_emitter_removes_empty_pages_from_tool_arguments() {
fn claude_client_emitter_removes_empty_pages_from_read_tool_arguments() {
let mut emitter = ClaudeClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
@@ -1257,11 +1297,60 @@ mod tests {
.expect("tool delta should encode"),
);
let pending_sse = String::from_utf8(bytes.clone()).expect("sse should be utf8");
assert!(!pending_sse.contains("\\\"pages\\\":\\\"\\\""));
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("tool_calls".to_string()),
usage: None,
},
})
.expect("finish should close read tool block"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/a.txt\\\",\\\"offset\\\":1,\\\"limit\\\":20}\""));
assert!(!sse.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
fn claude_client_emitter_preserves_empty_pages_for_other_tool_arguments() {
let mut emitter = ClaudeClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::ToolCallStart {
index: 0,
call_id: "toolu_search".to_string(),
name: "Search".to_string(),
},
})
.expect("tool start should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments: r#"{"query":"","pages":""}"#.to_string(),
},
})
.expect("tool delta should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(
sse.contains("\"partial_json\":\"{\\\"query\\\":\\\"\\\",\\\"pages\\\":\\\"\\\"}\"")
);
}
#[test]
fn claude_client_emitter_injects_default_usage_into_finish_events() {
let mut emitter = ClaudeClientEmitter::default();