mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
fix(ai): sanitize empty Read pages for Claude tools
This commit is contained in:
@@ -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();
|
||||
};
|
||||
@@ -153,16 +156,21 @@ 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":"#
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2531,7 +2531,12 @@ pub fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
|
||||
}
|
||||
"tool_use" => {
|
||||
if !state.partial_json.is_empty() {
|
||||
let arguments = remove_empty_pages_from_tool_arguments(&state.partial_json);
|
||||
let tool_name = block
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
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 +3119,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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!(
|
||||
|
||||
@@ -667,16 +667,17 @@ fn maybe_bridge_aether_sse_response_capture_to_stream(
|
||||
captured_api_format.as_str(),
|
||||
client_api_format,
|
||||
);
|
||||
let sse_body = if captured_api_format == client_api_format {
|
||||
body_text.as_bytes().to_vec()
|
||||
} else {
|
||||
rewrite_sse_body_between_formats(
|
||||
body_text.as_bytes(),
|
||||
captured_api_format.as_str(),
|
||||
client_api_format,
|
||||
&bridge_context,
|
||||
)?
|
||||
};
|
||||
let sse_body =
|
||||
if captured_api_format == client_api_format && captured_api_format != "claude:messages" {
|
||||
body_text.as_bytes().to_vec()
|
||||
} else {
|
||||
rewrite_sse_body_between_formats(
|
||||
body_text.as_bytes(),
|
||||
captured_api_format.as_str(),
|
||||
client_api_format,
|
||||
&bridge_context,
|
||||
)?
|
||||
};
|
||||
let terminal_summary = observe_sse_terminal_summary(
|
||||
body_text.as_bytes(),
|
||||
captured_api_format.as_str(),
|
||||
@@ -1349,6 +1350,42 @@ 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: 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("\\\"pages\\\":\\\"\\\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_aether_sse_response_capture_to_requested_client_stream() {
|
||||
let captured_body = concat!(
|
||||
|
||||
@@ -3825,6 +3825,7 @@ pub(crate) fn canonical_block_to_claude(
|
||||
input,
|
||||
extensions,
|
||||
} => {
|
||||
let input = claude_tool_use_input_without_empty_read_pages(name, input);
|
||||
let mut out = Map::new();
|
||||
out.insert("type".to_string(), Value::String("tool_use".to_string()));
|
||||
out.insert(
|
||||
@@ -3832,7 +3833,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)))
|
||||
}
|
||||
@@ -3867,6 +3868,18 @@ pub(crate) fn canonical_block_to_claude(
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_tool_use_input_without_empty_read_pages(name: &str, input: &Value) -> Value {
|
||||
if 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)
|
||||
}
|
||||
|
||||
fn canonical_tool_result_content_to_claude(
|
||||
output: Option<&Value>,
|
||||
content_text: Option<&str>,
|
||||
@@ -5596,6 +5609,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!({
|
||||
|
||||
Reference in New Issue
Block a user