Render write_todos as an ACP plan update
Zed and other clients render session/update plans with proper progress UI, so route the model's write_todos tool calls there instead of showing them as a generic tool-call card. Co-Authored-By: siGit Code <noreply@sigit.si>
Seto Elkahfi committed
Sep 7, 2026 at 07:51 UTC
4cda4cb58f3ae14d3a73e86242bb2e97c7f6102b
2 files changed
+298
-38
src/main.rs
+95
-38
@@ -75,10 +75,11 @@ use agent_client_protocol::schema::v1::{
75
ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
76
ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
77
LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
78
- PermissionOptionKind, PromptRequest, PromptResponse, RequestPermissionOutcome,
79
- RequestPermissionRequest, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
80
- SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
81
- SessionConfigValueId, SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
78
+ PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest,
79
+ PromptResponse, RequestPermissionOutcome, RequestPermissionRequest,
80
+ SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionConfigOption,
81
+ SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
82
+ SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
83
SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
84
ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
85
};
@@ -348,6 +349,35 @@ fn tool_kind_for(tool_name: &str) -> ToolKind {
349
}
350
}
351
352
+fn todos_arguments_to_plan(arguments: &str) -> Option<Plan> {
353
+ let args: serde_json::Value = serde_json::from_str(arguments).ok()?;
354
+ let todos = args.get("todos")?.as_array()?;
355
+ if todos.is_empty() {
356
+ return None;
357
+ }
358
+
359
+ let mut entries = Vec::with_capacity(todos.len());
360
+ for todo in todos {
361
+ let content = todo.get("content")?.as_str()?.trim();
362
+ if content.is_empty() {
363
+ return None;
364
+ }
365
+ // Unknown and absent statuses both fall back to pending, matching how
366
+ // `exec_write_todos` renders them. The two have to agree: the executor
367
+ // reports success for an off-enum status, so rejecting the plan here
368
+ // would leave a stale list on screen while the model believes it
369
+ // reported progress.
370
+ let status = match todo.get("status").and_then(serde_json::Value::as_str) {
371
+ Some("completed") => PlanEntryStatus::Completed,
372
+ Some("in_progress") => PlanEntryStatus::InProgress,
373
+ _ => PlanEntryStatus::Pending,
374
+ };
375
+ entries.push(PlanEntry::new(content, PlanEntryPriority::Medium, status));
376
+ }
377
+
378
+ Some(Plan::new(entries))
379
+}
380
+
381
/// Shown when a siGit Code Cloud tier is selected without a signed-in account.
382
const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
383
`/login <email> <password>` (or the Authenticate button), then pick the tier again. \
@@ -876,6 +906,18 @@ impl SiGitAgent {
906
cx.send_notification(SessionNotification::new(session_id, update))
907
}
908
909
+ fn send_plan_update(
910
+ &self,
911
+ cx: &ConnectionTo<Client>,
912
+ session_id: SessionId,
913
+ plan: Plan,
914
+ ) -> agent_client_protocol::Result<()> {
915
+ cx.send_notification(SessionNotification::new(
916
+ session_id,
917
+ SessionUpdate::Plan(plan),
918
+ ))
919
+ }
920
+
921
/// Advertise siGit's slash commands to the client. Editors like Zed parse
922
/// `/`-prefixed input and only forward commands they've been told about, so
923
/// without this `/login`, `/models`, etc. are rejected client-side.
@@ -1609,29 +1651,42 @@ impl SiGitAgent {
1651
tc.arguments.chars().take(120).collect::<String>()
1652
);
1653
1612
- // Model tool calls are agent actions in ACP too, not merely an
1613
- // implementation detail of the completion loop. Announce each
1614
- // one before it runs and close it afterwards so clients such as
1615
- // Zed can render activity while the next inference round is in
1616
- // flight. Previously only model loading emitted ToolCall events,
1617
- // leaving an otherwise working tool round visually indistinct
1618
- // from a stalled response.
1619
- let raw_input: serde_json::Value = serde_json::from_str(&tc.arguments)
1620
- .unwrap_or_else(|e| {
1621
- log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
1622
- serde_json::Value::String(tc.arguments.clone())
1623
- });
1624
- self.send_tool_call_update(
1625
- cx,
1626
- session_id.clone(),
1627
- SessionUpdate::ToolCall(
1628
- ToolCall::new(tc.id.clone(), tc.name.clone())
1629
- .kind(tool_kind_for(&tc.name))
1630
- .status(ToolCallStatus::InProgress)
1631
- .raw_input(raw_input),
1632
- ),
1633
- )
1634
- .ok();
1654
+ // Only treat the call as a plan once its arguments have actually
1655
+ // produced one. Keying off the tool name alone means a
1656
+ // `write_todos` the converter rejects sends no plan *and* skips
1657
+ // the tool-call card below, so the call disappears from the
1658
+ // client while the model is told the list was updated.
1659
+ let plan = (tc.name == "write_todos")
1660
+ .then(|| todos_arguments_to_plan(&tc.arguments))
1661
+ .flatten();
1662
+ let render_as_plan = plan.is_some();
1663
+ if let Some(plan) = plan {
1664
+ self.send_plan_update(cx, session_id.clone(), plan).ok();
1665
+ } else {
1666
+ // Model tool calls are agent actions in ACP too, not merely an
1667
+ // implementation detail of the completion loop. Announce each
1668
+ // one before it runs and close it afterwards so clients such as
1669
+ // Zed can render activity while the next inference round is in
1670
+ // flight. Previously only model loading emitted ToolCall events,
1671
+ // leaving an otherwise working tool round visually indistinct
1672
+ // from a stalled response.
1673
+ let raw_input: serde_json::Value = serde_json::from_str(&tc.arguments)
1674
+ .unwrap_or_else(|e| {
1675
+ log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
1676
+ serde_json::Value::String(tc.arguments.clone())
1677
+ });
1678
+ self.send_tool_call_update(
1679
+ cx,
1680
+ session_id.clone(),
1681
+ SessionUpdate::ToolCall(
1682
+ ToolCall::new(tc.id.clone(), tc.name.clone())
1683
+ .kind(tool_kind_for(&tc.name))
1684
+ .status(ToolCallStatus::InProgress)
1685
+ .raw_input(raw_input),
1686
+ ),
1687
+ )
1688
+ .ok();
1689
+ }
1690
1691
let signature = format!("{}\n{}", tc.name, tc.arguments);
1692
let repeat_count = repeated_tool_calls
@@ -1727,17 +1782,19 @@ impl SiGitAgent {
1782
1783
log::info!(" ← {} chars", output.len());
1784
1730
- self.send_tool_call_update(
1731
- cx,
1732
- session_id.clone(),
1733
- SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1734
- tc.id.clone(),
1735
- ToolCallUpdateFields::new()
1736
- .status(ToolCallStatus::Completed)
1737
- .raw_output(serde_json::Value::String(output.clone())),
1738
- )),
1739
- )
1740
- .ok();
1785
+ if !render_as_plan {
1786
+ self.send_tool_call_update(
1787
+ cx,
1788
+ session_id.clone(),
1789
+ SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1790
+ tc.id.clone(),
1791
+ ToolCallUpdateFields::new()
1792
+ .status(ToolCallStatus::Completed)
1793
+ .raw_output(serde_json::Value::String(output.clone())),
1794
+ )),
1795
+ )
1796
+ .ok();
1797
+ }
1798
1799
tool_results.push(BackendToolResult {
1800
tool_call_id: tc.id.clone(),
tests/acp_permissions.rs
+203
@@ -283,6 +283,24 @@ impl AgentUnderTest {
283
}
284
}
285
286
+ fn wait_for_prompt_updates(&mut self, id: u64) -> (Value, Vec<Value>) {
287
+ let mut updates = Vec::new();
288
+ let deadline = Instant::now() + TIMEOUT;
289
+ loop {
290
+ let remaining = deadline.saturating_duration_since(Instant::now());
291
+ let Ok(message) = self.incoming.recv_timeout(remaining) else {
292
+ panic!("timed out waiting for the prompt response");
293
+ };
294
+ if message["method"] == "session/update" {
295
+ updates.push(message["params"]["update"].clone());
296
+ }
297
+ if message["id"] == id && message.get("method").is_none() {
298
+ assert!(message.get("error").is_none(), "prompt failed: {message}");
299
+ return (message, updates);
300
+ }
301
+ }
302
+ }
303
+
304
/// A request *from* the agent (has a `method` and its own id).
305
fn wait_for_agent_request(&mut self, method: &str) -> Value {
306
self.wait_for(&format!("agent request {method}"), |message| {
@@ -662,3 +680,188 @@ fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
680
drop(agent);
681
let _ = std::fs::remove_dir_all(&scratch);
682
}
683
+
684
+#[test]
685
+fn write_todos_reaches_the_client_as_an_acp_plan() {
686
+ let endpoint = start_fake_endpoint(vec![
687
+ sse_tool_call(
688
+ "call_1",
689
+ "write_todos",
690
+ &json!({
691
+ "todos": [
692
+ {"content": "Fetch issue screenshot", "status": "completed"},
693
+ {"content": "Implement ACP styling", "status": "in_progress"},
694
+ {"content": "Build and verify", "status": "pending"}
695
+ ]
696
+ })
697
+ .to_string(),
698
+ ),
699
+ sse_text("Continuing."),
700
+ ]);
701
+
702
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_{}", std::process::id()));
703
+ let config_dir = scratch.join("config");
704
+ let cwd = scratch.join("cwd");
705
+ std::fs::create_dir_all(&config_dir).unwrap();
706
+ std::fs::create_dir_all(&cwd).unwrap();
707
+
708
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
709
+
710
+ let id = agent.request(
711
+ "initialize",
712
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
713
+ );
714
+ agent.wait_for_response(id);
715
+
716
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
717
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
718
+ .as_str()
719
+ .expect("session id")
720
+ .to_string();
721
+
722
+ let prompt_id = agent.request(
723
+ "session/prompt",
724
+ json!({
725
+ "sessionId": session_id,
726
+ "prompt": [{"type": "text", "text": "fix the ACP display"}],
727
+ }),
728
+ );
729
+ let (response, updates) = agent.wait_for_prompt_updates(prompt_id);
730
+ assert_eq!(response["result"]["stopReason"], "end_turn");
731
+
732
+ let plan = updates
733
+ .iter()
734
+ .find(|update| update["sessionUpdate"] == "plan")
735
+ .expect("write_todos should be rendered as an ACP plan update");
736
+ assert_eq!(plan["entries"][0]["content"], "Fetch issue screenshot");
737
+ assert_eq!(plan["entries"][0]["status"], "completed");
738
+ assert_eq!(plan["entries"][1]["status"], "in_progress");
739
+ assert_eq!(plan["entries"][2]["status"], "pending");
740
+
741
+ assert!(
742
+ !updates.iter().any(|update| {
743
+ update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
744
+ }),
745
+ "write_todos must not show up as a generic tool-call card: {updates:?}"
746
+ );
747
+
748
+ drop(agent);
749
+ let _ = std::fs::remove_dir_all(&scratch);
750
+}
751
+
752
+/// An off-enum status must not make the whole call vanish. `exec_write_todos`
753
+/// renders it as pending and reports success, so the plan has to carry it the
754
+/// same way; dropping the plan here would leave the client showing a stale list.
755
+#[test]
756
+fn write_todos_with_an_unknown_status_still_reaches_the_client() {
757
+ let endpoint = start_fake_endpoint(vec![
758
+ sse_tool_call(
759
+ "call_1",
760
+ "write_todos",
761
+ &json!({
762
+ "todos": [
763
+ {"content": "Fetch issue screenshot", "status": "completed"},
764
+ {"content": "Ship it", "status": "cancelled"}
765
+ ]
766
+ })
767
+ .to_string(),
768
+ ),
769
+ sse_text("Continuing."),
770
+ ]);
771
+
772
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_odd_{}", std::process::id()));
773
+ let config_dir = scratch.join("config");
774
+ let cwd = scratch.join("cwd");
775
+ std::fs::create_dir_all(&config_dir).unwrap();
776
+ std::fs::create_dir_all(&cwd).unwrap();
777
+
778
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
779
+
780
+ let id = agent.request(
781
+ "initialize",
782
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
783
+ );
784
+ agent.wait_for_response(id);
785
+
786
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
787
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
788
+ .as_str()
789
+ .expect("session id")
790
+ .to_string();
791
+
792
+ let prompt_id = agent.request(
793
+ "session/prompt",
794
+ json!({
795
+ "sessionId": session_id,
796
+ "prompt": [{"type": "text", "text": "plan the work"}],
797
+ }),
798
+ );
799
+ let (_response, updates) = agent.wait_for_prompt_updates(prompt_id);
800
+
801
+ let plan = updates
802
+ .iter()
803
+ .find(|update| update["sessionUpdate"] == "plan")
804
+ .expect("an unknown status must not drop the plan");
805
+ assert_eq!(plan["entries"][1]["content"], "Ship it");
806
+ assert_eq!(plan["entries"][1]["status"], "pending");
807
+
808
+ drop(agent);
809
+ let _ = std::fs::remove_dir_all(&scratch);
810
+}
811
+
812
+/// Arguments the converter cannot turn into a plan fall back to the ordinary
813
+/// tool-call card. Without that fallback the call is announced nowhere at all.
814
+#[test]
815
+fn unconvertible_write_todos_falls_back_to_a_tool_call_card() {
816
+ let endpoint = start_fake_endpoint(vec![
817
+ // `todos` is absent, so there is no plan to build. `exec_write_todos`
818
+ // answers with an error string, and the client still has to see the call.
819
+ sse_tool_call("call_1", "write_todos", &json!({"items": []}).to_string()),
820
+ sse_text("Continuing."),
821
+ ]);
822
+
823
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_bad_{}", std::process::id()));
824
+ let config_dir = scratch.join("config");
825
+ let cwd = scratch.join("cwd");
826
+ std::fs::create_dir_all(&config_dir).unwrap();
827
+ std::fs::create_dir_all(&cwd).unwrap();
828
+
829
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
830
+
831
+ let id = agent.request(
832
+ "initialize",
833
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
834
+ );
835
+ agent.wait_for_response(id);
836
+
837
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
838
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
839
+ .as_str()
840
+ .expect("session id")
841
+ .to_string();
842
+
843
+ let prompt_id = agent.request(
844
+ "session/prompt",
845
+ json!({
846
+ "sessionId": session_id,
847
+ "prompt": [{"type": "text", "text": "plan the work"}],
848
+ }),
849
+ );
850
+ let (_response, updates) = agent.wait_for_prompt_updates(prompt_id);
851
+
852
+ assert!(
853
+ !updates
854
+ .iter()
855
+ .any(|update| update["sessionUpdate"] == "plan"),
856
+ "there is no plan to send: {updates:?}"
857
+ );
858
+ assert!(
859
+ updates.iter().any(|update| {
860
+ update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
861
+ }),
862
+ "the call must still be announced as a tool call: {updates:?}"
863
+ );
864
+
865
+ drop(agent);
866
+ let _ = std::fs::remove_dir_all(&scratch);
867
+}