@setoelkahfi / sigit / commits / a64cd34

Fix review findings: cancelled turns, approval visibility

Three fixes from review of the permission system: Cancelling at the ACP permission gate used to return early and leave the assistant's tool calls unanswered in the backend history, so strict OpenAI-compatible endpoints rejected every later request in the session. The prompt loop now closes out the current and unreached calls with cancellation results via a new InferenceBackend method that records them without asking the model to continue. The local backend documents why it stays a no-op (onde can't append tool results without inference, and its chat template tolerates the dangling call). The TUI approval prompt only named the tool, so the user approved run_command without seeing the command. The prompt now shows an arguments preview. Both surfaces previously clipped arguments at 120 chars with no marker, which could hide the tail of a long command from the person approving it. A shared approval_preview helper caps at 500 chars and flags any hidden remainder explicitly; the ACP dialog also carries the full arguments as rawInput.

paydii committed Jul 4, 2026 at 19:25 UTC a64cd344294e4a6a2e659793c3b07cea10d46c54
4 files changed +124 -5
src/backend.rs
+56
@@ -98,6 +98,14 @@ pub trait InferenceBackend: Send + Sync {
98 sink: Option<&TokenSink>,
99 ) -> Result<TurnResult, BackendError>;
100
101 + /// Record tool results in the conversation history *without* asking the
102 + /// model to continue the turn. Used when a turn is abandoned mid-round
103 + /// (the user cancelled at the permission gate): by then the assistant
104 + /// message carrying the tool calls is already in history, and leaving them
105 + /// unanswered makes strict OpenAI-compatible endpoints reject every later
106 + /// request in the session.
107 + async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>);
108 +
109 /// Whether inference runs over the network (a configured provider) rather
110 /// than on-device. Drives UI labelling so the displayed model can't claim a
111 /// local model while requests actually go to the cloud.
@@ -195,6 +203,13 @@ impl InferenceBackend for LocalBackend {
203 Ok(onde_result_to_turn(result))
204 }
205
206 + async fn record_cancelled_tool_results(&self, _results: Vec<ToolResult>) {
207 + // onde's public API cannot append tool-result history entries without
208 + // running another inference round, so the dangling tool call stays in
209 + // its history. The chat template replays it as-is, which local models
210 + // tolerate — worst case the model re-issues the call next turn.
211 + }
212 +
213 fn is_remote(&self) -> bool {
214 false
215 }
@@ -562,6 +577,17 @@ impl InferenceBackend for OpenAiBackend {
577 self.complete(tools, sink).await
578 }
579
580 + async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
581 + let mut history = self.history.lock().await;
582 + for result in results {
583 + history.push(serde_json::json!({
584 + "role": "tool",
585 + "tool_call_id": result.tool_call_id,
586 + "content": result.content,
587 + }));
588 + }
589 + }
590 +
591 fn is_remote(&self) -> bool {
592 true
593 }
@@ -727,6 +753,36 @@ mod tests {
753 );
754 }
755
756 + #[tokio::test]
757 + async fn cancelled_tool_results_close_out_history() {
758 + let backend = OpenAiBackend::new("http://localhost", "", "test-model", None);
759 + backend
760 + .history
761 + .lock()
762 + .await
763 + .push(streamed_assistant_history(
764 + "",
765 + &[ToolCall {
766 + id: "call_9".to_string(),
767 + name: "run_command".to_string(),
768 + arguments: r#"{"command":"ls"}"#.to_string(),
769 + }],
770 + ));
771 +
772 + backend
773 + .record_cancelled_tool_results(vec![ToolResult {
774 + tool_call_id: "call_9".to_string(),
775 + content: "cancelled by the user".to_string(),
776 + }])
777 + .await;
778 +
779 + let history = backend.history.lock().await;
780 + let last = history.last().unwrap();
781 + assert_eq!(last["role"], "tool");
782 + assert_eq!(last["tool_call_id"], "call_9");
783 + assert_eq!(last["content"], "cancelled by the user");
784 + }
785 +
786 #[test]
787 fn assistant_message_with_tool_calls_round_trips() {
788 let message = ResponseMessage {
src/chat.rs
+10 -2
@@ -161,6 +161,8 @@ mod tui {
161 /// `reply`; the user answers with y (once) / a (session) / n (deny)
162 ApprovalRequest {
163 tool: String,
164 + /// arguments preview so the user can see what they are approving
165 + args: String,
166 reply: oneshot::Sender<ApprovalChoice>,
167 },
168 }
@@ -1689,6 +1691,7 @@ mod tui {
1691 let _ = tx
1692 .send(InferenceUpdate::ApprovalRequest {
1693 tool: tc.name.clone(),
1694 + args: permissions::approval_preview(&tc.arguments),
1695 reply: reply_tx,
1696 })
1697 .await;
@@ -1928,9 +1931,14 @@ mod tui {
1931 app.stop_thinking();
1932 app.messages.push(ChatMessage::system(format!("error: {msg}")));
1933 }
1931 - Some(InferenceUpdate::ApprovalRequest { tool, reply }) => {
1934 + Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
1935 + let call = if args.is_empty() {
1936 + tool.clone()
1937 + } else {
1938 + format!("{tool}({args})")
1939 + };
1940 app.messages.push(ChatMessage::system(format!(
1933 - "⚠ permission — allow {tool}? [y]es · [a]lways this session · [n]o"
1941 + "⚠ permission — allow {call}? [y]es · [a]lways this session · [n]o"
1942 )));
1943 app.pending_approval = Some((tool, reply));
1944 }
src/main.rs
+27 -3
@@ -1295,7 +1295,7 @@ impl SiGitAgent {
1295
1296 let mut tool_results = Vec::new();
1297
1298 - for tc in &result.tool_calls {
1298 + for (call_index, tc) in result.tool_calls.iter().enumerate() {
1299 log::info!(
1300 " → {}({})",
1301 tc.name,
@@ -1326,6 +1326,23 @@ impl SiGitAgent {
1326 }
1327 PermissionVerdict::TurnCancelled => {
1328 log::info!("prompt({}) cancelled at permission gate", session_id);
1329 + // The assistant message carrying these tool
1330 + // calls is already in the backend history;
1331 + // leaving any of them unanswered makes strict
1332 + // OpenAI-compatible endpoints reject every
1333 + // later request in the session. Close out this
1334 + // call and the ones this round never reached.
1335 + for pending in &result.tool_calls[call_index..] {
1336 + tool_results.push(BackendToolResult {
1337 + tool_call_id: pending.id.clone(),
1338 + content: format!(
1339 + "`{}` was not executed: the user cancelled the turn \
1340 + at the permission prompt.",
1341 + pending.name
1342 + ),
1343 + });
1344 + }
1345 + backend.record_cancelled_tool_results(tool_results).await;
1346 return Ok(PromptResponse::new(StopReason::Cancelled));
1347 }
1348 }
@@ -1409,12 +1426,18 @@ impl SiGitAgent {
1426 tool_name: &str,
1427 arguments: &str,
1428 ) -> PermissionVerdict {
1412 - let args_preview: String = arguments.chars().take(120).collect();
1429 + // The user decides from this dialog, so show the arguments with any
1430 + // truncation flagged (a silently clipped command could hide its tail
1431 + // from the person approving it). The full arguments also travel as
1432 + // `raw_input` for clients that render it.
1433 + let args_preview = permissions::approval_preview(arguments);
1434 let title = if args_preview.is_empty() {
1435 tool_name.to_string()
1436 } else {
1437 format!("{tool_name}({args_preview})")
1438 };
1439 + let raw_input: serde_json::Value = serde_json::from_str(arguments)
1440 + .unwrap_or_else(|_| serde_json::Value::String(arguments.to_string()));
1441
1442 let request = RequestPermissionRequest::new(
1443 session_id.clone(),
@@ -1423,7 +1446,8 @@ impl SiGitAgent {
1446 ToolCallUpdateFields::new()
1447 .title(title)
1448 .kind(tool_kind_for(tool_name))
1426 - .status(ToolCallStatus::Pending),
1449 + .status(ToolCallStatus::Pending)
1450 + .raw_input(raw_input),
1451 ),
1452 vec![
1453 PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
src/permissions.rs
+31
@@ -113,6 +113,20 @@ pub fn user_denial(tool_name: &str) -> String {
113 )
114 }
115
116 +/// Render a tool call's arguments for an approval prompt. The person deciding
117 +/// must be able to see what they are approving, so the cap is generous and any
118 +/// cut is marked with how much is hidden — silently truncating could hide the
119 +/// tail of a command from the user who is about to allow it.
120 +pub fn approval_preview(arguments: &str) -> String {
121 + const MAX_CHARS: usize = 500;
122 + let total = arguments.chars().count();
123 + if total <= MAX_CHARS {
124 + return arguments.to_string();
125 + }
126 + let shown: String = arguments.chars().take(MAX_CHARS).collect();
127 + format!("{shown}… [+{} more chars]", total - MAX_CHARS)
128 +}
129 +
130 /// Policy check for one tool call. See the module docs for the layering.
131 pub fn decision_for(session: &str, tool_name: &str) -> Decision {
132 if classify(tool_name) == ToolRisk::ReadOnly {
@@ -272,6 +286,23 @@ mod tests {
286 assert_ne!(decision_for(session, "edit_file"), Decision::Allow);
287 }
288
289 + #[test]
290 + fn approval_preview_shows_short_arguments_in_full() {
291 + let args = r#"{"command":"cargo test"}"#;
292 + assert_eq!(approval_preview(args), args);
293 + }
294 +
295 + #[test]
296 + fn approval_preview_marks_truncation_explicitly() {
297 + let args = format!(r#"{{"command":"echo {}; rm -rf /"}}"#, "x".repeat(600));
298 + let preview = approval_preview(&args);
299 + assert!(preview.chars().count() < args.chars().count());
300 + assert!(
301 + preview.contains("more chars]"),
302 + "hidden content must be flagged, got: {preview}"
303 + );
304 + }
305 +
306 #[test]
307 fn plan_mode_outranks_session_grant() {
308 let _guard = env_guard();