From 4d5f312b5a28a5117a4f5f58f77777156f1fc587 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Sat, 27 Jun 2026 18:16:02 +0800 Subject: [PATCH] feat(agent-tools): handle tool rejection and approval states - Return structured tool results for user rejection, confirmation timeout, and execution timeout instead of generic failures - Add shared tool execution presentation helpers and contract coverage for rejection / timeout assistant messages - Introduce FlowChat approval bar with approve, reject, reject-with-instruction, permission options, and countdown UI - Render rejected tool state distinctly across FlowChat event handling and tool cards - Add focused web UI tests for approval controls, terminal card rejection display, and tool event handling - Update agentic tool settings and FlowChat locale strings --- src/apps/cli/src/chat_state.rs | 1 + .../assembly/core/src/agentic/core/state.rs | 16 + .../implementations/exec_command/control.rs | 4 - .../agentic/tools/pipeline/state_manager.rs | 14 +- .../agentic/tools/pipeline/tool_pipeline.rs | 305 +++++++++++++++++- .../assembly/core/src/service/config/types.rs | 8 +- src/crates/contracts/events/src/agentic.rs | 2 + .../agent-runtime/src/tool_confirmation.rs | 26 +- .../tests/tool_confirmation_contracts.rs | 9 + .../execution/tool-contracts/src/lib.rs | 10 +- .../src/tool_execution_presentation.rs | 78 +++++ .../tool-contracts/tests/tool_contracts.rs | 94 +++++- .../execution/tool-execution/src/pipeline.rs | 42 ++- .../tests/tool_pipeline_planning.rs | 9 +- .../interfaces/acp/src/runtime/prompt.rs | 1 + .../component-library/components/registry.tsx | 6 - .../flow_chat/components/FlowItemRenderer.tsx | 4 +- .../src/flow_chat/components/FlowToolCard.tsx | 16 +- .../flow_chat/components/ToolApprovalBar.scss | 100 ++++++ .../components/ToolApprovalBar.test.tsx | 217 +++++++++++++ .../flow_chat/components/ToolApprovalBar.tsx | 220 +++++++++++++ .../modern/ExploreGroupRenderer.tsx | 6 +- .../components/modern/FlowChatContext.tsx | 4 +- .../components/modern/ModelRoundItem.tsx | 6 +- .../modern/modelRoundItemGrouping.ts | 2 +- .../modern/useFlowChatToolActions.ts | 32 +- .../subagent/SubagentProjectionView.tsx | 2 +- .../src/flow_chat/services/EventBatcher.ts | 3 +- .../flow-chat-manager/PersistenceModule.ts | 1 - .../flow-chat-manager/ToolEventModule.test.ts | 67 ++++ .../flow-chat-manager/ToolEventModule.ts | 43 ++- .../src/flow_chat/store/FlowChatStore.ts | 3 +- .../flow_chat/store/modernFlowChatStore.ts | 4 +- .../tool-cards/AcpPermissionActions.tsx | 8 +- .../src/flow_chat/tool-cards/BaseToolCard.tsx | 3 +- .../flow_chat/tool-cards/DefaultToolCard.tsx | 68 +--- .../ExecProcessToolCardView.test.tsx | 168 ++++++++++ .../tool-cards/ExecProcessToolCardView.tsx | 67 +++- .../tool-cards/FileOperationToolCard.tsx | 86 +---- .../flow_chat/tool-cards/GitToolDisplay.tsx | 84 +---- .../flow_chat/tool-cards/MCPToolDisplay.tsx | 54 +--- .../tool-cards/ReadFileDisplay.test.tsx | 38 +-- .../flow_chat/tool-cards/ReadFileDisplay.tsx | 59 +--- .../flow_chat/tool-cards/TaskToolDisplay.tsx | 49 +-- .../tool-cards/TerminalToolCard.scss | 16 - .../flow_chat/tool-cards/TerminalToolCard.tsx | 88 +---- .../tool-cards/ToolCardStatusSlot.tsx | 1 + src/web-ui/src/flow_chat/types/flow-chat.ts | 12 +- .../utils/dialogTurnStability.test.ts | 53 ++- .../flow_chat/utils/dialogTurnStability.ts | 41 ++- .../infrastructure/api/service-api/ToolAPI.ts | 2 +- .../config/components/AIFeaturesConfig.scss | 20 ++ .../config/components/SessionConfig.tsx | 103 +++++- src/web-ui/src/locales/en-US/flow-chat.json | 16 + .../locales/en-US/settings/agentic-tools.json | 6 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 16 + .../locales/zh-CN/settings/agentic-tools.json | 6 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 16 + .../locales/zh-TW/settings/agentic-tools.json | 6 +- .../src/shared/services/agent-service.ts | 6 +- 60 files changed, 1801 insertions(+), 646 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/ToolApprovalBar.scss create mode 100644 src/web-ui/src/flow_chat/components/ToolApprovalBar.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/ToolApprovalBar.tsx create mode 100644 src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 4a154a369..0febcc659 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -592,6 +592,7 @@ impl ChatState { tool_id, tool_name, params, + .. } => { self.update_tool(tool_id, |tool| { tool.status = ToolDisplayStatus::ConfirmationNeeded; diff --git a/src/crates/assembly/core/src/agentic/core/state.rs b/src/crates/assembly/core/src/agentic/core/state.rs index 88e21b2f2..f7c5ffa6d 100644 --- a/src/crates/assembly/core/src/agentic/core/state.rs +++ b/src/crates/assembly/core/src/agentic/core/state.rs @@ -92,6 +92,21 @@ pub enum ToolExecutionState { execution_ms: Option, }, + /// Rejected by user before execution + Rejected { + reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + queue_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + preflight_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + confirmation_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + execution_ms: Option, + }, + /// Cancelled Cancelled { reason: String, @@ -119,6 +134,7 @@ pub struct ToolStats { pub awaiting_confirmation: usize, pub completed: usize, pub failed: usize, + pub rejected: usize, pub cancelled: usize, } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs index 22273e4e5..ac22e2633 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs @@ -319,10 +319,6 @@ Output is only what was produced during this tool call's wait window."# false } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - true - } - fn manages_own_execution_timeout(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index 03a892592..7fa05ab9d 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -23,6 +23,7 @@ pub(crate) fn tool_task_state_kind(state: &ToolExecutionState) -> ToolTaskStateK ToolExecutionState::AwaitingConfirmation { .. } => ToolTaskStateKind::AwaitingConfirmation, ToolExecutionState::Completed { .. } => ToolTaskStateKind::Completed, ToolExecutionState::Failed { .. } => ToolTaskStateKind::Failed, + ToolExecutionState::Rejected { .. } => ToolTaskStateKind::Rejected, ToolExecutionState::Cancelled { .. } => ToolTaskStateKind::Cancelled, } } @@ -159,9 +160,17 @@ impl ToolStateManager { } => ToolStateEventKind::Streaming { chunks_received: *chunks_received, }, - ToolExecutionState::AwaitingConfirmation { params, .. } => { + ToolExecutionState::AwaitingConfirmation { params, timeout_at } => { + let confirmation_timeout_secs = task.options.confirmation_timeout_secs.filter(|seconds| *seconds > 0); ToolStateEventKind::AwaitingConfirmation { params: params.clone(), + timeout_at: confirmation_timeout_secs.map(|_| { + timeout_at + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64 + }), } } ToolExecutionState::Completed { @@ -202,6 +211,7 @@ impl ToolStateManager { confirmation_wait_ms: *confirmation_wait_ms, execution_ms: *execution_ms, }, + ToolExecutionState::Rejected { .. } => ToolStateEventKind::Rejected, ToolExecutionState::Cancelled { reason, duration_ms, @@ -250,6 +260,7 @@ impl ToolStateManager { awaiting_confirmation: counts.awaiting_confirmation, completed: counts.completed, failed: counts.failed, + rejected: counts.rejected, cancelled: counts.cancelled, } } @@ -367,5 +378,6 @@ pub struct ToolStats { pub awaiting_confirmation: usize, pub completed: usize, pub failed: usize, + pub rejected: usize, pub cancelled: usize, } diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index eb2bd7b08..a3baaf244 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -22,10 +22,13 @@ use bitfun_agent_runtime::tool_confirmation::{ }; use bitfun_agent_tools::{ build_invalid_tool_call_error_message, build_tool_call_truncation_recovery_notice, - build_tool_execution_error_presentation, build_user_steering_interrupted_presentation, - render_tool_result_for_assistant, truncate_raw_tool_arguments_preview, - truncate_tool_arguments_preview, validate_tool_execution_admission, - ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, GET_TOOL_SPEC_TOOL_NAME, + build_tool_confirmation_timeout_presentation, build_tool_execution_error_presentation, + build_tool_execution_timeout_presentation, + build_user_rejected_tool_presentation_with_instruction, + build_user_steering_interrupted_presentation, render_tool_result_for_assistant, + truncate_raw_tool_arguments_preview, truncate_tool_arguments_preview, + validate_tool_execution_admission, ToolExecutionAdmissionRejection, + ToolExecutionAdmissionRequest, GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, }; use bitfun_runtime_ports::RoundInjectionToolPreemption; @@ -210,6 +213,44 @@ fn build_user_steering_interrupted_result( } } +fn build_user_rejected_tool_result( + task_id: &str, + task: Option, + instruction: Option<&str>, +) -> ToolExecutionResult { + let (tool_id, tool_name, execution_time_ms) = if let Some(task) = task { + ( + task.tool_call.tool_id, + task.tool_call.tool_name, + elapsed_ms_since(task.created_at), + ) + } else { + warn!( + "Task not found while building user-rejected result: {}", + task_id + ); + (task_id.to_string(), "unknown".to_string(), 0) + }; + + let presentation = + build_user_rejected_tool_presentation_with_instruction(&tool_name, instruction); + + ToolExecutionResult { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + result: ModelToolResult { + tool_id, + tool_name, + result: presentation.result_json, + result_for_assistant: Some(presentation.result_for_assistant), + is_error: false, + duration_ms: Some(execution_time_ms), + image_attachments: None, + }, + execution_time_ms, + } +} + const ROUND_INJECTION_RUNNING_TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled because a pending round injection requested running-tool preemption for this turn."; @@ -797,26 +838,55 @@ impl ToolPipeline { self.state_manager .update_state( &tool_id, - ToolExecutionState::Cancelled { - reason: failure.state_reason, - duration_ms: Some(elapsed_ms_u64(start_time)), - queue_wait_ms: Some(queue_wait_ms), - preflight_ms: Some(preflight_ms), - confirmation_wait_ms: Some(elapsed_ms_u64(confirmation_started_at)), - execution_ms: None, + match failure.kind { + ConfirmationFailureKind::Rejected => ToolExecutionState::Rejected { + reason: failure.state_reason, + duration_ms: Some(elapsed_ms_u64(start_time)), + queue_wait_ms: Some(queue_wait_ms), + preflight_ms: Some(preflight_ms), + confirmation_wait_ms: Some(elapsed_ms_u64(confirmation_started_at)), + execution_ms: None, + }, + ConfirmationFailureKind::ChannelClosed + | ConfirmationFailureKind::Timeout => ToolExecutionState::Cancelled { + reason: failure.state_reason, + duration_ms: Some(elapsed_ms_u64(start_time)), + queue_wait_ms: Some(queue_wait_ms), + preflight_ms: Some(preflight_ms), + confirmation_wait_ms: Some(elapsed_ms_u64(confirmation_started_at)), + execution_ms: None, + }, }, ) .await; match failure.kind { ConfirmationFailureKind::Rejected => { - return Err(BitFunError::Validation(failure.error_message)); + return Ok(build_user_rejected_tool_result( + &tool_id, + self.state_manager.get_task(&tool_id), + failure.rejection_instruction.as_deref(), + )); } ConfirmationFailureKind::ChannelClosed => { return Err(BitFunError::service(failure.error_message)); } ConfirmationFailureKind::Timeout => { - return Err(BitFunError::Timeout(failure.error_message)); + let presentation = build_tool_confirmation_timeout_presentation(&tool_name); + return Ok(ToolExecutionResult { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + result: ModelToolResult { + tool_id, + tool_name, + result: presentation.result_json, + result_for_assistant: Some(presentation.result_for_assistant), + is_error: false, + duration_ms: Some(elapsed_ms_u64(start_time)), + image_attachments: None, + }, + execution_time_ms: elapsed_ms_u64(start_time), + }); } } } @@ -967,6 +1037,55 @@ impl ToolPipeline { return Err(e); } + if matches!(e, BitFunError::Timeout(_)) { + let duration_ms = elapsed_ms_u64(start_time); + let presentation = build_tool_execution_timeout_presentation( + &tool_name, + task.options.timeout_secs, + ); + let timed_out_tool_id = tool_id.clone(); + let timed_out_tool_name = tool_name.clone(); + + self.state_manager + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: presentation.result_for_assistant.clone(), + duration_ms: Some(duration_ms), + queue_wait_ms: Some(queue_wait_ms), + preflight_ms: Some(preflight_ms), + confirmation_wait_ms: Some(confirmation_wait_ms), + execution_ms: Some(execution_ms), + }, + ) + .await; + + warn!( + "Tool execution timed out: tool_name={}, duration_ms={}, queue_wait_ms={}, preflight_ms={}, confirmation_wait_ms={}, execution_ms={}", + tool_name, + duration_ms, + queue_wait_ms, + preflight_ms, + confirmation_wait_ms, + execution_ms + ); + + return Ok(ToolExecutionResult { + tool_id: timed_out_tool_id.clone(), + tool_name: timed_out_tool_name.clone(), + result: ModelToolResult { + tool_id: timed_out_tool_id, + tool_name: timed_out_tool_name, + result: presentation.result_json, + result_for_assistant: Some(presentation.result_for_assistant), + is_error: false, + duration_ms: Some(duration_ms), + image_attachments: None, + }, + execution_time_ms: duration_ms, + }); + } + let error_msg = e.to_string(); let is_retryable = task.options.max_retries > 0; @@ -1309,11 +1428,11 @@ impl ToolPipeline { ); Ok(()) } else { - // If the channel does not exist, mark it as cancelled directly + // If the channel does not exist, mark it as rejected directly. self.state_manager .update_state( tool_id, - ToolExecutionState::Cancelled { + ToolExecutionState::Rejected { reason: format!("User rejected: {}", reason), duration_ms: None, queue_wait_ms: None, @@ -1356,6 +1475,7 @@ mod tests { name: String, response: serde_json::Value, delay_ms: u64, + needs_permissions: bool, } #[async_trait] @@ -1373,7 +1493,11 @@ mod tests { } fn is_readonly(&self) -> bool { - true + !self.needs_permissions + } + + fn needs_permissions(&self, _input: Option<&serde_json::Value>) -> bool { + self.needs_permissions } fn input_schema(&self) -> serde_json::Value { @@ -1473,6 +1597,25 @@ mod tests { name: name.to_string(), response, delay_ms, + needs_permissions: false, + })); + } + + async fn register_permissioned_static_test_tool( + pipeline: &ToolPipeline, + name: &str, + response: serde_json::Value, + delay_ms: u64, + ) { + pipeline + .tool_registry + .write() + .await + .register_tool(Arc::new(StaticTestTool { + name: name.to_string(), + response, + delay_ms, + needs_permissions: true, })); } @@ -1553,6 +1696,136 @@ mod tests { .contains("Raw arguments:")); } + #[tokio::test] + async fn confirmation_timeout_returns_timeout_result_without_argument_error() { + let pipeline = test_tool_pipeline(); + register_permissioned_static_test_tool( + &pipeline, + "ExecCommand", + json!({ "unexpected": true }), + 0, + ) + .await; + + let task_id = "tool_1".to_string(); + pipeline + .state_manager + .create_task(ToolTask::new( + test_tool_call(&task_id, "ExecCommand"), + { + let context = test_tool_execution_context(); + context + }, + { + let mut options = ToolExecutionOptions::default(); + options.confirm_before_run = true; + options.confirmation_timeout_secs = Some(1); + options + }, + )) + .await; + + // The public execute_tools path is easier to keep stable via timeout + // by not delivering confirmation. We use a second task with the same + // setup to exercise the actual pipeline path. + let results = pipeline + .execute_tools( + vec![test_tool_call("tool_1", "ExecCommand")], + test_tool_execution_context(), + { + let mut options = ToolExecutionOptions::default(); + options.confirmation_timeout_secs = Some(0); + options + }, + ) + .await + .expect("timeout should be returned as a tool result"); + + assert_eq!(results.len(), 1); + let result = &results[0].result; + assert!(!result.is_error); + assert_eq!(result.result["category"], json!("confirmation_timeout")); + assert_eq!(result.result["tool_name"], json!("ExecCommand")); + assert!(result.result["provided_arguments"].is_null()); + let assistant_text = result.result_for_assistant.as_deref().unwrap_or_default(); + assert!(assistant_text.contains("confirmation window expired")); + assert!(!assistant_text.contains("failed")); + assert!(!assistant_text.contains("Provided arguments")); + } + + #[tokio::test] + async fn confirmation_rejection_returns_user_rejected_result_without_argument_error() { + let pipeline = test_tool_pipeline(); + register_permissioned_static_test_tool( + &pipeline, + "ExecCommand", + json!({ "unexpected": true }), + 0, + ) + .await; + + let reject_pipeline = pipeline.clone(); + let reject_handle = tokio::spawn(async move { + for _ in 0..50 { + if reject_pipeline + .state_manager + .get_task("tool_1") + .is_some_and(|task| { + matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) + }) + { + reject_pipeline + .reject_tool( + "tool_1", + "Use the built-in status view instead.".to_string(), + ) + .await + .expect("reject tool confirmation"); + return; + } + sleep(Duration::from_millis(10)).await; + } + panic!("tool should enter awaiting confirmation"); + }); + + let results = pipeline + .execute_tools( + vec![test_tool_call("tool_1", "ExecCommand")], + test_tool_execution_context(), + ToolExecutionOptions::default(), + ) + .await + .expect("user rejection should be returned as a tool result"); + reject_handle.await.expect("rejection task should finish"); + + assert_eq!(results.len(), 1); + let result = &results[0].result; + assert!(!result.is_error); + assert_eq!(result.result["status"], json!("rejected")); + assert_eq!(result.result["category"], json!("user_rejected")); + assert_eq!(result.result["tool_name"], json!("ExecCommand")); + assert_eq!( + result.result["instruction"], + json!("Use the built-in status view instead.") + ); + assert!(result.result["provided_arguments"].is_null()); + + let assistant_text = result.result_for_assistant.as_deref().unwrap_or_default(); + assert!(assistant_text.contains( + "The user rejected this tool call with the following instruction: \"Use the built-in status view instead.\"" + )); + assert!(assistant_text.contains("Do not retry it unless the user explicitly asks you to.")); + assert!(!assistant_text.contains("invalid_arguments")); + assert!(!assistant_text.contains("Provided arguments")); + assert!(!assistant_text.contains("failed")); + + let task = pipeline + .state_manager + .get_task("tool_1") + .expect("tool task should be retained"); + assert!(matches!(task.state, ToolExecutionState::Rejected { .. })); + } + #[tokio::test] async fn pipeline_admission_allowed_list_rejection_updates_failed_state_before_registry_lookup() { diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 624605f8c..842a7ce37 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -640,9 +640,9 @@ pub struct AIConfig { #[serde(rename_all = "snake_case")] pub enum SubagentBatchExecutionPolicy { /// Preserve the tool-owned concurrency-safety decision. - #[default] SafeOnly, /// Force multiple Task calls from the same model batch into parallel scheduling. + #[default] ForceParallel, /// Treat all Task calls as serial even when a subagent is read-only. Serial, @@ -794,7 +794,7 @@ fn default_subagent_max_concurrency() -> usize { } fn default_subagent_batch_execution_policy() -> SubagentBatchExecutionPolicy { - SubagentBatchExecutionPolicy::SafeOnly + SubagentBatchExecutionPolicy::ForceParallel } pub const DEFAULT_MAX_ROUNDS: usize = 200; @@ -2106,7 +2106,7 @@ mod tests { assert_eq!(config.subagent_max_concurrency, 5); assert_eq!( config.subagent_batch_execution_policy, - SubagentBatchExecutionPolicy::SafeOnly + SubagentBatchExecutionPolicy::ForceParallel ); let review_team = config .review_teams @@ -2141,7 +2141,7 @@ mod tests { assert_eq!(config.subagent_max_concurrency, 5); assert_eq!( config.subagent_batch_execution_policy, - SubagentBatchExecutionPolicy::SafeOnly + SubagentBatchExecutionPolicy::ForceParallel ); assert!(config.review_teams.contains_key("default")); } diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index a220462c2..e090dee4c 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -385,6 +385,8 @@ pub enum ToolEventData { tool_id: String, tool_name: String, params: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + timeout_at: Option, }, Confirmed { tool_id: String, diff --git a/src/crates/execution/agent-runtime/src/tool_confirmation.rs b/src/crates/execution/agent-runtime/src/tool_confirmation.rs index ff96339be..8c6c902f2 100644 --- a/src/crates/execution/agent-runtime/src/tool_confirmation.rs +++ b/src/crates/execution/agent-runtime/src/tool_confirmation.rs @@ -58,6 +58,7 @@ pub struct ToolConfirmationFailure { pub kind: ConfirmationFailureKind, pub state_reason: String, pub error_message: String, + pub rejection_instruction: Option, } #[derive(Debug, Clone, Default)] @@ -123,24 +124,39 @@ pub fn resolve_confirmation_failure( ) -> Option { match outcome { ToolConfirmationOutcome::Confirmed => None, - ToolConfirmationOutcome::Rejected { reason } => Some(ToolConfirmationFailure { - kind: ConfirmationFailureKind::Rejected, - state_reason: format!("User rejected: {reason}"), - error_message: format!("Tool was rejected by user: {reason}"), - }), + ToolConfirmationOutcome::Rejected { reason } => { + let rejection_instruction = normalize_rejection_instruction(&reason); + Some(ToolConfirmationFailure { + kind: ConfirmationFailureKind::Rejected, + state_reason: format!("User rejected: {reason}"), + error_message: format!("Tool was rejected by user: {reason}"), + rejection_instruction, + }) + } ToolConfirmationOutcome::ChannelClosed => Some(ToolConfirmationFailure { kind: ConfirmationFailureKind::ChannelClosed, state_reason: "Confirmation channel closed".to_string(), error_message: "Confirmation channel closed".to_string(), + rejection_instruction: None, }), ToolConfirmationOutcome::Timeout { tool_name } => Some(ToolConfirmationFailure { kind: ConfirmationFailureKind::Timeout, state_reason: "Confirmation timeout".to_string(), error_message: format!("Confirmation timeout: {tool_name}"), + rejection_instruction: None, }), } } +fn normalize_rejection_instruction(reason: &str) -> Option { + let trimmed = reason.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("User rejected") { + None + } else { + Some(trimmed.to_string()) + } +} + pub fn resolve_confirmation_wait_result( result: ToolConfirmationWaitResult, tool_name: &str, diff --git a/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs b/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs index 97184e41d..172d07e51 100644 --- a/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs @@ -72,6 +72,14 @@ fn confirmation_failure_mapping_preserves_legacy_reasons_and_errors() { assert_eq!(rejected.kind, ConfirmationFailureKind::Rejected); assert_eq!(rejected.state_reason, "User rejected: unsafe"); assert_eq!(rejected.error_message, "Tool was rejected by user: unsafe"); + assert_eq!(rejected.rejection_instruction.as_deref(), Some("unsafe")); + + let default_rejected = resolve_confirmation_failure(ToolConfirmationOutcome::Rejected { + reason: "User rejected".to_string(), + }) + .expect("rejection should produce failure"); + assert_eq!(default_rejected.kind, ConfirmationFailureKind::Rejected); + assert_eq!(default_rejected.rejection_instruction, None); let closed = resolve_confirmation_failure(ToolConfirmationOutcome::ChannelClosed) .expect("closed channel should produce failure"); @@ -86,6 +94,7 @@ fn confirmation_failure_mapping_preserves_legacy_reasons_and_errors() { assert_eq!(timeout.kind, ConfirmationFailureKind::Timeout); assert_eq!(timeout.state_reason, "Confirmation timeout"); assert_eq!(timeout.error_message, "Confirmation timeout: Bash"); + assert_eq!(timeout.rejection_instruction, None); } #[test] diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index b0cac718d..a37769077 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -71,10 +71,14 @@ pub use framework::{ pub use input_validator::InputValidator; pub use tool_execution_presentation::{ build_invalid_tool_call_error_message, build_tool_call_truncation_recovery_notice, - build_tool_execution_error_presentation, build_user_steering_interrupted_presentation, - is_write_like_tool_name, render_tool_result_for_assistant, truncate_raw_tool_arguments_preview, + build_tool_confirmation_timeout_presentation, build_tool_execution_error_presentation, + build_tool_execution_timeout_presentation, + build_user_rejected_tool_presentation, build_user_rejected_tool_presentation_with_instruction, + build_user_steering_interrupted_presentation, is_write_like_tool_name, + render_tool_result_for_assistant, truncate_raw_tool_arguments_preview, truncate_raw_tool_arguments_preview_to, truncate_tool_arguments_preview, - ToolExecutionErrorPresentation, TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, + ToolExecutionErrorPresentation, TOOL_CONFIRMATION_TIMEOUT_MESSAGE, + TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, USER_REJECTED_TOOL_MESSAGE, USER_STEERING_INTERRUPTED_MESSAGE, }; pub use tool_result_storage::{ diff --git a/src/crates/execution/tool-contracts/src/tool_execution_presentation.rs b/src/crates/execution/tool-contracts/src/tool_execution_presentation.rs index 89351d899..a4cca6b1a 100644 --- a/src/crates/execution/tool-contracts/src/tool_execution_presentation.rs +++ b/src/crates/execution/tool-contracts/src/tool_execution_presentation.rs @@ -2,6 +2,10 @@ use serde_json::Value; pub const TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES: usize = 1024; pub const USER_STEERING_INTERRUPTED_MESSAGE: &str = "Tool execution skipped because the user sent a new steering message for the running turn. Stop the remaining old tool plan and handle the new user message next."; +pub const USER_REJECTED_TOOL_MESSAGE: &str = + "The user rejected this tool call. Do not retry it unless the user explicitly asks you to. If you cannot complete the task without running this tool call, stop and ask the user how to proceed."; +pub const TOOL_CONFIRMATION_TIMEOUT_MESSAGE: &str = + "The tool confirmation window expired before the user responded. Do not retry the same tool call unless the user explicitly asks you to. If you still need this tool to complete the task, stop and ask the user how to proceed."; #[derive(Debug, Clone, PartialEq)] pub struct ToolExecutionErrorPresentation { @@ -98,6 +102,80 @@ pub fn build_user_steering_interrupted_presentation( } } +pub fn build_tool_confirmation_timeout_presentation( + tool_name: &str, +) -> ToolExecutionErrorPresentation { + ToolExecutionErrorPresentation { + result_json: serde_json::json!({ + "status": "cancelled", + "category": "confirmation_timeout", + "tool_name": tool_name, + "message": TOOL_CONFIRMATION_TIMEOUT_MESSAGE, + }), + result_for_assistant: TOOL_CONFIRMATION_TIMEOUT_MESSAGE.to_string(), + } +} + +pub fn build_tool_execution_timeout_presentation( + tool_name: &str, + timeout_secs: Option, +) -> ToolExecutionErrorPresentation { + let timeout_seconds_text = timeout_secs + .map(|seconds| format!("{seconds} seconds")) + .unwrap_or_else(|| "an unspecified limit".to_string()); + let message = format!( + "This tool call was cancelled because the global tool execution time limit ({timeout_seconds_text}) expired before the tool finished." + ); + + let mut result_json = serde_json::json!({ + "status": "timeout", + "category": "execution_timeout", + "tool_name": tool_name, + "message": message, + }); + if let Some(timeout_secs) = timeout_secs { + result_json["timeout_seconds"] = Value::from(timeout_secs); + } + + ToolExecutionErrorPresentation { + result_json, + result_for_assistant: message, + } +} + +pub fn build_user_rejected_tool_presentation(tool_name: &str) -> ToolExecutionErrorPresentation { + build_user_rejected_tool_presentation_with_instruction(tool_name, None) +} + +pub fn build_user_rejected_tool_presentation_with_instruction( + tool_name: &str, + instruction: Option<&str>, +) -> ToolExecutionErrorPresentation { + let normalized_instruction = instruction.map(str::trim).filter(|value| !value.is_empty()); + let message = if let Some(instruction) = normalized_instruction { + format!( + "The user rejected this tool call with the following instruction: \"{instruction}\". Do not retry it unless the user explicitly asks you to. If you cannot complete the task without running this tool call, stop and ask the user how to proceed." + ) + } else { + USER_REJECTED_TOOL_MESSAGE.to_string() + }; + + let mut result_json = serde_json::json!({ + "status": "rejected", + "category": "user_rejected", + "tool_name": tool_name, + "message": message, + }); + if let Some(instruction) = normalized_instruction { + result_json["instruction"] = Value::String(instruction.to_string()); + } + + ToolExecutionErrorPresentation { + result_json, + result_for_assistant: message, + } +} + pub fn build_invalid_tool_call_error_message( tool_name: &str, tool_is_error: bool, diff --git a/src/crates/execution/tool-contracts/tests/tool_contracts.rs b/src/crates/execution/tool-contracts/tests/tool_contracts.rs index 8fa699791..433bb80a1 100644 --- a/src/crates/execution/tool-contracts/tests/tool_contracts.rs +++ b/src/crates/execution/tool-contracts/tests/tool_contracts.rs @@ -2,8 +2,9 @@ use bitfun_agent_tools::{ build_bitfun_runtime_uri, build_collapsed_tool_stub_definition, build_get_tool_spec_assistant_detail, build_get_tool_spec_detail_result, build_get_tool_spec_duplicate_load_hint, build_get_tool_spec_duplicate_load_result, - build_prompt_visible_tool_manifest_definitions, build_tool_path_policy_denial_message, - build_tool_runtime_artifact_reference, build_tool_session_runtime_artifact_reference, + build_prompt_visible_tool_manifest_definitions, build_tool_execution_timeout_presentation, + build_tool_path_policy_denial_message, build_tool_runtime_artifact_reference, + build_tool_session_runtime_artifact_reference, collect_loaded_collapsed_tool_names, get_tool_spec_input_schema, get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_needs_permissions, get_tool_spec_short_description, is_bitfun_runtime_uri, is_remote_posix_path_within_root, @@ -27,10 +28,13 @@ use bitfun_agent_tools::{ }; use bitfun_agent_tools::{ build_invalid_tool_call_error_message, build_tool_call_truncation_recovery_notice, - build_tool_execution_error_presentation, build_user_steering_interrupted_presentation, - is_write_like_tool_name, render_tool_result_for_assistant, - truncate_raw_tool_arguments_preview_to, truncate_tool_arguments_preview, - TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, USER_STEERING_INTERRUPTED_MESSAGE, + build_tool_confirmation_timeout_presentation, build_tool_execution_error_presentation, + build_user_rejected_tool_presentation, build_user_rejected_tool_presentation_with_instruction, + build_user_steering_interrupted_presentation, is_write_like_tool_name, + render_tool_result_for_assistant, truncate_raw_tool_arguments_preview_to, + truncate_tool_arguments_preview, TOOL_CONFIRMATION_TIMEOUT_MESSAGE, + TOOL_ERROR_ARGUMENTS_PREVIEW_BYTES, USER_REJECTED_TOOL_MESSAGE, + USER_STEERING_INTERRUPTED_MESSAGE, }; use bitfun_agent_tools::{ build_persisted_tool_output_message, count_tool_result_lines, file_tool_guidance_message, @@ -157,6 +161,84 @@ fn steering_interrupted_presentation_preserves_current_contract() { ); } +#[test] +fn tool_confirmation_timeout_presentation_is_not_an_execution_failure() { + let presentation = build_tool_confirmation_timeout_presentation("ExecCommand"); + + assert_eq!(presentation.result_json["status"], "cancelled"); + assert_eq!(presentation.result_json["category"], "confirmation_timeout"); + assert_eq!(presentation.result_json["tool_name"], "ExecCommand"); + assert_eq!( + presentation.result_json["message"], + TOOL_CONFIRMATION_TIMEOUT_MESSAGE + ); + assert!(presentation.result_json["provided_arguments"].is_null()); + assert_eq!( + presentation.result_for_assistant, + TOOL_CONFIRMATION_TIMEOUT_MESSAGE + ); + assert!(!presentation.result_for_assistant.contains("failed")); + assert!(!presentation + .result_for_assistant + .contains("Provided arguments")); +} + +#[test] +fn tool_execution_timeout_presentation_includes_timeout_seconds() { + let presentation = build_tool_execution_timeout_presentation("ExecCommand", Some(120)); + + assert_eq!(presentation.result_json["status"], "timeout"); + assert_eq!(presentation.result_json["category"], "execution_timeout"); + assert_eq!(presentation.result_json["tool_name"], "ExecCommand"); + assert_eq!(presentation.result_json["timeout_seconds"], 120); + assert!( + presentation + .result_for_assistant + .contains("This tool call was cancelled because the global tool execution time limit (120 seconds)") + ); + assert!(!presentation.result_for_assistant.contains("Provided arguments")); + assert!(!presentation.result_for_assistant.contains("failed")); +} + +#[test] +fn user_rejected_tool_presentation_is_not_an_argument_error() { + let presentation = build_user_rejected_tool_presentation("ExecCommand"); + + assert_eq!(presentation.result_json["status"], "rejected"); + assert_eq!(presentation.result_json["category"], "user_rejected"); + assert_eq!(presentation.result_json["tool_name"], "ExecCommand"); + assert_eq!( + presentation.result_json["message"], + USER_REJECTED_TOOL_MESSAGE + ); + assert!(presentation.result_json["provided_arguments"].is_null()); + assert_eq!( + presentation.result_for_assistant, + USER_REJECTED_TOOL_MESSAGE + ); + assert!(!presentation + .result_for_assistant + .contains("invalid_arguments")); + assert!(!presentation.result_for_assistant.contains("failed")); + assert!(!presentation + .result_for_assistant + .contains("Provided arguments")); + + let presentation = build_user_rejected_tool_presentation_with_instruction( + "ExecCommand", + Some("Use the built-in status view instead."), + ); + assert_eq!( + presentation.result_json["instruction"], + "Use the built-in status view instead." + ); + assert!(presentation.result_json["provided_arguments"].is_null()); + assert_eq!( + presentation.result_for_assistant, + "The user rejected this tool call with the following instruction: \"Use the built-in status view instead.\". Do not retry it unless the user explicitly asks you to. If you cannot complete the task without running this tool call, stop and ask the user how to proceed." + ); +} + #[test] fn invalid_tool_call_error_message_preserves_current_contract() { let message = diff --git a/src/crates/execution/tool-execution/src/pipeline.rs b/src/crates/execution/tool-execution/src/pipeline.rs index f9faf262c..69c84346f 100644 --- a/src/crates/execution/tool-execution/src/pipeline.rs +++ b/src/crates/execution/tool-execution/src/pipeline.rs @@ -13,8 +13,8 @@ pub struct ToolBatch { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SubagentBatchExecutionPolicy { - #[default] SafeOnly, + #[default] ForceParallel, Serial, } @@ -41,12 +41,16 @@ pub enum ToolTaskStateKind { AwaitingConfirmation, Completed, Failed, + Rejected, Cancelled, } impl ToolTaskStateKind { pub fn is_terminal(self) -> bool { - matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + matches!( + self, + Self::Completed | Self::Failed | Self::Rejected | Self::Cancelled + ) } pub fn is_cancellable(self) -> bool { @@ -75,6 +79,7 @@ pub struct ToolStateCounts { pub awaiting_confirmation: usize, pub completed: usize, pub failed: usize, + pub rejected: usize, pub cancelled: usize, } @@ -102,6 +107,7 @@ pub enum ToolStateEventKind { }, AwaitingConfirmation { params: serde_json::Value, + timeout_at: Option, }, Completed { result: serde_json::Value, @@ -120,6 +126,7 @@ pub enum ToolStateEventKind { confirmation_wait_ms: Option, execution_ms: Option, }, + Rejected, Cancelled { reason: String, duration_ms: Option, @@ -254,6 +261,7 @@ pub fn count_tool_states(states: impl IntoIterator) -> ToolTaskStateKind::AwaitingConfirmation => counts.awaiting_confirmation += 1, ToolTaskStateKind::Completed => counts.completed += 1, ToolTaskStateKind::Failed => counts.failed += 1, + ToolTaskStateKind::Rejected => counts.rejected += 1, ToolTaskStateKind::Cancelled => counts.cancelled += 1, } } @@ -299,11 +307,14 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { tool_name, chunks_received, }, - ToolStateEventKind::AwaitingConfirmation { params } => ToolEventData::ConfirmationNeeded { - tool_id, - tool_name, - params, - }, + ToolStateEventKind::AwaitingConfirmation { params, timeout_at } => { + ToolEventData::ConfirmationNeeded { + tool_id, + tool_name, + params, + timeout_at, + } + } ToolStateEventKind::Completed { result, result_for_assistant, @@ -340,6 +351,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { confirmation_wait_ms, execution_ms, }, + ToolStateEventKind::Rejected => ToolEventData::Rejected { tool_id, tool_name }, ToolStateEventKind::Cancelled { reason, duration_ms, @@ -415,6 +427,22 @@ mod tests { assert!(result["nested"][0].get("data_url").is_none()); } + #[test] + fn rejected_state_maps_to_rejected_event() { + let data = tool_state_event_data(ToolStateEventFacts { + tool_id: "tool-1".to_string(), + tool_name: "ExecCommand".to_string(), + state: ToolStateEventKind::Rejected, + }); + + let ToolEventData::Rejected { tool_id, tool_name } = data else { + panic!("expected rejected event"); + }; + + assert_eq!(tool_id, "tool-1"); + assert_eq!(tool_name, "ExecCommand"); + } + #[test] fn sanitize_keeps_values_without_data_urls() { let result = sanitize_tool_result_for_event(&json!({ "text": "ok" })); diff --git a/src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs b/src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs index ce3edb168..f58f1d634 100644 --- a/src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs +++ b/src/crates/execution/tool-execution/tests/tool_pipeline_planning.rs @@ -115,10 +115,12 @@ fn cancellation_policy_preserves_cancellable_and_terminal_state_contract() { assert!(!should_cancel_tool_state(ToolTaskStateKind::Streaming)); assert!(!should_cancel_tool_state(ToolTaskStateKind::Completed)); assert!(!should_cancel_tool_state(ToolTaskStateKind::Failed)); + assert!(!should_cancel_tool_state(ToolTaskStateKind::Rejected)); assert!(!should_cancel_tool_state(ToolTaskStateKind::Cancelled)); assert!(ToolTaskStateKind::Completed.is_terminal()); assert!(ToolTaskStateKind::Failed.is_terminal()); + assert!(ToolTaskStateKind::Rejected.is_terminal()); assert!(ToolTaskStateKind::Cancelled.is_terminal()); assert!(!ToolTaskStateKind::Running.is_terminal()); } @@ -129,11 +131,12 @@ fn dialog_turn_cancellation_summary_counts_cancelled_and_skipped_tasks() { ToolTaskStateKind::Queued, ToolTaskStateKind::Running, ToolTaskStateKind::Completed, + ToolTaskStateKind::Rejected, ToolTaskStateKind::Cancelled, ]); assert_eq!(summary.cancelled, 2); - assert_eq!(summary.skipped, 2); + assert_eq!(summary.skipped, 3); } #[test] @@ -161,10 +164,11 @@ fn state_counts_preserve_pipeline_stats_contract() { ToolTaskStateKind::AwaitingConfirmation, ToolTaskStateKind::Completed, ToolTaskStateKind::Failed, + ToolTaskStateKind::Rejected, ToolTaskStateKind::Cancelled, ]); - assert_eq!(counts.total, 9); + assert_eq!(counts.total, 10); assert_eq!(counts.queued, 2); assert_eq!(counts.waiting, 1); assert_eq!(counts.running, 1); @@ -172,5 +176,6 @@ fn state_counts_preserve_pipeline_stats_contract() { assert_eq!(counts.awaiting_confirmation, 1); assert_eq!(counts.completed, 1); assert_eq!(counts.failed, 1); + assert_eq!(counts.rejected, 1); assert_eq!(counts.cancelled, 1); } diff --git a/src/crates/interfaces/acp/src/runtime/prompt.rs b/src/crates/interfaces/acp/src/runtime/prompt.rs index 4c8428418..59062484c 100644 --- a/src/crates/interfaces/acp/src/runtime/prompt.rs +++ b/src/crates/interfaces/acp/src/runtime/prompt.rs @@ -165,6 +165,7 @@ async fn wait_for_prompt_completion( tool_id, tool_name, params, + .. } = tool_event { handle_permission_request( diff --git a/src/web-ui/src/component-library/components/registry.tsx b/src/web-ui/src/component-library/components/registry.tsx index 28512a549..53cfaa736 100644 --- a/src/web-ui/src/component-library/components/registry.tsx +++ b/src/web-ui/src/component-library/components/registry.tsx @@ -1085,8 +1085,6 @@ console.log(user.greet());`); )} config={TOOL_CARD_CONFIGS['Write']} sessionId="preview-session" - onConfirm={async () => alert('已确认')} - onReject={async () => alert('已拒绝')} />

编辑文件

@@ -1102,8 +1100,6 @@ console.log(user.greet());`); )} config={TOOL_CARD_CONFIGS['Edit']} sessionId="preview-session" - onConfirm={async () => alert('已确认')} - onReject={async () => alert('已拒绝')} />

删除文件

@@ -1115,8 +1111,6 @@ console.log(user.greet());`); )} config={TOOL_CARD_CONFIGS['Delete']} sessionId="preview-session" - onConfirm={async () => alert('已删除')} - onReject={async () => alert('已取消')} /> ), diff --git a/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx b/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx index d7d4929f5..553730d90 100644 --- a/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem, FlowUserSteeringItem } from '../types/flow-chat'; +import { FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem, FlowUserSteeringItem, type ToolRejectOptions } from '../types/flow-chat'; import { FlowTextBlock } from './FlowTextBlock'; import { FlowToolCard } from './FlowToolCard'; import { ModelThinkingDisplay } from '../tool-cards/ModelThinkingDisplay'; @@ -16,7 +16,7 @@ interface FlowItemRendererProps { onFileViewRequest?: (filePath: string) => void; onTabOpen?: (tabInfo: any) => void; onConfirm?: (toolId: string, updatedInput?: any, permissionOptionId?: string, approve?: boolean) => void; - onReject?: (toolId: string, permissionOptionId?: string) => void; + onReject?: (toolId: string, options?: ToolRejectOptions) => void; sessionId?: string; } diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx index 02959c4f8..0ee692713 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx @@ -6,18 +6,19 @@ import React from 'react'; import { getToolCardComponent } from '../tool-cards'; import { getToolCardConfig } from '../tool-cards/toolCardMetadata'; -import type { FlowToolItem, ToolCardDisplayContext } from '../types/flow-chat'; +import type { FlowToolItem, ToolCardDisplayContext, ToolRejectOptions } from '../types/flow-chat'; import { createLogger } from '@/shared/utils/logger'; import { FlowToolCardErrorBoundary } from './FlowToolCardErrorBoundary'; import { useTranslation } from 'react-i18next'; import { getToolInterruptionNote } from '../utils/toolInterruption'; +import { ToolApprovalBar } from './ToolApprovalBar'; const log = createLogger('FlowToolCard'); interface FlowToolCardProps { toolItem: FlowToolItem; onConfirm?: (toolId: string, updatedInput?: any, permissionOptionId?: string, approve?: boolean) => void; - onReject?: (toolId: string, permissionOptionId?: string) => void; + onReject?: (toolId: string, options?: ToolRejectOptions) => void; onOpenInEditor?: (filePath: string) => void; onOpenInPanel?: (panelType: string, data: any) => void; onExpand?: (toolId: string) => void; @@ -62,8 +63,8 @@ export const FlowToolCard: React.FC = React.memo(({ onConfirm?.(toolItem.id, updatedInput, permissionOptionId, approve); }, [toolItem.id, toolItem.toolName, onConfirm]); - const handleReject = React.useCallback((permissionOptionId?: string) => { - onReject?.(toolItem.id, permissionOptionId); + const handleReject = React.useCallback((options?: ToolRejectOptions) => { + onReject?.(toolItem.id, options); }, [toolItem.id, onReject]); const handleExpand = React.useCallback(() => { @@ -86,8 +87,6 @@ export const FlowToolCard: React.FC = React.memo(({ toolItem={toolItem} config={config} interruptionNote={interruptionNote} - onConfirm={handleConfirm} - onReject={handleReject} onOpenInEditor={onOpenInEditor} onOpenInPanel={onOpenInPanel} onExpand={handleExpand} @@ -95,6 +94,11 @@ export const FlowToolCard: React.FC = React.memo(({ displayContext={displayContext} /> + {interruptionNote && !cardHandlesInterruptionNote && (
{interruptionNote} diff --git a/src/web-ui/src/flow_chat/components/ToolApprovalBar.scss b/src/web-ui/src/flow_chat/components/ToolApprovalBar.scss new file mode 100644 index 000000000..1f5c22b7b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ToolApprovalBar.scss @@ -0,0 +1,100 @@ +.tool-approval-bar { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 6px; + margin: 0 0 var(--flowchat-card-gap); + padding: 7px var(--tool-card-expanded-pad-x); + border: 1px solid var(--color-warning); + border-radius: var(--flowchat-card-radius); + background: color-mix(in srgb, var(--color-warning) 7%, var(--color-bg-scene)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-warning) 12%, transparent); + box-sizing: border-box; +} + +.tool-approval-bar__main { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--flowchat-inline-gap); +} + +.tool-approval-bar__message { + min-width: 0; + color: var(--color-text-secondary); + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-support-line-height); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tool-approval-bar__countdown { + color: var(--color-text-muted); +} + +.tool-approval-bar__actions, +.tool-approval-bar__permission-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: var(--flowchat-inline-gap); + flex-shrink: 0; +} + +.tool-approval-bar__icon-button { + flex-shrink: 0; +} + +.tool-approval-bar__instruction { + display: flex; + align-items: center; + gap: var(--flowchat-inline-gap); + min-width: 0; +} + +.tool-approval-bar .tool-approval-bar__instruction-input, +.tool-approval-bar .tool-approval-bar__instruction-input:focus, +.tool-approval-bar .tool-approval-bar__instruction-input:focus-visible { + flex: 1 1 auto; + min-width: 0; + height: 28px; + padding: 0 8px; + border: 1px solid var(--border-subtle); + border-radius: 6px; + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-size: var(--flowchat-font-size-sm); + outline: none; + box-shadow: none; +} + +.tool-approval-bar__instruction-submit { + flex-shrink: 0; + height: 28px; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--color-error) 45%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--color-error) 10%, transparent); + color: var(--color-error); + font-size: var(--flowchat-font-size-sm); + cursor: pointer; +} + +.tool-approval-bar__instruction-submit:hover { + background: color-mix(in srgb, var(--color-error) 16%, transparent); +} + +@media (max-width: 640px) { + .tool-approval-bar__main, + .tool-approval-bar__instruction { + align-items: flex-start; + flex-direction: column; + } + + .tool-approval-bar__actions, + .tool-approval-bar__permission-actions { + justify-content: flex-start; + } +} diff --git a/src/web-ui/src/flow_chat/components/ToolApprovalBar.test.tsx b/src/web-ui/src/flow_chat/components/ToolApprovalBar.test.tsx new file mode 100644 index 000000000..f16d7ca10 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ToolApprovalBar.test.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; + +import { ToolApprovalBar } from './ToolApprovalBar'; +import type { FlowToolItem } from '../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const messages: Record = { + 'toolCards.approval.waiting': 'Waiting for confirmation', + 'toolCards.approval.confirm': 'Allow', + 'toolCards.approval.reject': 'Reject', + 'toolCards.approval.rejectWithInstruction': 'Reject with instruction', + 'toolCards.approval.confirmTooltip': 'Allow this tool run', + 'toolCards.approval.rejectTooltip': 'Reject this tool run', + 'toolCards.approval.rejectWithInstructionTooltip': 'Reject and tell the assistant what to do next', + 'toolCards.approval.rejectInstructionLabel': 'Rejection instruction', + 'toolCards.approval.rejectInstructionPlaceholder': 'Tell the assistant what to do instead...', + 'toolCards.approval.rejectWithInstructionSubmit': 'Reject', + 'toolCards.approval.emptyInputTooltip': 'This tool has no executable input', + 'toolCards.approval.ariaLabel': 'Tool approval', + 'toolCards.approval.remaining': 'remaining {{time}}', +}; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const template = messages[key] ?? (typeof options?.defaultValue === 'string' ? options.defaultValue : key); + return template.replace(/\{\{(\w+)\}\}/g, (_match, token) => String(options?.[token] ?? `{{${token}}}`)); + }, + }), + }; +}); + +vi.mock('@/component-library', () => ({ + IconButton: ({ + children, + tooltip, + ...props + }: React.ButtonHTMLAttributes & { tooltip?: React.ReactNode }) => ( + + ), +})); + +function execCommandItem(cmd: string, status: FlowToolItem['status'] = 'pending_confirmation'): FlowToolItem { + return { + id: 'tool-exec-1', + type: 'tool', + toolName: 'ExecCommand', + status, + timestamp: Date.now(), + toolCall: { + id: 'call-exec-1', + input: { cmd }, + }, + }; +} + +describe('ToolApprovalBar', () => { + let dom: JSDOM; + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + dom = new JSDOM('
', { + pretendToBeVisual: true, + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + (dom.window.HTMLElement.prototype as any).attachEvent = vi.fn(); + (dom.window.HTMLElement.prototype as any).detachEvent = vi.fn(); + + container = dom.window.document.getElementById('root') as HTMLDivElement; + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + vi.unstubAllGlobals(); + }); + + it('renders shared approval actions for pending ExecCommand tools', () => { + const onConfirm = vi.fn(); + const onReject = vi.fn(); + const input = { cmd: 'npm test' }; + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('Waiting for confirmation'); + + const allowButton = container.querySelector('button[aria-label="Allow"]') as HTMLButtonElement; + const rejectButton = container.querySelector('button[aria-label="Reject"]') as HTMLButtonElement; + + act(() => { + allowButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + rejectButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(onConfirm).toHaveBeenCalledWith(input); + expect(onReject).toHaveBeenCalledWith(); + }); + + it('submits a rejection instruction from the shared approval bar', () => { + const onReject = vi.fn(); + + act(() => { + root.render( + , + ); + }); + + const rejectWithInstructionButton = container.querySelector( + 'button[aria-label="Reject with instruction"]', + ) as HTMLButtonElement; + act(() => { + rejectWithInstructionButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const input = container.querySelector('input[aria-label="Rejection instruction"]') as HTMLInputElement; + act(() => { + input.value = 'Use the status panel instead'; + input.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + }); + + const submitButton = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Reject') as HTMLButtonElement; + act(() => { + submitButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(onReject).toHaveBeenCalledWith({ instruction: 'Use the status panel instead' }); + }); + + it('disables approval for an empty ExecCommand input', () => { + act(() => { + root.render(); + }); + + const allowButton = container.querySelector('button[aria-label="Allow"]') as HTMLButtonElement; + expect(allowButton.disabled).toBe(true); + expect(allowButton.title).toBe('This tool has no executable input'); + }); + + it('does not render for non-confirmation statuses', () => { + act(() => { + root.render(); + }); + + expect(container.textContent).toBe(''); + }); + + it('shows remaining confirmation time when confirmation timeout is set', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')); + + try { + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('Waiting for confirmation'); + expect(container.textContent).toContain('1m 5s'); + } finally { + vi.useRealTimers(); + } + }); + + it('hides remaining confirmation time when timeout is longer than ten minutes', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')); + + try { + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('Waiting for confirmation'); + expect(container.textContent).not.toContain('remaining'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/web-ui/src/flow_chat/components/ToolApprovalBar.tsx b/src/web-ui/src/flow_chat/components/ToolApprovalBar.tsx new file mode 100644 index 000000000..fa1b27212 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ToolApprovalBar.tsx @@ -0,0 +1,220 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Check, MessageSquareX, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { IconButton } from '@/component-library'; +import { AcpPermissionActions } from '../tool-cards/AcpPermissionActions'; +import { hasAcpPermissionOptions } from '../tool-cards/AcpPermissionActions.utils'; +import type { FlowToolItem, ToolRejectOptions } from '../types/flow-chat'; +import './ToolApprovalBar.scss'; + +interface ToolApprovalBarProps { + toolItem: FlowToolItem; + onConfirm?: (updatedInput?: any, permissionOptionId?: string, approve?: boolean) => void; + onReject?: (options?: ToolRejectOptions) => void; +} + +function hasPendingToolConfirmation(toolItem: FlowToolItem): boolean { + return toolItem.status === 'pending_confirmation'; +} + +function formatRemainingConfirmationTime(remainingMs: number): string { + if (remainingMs < 1000) return '1s'; + const totalSeconds = Math.ceil(remainingMs / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + +export const ToolApprovalBar: React.FC = ({ + toolItem, + onConfirm, + onReject, +}) => { + const { t } = useTranslation('flow-chat'); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [showInstructionInput, setShowInstructionInput] = useState(false); + const [instruction, setInstruction] = useState(''); + const instructionInputRef = useRef(null); + const input = toolItem.toolCall?.input; + const hasPermissionOptions = hasAcpPermissionOptions(toolItem); + const canConfirm = useMemo(() => { + if (toolItem.toolName === 'Bash') { + const command = typeof input?.command === 'string' ? input.command : ''; + return Boolean(command.trim()); + } + + if (toolItem.toolName === 'ExecCommand') { + const command = typeof input?.cmd === 'string' ? input.cmd : ''; + return Boolean(command.trim()); + } + + return true; + }, [input, toolItem.toolName]); + const confirmationTimeoutAt = toolItem.confirmationTimeoutAt; + const remainingConfirmationMs = useMemo(() => { + if (typeof confirmationTimeoutAt !== 'number') { + return null; + } + return Math.max(0, confirmationTimeoutAt - nowMs); + }, [confirmationTimeoutAt, nowMs]); + const confirmationCountdownLabel = useMemo(() => { + if (remainingConfirmationMs == null) { + return null; + } + if (remainingConfirmationMs > 10 * 60 * 1000) { + return null; + } + return formatRemainingConfirmationTime(remainingConfirmationMs); + }, [remainingConfirmationMs]); + + useEffect(() => { + if (typeof confirmationTimeoutAt !== 'number') { + return undefined; + } + + const tick = () => setNowMs(Date.now()); + tick(); + const handle = window.setInterval(tick, 1000); + return () => window.clearInterval(handle); + }, [confirmationTimeoutAt]); + + if (!hasPendingToolConfirmation(toolItem)) { + return null; + } + + const handleConfirm = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (!canConfirm) { + return; + } + + onConfirm?.(input); + }; + + const handleReject = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onReject?.(); + }; + + const handleRejectWithInstruction = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const trimmedInstruction = (instructionInputRef.current?.value ?? instruction).trim(); + onReject?.(trimmedInstruction ? { instruction: trimmedInstruction } : undefined); + }; + + const handleToggleInstructionInput = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setShowInstructionInput((value) => !value); + }; + + const handleInstructionKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + + if (event.key === 'Enter') { + event.preventDefault(); + const trimmedInstruction = event.currentTarget.value.trim(); + onReject?.(trimmedInstruction ? { instruction: trimmedInstruction } : undefined); + } else if (event.key === 'Escape') { + event.preventDefault(); + setShowInstructionInput(false); + setInstruction(''); + } + }; + + return ( +
+
+ + {t('toolCards.approval.waiting')} + {confirmationCountdownLabel ? ( + + {' '} + · {t('toolCards.approval.remaining', { time: confirmationCountdownLabel })} + + ) : null} + + + {hasPermissionOptions ? ( + + ) : ( + <> + + + + + + + + + + + )} + +
+ {showInstructionInput && !hasPermissionOptions && ( +
+ setInstruction(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={handleInstructionKeyDown} + placeholder={t('toolCards.approval.rejectInstructionPlaceholder')} + aria-label={t('toolCards.approval.rejectInstructionLabel')} + /> + +
+ )} +
+ ); +}; + +export default ToolApprovalBar; diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx index 1e8c6d20d..81fc228c4 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -7,7 +7,7 @@ import React, { useRef, useMemo, useCallback, useEffect, useState } from 'react'; import { ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import type { FlowItem, FlowToolItem, FlowTextItem, FlowThinkingItem } from '../../types/flow-chat'; +import type { FlowItem, FlowToolItem, FlowTextItem, FlowThinkingItem, ToolRejectOptions } from '../../types/flow-chat'; import type { ExploreGroupData } from '../../store/modernFlowChatStore'; import { createLogger } from '@/shared/utils/logger'; @@ -313,9 +313,9 @@ const ExploreItemRenderer = React.memo(({ item, turnId } }, [onToolConfirm]); - const handleReject = useCallback(async (toolId: string, permissionOptionId?: string) => { + const handleReject = useCallback(async (toolId: string, options?: ToolRejectOptions) => { if (onToolReject) { - await onToolReject(toolId, permissionOptionId); + await onToolReject(toolId, options); } }, [onToolReject]); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx index f714baa2b..e19f4fbe5 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx @@ -5,7 +5,7 @@ import { createContext, useContext } from 'react'; import type React from 'react'; -import type { FlowChatConfig, Session } from '../../types/flow-chat'; +import type { FlowChatConfig, Session, ToolRejectOptions } from '../../types/flow-chat'; import type { LineRange } from '@/component-library'; export interface FlowChatContextValue { @@ -18,7 +18,7 @@ export interface FlowChatContextValue { // Tool actions onToolConfirm?: (toolId: string, updatedInput?: any, permissionOptionId?: string, approve?: boolean) => Promise; - onToolReject?: (toolId: string, permissionOptionId?: string) => Promise; + onToolReject?: (toolId: string, options?: ToolRejectOptions) => Promise; // Session info sessionId?: string; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index d0b78169a..f77e752fc 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -10,7 +10,7 @@ import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Copy, Check } from 'lucide-react'; -import type { ModelRound, ModelRoundAttempt, FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem, TokenUsage } from '../../types/flow-chat'; +import type { ModelRound, ModelRoundAttempt, FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem, TokenUsage, ToolRejectOptions } from '../../types/flow-chat'; import { useI18n } from '@/infrastructure/i18n'; import { FlowTextBlock } from '../FlowTextBlock'; import { FlowToolCard } from '../FlowToolCard'; @@ -882,9 +882,9 @@ const FlowItemRenderer: React.FC = ({ await onToolConfirm(toolId, updatedInput, permissionOptionId, approve); } }} - onReject={async (_toolId: string, permissionOptionId?: string) => { + onReject={async (_toolId: string, options?: ToolRejectOptions) => { if (onToolReject) { - await onToolReject(item.id, permissionOptionId); + await onToolReject(item.id, options); } }} onOpenInEditor={(filePath: string) => { diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts index 4c0744c41..4e62174b6 100644 --- a/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts @@ -25,7 +25,7 @@ function hasActiveStreamingNarrative(items: FlowItem[]): boolean { function isActiveToolItem(item: FlowItem): boolean { if (item.type !== 'tool') return false; - return item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'error'; + return item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'rejected' && item.status !== 'error'; } export function isCompletedToolInTransientWindow(item: FlowItem, nowMs: number): boolean { diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts index da651a39c..cb357fcb1 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts @@ -9,7 +9,7 @@ import { ACPClientAPI, } from '@/infrastructure/api/service-api/ACPClientAPI'; import { flowChatStore } from '../../store/FlowChatStore'; -import type { DialogTurn, FlowItem, FlowToolItem, ModelRound } from '../../types/flow-chat'; +import type { DialogTurn, FlowItem, FlowToolItem, ModelRound, ToolRejectOptions } from '../../types/flow-chat'; const log = createLogger('useFlowChatToolActions'); @@ -74,11 +74,22 @@ export function useFlowChatToolActions() { flowChatStore.updateModelRoundItem(sessionId, turnId, toolId, { userConfirmed: approve, - status: approve ? 'confirmed' : 'cancelled', + status: approve ? 'confirmed' : 'rejected', toolCall: { ...toolItem.toolCall, input: finalInput, }, + ...(approve ? {} : { + requiresConfirmation: false, + acpPermission: undefined, + isParamsStreaming: false, + toolResult: { + result: null, + success: false, + error: 'User rejected operation', + }, + endTime: Date.now(), + }), } as any); const acpPermission = toolItem.acpPermission; @@ -104,7 +115,7 @@ export function useFlowChatToolActions() { } }, []); - const handleToolReject = useCallback(async (toolId: string, permissionOptionId?: string) => { + const handleToolReject = useCallback(async (toolId: string, options?: ToolRejectOptions) => { try { const { sessionId, toolItem, turnId } = resolveToolContext(toolId); @@ -115,7 +126,16 @@ export function useFlowChatToolActions() { flowChatStore.updateModelRoundItem(sessionId, turnId, toolId, { userConfirmed: false, - status: 'cancelled', + status: 'rejected', + requiresConfirmation: false, + acpPermission: undefined, + isParamsStreaming: false, + toolResult: { + result: null, + success: false, + error: 'User rejected operation', + }, + endTime: Date.now(), } as any); const acpPermission = toolItem.acpPermission; @@ -123,7 +143,7 @@ export function useFlowChatToolActions() { await ACPClientAPI.submitPermissionResponse({ permissionId: acpPermission.permissionId, approve: false, - optionId: permissionOptionId, + optionId: options?.permissionOptionId, }); return; } @@ -133,6 +153,8 @@ export function useFlowChatToolActions() { sessionId, toolId, 'reject', + undefined, + options?.instruction, ); } catch (error) { log.error('Tool rejection failed', error); diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx index b5db5c1ca..9052ea116 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx @@ -258,7 +258,7 @@ export const SubagentProjectionView: React.FC = ({ const lastActiveItemId = useMemo(() => { for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]; - if (item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'error') { + if (item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'rejected' && item.status !== 'error') { return item.id; } if (item.type === 'thinking' && (item as FlowThinkingItem).isStreaming) { diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index 2230c8141..d987fe693 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -296,6 +296,7 @@ export interface StreamChunkToolEvent extends BaseToolEvent<'StreamChunk'> { export interface ConfirmationNeededToolEvent extends BaseToolEvent<'ConfirmationNeeded'> { params: unknown; + timeout_at?: number; } export type ConfirmedToolEvent = BaseToolEvent<'Confirmed'>; @@ -402,7 +403,7 @@ export function generateToolEventKey(data: ToolEventData): { key: string; strate const eventType = toolEvent.event_type; const attemptToken = resolveAttemptMergeToken(data); - const isolatedEvents: ToolEventType[] = ['EarlyDetected', 'Started', 'Completed', 'Failed', 'Cancelled', 'ConfirmationNeeded']; + const isolatedEvents: ToolEventType[] = ['EarlyDetected', 'Started', 'Completed', 'Failed', 'Cancelled', 'Rejected', 'ConfirmationNeeded']; if (isolatedEvents.includes(eventType)) { return null; } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 72815e9a8..8194b4b24 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -328,7 +328,6 @@ export async function saveAllInProgressTurns(context: FlowChatContext): Promise< const settledAt = Date.now(); context.flowChatStore.updateDialogTurn(sessionId, lastTurn.id, turn => settleInterruptedDialogTurn(turn, settledAt, { - preservePendingConfirmation: true, interruptionReason: 'app_restart', }) ); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts index 620ea463d..b7f5cd6be 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts @@ -48,10 +48,16 @@ function createSessionWithTool(tool: FlowToolItem): Session { function makeToolContext(): any { return { + flowChatStore: FlowChatStore.getInstance(), eventBatcher: { getBufferSize: () => 0, flushNow: () => {}, }, + saveDebouncers: new Map(), + lastSaveTimestamps: new Map(), + lastSaveHashes: new Map(), + turnSaveInFlight: new Map(), + turnSavePending: new Set(), }; } @@ -316,6 +322,67 @@ describe('processToolEvent late Started event behavior', () => { }); }); +describe('processToolEvent rejected event behavior', () => { + afterEach(() => { + resetStore(); + }); + + it('marks a rejected tool as rejected and clears pending confirmation state', () => { + const tool: FlowToolItem = { + id: 'tool-1', + type: 'tool', + toolName: 'ExecCommand', + timestamp: 1001, + status: 'pending_confirmation', + requiresConfirmation: true, + userConfirmed: undefined, + isParamsStreaming: true, + acpPermission: { + permissionId: 'permission-1', + requestedAt: 1001, + }, + toolCall: { + id: 'tool-1', + input: { cmd: 'npm test' }, + }, + }; + + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([['session-1', createSessionWithTool(tool)]]), + activeSessionId: 'session-1', + })); + + processToolEvent( + makeToolContext(), + 'session-1', + 'turn-1', + 'round-1', + { + event_type: 'Rejected', + tool_id: 'tool-1', + tool_name: 'ExecCommand', + }, + ); + + const updatedTool = FlowChatStore.getInstance() + .findToolItem('session-1', 'turn-1', 'tool-1') as FlowToolItem; + + expect(updatedTool).toMatchObject({ + status: 'rejected', + userConfirmed: false, + requiresConfirmation: false, + isParamsStreaming: false, + toolResult: { + result: null, + success: false, + error: 'User rejected operation', + }, + }); + expect(updatedTool.acpPermission).toBeUndefined(); + expect(typeof updatedTool.endTime).toBe('number'); + }); +}); + describe('processToolEvent AskUserQuestion retry superseded handling', () => { afterEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 91934e8f8..f5458afa7 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -20,6 +20,7 @@ import type { ParamsPartialToolEvent, ProgressToolEvent, QueuedToolEvent, + RejectedToolEvent, StartedToolEvent, WaitingToolEvent, } from '../EventBatcher'; @@ -106,6 +107,12 @@ export function processToolEvent( handleCancelled(context, store, sessionId, turnId, toolEvent); break; } + + case 'Rejected': { + flushPendingBatchedEvents(context); + handleRejected(context, store, sessionId, turnId, toolEvent); + break; + } case 'ConfirmationNeeded': { flushPendingBatchedEvents(context); @@ -173,10 +180,10 @@ function isWriteLikeToolName(toolName: string): boolean { function shouldIgnoreParamsPartial(status: FlowToolItem['status'], toolName: string): boolean { if (isWriteLikeToolName(toolName)) { - return ['completed', 'error', 'cancelled', 'pending_confirmation', 'confirmed'].includes(status); + return ['completed', 'error', 'cancelled', 'rejected', 'pending_confirmation', 'confirmed'].includes(status); } - return ['running', 'completed', 'error', 'cancelled', 'pending_confirmation', 'confirmed'].includes(status); + return ['running', 'completed', 'error', 'cancelled', 'rejected', 'pending_confirmation', 'confirmed'].includes(status); } function applyParamsPartial( @@ -550,6 +557,35 @@ function handleCancelled( immediateSaveDialogTurn(context, sessionId, turnId); } +/** + * Handle tool rejected event + */ +function handleRejected( + context: FlowChatContext, + store: FlowChatStore, + sessionId: string, + turnId: string, + toolEvent: RejectedToolEvent +): void { + store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, { + toolResult: { + result: null, + success: false, + error: 'User rejected operation', + }, + status: 'rejected', + userConfirmed: false, + requiresConfirmation: false, + acpPermission: undefined, + isParamsStreaming: false, + endTime: Date.now(), + } as any); + + store.clearSessionNeedsAttention(sessionId); + + immediateSaveDialogTurn(context, sessionId, turnId); +} + /** * Handle tool confirmation needed event */ @@ -561,7 +597,8 @@ function handleConfirmationNeeded( ): void { store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, { requiresConfirmation: true, - status: 'pending_confirmation' + status: 'pending_confirmation', + confirmationTimeoutAt: typeof toolEvent.timeout_at === 'number' ? toolEvent.timeout_at : undefined, } as any); const state = store.getState(); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 71d18e5ea..598963f41 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -3001,6 +3001,7 @@ export class FlowChatStore { preflightMs: (item as any).preflightMs, confirmationWaitMs: (item as any).confirmationWaitMs, executionMs: (item as any).executionMs, + confirmationTimeoutAt: (item as any).confirmationTimeoutAt, attemptId: item.attemptId, attemptIndex: item.attemptIndex, })); @@ -4228,6 +4229,7 @@ export class FlowChatStore { userConfirmed: tool.userConfirmed, acpPermission: tool.acpPermission, startTime: tool.startTime, + confirmationTimeoutAt: tool.confirmationTimeoutAt, endTime: tool.endTime, durationMs: tool.durationMs, queueWaitMs: tool.queueWaitMs, @@ -4239,7 +4241,6 @@ export class FlowChatStore { tool.status, normalizedTurnStatus, tool.toolResult, - { preservePendingConfirmation: true }, ), orderIndex: tool.orderIndex, subagentSessionId: tool.subagentSessionId, diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 62243f335..a31131369 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -127,7 +127,7 @@ function hasActiveStreamingNarrative(round: ModelRound): boolean { function hasActiveTool(round: ModelRound): boolean { return round.items.some(item => { if (item.type !== 'tool') return false; - return item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'error'; + return item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'rejected' && item.status !== 'error'; }); } @@ -300,7 +300,7 @@ function isTerminalTurnStatus(status: DialogTurn['status']): boolean { } function isTerminalRoundStatus(status: ModelRound['status']): boolean { - return status === 'completed' || status === 'cancelled' || status === 'error'; + return status === 'completed' || status === 'cancelled' || status === 'rejected' || status === 'error'; } function isActiveFlowItem(item: AnyFlowItem): boolean { diff --git a/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.tsx b/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.tsx index 27dff306b..89e5b6a49 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AcpPermissionActions.tsx @@ -3,7 +3,7 @@ import { Check, ShieldCheck, ShieldX, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { IconButton } from '../../component-library'; -import type { FlowToolItem, ToolCardProps } from '../types/flow-chat'; +import type { FlowToolItem, ToolRejectOptions } from '../types/flow-chat'; import type { AcpPermissionOption } from '@/infrastructure/api/service-api/ACPClientAPI'; import './AcpPermissionActions.scss'; @@ -21,8 +21,8 @@ interface AcpPermissionActionsProps { presentation?: 'icon' | 'text'; className?: string; buttonClassName?: string; - onConfirm?: ToolCardProps['onConfirm']; - onReject?: ToolCardProps['onReject']; + onConfirm?: (updatedInput?: any, permissionOptionId?: string, approve?: boolean) => void; + onReject?: (options?: ToolRejectOptions) => void; } function isApprovalKind(kind: AcpPermissionOption['kind']): boolean { @@ -96,7 +96,7 @@ export const AcpPermissionActions: React.FC = ({ if (approve) { onConfirm?.(input, option.optionId, true); } else { - onReject?.(option.optionId); + onReject?.({ permissionOptionId: option.optionId }); } }; diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx index 6837b8823..eb67c5ffb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx @@ -31,7 +31,7 @@ function statusUsesLoadingShimmer(status: string): boolean { export interface BaseToolCardProps { /** Tool status */ - status: 'pending' | 'queued' | 'waiting' | 'preparing' | 'streaming' | 'receiving' | 'running' | 'completed' | 'error' | 'cancelled' | 'analyzing' | 'pending_confirmation' | 'confirmed'; + status: 'pending' | 'queued' | 'waiting' | 'preparing' | 'streaming' | 'receiving' | 'running' | 'completed' | 'error' | 'cancelled' | 'rejected' | 'analyzing' | 'pending_confirmation' | 'confirmed'; /** Whether expanded */ isExpanded?: boolean; /** Card click callback */ @@ -90,6 +90,7 @@ export const BaseToolCard: React.FC = ({ status !== 'completed' && status !== 'confirmed' && status !== 'cancelled' && + status !== 'rejected' && status !== 'error'; const resolvedHeaderExpandAffordance = diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx index f99adfee9..1661f26c9 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx @@ -10,8 +10,6 @@ import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; -import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; -import { AcpPermissionActions } from './AcpPermissionActions'; import { formatSessionViewPreviewText, isOnlySessionViewPreviewText, @@ -98,9 +96,7 @@ function getInlinePreview(value: any): string | null { export const DefaultToolCard: React.FC = ({ toolItem, config, - onConfirm, - onReject, - onExpand + onExpand, }) => { const { t } = useTranslation('flow-chat'); const { toolCall, toolResult, status, requiresConfirmation, userConfirmed } = toolItem; @@ -116,11 +112,12 @@ export const DefaultToolCard: React.FC = ({ const hasResult = toolResult !== undefined && toolResult !== null && config.resultDisplayType !== 'hidden'; const errorMessage = toolResult?.success === false ? toolResult.error || t('toolCards.default.failed') : null; const hasError = Boolean(errorMessage); - const showConfirmationActions = requiresConfirmation && !userConfirmed && + const needsConfirmation = requiresConfirmation && !userConfirmed && status !== 'completed' && status !== 'cancelled' && + status !== 'rejected' && status !== 'error'; - const canExpand = hasInput || hasResult || hasError || showConfirmationActions; + const canExpand = hasInput || hasResult || hasError; const inputPreview = useMemo(() => { if (!isExpanded || !hasInput) return null; @@ -132,14 +129,6 @@ export const DefaultToolCard: React.FC = ({ return truncatePreview(stringifyValue(toolResult?.result)); }, [hasResult, isExpanded, toolResult?.result]); - const handleConfirm = () => { - onConfirm?.(toolCall?.input); - }; - - const handleReject = () => { - onReject?.(); - }; - const handleToggleExpand = useCallback(() => { if (!canExpand) return; @@ -150,7 +139,7 @@ export const DefaultToolCard: React.FC = ({ }, [applyExpandedState, canExpand, isExpanded, onExpand]); const getStatusText = () => { - if (requiresConfirmation && !userConfirmed) { + if (needsConfirmation) { return t('toolCards.default.waitingConfirm'); } @@ -171,6 +160,8 @@ export const DefaultToolCard: React.FC = ({ return t('toolCards.default.completed'); case 'cancelled': return t('toolCards.default.cancelled'); + case 'rejected': + return t('toolCards.default.rejected'); case 'error': return t('toolCards.default.failed'); default: @@ -179,7 +170,7 @@ export const DefaultToolCard: React.FC = ({ }; const getSummaryText = () => { - if (requiresConfirmation && !userConfirmed) { + if (needsConfirmation) { const preview = getInlinePreview(filteredInput); return preview ? `${t('toolCards.default.waitingConfirm')} - ${preview}` @@ -216,6 +207,10 @@ export const DefaultToolCard: React.FC = ({ return errorMessage || t('toolCards.default.failed'); } + if (status === 'rejected') { + return errorMessage || t('toolCards.default.rejected'); + } + if (status === 'running' || status === 'streaming') { const preview = getInlinePreview(filteredInput); return preview @@ -233,10 +228,7 @@ export const DefaultToolCard: React.FC = ({ return getStatusText(); }; - const showConfirmationHighlight = requiresConfirmation && !userConfirmed && - status !== 'completed' && - status !== 'cancelled' && - status !== 'error'; + const showConfirmationHighlight = needsConfirmation; return (
@@ -270,40 +262,6 @@ export const DefaultToolCard: React.FC = ({
)} - {showConfirmationActions && ( -
- {hasAcpPermissionOptions(toolItem) ? ( - - ) : ( - <> - - - - )} -
- )} - {hasResult && (
{t('toolCards.common.executionResult')}
diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx new file mode 100644 index 000000000..7098c3d57 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; + +import { ExecProcessToolCardView, type ExecProcessCardModel } from './ExecProcessToolCardView'; +import type { FlowToolItem } from '../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const messages: Record = { + 'toolCards.terminal.cancelled': 'Cancelled', + 'toolCards.terminal.rejected': 'Rejected', + 'toolCards.terminal.receivingParams': 'Receiving parameters...', + 'toolCards.terminal.exitCode': 'Exit code: {{code}}', + 'toolCards.approval.waiting': 'Waiting for confirmation', + 'toolCards.execProcess.copyPrimary': 'Copy', + 'toolCards.execProcess.primaryCopied': 'Copied', + 'toolCards.execProcess.copyPrimaryFailed': 'Failed to copy', +}; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const template = messages[key] ?? key; + return template.replace(/{{(\w+)}}/g, (_, name) => String(options?.[name] ?? '')); + }, + }), + }; +}); + +vi.mock('../../component-library', () => ({ + DotMatrixLoader: () => , + ToolProcessingDots: () => , + IconButton: ({ + children, + tooltip, + ...props + }: React.ButtonHTMLAttributes & { tooltip?: React.ReactNode }) => ( + + ), +})); + +vi.mock('@/tools/terminal/components/LazyTerminalOutputRenderer', () => ({ + LazyTerminalOutputRenderer: React.forwardRef< + { getVisibleText: () => string }, + { content: string; className?: string } + >(({ content, className }, ref) => { + React.useImperativeHandle(ref, () => ({ getVisibleText: () => content }), [content]); + return
{content}
; + }), +})); + +const model: ExecProcessCardModel = { + kind: 'command', + actionLabel: 'Run command:', + primaryText: 'npm test', + emptyText: '[No command]', + copyText: 'npm test', + waitingText: 'Running command...', + noOutputText: 'No output', + resultOutput: '', +}; + +function toolItem(status: FlowToolItem['status'], isParamsStreaming = false): FlowToolItem { + return { + id: 'tool-exec-1', + type: 'tool', + toolName: 'ExecCommand', + status, + timestamp: Date.now(), + isParamsStreaming, + toolCall: { + id: 'call-exec-1', + input: { cmd: 'npm test' }, + }, + }; +} + +describe('ExecProcessToolCardView', () => { + let dom: JSDOM; + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + dom = new JSDOM('
', { + pretendToBeVisual: true, + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('CustomEvent', dom.window.CustomEvent); + vi.stubGlobal('ResizeObserver', class { + observe = vi.fn(); + disconnect = vi.fn(); + }); + + container = dom.window.document.getElementById('root') as HTMLDivElement; + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + vi.unstubAllGlobals(); + }); + + it('shows cancelled state instead of receiving params when a stale streaming flag remains', () => { + act(() => { + root.render(); + }); + + act(() => { + root.render(); + }); + + expect(container.textContent).toContain('Cancelled'); + expect(container.textContent).not.toContain('Receiving parameters...'); + }); + + it('shows rejected state for user-rejected command confirmation', () => { + act(() => { + root.render(); + }); + + expect(container.textContent).toContain('Rejected'); + expect(container.textContent).not.toContain('Receiving parameters...'); + }); + + it('keeps legacy cancelled rejection state labeled as rejected', () => { + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain('Rejected'); + expect(container.textContent).not.toContain('Receiving parameters...'); + }); + + it('shows waiting confirmation instead of receiving params while confirmation is pending', () => { + act(() => { + root.render(); + }); + + expect(container.querySelector('.base-tool-card')).not.toBeNull(); + expect(container.querySelector('.compact-tool-card')).toBeNull(); + expect(container.textContent).toContain('Waiting for confirmation'); + expect(container.textContent).not.toContain('Receiving parameters...'); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx index b8ad0f798..a54880dec 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx @@ -66,6 +66,31 @@ function getAutoExpandedStateForStatus(status: string): boolean | null { return null; } +function isCancelledStatus(status: string): boolean { + return status === 'cancelled'; +} + +function isUserRejectedTool(toolItem: FlowToolItem): boolean { + if (toolItem.status === 'rejected') { + return true; + } + + if (toolItem.status === 'cancelled') { + if (toolItem.userConfirmed === false) { + return true; + } + + const error = toolItem.toolResult?.error; + return typeof error === 'string' && /\buser rejected\b/i.test(error); + } + + return false; +} + +function isRejectedOrCancelledStatus(toolItem: FlowToolItem): boolean { + return isCancelledStatus(toolItem.status) || isUserRejectedTool(toolItem); +} + function readProgressLogs(toolItem: FlowToolItem): string[] { const logs = (toolItem as any)._progressLogs; return Array.isArray(logs) ? logs.filter((entry): entry is string => typeof entry === 'string') : []; @@ -149,6 +174,13 @@ export const ExecProcessToolCardView: React.FC = ( return typeof progressMessage === 'string' ? progressMessage : ''; }, [progressLogs, toolItem]); const isRunning = status === 'preparing' || status === 'streaming' || status === 'running' || status === 'receiving'; + const rejectedOrCancelled = isRejectedOrCancelledStatus(toolItem); + const cancelledStatusLabelKey = isUserRejectedTool(toolItem) + ? 'toolCards.terminal.rejected' + : 'toolCards.terminal.cancelled'; + const cancelledStatusClassName = isUserRejectedTool(toolItem) + ? 'status-rejected' + : 'status-cancelled'; const maxRows = isRunning ? EXEC_OUTPUT_STREAMING_MAX_ROWS : EXEC_OUTPUT_EXPANDED_MAX_ROWS; const toolId = toolItem.id ?? toolItem.toolCall?.id; const icon = ; @@ -333,7 +365,7 @@ export const ExecProcessToolCardView: React.FC = ( completedStatus={ status === 'completed' ? model.exitCode === 0 || model.exitCode == null ? 'success' : 'error' - : status === 'error' ? 'error' : status === 'cancelled' ? 'cancelled' : undefined + : status === 'error' ? 'error' : rejectedOrCancelled ? 'cancelled' : undefined } />
@@ -342,6 +374,11 @@ export const ExecProcessToolCardView: React.FC = ( const renderHeaderExtra = () => ( {renderTimeoutIndicator()} + {rejectedOrCancelled && ( + + {t(cancelledStatusLabelKey)} + + )} {renderCopyButton()} @@ -378,19 +415,31 @@ export const ExecProcessToolCardView: React.FC = ( ); } - if (status === 'cancelled' && liveOutput) { + if (rejectedOrCancelled) { return (
-
- {renderOutputWithCopyAction(liveOutput)} -
+ {liveOutput && ( +
+ {renderOutputWithCopyAction(liveOutput)} +
+ )}
- {t('toolCards.terminal.commandInterrupted')} + + {t(cancelledStatusLabelKey)} +
); } + if (status === 'pending_confirmation') { + return ( +
+ {t('toolCards.approval.waiting')} +
+ ); + } + if (liveOutput && isRunning) { return (
@@ -443,6 +492,11 @@ export const ExecProcessToolCardView: React.FC = ( {renderPrimaryText('compact')} {renderTimeoutIndicator()} + {rejectedOrCancelled && ( + + {t(cancelledStatusLabelKey)} + + )} {renderCopyButton()} @@ -464,6 +518,7 @@ export const ExecProcessToolCardView: React.FC = ( expandedContent={renderExpandedContent()} errorContent={renderErrorContent()} isFailed={status === 'error'} + requiresConfirmation={status === 'pending_confirmation'} /> ) : ( = ({ config, sessionId, onOpenInEditor, - onConfirm, - onReject, displayContext, }) => { const { t } = useTranslation('flow-chat'); @@ -261,6 +256,7 @@ export const FileOperationToolCard: React.FC = ({ !userConfirmed && status !== 'completed' && status !== 'cancelled' && + status !== 'rejected' && status !== 'error' ); @@ -639,18 +635,6 @@ export const FileOperationToolCard: React.FC = ({ toolItem.toolName, ]); - const handleConfirmClick = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onConfirm?.(toolCall?.input); - }, [onConfirm, toolCall?.input]); - - const handleRejectClick = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onReject?.(); - }, [onReject]); - const handleOpenFullCodeClick = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -1100,38 +1084,6 @@ export const FileOperationToolCard: React.FC = ({ {currentFilePath ? t('toolCards.file.receivingParams') : t('toolCards.file.analyzing')} )} - {showConfirmationActions && ( - hasAcpPermissionOptions(toolItem) ? ( - - ) : ( - <> - - - - - - - - ) - )} {canOpenFullCode && (
); diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx index c3fcab8d1..6b28d7978 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx @@ -25,23 +25,6 @@ vi.mock('react-i18next', async () => { vi.mock('../../component-library', () => ({ ToolProcessingDots: () => , - IconButton: ({ - children, - tooltip, - ...props - }: React.ButtonHTMLAttributes & { tooltip?: React.ReactNode }) => ( - - ), -})); - -vi.mock('./ToolCardHeaderActions', () => ({ - ToolCardHeaderActions: ({ children }: { children: React.ReactNode }) => {children}, })); describe('ReadFileDisplay', () => { @@ -69,10 +52,7 @@ describe('ReadFileDisplay', () => { vi.unstubAllGlobals(); }); - it('renders ACP permission actions for pending read confirmation', () => { - const onConfirm = vi.fn(); - const onReject = vi.fn(); - + it('renders pending read confirmation copy without inline approval actions', () => { const toolItem: FlowToolItem = { id: 'tool-read-1', type: 'tool', @@ -120,27 +100,13 @@ describe('ReadFileDisplay', () => { ); }); expect(container.textContent).toContain('Requesting read permission:'); expect(container.textContent).toContain('/'); - - const actionButtons = container.querySelectorAll('button'); - expect(actionButtons).toHaveLength(2); - - act(() => { - actionButtons[0]?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); - }); - expect(onConfirm).toHaveBeenCalledWith(toolItem.toolCall.input, 'once', true); - - act(() => { - actionButtons[1]?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); - }); - expect(onReject).toHaveBeenCalledWith('reject'); + expect(container.querySelectorAll('button')).toHaveLength(0); }); it('does not report a file size for session preview truncation markers', () => { diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx index 4fef6e041..33149cb76 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx @@ -3,22 +3,16 @@ */ import React, { useMemo } from 'react'; -import { Check, FileText, X } from 'lucide-react'; +import { FileText } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { IconButton } from '../../component-library'; import type { ToolCardProps } from '../types/flow-chat'; -import { AcpPermissionActions } from './AcpPermissionActions'; -import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; -import { ToolCardHeaderActions } from './ToolCardHeaderActions'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { isSessionViewPreviewText } from '../utils/sessionViewPreview'; export const ReadFileDisplay: React.FC = React.memo(({ toolItem, - onConfirm, - onReject, - onOpenInEditor + onOpenInEditor, }) => { const { t } = useTranslation('flow-chat'); const { toolCall, toolResult, status, requiresConfirmation, userConfirmed } = toolItem; @@ -109,6 +103,7 @@ export const ReadFileDisplay: React.FC = React.memo(({ !userConfirmed && status !== 'completed' && status !== 'cancelled' && + status !== 'rejected' && status !== 'error' ); @@ -154,53 +149,6 @@ export const ReadFileDisplay: React.FC = React.memo(({ return null; }; - const renderActions = () => { - if (!showConfirmationActions) { - return undefined; - } - - return ( - - {hasAcpPermissionOptions(toolItem) ? ( - - ) : ( - <> - { - event.stopPropagation(); - onConfirm?.(toolCall?.input); - }} - tooltip={t('toolCards.default.waitingConfirm')} - > - - - { - event.stopPropagation(); - onReject?.(); - }} - tooltip={t('toolCards.acpPermission.reject')} - > - - - - )} - - ); - }; - return ( = React.memo(({ } />} content={renderContent()} - extra={renderActions()} /> } /> diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx index 6e7e7ac20..a4625f1f5 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx @@ -10,7 +10,7 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { CubeLoading, Button } from '../../component-library'; +import { CubeLoading } from '../../component-library'; import { Markdown } from '@/component-library/components/Markdown/Markdown'; import type { FlowToolItem, ToolCardProps } from '../types/flow-chat'; import { BaseToolCard } from './BaseToolCard'; @@ -21,8 +21,6 @@ import { useToolCardHeightContract } from './useToolCardHeightContract'; import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import { getReviewerContextBySubagentId } from '@/shared/services/reviewTeamService'; import type { ReviewerContext } from '@/shared/services/reviewTeamService'; -import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; -import { AcpPermissionActions } from './AcpPermissionActions'; import { openBtwSessionInAuxPane } from '../services/btwSessionPane'; import { flowChatStore } from '../store/FlowChatStore'; import { useSessionGoalModeActive } from '../hooks/useSessionGoalModeActive'; @@ -95,10 +93,8 @@ function isDeepReviewReviewerTask(toolItem: FlowToolItem): boolean { export const TaskToolDisplay: React.FC = ({ toolItem, interruptionNote, - onConfirm, - onReject, onOpenInPanel, - sessionId + sessionId, }) => { const { t } = useTranslation('flow-chat'); const defaultTimeoutDisabled = useSessionGoalModeActive(sessionId); @@ -223,7 +219,7 @@ export const TaskToolDisplay: React.FC = ({ ); const hasInterruptionNote = Boolean(interruptionNote); const needsConfirmation = - requiresConfirmation && !userConfirmed && status !== 'completed'; + requiresConfirmation && !userConfirmed && status !== 'completed' && status !== 'cancelled' && status !== 'rejected' && status !== 'error'; /* Prompt body: same scroll + Markdown shell as ModelThinkingDisplay. */ const promptContentRef = useRef(null); @@ -258,7 +254,7 @@ export const TaskToolDisplay: React.FC = ({ const taskErrorMessage = readTaskErrorMessage(toolResult); const completedDurationStatus = isFailed ? 'error' - : status === 'cancelled' + : status === 'cancelled' || status === 'rejected' ? 'cancelled' : status === 'completed' && taskDurationMs != null ? 'success' @@ -509,41 +505,6 @@ export const TaskToolDisplay: React.FC = ({
) )} - {needsConfirmation && ( -
- {hasAcpPermissionOptions(toolItem) ? ( - - ) : ( - <> - - - - )} -
- )}
); }; @@ -559,7 +520,7 @@ export const TaskToolDisplay: React.FC = ({ expandedContent={renderExpandedContent()} headerExpandAffordance={showHeaderExpandHint} isFailed={isFailed} - requiresConfirmation={requiresConfirmation && !userConfirmed} + requiresConfirmation={needsConfirmation} /> ); diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss index d6b217eda..23cbaf208 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss @@ -286,16 +286,6 @@ flex-shrink: 0; } -/* ========== Confirmation action button group ========== */ -.terminal-confirm-actions { - display: inline-flex; - align-items: center; - justify-content: flex-start; - gap: 0.125rem; - flex-shrink: 0; - width: auto; -} - /* ========== Common action button styles ========== */ .terminal-action-btn { display: flex; @@ -615,12 +605,6 @@ height: 16px; } - .terminal-confirm-actions { - gap: 1px; - width: auto !important; - justify-content: flex-start !important; - } - .terminal-command, .terminal-command-input { font-size: var(--flowchat-font-size-xs); diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 3472a350a..499d3d289 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -16,7 +16,7 @@ import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; -import { Terminal, Play, X, ExternalLink, Square } from 'lucide-react'; +import { Terminal, ExternalLink, Square } from 'lucide-react'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { createTerminalTab } from '@/shared/utils/tabUtils'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; @@ -29,8 +29,6 @@ import { getTerminalViewState, type TerminalViewState } from './terminalToolCard import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActions'; import { ToolCommandPreview } from './ToolCommandPreview'; -import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; -import { AcpPermissionActions } from './AcpPermissionActions'; import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; import './TerminalToolCard.scss'; @@ -224,8 +222,6 @@ function parseTerminalResult(raw: unknown, durationMs?: number): ParsedTerminalR export const TerminalToolCard: React.FC = ({ toolItem, - onConfirm, - onReject, onExpand, terminalSessionId: propTerminalSessionId, }) => { @@ -385,22 +381,6 @@ export const TerminalToolCard: React.FC = ({ ]); const waitingMessage = viewState.waitingMessageKey ? t(viewState.waitingMessageKey) : null; - const handleExecute = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - - if (!canExecuteCommand) { - return; - } - - applyTerminalExpandedState(true, { reason: 'manual' }); - onConfirm?.(toolCall?.input); - }, [applyTerminalExpandedState, canExecuteCommand, onConfirm, toolCall?.input]); - - const handleReject = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - onReject?.(); - }, [onReject]); - const handleInterrupt = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); @@ -437,7 +417,7 @@ export const TerminalToolCard: React.FC = ({ const handleCardClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; - if (target.closest('.tool-card-header-actions, .terminal-action-btn, .terminal-confirm-actions')) { + if (target.closest('.tool-card-header-actions, .terminal-action-btn')) { return; } @@ -518,60 +498,18 @@ export const TerminalToolCard: React.FC = ({ const renderHeaderExtra = (includeInterrupt: boolean) => ( - {/* Always visible: confirmation actions + interrupt */} - {(showConfirmButtons || (includeInterrupt && viewState.showInterruptButton)) && ( + {/* Always visible while running: interrupt */} + {includeInterrupt && viewState.showInterruptButton && ( - {showConfirmButtons && ( - e.stopPropagation()}> - {hasAcpPermissionOptions(toolItem) ? ( - - ) : ( - <> - - - - - - - - )} - - )} - {includeInterrupt && viewState.showInterruptButton && ( - - - - )} + + + )} diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.tsx b/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.tsx index 1860b0f7b..2813a9eec 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.tsx @@ -42,6 +42,7 @@ function StatusIcon({ status, size }: { status: ToolCardStatusSlotStatus; size: case 'error': return ; case 'cancelled': + case 'rejected': return ; case 'queued': case 'waiting': diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index a2d3e18cc..43fb12408 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -15,7 +15,7 @@ export interface FlowItem { id: string; type: 'text' | 'tool' | 'image-analysis' | 'thinking' | 'user-steering'; timestamp: number; - status: 'pending' | 'queued' | 'waiting' | 'preparing' | 'running' | 'streaming' | 'receiving' | 'completed' | 'cancelled' | 'error' | 'analyzing' | 'pending_confirmation' | 'confirmed'; // Includes error, analyzing, and confirmation states. + status: 'pending' | 'queued' | 'waiting' | 'preparing' | 'running' | 'streaming' | 'receiving' | 'completed' | 'cancelled' | 'rejected' | 'error' | 'analyzing' | 'pending_confirmation' | 'confirmed'; // Includes error, analyzing, and confirmation states. attemptId?: string; attemptIndex?: number; @@ -87,6 +87,7 @@ export interface FlowToolItem extends FlowItem { }; aiIntent?: string; // AI rationale for calling the tool. startTime?: number; // Tool start time. + confirmationTimeoutAt?: number; endTime?: number; // Tool end time. durationMs?: number; queueWaitMs?: number; @@ -104,6 +105,11 @@ export interface FlowToolItem extends FlowItem { _paramsBuffer?: string; // Internal buffer for accumulated params. } +export interface ToolRejectOptions { + permissionOptionId?: string; + instruction?: string; +} + export interface FlowImageAnalysisItem extends FlowItem { type: 'image-analysis'; imageContext: import('@/shared/types/context').ImageContext; @@ -165,7 +171,7 @@ export interface ModelRound { historyRounds?: ModelRound[]; isStreaming: boolean; isComplete: boolean; - status: 'pending' | 'streaming' | 'completed' | 'cancelled' | 'error' | 'pending_confirmation'; + status: 'pending' | 'streaming' | 'completed' | 'cancelled' | 'rejected' | 'error' | 'pending_confirmation'; startTime: number; endTime?: number; durationMs?: number; @@ -535,8 +541,6 @@ export interface ToolCardProps { toolItem: FlowToolItem; config: ToolCardConfig; interruptionNote?: string | null; - onConfirm?: (updatedInput?: any, permissionOptionId?: string, approve?: boolean) => void; // toolId is known within the card. - onReject?: (permissionOptionId?: string) => void; onOpenInEditor?: (filePath: string) => void; onOpenInPanel?: (panelType: string, data: any) => void; onExpand?: () => void; diff --git a/src/web-ui/src/flow_chat/utils/dialogTurnStability.test.ts b/src/web-ui/src/flow_chat/utils/dialogTurnStability.test.ts index 7bc80e168..79e950f38 100644 --- a/src/web-ui/src/flow_chat/utils/dialogTurnStability.test.ts +++ b/src/web-ui/src/flow_chat/utils/dialogTurnStability.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { DialogTurn } from '../types/flow-chat'; import { + normalizeRecoveredRoundStatus, normalizeRecoveredToolStatus, normalizeRecoveredTurnStatus, settleInterruptedDialogTurn, @@ -61,15 +62,10 @@ describe('dialogTurnStability', () => { expect(normalizeRecoveredTurnStatus('processing', { error: null })).toBe('cancelled'); }); - it('keeps pending confirmation tools actionable after recovery', () => { - expect( - normalizeRecoveredToolStatus( - 'pending_confirmation', - 'cancelled', - null, - { preservePendingConfirmation: true }, - ), - ).toBe('pending_confirmation'); + it('cancels recovered confirmation states because their runtime approval channel is gone', () => { + expect(normalizeRecoveredToolStatus('pending_confirmation', 'cancelled', null)).toBe('cancelled'); + expect(normalizeRecoveredToolStatus('confirmed', 'cancelled', null)).toBe('cancelled'); + expect(normalizeRecoveredRoundStatus('pending_confirmation', 'cancelled')).toBe('cancelled'); }); it('cancels pending confirmation tools during an explicit cancellation settle', () => { @@ -101,9 +97,48 @@ describe('dialogTurnStability', () => { }); const settled = settleInterruptedDialogTurn(turn, 42); + expect(settled.modelRounds[0].status).toBe('cancelled'); expect(settled.modelRounds[0].items[0].status).toBe('cancelled'); }); + it('cancels stale pending confirmation tools even when the persisted turn is already cancelled', () => { + const turn = createDialogTurn({ + status: 'cancelled', + endTime: 40, + modelRounds: [ + { + id: 'round-1', + index: 0, + items: [ + { + id: 'tool-1', + type: 'tool', + toolName: 'Terminal', + toolCall: { + input: { command: 'echo ok' }, + id: 'tool-1', + }, + timestamp: 2, + status: 'pending_confirmation', + startTime: 2, + }, + ], + isStreaming: false, + isComplete: false, + status: 'pending_confirmation', + startTime: 2, + }, + ], + }); + + const settled = settleInterruptedDialogTurn(turn, 42); + const tool = settled.modelRounds[0].items[0]; + + expect(settled.status).toBe('cancelled'); + expect(settled.modelRounds[0].status).toBe('cancelled'); + expect(tool.status).toBe('cancelled'); + }); + it('cancels transient nested states when settling an interrupted turn', () => { const settledAt = 99; const settled = settleInterruptedDialogTurn(createDialogTurn(), settledAt, { diff --git a/src/web-ui/src/flow_chat/utils/dialogTurnStability.ts b/src/web-ui/src/flow_chat/utils/dialogTurnStability.ts index db68210e8..f55a6f355 100644 --- a/src/web-ui/src/flow_chat/utils/dialogTurnStability.ts +++ b/src/web-ui/src/flow_chat/utils/dialogTurnStability.ts @@ -8,15 +8,24 @@ import type { } from '../types/flow-chat'; const TRANSIENT_TURN_STATUSES = new Set(['pending', 'processing', 'finishing', 'image_analyzing', 'cancelling', 'inprogress']); -const TRANSIENT_ROUND_STATUSES = new Set(['pending', 'streaming']); -const TERMINAL_ROUND_STATUSES = new Set(['completed', 'cancelled', 'error', 'pending_confirmation']); -const TRANSIENT_TOOL_STATUSES = new Set(['pending', 'preparing', 'streaming', 'running', 'receiving', 'starting', 'analyzing']); -const TERMINAL_TOOL_STATUSES = new Set(['completed', 'cancelled', 'error', 'pending_confirmation', 'confirmed']); -const TERMINAL_ITEM_STATUSES = new Set(['completed', 'cancelled', 'error']); -const STABLE_ITEM_STATUSES = new Set(['completed', 'cancelled', 'error', 'pending_confirmation', 'confirmed']); +const TRANSIENT_ROUND_STATUSES = new Set(['pending', 'streaming', 'pending_confirmation']); +const TERMINAL_ROUND_STATUSES = new Set(['completed', 'cancelled', 'rejected', 'error']); +const TRANSIENT_TOOL_STATUSES = new Set([ + 'pending', + 'preparing', + 'streaming', + 'running', + 'receiving', + 'starting', + 'analyzing', + 'pending_confirmation', + 'confirmed', +]); +const TERMINAL_TOOL_STATUSES = new Set(['completed', 'cancelled', 'rejected', 'error']); +const TERMINAL_ITEM_STATUSES = new Set(['completed', 'cancelled', 'rejected', 'error']); +const STABLE_ITEM_STATUSES = new Set(['completed', 'cancelled', 'rejected', 'error']); type SettleInterruptedDialogTurnOptions = { - preservePendingConfirmation?: boolean; interruptionReason?: FlowToolItem['interruptionReason']; }; @@ -51,11 +60,7 @@ export function normalizeRecoveredRoundStatus( status: unknown, parentTurnStatus: DialogTurn['status'], ): ModelRound['status'] { - if (status === 'pending_confirmation') { - return status; - } - - if (status === 'completed' || status === 'cancelled' || status === 'error') { + if (status === 'completed' || status === 'cancelled' || status === 'rejected' || status === 'error') { return status; } @@ -72,7 +77,7 @@ export function normalizeRecoveredTextStatus( status: unknown, parentTurnStatus: DialogTurn['status'], ): FlowTextItem['status'] { - if (status === 'completed' || status === 'cancelled' || status === 'error') { + if (status === 'completed' || status === 'cancelled' || status === 'rejected' || status === 'error') { return status; } @@ -91,7 +96,7 @@ export function normalizeRecoveredThinkingStatus( status: unknown, parentTurnStatus: DialogTurn['status'], ): FlowThinkingItem['status'] { - if (status === 'completed' || status === 'cancelled' || status === 'error') { + if (status === 'completed' || status === 'cancelled' || status === 'rejected' || status === 'error') { return status; } @@ -110,13 +115,8 @@ export function normalizeRecoveredToolStatus( status: unknown, parentTurnStatus: DialogTurn['status'], toolResult?: Pick, 'success' | 'error'> | null, - options?: { preservePendingConfirmation?: boolean }, ): FlowToolItem['status'] { - if ((status === 'pending_confirmation' || status === 'confirmed') && options?.preservePendingConfirmation) { - return status; - } - - if (status === 'completed' || status === 'cancelled' || status === 'error') { + if (status === 'completed' || status === 'cancelled' || status === 'rejected' || status === 'error') { return status; } @@ -181,7 +181,6 @@ function settleInterruptedItem( item.status, finalTurnStatus, item.toolResult, - options, ); return { ...item, diff --git a/src/web-ui/src/infrastructure/api/service-api/ToolAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ToolAPI.ts index 83bbfe302..5c4f4f785 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ToolAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ToolAPI.ts @@ -80,7 +80,7 @@ export class ToolAPI { const rejectRequest = { sessionId: request.sessionId, toolId: request.toolId, - reason: 'User rejected' + reason: request.rejectReason || 'User rejected' }; const result = await api.invoke('reject_tool_execution', { request: rejectRequest }); diff --git a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss index 58d1ac1fa..7570de77b 100644 --- a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss @@ -34,6 +34,26 @@ color: var(--color-text-secondary); } + &__inline-label { + display: inline-flex; + align-items: center; + gap: $size-gap-1; + min-width: 0; + } + + &__inline-info { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + cursor: help; + flex-shrink: 0; + } + + &__inline-info:hover { + color: var(--color-text-secondary); + } + &__policy-tooltip { max-width: min(320px, calc(100vw - 32px)); display: grid; diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index e07e438b2..396fe3a85 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -71,7 +71,8 @@ type BrowserControlBrowserOption = { type SubagentBatchExecutionPolicy = 'safe_only' | 'force_parallel' | 'serial'; -const DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY: SubagentBatchExecutionPolicy = 'safe_only'; +const DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY: SubagentBatchExecutionPolicy = 'force_parallel'; +const DEFAULT_SUBAGENT_MAX_CONCURRENCY = 5; function normalizeSubagentBatchExecutionPolicy(value: unknown): SubagentBatchExecutionPolicy { return value === 'force_parallel' || value === 'serial' || value === 'safe_only' @@ -105,6 +106,7 @@ const SessionSettingsPanels: React.FC = ({ variant } const [models, setModels] = useState([]); const [funcAgentModels, setFuncAgentModels] = useState>({}); const [skipToolConfirmation, setSkipToolConfirmation] = useState(true); + const [subagentMaxConcurrency, setSubagentMaxConcurrency] = useState(DEFAULT_SUBAGENT_MAX_CONCURRENCY); const [executionTimeout, setExecutionTimeout] = useState(''); const [confirmationTimeout, setConfirmationTimeout] = useState(''); const [subagentBatchExecutionPolicy, setSubagentBatchExecutionPolicy] = @@ -206,6 +208,7 @@ const SessionSettingsPanels: React.FC = ({ variant } allModels, funcAgentModelsData, skipConfirm, + loadedSubagentMaxConcurrency, execTimeout, confirmTimeout, loadedSubagentBatchExecutionPolicy, @@ -218,6 +221,7 @@ const SessionSettingsPanels: React.FC = ({ variant } configManager.getConfig('ai.models') || [], configManager.getConfig>('ai.func_agent_models') || {}, configManager.getConfig('ai.skip_tool_confirmation'), + configManager.getConfig('ai.subagent_max_concurrency'), configManager.getConfig('ai.tool_execution_timeout_secs'), configManager.getConfig('ai.tool_confirmation_timeout_secs'), configManager.getConfig('ai.subagent_batch_execution_policy'), @@ -232,6 +236,9 @@ const SessionSettingsPanels: React.FC = ({ variant } setModels(allModels as AIModelConfig[]); setFuncAgentModels(funcAgentModelsData as Record); setSkipToolConfirmation(skipConfirm ?? true); + setSubagentMaxConcurrency(loadedSubagentMaxConcurrency != null + ? loadedSubagentMaxConcurrency + : DEFAULT_SUBAGENT_MAX_CONCURRENCY); setExecutionTimeout(execTimeout != null ? String(execTimeout) : ''); setConfirmationTimeout(confirmTimeout != null ? String(confirmTimeout) : ''); setSubagentBatchExecutionPolicy(normalizeSubagentBatchExecutionPolicy(loadedSubagentBatchExecutionPolicy)); @@ -491,6 +498,17 @@ const SessionSettingsPanels: React.FC = ({ variant } } }; + const handleSubagentMaxConcurrencyChange = async (value: number) => { + if (Number.isNaN(value) || value < 1) return; + setSubagentMaxConcurrency(value); + try { + await configManager.setConfig('ai.subagent_max_concurrency', value); + } catch (error) { + log.error('Failed to save subagent_max_concurrency', error); + notificationService.error(tTools('messages.saveFailed')); + } + }; + const handleComputerUseEnabledChange = async (checked: boolean) => { setComputerUseBusy(true); setComputerUseEnabled(checked); @@ -1059,18 +1077,25 @@ const SessionSettingsPanels: React.FC = ({ variant } /> - -
- +
+
+ + {tTools('config.subagentMaxConcurrency')} +
+ )} + description={tTools('config.subagentMaxConcurrencyDesc')} + align="center" + > +
+ void handleSubagentMaxConcurrencyChange(val)} + min={1} + max={100} + step={1} + size="small" + variant="compact" + /> +
+ {/* ── Computer use (desktop) ─────────────────────────────── */} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 58af983ef..0c3f6f480 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1356,6 +1356,21 @@ "commandInterrupted": "Command interrupted", "executionFailed": "Command execution failed" }, + "approval": { + "waiting": "Waiting for confirmation", + "confirm": "Allow", + "reject": "Reject", + "rejectWithInstruction": "Reject with instruction", + "confirmTooltip": "Allow this tool run", + "rejectTooltip": "Reject this tool run", + "rejectWithInstructionTooltip": "Reject and tell the assistant what to do next", + "rejectInstructionLabel": "Rejection instruction", + "rejectInstructionPlaceholder": "Tell the assistant what to do instead...", + "rejectWithInstructionSubmit": "Reject", + "emptyInputTooltip": "This tool has no executable input", + "remaining": "remaining {{time}}", + "ariaLabel": "Tool approval" + }, "execProcess": { "executeCommand": "Run command:", "writeStdin": "Write stdin:", @@ -1579,6 +1594,7 @@ "executing": "Executing...", "completed": "Completed", "cancelled": "Cancelled", + "rejected": "Rejected", "failed": "Execution failed", "preparing": "Preparing" }, diff --git a/src/web-ui/src/locales/en-US/settings/agentic-tools.json b/src/web-ui/src/locales/en-US/settings/agentic-tools.json index 1a1ce6265..97872be0f 100644 --- a/src/web-ui/src/locales/en-US/settings/agentic-tools.json +++ b/src/web-ui/src/locales/en-US/settings/agentic-tools.json @@ -14,10 +14,14 @@ "config": { "autoExecute": "Auto Execute", "autoExecuteDesc": "Skip user confirmation before tool execution.", + "subagentMaxConcurrency": "Subagent Concurrency Limit", + "subagentMaxConcurrencyDesc": "How many subagents may run in parallel at the same time.", "confirmTimeout": "Confirm Timeout", "confirmTimeoutDesc": "Maximum time (seconds) to wait for user confirmation of tool calls.", + "confirmTimeoutHint": "Set 0 to disable confirmation timeout.", "executionTimeout": "Execution Timeout", - "executionTimeoutDesc": "Maximum time (seconds) for tool execution, including Task subagent runs.", + "executionTimeoutDesc": "Maximum time (seconds) for tool execution.", + "executionTimeoutHint": "Set 0 to disable execution timeout. Subagents and command execution tools manage their own execution limits and are not capped by this setting.", "subagentBatchPolicy": { "label": "Subagent Batch Scheduling", "desc": "Choose how multiple subagent launches from the same model response are scheduled.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 405689814..1cabedc7f 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1356,6 +1356,21 @@ "commandInterrupted": "命令已被中断", "executionFailed": "命令执行失败" }, + "approval": { + "waiting": "等待确认", + "confirm": "允许", + "reject": "拒绝", + "rejectWithInstruction": "说明后拒绝", + "confirmTooltip": "允许执行这个工具", + "rejectTooltip": "拒绝执行这个工具", + "rejectWithInstructionTooltip": "拒绝并告诉助手下一步怎么做", + "rejectInstructionLabel": "拒绝说明", + "rejectInstructionPlaceholder": "告诉助手改怎么做...", + "rejectWithInstructionSubmit": "拒绝", + "emptyInputTooltip": "这个工具没有可执行输入", + "remaining": "剩余 {{time}}", + "ariaLabel": "工具执行确认" + }, "execProcess": { "executeCommand": "运行命令:", "writeStdin": "写入标准输入:", @@ -1579,6 +1594,7 @@ "executing": "执行中...", "completed": "已完成", "cancelled": "已取消", + "rejected": "已拒绝", "failed": "执行失败", "preparing": "准备中" }, diff --git a/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json b/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json index 266bb8d7d..92d67bebb 100644 --- a/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json +++ b/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json @@ -14,10 +14,14 @@ "config": { "autoExecute": "自动执行", "autoExecuteDesc": "跳过工具执行前的用户确认步骤。", + "subagentMaxConcurrency": "子智能体并发上限", + "subagentMaxConcurrencyDesc": "同一时间允许并行运行的子智能体数量。", "confirmTimeout": "确认超时", "confirmTimeoutDesc": "等待用户确认工具调用的最长时间(秒)。", + "confirmTimeoutHint": "设置为 0 可关闭确认超时。", "executionTimeout": "执行超时", - "executionTimeoutDesc": "工具执行的最长时间(秒),包含 Task 子智能体运行时长。", + "executionTimeoutDesc": "工具执行的最长时间(秒)。", + "executionTimeoutHint": "设置为 0 可关闭执行超时。子智能体和命令执行工具有各自的执行限制,不受此项约束。", "subagentBatchPolicy": { "label": "子智能体批量调度", "desc": "选择同一模型回复中多个子智能体启动请求的调度方式。", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 5d19ecce1..ebe30bf17 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1356,6 +1356,21 @@ "commandInterrupted": "命令已被中斷", "executionFailed": "命令執行失敗" }, + "approval": { + "waiting": "等待確認", + "confirm": "允許", + "reject": "拒絕", + "rejectWithInstruction": "說明後拒絕", + "confirmTooltip": "允許執行這個工具", + "rejectTooltip": "拒絕執行這個工具", + "rejectWithInstructionTooltip": "拒絕並告訴助手下一步怎麼做", + "rejectInstructionLabel": "拒絕說明", + "rejectInstructionPlaceholder": "告訴助手改怎麼做...", + "rejectWithInstructionSubmit": "拒絕", + "emptyInputTooltip": "這個工具沒有可執行輸入", + "remaining": "剩餘 {{time}}", + "ariaLabel": "工具執行確認" + }, "execProcess": { "executeCommand": "執行命令:", "writeStdin": "寫入標準輸入:", @@ -1579,6 +1594,7 @@ "executing": "執行中...", "completed": "已完成", "cancelled": "已取消", + "rejected": "已拒絕", "failed": "執行失敗", "preparing": "準備中" }, diff --git a/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json b/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json index f5f5fa766..a0d37abf2 100644 --- a/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json +++ b/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json @@ -14,10 +14,14 @@ "config": { "autoExecute": "自動執行", "autoExecuteDesc": "跳過工具執行前的用戶確認步驟。", + "subagentMaxConcurrency": "子智能體並發上限", + "subagentMaxConcurrencyDesc": "同一時間允許並行執行的子智能體數量。", "confirmTimeout": "確認超時", "confirmTimeoutDesc": "等待用戶確認工具調用的最長時間(秒)。", + "confirmTimeoutHint": "設為 0 可關閉確認超時。", "executionTimeout": "執行超時", - "executionTimeoutDesc": "工具執行的最長時間(秒),包含 Task 子智能體執行時長。", + "executionTimeoutDesc": "工具執行的最長時間(秒)。", + "executionTimeoutHint": "設為 0 可關閉執行超時。子智能體和命令執行工具有各自的執行限制,不受此項約束。", "subagentBatchPolicy": { "label": "子智能體批量調度", "desc": "選擇同一模型回覆中多個子智能體啟動請求的調度方式。", diff --git a/src/web-ui/src/shared/services/agent-service.ts b/src/web-ui/src/shared/services/agent-service.ts index 2750fa431..6dc49e1db 100644 --- a/src/web-ui/src/shared/services/agent-service.ts +++ b/src/web-ui/src/shared/services/agent-service.ts @@ -630,13 +630,15 @@ export class AgentService { sessionId: string, toolUseId: string, action: 'confirm' | 'reject', - updatedInput?: any + updatedInput?: any, + rejectReason?: string ): Promise { const requestPayload = { sessionId, toolId: toolUseId, action, - updatedInput: updatedInput || null + updatedInput: updatedInput || null, + rejectReason }; return toolAPI.confirmToolExecution(requestPayload);