Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 135 additions & 7 deletions codex-rs/codex-api/src/sse/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ pub async fn process_chat_sse<S>(
}

if let Some(func) = tool_call.get("function") {
// Only update name if non-empty to prevent subsequent deltas
// from overwriting the initial name with empty strings.
// See: https://github.com/openai/codex/issues/7579
if let Some(fname) = func.get("name").and_then(|n| n.as_str())
&& !fname.is_empty()
{
Expand Down Expand Up @@ -226,13 +229,31 @@ pub async fn process_chat_sse<S>(

for call_id in tool_call_order.drain(..) {
let state = tool_calls.remove(&call_id).unwrap_or_default();
let item = ResponseItem::FunctionCall {
id: None,
name: state.name.unwrap_or_default(),
arguments: state.arguments,
call_id: call_id.clone(),
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
// Skip tool calls with empty names to avoid API errors
match state.name {
Some(name) if !name.is_empty() => {
let item = ResponseItem::FunctionCall {
id: None,
name,
arguments: state.arguments,
call_id: call_id.clone(),
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
}
Some(name) if name.is_empty() => {
debug!(
"Skipping tool call with empty name: call_id={}, arguments={}",
call_id, state.arguments
);
}
None => {
debug!(
"Skipping tool call with missing name: call_id={}, arguments={}",
call_id, state.arguments
);
}
_ => {}
}
}
}
}
Expand Down Expand Up @@ -544,4 +565,111 @@ mod tests {
}));
assert_matches!(events.last(), Some(ResponseEvent::Completed { .. }));
}

#[tokio::test]
async fn preserves_name_when_subsequent_deltas_have_empty_names() {
// Regression test for https://github.com/openai/codex/issues/7579
// First delta has the name, subsequent deltas have empty names
let delta_with_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "name": "my_tool", "arguments": "{\"arg\":" }
}]
}
}]
});

let delta_with_empty_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_a",
"function": { "name": "", "arguments": "123}" }
}]
}
}]
});

let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});

let body = build_body(&[delta_with_name, delta_with_empty_name, finish]);
let events = collect_events(&body).await;

// Should preserve the original name "my_tool" despite empty name in second delta
assert_matches!(
&events[..],
[
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }),
ResponseEvent::Completed { .. }
] if call_id == "call_a" && name == "my_tool" && arguments == "{\"arg\":123}"
);
}

#[tokio::test]
async fn skips_tool_calls_with_empty_or_missing_names() {
// Test case for tool call with empty name
let delta_empty_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_empty",
"function": { "name": "", "arguments": "{}" }
}]
}
}]
});

// Test case for tool call with missing name
let delta_no_name = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_no_name",
"function": { "arguments": "{}" }
}]
}
}]
});

// Valid tool call for comparison
let delta_valid = json!({
"choices": [{
"delta": {
"tool_calls": [{
"id": "call_valid",
"function": { "name": "valid_tool", "arguments": "{}" }
}]
}
}]
});

let finish = json!({
"choices": [{
"finish_reason": "tool_calls"
}]
});

let body = build_body(&[delta_empty_name, delta_no_name, delta_valid, finish]);
let events = collect_events(&body).await;

// Should only emit the valid tool call
let function_calls: Vec<_> = events
.iter()
.filter_map(|ev| match ev {
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { name, .. }) => {
Some(name.clone())
}
_ => None,
})
.collect();

assert_eq!(function_calls.len(), 1);
assert_eq!(function_calls[0], "valid_tool");
}
}
Loading