This commit is contained in:
zhefox
2026-05-26 12:31:03 +08:00
25 changed files with 2276 additions and 294 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();
@@ -29,6 +29,7 @@ struct OpenAIResponsesProviderToolState {
call_id: String,
name: String,
arguments: String,
emitted_arguments_len: usize,
started_emitted: bool,
}
@@ -510,6 +511,74 @@ impl OpenAIResponsesProviderState {
});
}
fn emit_ready_tool_call(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
index: usize,
) {
let (id, model) = self.identity(report_context);
let Some(state) = self.tool_calls.get_mut(&index) else {
return;
};
if state.name.is_empty() {
return;
}
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
},
name: state.name.clone(),
},
});
state.started_emitted = true;
}
if state.emitted_arguments_len > state.arguments.len() {
state.emitted_arguments_len = 0;
}
let pending = state
.arguments
.get(state.emitted_arguments_len..)
.unwrap_or_default()
.to_string();
if pending.is_empty() {
return;
}
state.emitted_arguments_len = state.arguments.len();
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: pending,
},
});
}
fn merge_tool_call_arguments(state: &mut OpenAIResponsesProviderToolState, arguments: &str) {
if arguments.is_empty() {
return;
}
if arguments.starts_with(&state.arguments) {
state
.arguments
.push_str(&arguments[state.arguments.len()..]);
} else if state.arguments != arguments {
if state.emitted_arguments_len == 0 {
state.arguments = arguments.to_string();
} else {
state.arguments.push_str(arguments);
}
}
}
fn emit_tool_call_item(
&mut self,
report_context: &Value,
@@ -527,7 +596,6 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let index = self.tool_index_for_key(key, output_index);
let (id, model) = self.identity(report_context);
let state = self.tool_calls.entry(index).or_default();
state.call_id = item
.get("call_id")
@@ -540,50 +608,13 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_str)
.unwrap_or(state.name.as_str())
.to_string();
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
},
name: if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
},
},
});
state.started_emitted = true;
}
let completed_arguments = item
.get("arguments")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let missing = if completed_arguments.starts_with(&state.arguments) {
completed_arguments[state.arguments.len()..].to_string()
} else if state.arguments == completed_arguments {
String::new()
} else {
completed_arguments.clone()
};
if missing.is_empty() {
return;
}
state.arguments.push_str(&missing);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: missing,
},
});
Self::merge_tool_call_arguments(state, &completed_arguments);
self.emit_ready_tool_call(report_context, out, index);
}
fn emit_missing_tool_result(
@@ -978,44 +1009,22 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let (id, model) = self.identity(report_context);
let state = self.tool_calls.entry(index).or_default();
state.call_id = value
.get("item_id")
.or_else(|| value.get("call_id"))
if let Some(call_id) = value
.get("call_id")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.unwrap_or(state.call_id.as_str())
.to_string();
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
},
name: if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
},
},
});
state.started_emitted = true;
{
state.call_id = call_id.to_string();
} else if state.call_id.is_empty() {
state.call_id = value
.get("item_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
}
state.arguments.push_str(delta);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: delta.to_string(),
},
});
self.emit_ready_tool_call(report_context, &mut out, index);
}
"response.function_call_arguments.done" => {
let arguments = value
@@ -1029,9 +1038,6 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_str)
})
.unwrap_or_default();
if arguments.is_empty() {
return Ok(out);
}
self.ensure_started(report_context, &mut out);
let key = value
.get("item_id")
@@ -1052,11 +1058,9 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let (id, model) = self.identity(report_context);
let state = self.tool_calls.entry(index).or_default();
state.call_id = value
.get("item_id")
.or_else(|| value.get("call_id"))
.get("call_id")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.or_else(|| {
@@ -1066,46 +1070,23 @@ impl OpenAIResponsesProviderState {
.and_then(|item| item.get("call_id").or_else(|| item.get("id")))
.and_then(Value::as_str)
})
.or_else(|| value.get("item_id").and_then(Value::as_str))
.unwrap_or(state.call_id.as_str())
.to_string();
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
},
name: if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
},
},
});
state.started_emitted = true;
}
let missing = if arguments.starts_with(&state.arguments) {
arguments[state.arguments.len()..].to_string()
} else if state.arguments == arguments {
String::new()
} else {
arguments.to_string()
};
if !missing.is_empty() {
state.arguments.push_str(&missing);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: missing,
},
});
}
state.name = value
.get("name")
.and_then(Value::as_str)
.or_else(|| {
value
.get("item")
.and_then(Value::as_object)
.and_then(|item| item.get("name"))
.and_then(Value::as_str)
})
.unwrap_or(state.name.as_str())
.to_string();
Self::merge_tool_call_arguments(state, arguments);
self.emit_ready_tool_call(report_context, &mut out, index);
}
"response.function_call_output.delta" | "response.function_call_output.done" => {
let tool_use_id = value
@@ -2661,6 +2642,7 @@ fn openai_tool_result_content_from_value(value: Option<&Value>) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::formats::claude::messages::stream::ClaudeClientEmitter;
fn data_line(value: Value) -> Vec<u8> {
format!("data: {}\n", value).into_bytes()
@@ -3176,6 +3158,91 @@ mod tests {
)));
}
#[test]
fn openai_responses_provider_state_delays_arguments_until_tool_name_is_known() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let arguments = r#"{"file_path":"D:/projects/UIAutoTest/docs/prd/msr.md","offset":0,"limit":2000,"pages":""}"#;
let mut frames = Vec::new();
let delta_frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_arguments.delta",
"response_id": "resp_read_123",
"output_index": 0,
"item_id": "fc_read_123",
"delta": arguments,
})),
)
.expect("arguments delta should parse");
assert!(matches!(
delta_frames.first().map(|frame| &frame.event),
Some(CanonicalStreamEvent::Start)
));
assert!(!delta_frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { .. }
| CanonicalStreamEvent::ToolCallArgumentsDelta { .. }
)));
frames.extend(delta_frames);
frames.extend(
state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_arguments.done",
"response_id": "resp_read_123",
"output_index": 0,
"item_id": "fc_read_123",
"item": {
"type": "function_call",
"id": "fc_read_123",
"call_id": "call_read_123",
"name": "Read",
"arguments": arguments,
}
})),
)
.expect("arguments done should parse"),
);
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart {
ref call_id,
ref name,
..
} if call_id == "call_read_123" && name == "Read"
)));
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallArgumentsDelta {
ref arguments,
..
} if arguments.contains(r#""pages":"""#)
)));
let mut emitter = ClaudeClientEmitter::default();
let mut bytes = Vec::new();
for frame in frames {
bytes.extend(emitter.emit(frame).expect("claude frame should encode"));
}
bytes.extend(
emitter
.finish()
.expect("claude stream finish should encode"),
);
let sse = String::from_utf8(bytes).expect("claude sse should be utf8");
assert!(sse.contains("\"name\":\"Read\""));
assert!(sse.contains("\\\"limit\\\":2000"));
assert!(!sse.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
fn openai_responses_provider_state_parses_function_call_output_as_tool_result() {
let mut state = OpenAIResponsesProviderState::default();
@@ -29,7 +29,10 @@ pub fn canonicalize_tool_arguments(value: Option<Value>) -> String {
}
}
pub fn remove_empty_pages_from_tool_arguments(arguments: &str) -> String {
pub fn remove_empty_pages_from_tool_arguments(tool_name: &str, arguments: &str) -> String {
if tool_name != "Read" {
return arguments.to_string();
}
let Ok(mut value) = serde_json::from_str::<Value>(arguments) else {
return arguments.to_string();
};
@@ -43,6 +46,44 @@ pub fn remove_empty_pages_from_tool_arguments(arguments: &str) -> String {
serde_json::to_string(&value).unwrap_or_else(|_| arguments.to_string())
}
pub fn remove_empty_pages_from_tool_input_value(tool_name: &str, input: &Value) -> Value {
if tool_name != "Read" || input.get("pages").and_then(Value::as_str) != Some("") {
return input.clone();
}
let Some(object) = input.as_object() else {
return input.clone();
};
let mut object = object.clone();
object.remove("pages");
Value::Object(object)
}
pub fn sanitize_claude_read_tool_inputs(value: &mut Value) -> bool {
let Some(content) = value.get_mut("content").and_then(Value::as_array_mut) else {
return false;
};
let mut changed = false;
for block in content {
let Some(block_object) = block.as_object_mut() else {
continue;
};
if block_object.get("type").and_then(Value::as_str) != Some("tool_use")
|| block_object.get("name").and_then(Value::as_str) != Some("Read")
{
continue;
}
let Some(input) = block_object.get("input") else {
continue;
};
let sanitized = remove_empty_pages_from_tool_input_value("Read", input);
if sanitized != *input {
block_object.insert("input".to_string(), sanitized);
changed = true;
}
}
changed
}
pub fn prepare_local_success_response_parts(
headers: &BTreeMap<String, String>,
body_json: &Value,
@@ -131,7 +172,8 @@ mod tests {
build_generated_tool_call_id, build_local_success_background_report,
build_local_success_conversion_background_report, canonicalize_tool_arguments,
prepare_local_success_response_parts, prepare_local_success_response_parts_owned,
remove_empty_pages_from_tool_arguments, LocalSyncReportParts,
remove_empty_pages_from_tool_arguments, sanitize_claude_read_tool_inputs,
LocalSyncReportParts,
};
use std::collections::BTreeMap;
@@ -153,20 +195,77 @@ mod tests {
fn removes_empty_pages_from_tool_arguments() {
assert_eq!(
remove_empty_pages_from_tool_arguments(
"Read",
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20,"pages":""}"#
),
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20}"#
);
assert_eq!(
remove_empty_pages_from_tool_arguments(r#"{"pages":"1-2"}"#),
remove_empty_pages_from_tool_arguments("Search", r#"{"query":"","pages":""}"#),
r#"{"query":"","pages":""}"#
);
assert_eq!(
remove_empty_pages_from_tool_arguments("Read", r#"{"pages":"1-2"}"#),
r#"{"pages":"1-2"}"#
);
assert_eq!(
remove_empty_pages_from_tool_arguments(r#"{"pages":"#),
remove_empty_pages_from_tool_arguments("Read", r#"{"pages":"#),
r#"{"pages":"#
);
}
#[test]
fn sanitizes_claude_read_tool_inputs_only() {
let mut value = serde_json::json!({
"content": [
{
"type": "tool_use",
"name": "Read",
"input": {
"file_path": "/tmp/a.txt",
"limit": 20,
"pages": ""
}
},
{
"type": "tool_use",
"name": "Search",
"input": {
"query": "",
"pages": ""
}
},
{
"type": "tool_use",
"name": "Read",
"input": {
"pages": "1-2"
}
}
]
});
assert!(sanitize_claude_read_tool_inputs(&mut value));
assert_eq!(
value["content"][0]["input"],
serde_json::json!({
"file_path": "/tmp/a.txt",
"limit": 20,
})
);
assert_eq!(
value["content"][1]["input"],
serde_json::json!({
"query": "",
"pages": "",
})
);
assert_eq!(
value["content"][2]["input"],
serde_json::json!({"pages": "1-2"})
);
}
#[test]
fn prepare_local_success_response_parts_normalizes_headers() {
let headers = BTreeMap::from([
@@ -1,7 +1,13 @@
use serde_json::Value;
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::formats::openai::image::stream::{OpenAiImageChatStreamState, OpenAiImageStreamState};
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::response::{
remove_empty_pages_from_tool_arguments, remove_empty_pages_from_tool_input_value,
};
use crate::formats::shared::sse::encode_json_sse;
use crate::formats::shared::stream_core::StreamingStandardFormatMatrix;
use crate::formats::shared::AiSurfaceFinalizeError;
use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
@@ -16,6 +22,7 @@ pub enum FinalizeStreamRewriteMode {
ModelDirectiveDisplay,
OpenAiImage,
OpenAiImageToOpenAiChat,
ClaudeReadToolSanitize,
Standard,
KiroToClaudeCli,
KiroToClaudeCliThenStandard,
@@ -84,6 +91,9 @@ pub fn resolve_finalize_stream_rewrite_mode(
// Parsing→rebuilding only adds overhead and may lose information
// (encrypted_content, original item IDs, etc.).
if is_same_format_family(provider_api_format.as_str(), client_api_format.as_str()) {
if provider_api_format == "claude:messages" && client_api_format == "claude:messages" {
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
return model_directive_display_model_from_report_context(report_context)
.map(|_| FinalizeStreamRewriteMode::ModelDirectiveDisplay);
}
@@ -108,9 +118,16 @@ pub fn resolve_finalize_stream_rewrite_mode(
provider_api_format.as_str(),
)
{
if provider_api_format == "claude:messages" {
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
return Some(FinalizeStreamRewriteMode::ModelDirectiveDisplay);
}
if provider_api_format == "claude:messages" && client_api_format == "claude:messages" {
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
(provider_api_format == client_api_format
&& provider_adaptation_should_unwrap_stream_envelope(
envelope_name.as_str(),
@@ -144,6 +161,7 @@ enum AiSurfaceStreamRewriteState {
ModelDirectiveDisplay,
OpenAiImage(Box<OpenAiImageStreamState>),
OpenAiImageToOpenAiChat(Box<OpenAiImageChatStreamState>),
ClaudeReadToolSanitize(Box<ClaudeReadToolStreamSanitizer>),
Standard(Box<StreamingStandardFormatMatrix>),
KiroToClaudeCli(Box<KiroToClaudeCliStreamState>),
KiroToClaudeCliThenStandard {
@@ -175,6 +193,11 @@ pub fn maybe_build_ai_surface_stream_rewriter<'a>(
Box::<OpenAiImageChatStreamState>::default(),
)
}
FinalizeStreamRewriteMode::ClaudeReadToolSanitize => {
AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(
Box::<ClaudeReadToolStreamSanitizer>::default(),
)
}
FinalizeStreamRewriteMode::Standard => {
AiSurfaceStreamRewriteState::Standard(Box::<StreamingStandardFormatMatrix>::default())
}
@@ -205,6 +228,9 @@ impl AiSurfaceStreamRewriter<'_> {
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(state) => {
state.push_chunk(self.report_context, chunk)
}
AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(state) => {
state.push_chunk(self.report_context, chunk)
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.push_chunk(self.report_context, chunk)
}
@@ -232,6 +258,9 @@ impl AiSurfaceStreamRewriter<'_> {
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(state) => {
state.finish(self.report_context)
}
AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(state) => {
state.finish(self.report_context)
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.finish(self.report_context)
}
@@ -278,12 +307,272 @@ impl AiSurfaceStreamRewriter<'_> {
}
AiSurfaceStreamRewriteState::OpenAiImage(_)
| AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(_)
| AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(_)
| AiSurfaceStreamRewriteState::KiroToClaudeCli(_)
| AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { .. } => Ok(Vec::new()),
}
}
}
#[derive(Default)]
struct ClaudeReadToolBlockState {
name: String,
buffered_input_json: String,
}
#[derive(Default)]
struct ClaudeReadToolStreamSanitizer {
buffered: Vec<u8>,
blocks: BTreeMap<usize, ClaudeReadToolBlockState>,
}
impl ClaudeReadToolStreamSanitizer {
fn push_chunk(
&mut self,
report_context: &Value,
chunk: &[u8],
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.buffered.extend_from_slice(chunk);
let mut output = Vec::new();
while let Some(record) = drain_next_sse_record(&mut self.buffered) {
output.extend(self.transform_record(report_context, record)?);
}
Ok(output)
}
fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.buffered.is_empty() {
return Ok(Vec::new());
}
let record = std::mem::take(&mut self.buffered);
self.transform_record(report_context, record)
}
fn transform_record(
&mut self,
report_context: &Value,
record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some((event, mut payload)) = parse_sse_record_json(&record) else {
return rewrite_model_directive_stream_record(report_context, record);
};
let event_type = payload
.get("type")
.and_then(Value::as_str)
.unwrap_or(event.as_deref().unwrap_or_default())
.to_string();
let mut output = match event_type.as_str() {
"content_block_start" => {
self.transform_content_block_start(event.as_deref(), payload, record)?
}
"content_block_delta" => self.transform_content_block_delta(payload, record)?,
"content_block_stop" => self.transform_content_block_stop(payload, record)?,
_ => {
if !rewrite_stream_payload_model_from_context(report_context, &mut payload) {
return Ok(record);
}
encode_json_sse(event.as_deref(), &payload)?
}
};
if model_directive_display_model_from_report_context(report_context).is_some()
&& !matches!(event_type.as_str(), "message_start" | "message_delta")
{
output = rewrite_model_directive_stream_record(report_context, output)?;
}
Ok(output)
}
fn transform_content_block_start(
&mut self,
event: Option<&str>,
mut payload: Value,
original_record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let index = payload
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let Some(block) = payload
.get_mut("content_block")
.and_then(Value::as_object_mut)
else {
return Ok(original_record);
};
let block_type = block
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if block_type != "tool_use" {
return Ok(original_record);
}
let name = block
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
self.blocks.insert(
index,
ClaudeReadToolBlockState {
name: name.clone(),
buffered_input_json: String::new(),
},
);
if sanitize_claude_tool_input_object(block, &name) {
encode_json_sse(event, &payload)
} else {
Ok(original_record)
}
}
fn transform_content_block_delta(
&mut self,
payload: Value,
original_record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let index = payload
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let delta_type = payload
.get("delta")
.and_then(Value::as_object)
.and_then(|delta| delta.get("type"))
.and_then(Value::as_str)
.unwrap_or_default();
let partial_json = payload
.get("delta")
.and_then(Value::as_object)
.and_then(|delta| delta.get("partial_json"))
.and_then(Value::as_str);
if delta_type != "input_json_delta" {
return Ok(original_record);
}
let Some(state) = self.blocks.get_mut(&index) else {
return Ok(original_record);
};
if state.name != "Read" {
return Ok(original_record);
}
if let Some(partial_json) = partial_json {
state.buffered_input_json.push_str(partial_json);
}
Ok(Vec::new())
}
fn transform_content_block_stop(
&mut self,
payload: Value,
original_record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let index = payload
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let Some(state) = self.blocks.remove(&index) else {
return Ok(original_record);
};
let mut output = Vec::new();
if state.name == "Read" && !state.buffered_input_json.is_empty() {
let partial_json =
remove_empty_pages_from_tool_arguments("Read", &state.buffered_input_json);
if !partial_json.is_empty() {
output.extend(encode_json_sse(
Some("content_block_delta"),
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "input_json_delta",
"partial_json": partial_json,
}
}),
)?);
}
}
if output.is_empty() {
output = original_record;
} else {
output.extend(original_record);
}
Ok(output)
}
}
fn sanitize_claude_tool_input_object(block: &mut Map<String, Value>, name: &str) -> bool {
let Some(input) = block.get("input") else {
return false;
};
let sanitized = remove_empty_pages_from_tool_input_value(name, input);
if sanitized == *input {
return false;
}
block.insert("input".to_string(), sanitized);
true
}
fn drain_next_sse_record(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
let mut line_start = 0usize;
let mut index = 0usize;
while index < buffer.len() {
if buffer[index] != b'\n' {
index += 1;
continue;
}
let line_end = index + 1;
let line = &buffer[line_start..line_end];
let line_without_newline = line
.strip_suffix(b"\n")
.unwrap_or(line)
.strip_suffix(b"\r")
.unwrap_or_else(|| line.strip_suffix(b"\n").unwrap_or(line));
if line_without_newline.is_empty() {
return Some(buffer.drain(..line_end).collect());
}
line_start = line_end;
index = line_end;
}
None
}
fn parse_sse_record_json(record: &[u8]) -> Option<(Option<String>, Value)> {
let text = std::str::from_utf8(record).ok()?;
let mut event = None;
let mut data = String::new();
for line in text.lines() {
let line = line.strip_suffix('\r').unwrap_or(line);
if let Some(value) = line.strip_prefix("event:") {
event = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("data:") {
if !data.is_empty() {
data.push('\n');
}
data.push_str(value.trim_start());
}
}
if data.trim().is_empty() || data.trim() == "[DONE]" {
return None;
}
let value = serde_json::from_str::<Value>(data.trim()).ok()?;
Some((event, value))
}
fn rewrite_model_directive_stream_record(
report_context: &Value,
record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut output = Vec::new();
for line in record.split_inclusive(|byte| *byte == b'\n') {
output.extend(rewrite_model_directive_stream_line(
report_context,
line.to_vec(),
)?);
}
Ok(output)
}
fn rewrite_model_directive_stream_line(
report_context: &Value,
line: Vec<u8>,
@@ -341,6 +630,14 @@ fn rewrite_stream_payload_model(value: &mut Value, display_model: &str) -> bool
changed
}
fn rewrite_stream_payload_model_from_context(report_context: &Value, value: &mut Value) -> bool {
let Some(display_model) = model_directive_display_model_from_report_context(report_context)
else {
return false;
};
rewrite_stream_payload_model(value, &display_model)
}
fn transform_standard_bytes(
standard: &mut StreamingStandardFormatMatrix,
report_context: &Value,
@@ -718,14 +1015,106 @@ data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"thinki
}
#[test]
fn same_format_claude_without_display_model_passes_through_verbatim() {
// Claude→Claude without display model: no rewriter needed at all.
fn same_format_claude_uses_read_tool_sanitizer_without_display_model() {
// Claude→Claude needs a narrow sanitizer for Claude Code Read input.
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": true,
});
assert!(maybe_build_ai_surface_stream_rewriter(Some(&report_context)).is_none());
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize)
);
}
#[test]
fn same_format_claude_stream_sanitizes_read_start_input() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": false,
});
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("same-format claude sanitizer should exist");
let output = rewriter
.push_chunk(
b"event: content_block_start\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_read_1\",\"name\":\"Read\",\"input\":{\"file_path\":\"/tmp/a.txt\",\"limit\":20,\"pages\":\"\"}}}\n\n",
)
.expect("rewrite should succeed");
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("\"name\":\"Read\""));
assert!(output.contains("\"file_path\":\"/tmp/a.txt\""));
assert!(!output.contains("\"pages\":\"\""));
}
#[test]
fn same_format_claude_stream_sanitizes_read_input_json_delta() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": false,
});
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("same-format claude sanitizer should exist");
let mut output = rewriter
.push_chunk(
b"event: content_block_start\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_read_1\",\"name\":\"Read\",\"input\":{}}}\n\n",
)
.expect("start should rewrite");
output.extend(
rewriter
.push_chunk(
b"event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/a.txt\\\",\"}}\n\n\
event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"limit\\\":20,\\\"pages\\\":\\\"\\\"}\"}}\n\n",
)
.expect("deltas should buffer"),
);
let buffered_output = String::from_utf8(output.clone()).expect("output should be utf8");
assert!(!buffered_output.contains("input_json_delta"));
output.extend(
rewriter
.push_chunk(
b"event: content_block_stop\n\
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
)
.expect("stop should flush sanitized delta"),
);
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("event: content_block_delta"));
assert!(output.contains("\\\"limit\\\":20"));
assert!(!output.contains("\\\"pages\\\":\\\"\\\""));
assert!(output.contains("event: content_block_stop"));
}
#[test]
fn same_format_claude_stream_preserves_other_tool_empty_pages() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": false,
});
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("same-format claude sanitizer should exist");
let output = rewriter
.push_chunk(
b"event: content_block_start\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_search_1\",\"name\":\"Search\",\"input\":{}}}\n\n\
event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"query\\\":\\\"\\\",\\\"pages\\\":\\\"\\\"}\"}}\n\n",
)
.expect("rewrite should succeed");
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("\"name\":\"Search\""));
assert!(output.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
@@ -21,7 +21,10 @@ use serde_json::{json, Map, Value};
use super::AiSurfaceFinalizeError;
use crate::formats::gemini::generate_content::stream::GeminiProviderState;
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::response::remove_empty_pages_from_tool_arguments;
use crate::formats::shared::response::{
remove_empty_pages_from_tool_arguments, remove_empty_pages_from_tool_input_value,
sanitize_claude_read_tool_inputs,
};
use crate::formats::shared::stream_core::common::{
content_part_from_openai_image_generation_item, map_openai_finish_reason_to_gemini,
parse_json_arguments_value, CanonicalContentPart, CanonicalStreamEvent, CanonicalUsage,
@@ -480,8 +483,13 @@ fn maybe_build_standard_same_format_sync_body(
return None;
}
let mut body_json = body_json.clone();
if expected_api_format == "claude:messages" {
sanitize_claude_read_tool_inputs(&mut body_json);
}
Some(client_body_with_report_context_model(
body_json.clone(),
body_json,
report_context,
&client_api_format,
))
@@ -2530,8 +2538,20 @@ pub fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
}
}
"tool_use" => {
let tool_name = block
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Some(input) = block.get("input") {
let sanitized = remove_empty_pages_from_tool_input_value(&tool_name, input);
if sanitized != *input {
block.insert("input".to_string(), sanitized);
}
}
if !state.partial_json.is_empty() {
let arguments = remove_empty_pages_from_tool_arguments(&state.partial_json);
let arguments =
remove_empty_pages_from_tool_arguments(&tool_name, &state.partial_json);
let input = serde_json::from_str::<Value>(&arguments)
.unwrap_or(Value::String(arguments));
block.insert("input".to_string(), input);
@@ -3114,6 +3134,59 @@ mod tests {
);
}
#[test]
fn aggregates_claude_stream_removes_empty_pages_from_start_tool_input() {
let body = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_read\",\"name\":\"Read\",\"input\":{\"file_path\":\"/tmp/a.txt\",\"limit\":20,\"pages\":\"\"}}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
let aggregated =
aggregate_claude_stream_sync_response(body.as_bytes()).expect("body should aggregate");
assert_eq!(
aggregated["content"][0]["input"],
json!({
"file_path": "/tmp/a.txt",
"limit": 20,
})
);
}
#[test]
fn aggregates_claude_stream_preserves_empty_pages_for_non_read_tool_input() {
let body = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_search\",\"name\":\"Search\",\"input\":{}}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"query\\\":\\\"\\\",\\\"pages\\\":\\\"\\\"}\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
let aggregated =
aggregate_claude_stream_sync_response(body.as_bytes()).expect("body should aggregate");
assert_eq!(aggregated["content"][0]["type"], "tool_use");
assert_eq!(
aggregated["content"][0]["input"],
json!({
"query": "",
"pages": "",
})
);
}
#[test]
fn aggregates_gemini_stream_deltas_media_and_signatures_into_sync_body() {
let body = concat!(
@@ -3385,6 +3458,67 @@ mod tests {
assert_eq!(body_json, provider_body_json);
}
#[test]
fn same_format_claude_sync_body_sanitizes_read_tool_input() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": false,
});
let provider_body_json = json!({
"id": "msg_read",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [
{
"type": "tool_use",
"id": "toolu_read",
"name": "Read",
"input": {
"file_path": "/tmp/a.txt",
"limit": 20,
"pages": ""
}
},
{
"type": "tool_use",
"id": "toolu_search",
"name": "Search",
"input": {
"query": "",
"pages": ""
}
}
]
});
let body_json = maybe_build_standard_same_format_sync_body_from_normalized_payload(
"claude_chat_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),
None,
)
.expect("same-format sync body should succeed")
.expect("body should exist");
assert_eq!(
body_json["content"][0]["input"],
json!({
"file_path": "/tmp/a.txt",
"limit": 20,
})
);
assert_eq!(
body_json["content"][1]["input"],
json!({
"query": "",
"pages": "",
})
);
}
#[test]
fn same_format_sync_response_restores_model_directive_display_model() {
let report_context = json!({
@@ -20,6 +20,7 @@ use crate::formats::shared::stream_core::common::{
use crate::formats::shared::stream_core::{
CanonicalStreamFrame, StreamingStandardFormatMatrix, StreamingStandardTerminalObserver,
};
use crate::formats::shared::stream_rewrite::maybe_build_ai_surface_stream_rewriter;
use crate::formats::shared::AiSurfaceFinalizeError;
pub struct SyncToStreamBridgeOutcome {
@@ -668,7 +669,11 @@ fn maybe_bridge_aether_sse_response_capture_to_stream(
client_api_format,
);
let sse_body = if captured_api_format == client_api_format {
body_text.as_bytes().to_vec()
if captured_api_format == "claude:messages" {
sanitize_same_format_claude_sse_body(body_text.as_bytes(), report_context)?
} else {
body_text.as_bytes().to_vec()
}
} else {
rewrite_sse_body_between_formats(
body_text.as_bytes(),
@@ -689,6 +694,34 @@ fn maybe_bridge_aether_sse_response_capture_to_stream(
}))
}
fn sanitize_same_format_claude_sse_body(
body: &[u8],
report_context: Option<&Value>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut context = report_context
.cloned()
.filter(Value::is_object)
.unwrap_or_else(|| json!({}));
let object = context
.as_object_mut()
.expect("same-format Claude context should stay object");
object.insert(
"provider_api_format".to_string(),
Value::String("claude:messages".to_string()),
);
object.insert(
"client_api_format".to_string(),
Value::String("claude:messages".to_string()),
);
let Some(mut rewriter) = maybe_build_ai_surface_stream_rewriter(Some(&context)) else {
return Ok(body.to_vec());
};
let mut out = rewriter.push_chunk(body)?;
out.extend(rewriter.finish()?);
Ok(out)
}
fn response_capture_header<'a>(headers: &'a Map<String, Value>, name: &str) -> Option<&'a str> {
headers
.iter()
@@ -1349,6 +1382,49 @@ mod tests {
);
}
#[test]
fn rewrites_same_format_claude_capture_to_sanitize_read_tool_input() {
let captured_body = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_read_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"gpt-5.5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_read_1\",\"name\":\"Read\",\"input\":{\"file_path\":\"D:/projects/UIAutoTest/docs/prd/msr.md\",\"offset\":0,\"limit\":2000,\"pages\":\"\"}}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"server_tool_use\",\"id\":\"srv_1\",\"name\":\"web_search\",\"input\":{\"query\":\"rust\"}}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"status_code": 200,
"headers": {
"content-type": "text/event-stream",
"x-aether-control-endpoint-signature": "claude:messages"
},
"body": captured_body
}),
"openai:responses",
"claude:messages",
None,
)
.expect("bridge should succeed")
.expect("capture should bridge");
let output = utf8(outcome.sse_body);
assert!(output.contains("\"name\":\"Read\""));
assert!(output.contains("\"limit\":2000"));
assert!(output.contains("\"type\":\"server_tool_use\""));
assert!(output.contains("\"name\":\"web_search\""));
assert!(!output.contains("\"pages\":\"\""));
assert!(!output.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
fn rewrites_aether_sse_response_capture_to_requested_client_stream() {
let captured_body = concat!(
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort;
use crate::formats::shared::response::remove_empty_pages_from_tool_input_value;
pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
@@ -3825,6 +3826,7 @@ pub(crate) fn canonical_block_to_claude(
input,
extensions,
} => {
let input = remove_empty_pages_from_tool_input_value(name, input);
let mut out = Map::new();
out.insert("type".to_string(), Value::String("tool_use".to_string()));
out.insert(
@@ -3832,7 +3834,7 @@ pub(crate) fn canonical_block_to_claude(
Value::String(claude_compatible_tool_use_id(id)),
);
out.insert("name".to_string(), Value::String(name.clone()));
out.insert("input".to_string(), input.clone());
out.insert("input".to_string(), input);
out.extend(namespace_extension_object(extensions, "claude", &out));
Some(Some(Value::Object(out)))
}
@@ -5596,6 +5598,74 @@ mod tests {
assert_eq!(rebuilt["service_tier"], "flex");
}
#[test]
fn openai_responses_to_claude_response_drops_empty_pages_only_for_read_tool() {
let response = json!({
"id": "resp_read_pages",
"object": "response",
"status": "completed",
"model": "gpt-5.5",
"output": [
{
"type": "function_call",
"id": "call_read",
"call_id": "call_read",
"name": "Read",
"arguments": "{\"file_path\":\"/tmp/a.txt\",\"offset\":0,\"limit\":20,\"pages\":\"\"}"
},
{
"type": "function_call",
"id": "call_search",
"call_id": "call_search",
"name": "Search",
"arguments": "{\"query\":\"\",\"pages\":\"\"}"
}
],
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
});
let canonical =
from_openai_responses_to_canonical_response(&response).expect("canonical response");
let claude = canonical_to_claude_response(&canonical);
assert_eq!(
claude["content"][0]["input"],
json!({
"file_path": "/tmp/a.txt",
"offset": 0,
"limit": 20,
})
);
assert_eq!(
claude["content"][1]["input"],
json!({
"query": "",
"pages": "",
})
);
let rebuilt_responses = canonical_to_openai_responses_response(&canonical, &json!({}));
let read_arguments = serde_json::from_str::<Value>(
rebuilt_responses["output"][0]["arguments"]
.as_str()
.expect("arguments should be a string"),
)
.expect("arguments should be json");
assert_eq!(
read_arguments,
json!({
"file_path": "/tmp/a.txt",
"offset": 0,
"limit": 20,
"pages": "",
})
);
}
#[test]
fn openai_responses_image_generation_call_becomes_canonical_image_block() {
let response = json!({