acp: emit ToolCall events for every tool execution; send tool_choice auto
Clients such as Zed had no visibility into tool activity between inference rounds — only model loading emitted ToolCall events. Now the agent loop sends ToolCall(InProgress) before each tool runs and ToolCallUpdate with Completed or Failed (including the cancelled-at-permission-prompt case) after it returns. Also send `tool_choice: "auto"` explicitly whenever tools are present. OpenAI's spec says auto is the default, but not every compatible gateway implements that default — without it some models describe the intended action in prose and end the turn without a tool call. Test coverage: assert tool_choice is present in acp_permissions. Co-Authored-By: siGit Code <noreply@sigit.si>
paydii committed
Aug 30, 2026 at 12:16 UTC
85a3af4350c449788a755c6ff4573302dda9c940
3 files changed
+59
src/backend.rs
+6
@@ -459,6 +459,12 @@ impl OpenAiBackend {
459
&& !tools.is_empty()
460
{
461
body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
462
+ // OpenAI specifies `auto` as the default when tools are present,
463
+ // but not every OpenAI-compatible gateway implements that default.
464
+ // Sending it explicitly is important for the cloud path: otherwise
465
+ // a model can describe the action it intends to take as text and
466
+ // end the turn without returning a tool call at all.
467
+ body["tool_choice"] = serde_json::Value::String("auto".to_string());
468
}
469
470
let response = self
src/main.rs
+47
@@ -1515,6 +1515,27 @@ impl SiGitAgent {
1515
tc.arguments.chars().take(120).collect::<String>()
1516
);
1517
1518
+ // Model tool calls are agent actions in ACP too, not merely an
1519
+ // implementation detail of the completion loop. Announce each
1520
+ // one before it runs and close it afterwards so clients such as
1521
+ // Zed can render activity while the next inference round is in
1522
+ // flight. Previously only model loading emitted ToolCall events,
1523
+ // leaving an otherwise working tool round visually indistinct
1524
+ // from a stalled response.
1525
+ let raw_input: serde_json::Value = serde_json::from_str(&tc.arguments)
1526
+ .unwrap_or_else(|_| serde_json::Value::String(tc.arguments.clone()));
1527
+ self.send_tool_call_update(
1528
+ cx,
1529
+ session_id.clone(),
1530
+ SessionUpdate::ToolCall(
1531
+ ToolCall::new(tc.id.clone(), tc.name.clone())
1532
+ .kind(tool_kind_for(&tc.name))
1533
+ .status(ToolCallStatus::InProgress)
1534
+ .raw_input(raw_input),
1535
+ ),
1536
+ )
1537
+ .ok();
1538
+
1539
let signature = format!("{}\n{}", tc.name, tc.arguments);
1540
let repeat_count = repeated_tool_calls
1541
.entry(signature)
@@ -1585,6 +1606,20 @@ impl SiGitAgent {
1606
),
1607
});
1608
}
1609
+ self.send_tool_call_update(
1610
+ cx,
1611
+ session_id.clone(),
1612
+ SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1613
+ tc.id.clone(),
1614
+ ToolCallUpdateFields::new()
1615
+ .status(ToolCallStatus::Failed)
1616
+ .raw_output(serde_json::Value::String(
1617
+ "Not executed: the user cancelled the turn at the permission prompt."
1618
+ .to_string(),
1619
+ )),
1620
+ )),
1621
+ )
1622
+ .ok();
1623
backend.record_cancelled_tool_results(tool_results).await;
1624
return Ok(PromptResponse::new(StopReason::Cancelled));
1625
}
@@ -1595,6 +1630,18 @@ impl SiGitAgent {
1630
1631
log::info!(" ← {} chars", output.len());
1632
1633
+ self.send_tool_call_update(
1634
+ cx,
1635
+ session_id.clone(),
1636
+ SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1637
+ tc.id.clone(),
1638
+ ToolCallUpdateFields::new()
1639
+ .status(ToolCallStatus::Completed)
1640
+ .raw_output(serde_json::Value::String(output.clone())),
1641
+ )),
1642
+ )
1643
+ .ok();
1644
+
1645
tool_results.push(BackendToolResult {
1646
tool_call_id: tc.id.clone(),
1647
content: output,
tests/acp_permissions.rs
+6
@@ -316,6 +316,12 @@ fn permission_round_trip_cancel_then_allow() {
316
let requests = endpoint.requests.lock().unwrap();
317
assert_eq!(requests.len(), 3, "expected exactly three completions");
318
319
+ // The client explicitly requests automatic tool selection. Some
320
+ // OpenAI-compatible gateways do not honour OpenAI's implicit `auto`
321
+ // default, which otherwise lets the model describe a tool action in prose
322
+ // and end the turn without a tool call.
323
+ assert_eq!(requests[0]["tool_choice"], "auto");
324
+
325
// Request 2 replays the full history: the cancelled round's tool call
326
// must be answered by a `role: "tool"` message, not left dangling.
327
let messages = requests[1]["messages"].as_array().expect("messages");