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");