aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/fsm/tests.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/fsm/tests.rs')
-rw-r--r--crates/atuin-ai/src/fsm/tests.rs890
1 files changed, 0 insertions, 890 deletions
diff --git a/crates/atuin-ai/src/fsm/tests.rs b/crates/atuin-ai/src/fsm/tests.rs
deleted file mode 100644
index 51c23915..00000000
--- a/crates/atuin-ai/src/fsm/tests.rs
+++ /dev/null
@@ -1,890 +0,0 @@
-//! Pure FSM transition tests. No IO, no async.
-
-use serde_json::json;
-
-use super::*;
-use effects::{Effect, ExitAction};
-use events::{Event, PermissionChoice, PermissionResponse};
-
-fn new_fsm() -> AgentFsm {
- AgentFsm::new(
- vec!["client_v1_read_file".to_string()],
- "test-inv".to_string(),
- )
-}
-
-// ============================================================================
-// Idle → Turn
-// ============================================================================
-
-#[test]
-fn user_submit_starts_turn() {
- let mut fsm = new_fsm();
-
- let effects = fsm.handle(Event::UserSubmit("hello".into()));
-
- assert!(matches!(
- fsm.state,
- AgentState::Turn {
- stream: StreamPhase::Connecting
- }
- ));
- assert_eq!(effects.len(), 1);
- assert!(matches!(effects[0], Effect::StartStream { .. }));
- // User message was pushed to events
- assert!(fsm.ctx.events.iter().any(|e| matches!(
- e,
- ConversationEvent::UserMessage { content } if content == "hello"
- )));
-}
-
-#[test]
-fn stream_started_transitions_to_streaming() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("hello".into()));
-
- let effects = fsm.handle(Event::StreamStarted);
-
- assert!(matches!(
- fsm.state,
- AgentState::Turn {
- stream: StreamPhase::Streaming { status: None }
- }
- ));
- assert!(effects.is_empty());
-}
-
-#[test]
-fn stream_chunk_accumulates_text() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("hello".into()));
- fsm.handle(Event::StreamStarted);
-
- fsm.handle(Event::StreamChunk("Hello ".into()));
- fsm.handle(Event::StreamChunk("world!".into()));
-
- assert_eq!(fsm.ctx.current_response, "Hello world!");
-}
-
-#[test]
-fn stream_done_without_tools_goes_idle() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("hello".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamChunk("Hi there!".into()));
-
- let effects = fsm.handle(Event::StreamDone {
- session_id: "s1".into(),
- });
-
- assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
- assert_eq!(fsm.ctx.session_id, Some("s1".to_string()));
- assert!(effects.iter().any(|e| matches!(e, Effect::Persist)));
- // Text was committed to events
- assert!(fsm.ctx.events.iter().any(|e| matches!(
- e,
- ConversationEvent::Text { content } if content == "Hi there!"
- )));
-}
-
-// ============================================================================
-// Tool lifecycle
-// ============================================================================
-
-#[test]
-fn stream_tool_call_tracks_tool_and_emits_check_permission() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read a file".into()));
- fsm.handle(Event::StreamStarted);
-
- let effects = fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
-
- assert!(fsm.ctx.tools.get("t1").is_some());
- assert_eq!(effects.len(), 1);
- assert!(matches!(effects[0], Effect::CheckPermission { .. }));
-}
-
-#[test]
-fn permission_allowed_transitions_to_executing() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
-
- let effects = fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- assert_eq!(fsm.ctx.tools.get("t1").unwrap().state, ToolState::Executing);
- assert!(matches!(effects[0], Effect::ExecuteTool { .. }));
-}
-
-#[test]
-fn permission_ask_transitions_to_awaiting() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
-
- let effects = fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Ask,
- });
-
- assert_eq!(
- fsm.ctx.tools.get("t1").unwrap().state,
- ToolState::AwaitingPermission
- );
- assert!(effects.is_empty());
-}
-
-#[test]
-fn tool_done_after_stream_done_continues_conversation() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
- fsm.handle(Event::StreamDone {
- session_id: "".into(),
- });
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- // Now in Turn { Done } with one tool Executing
- let effects = fsm.handle(Event::ToolExecutionDone {
- tool_id: "t1".into(),
- outcome: crate::tools::ToolOutcome::Success("file contents".into()),
- preview: None,
- });
-
- // Turn complete → continuation
- assert!(matches!(
- fsm.state,
- AgentState::Turn {
- stream: StreamPhase::Connecting
- }
- ));
- assert!(
- effects
- .iter()
- .any(|e| matches!(e, Effect::StartStream { .. }))
- );
-}
-
-#[test]
-fn continuation_turn_without_new_tools_goes_idle() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
- fsm.handle(Event::StreamDone {
- session_id: "".into(),
- });
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
- // Tool completes → continuation starts
- fsm.handle(Event::ToolExecutionDone {
- tool_id: "t1".into(),
- outcome: crate::tools::ToolOutcome::Success("contents".into()),
- preview: None,
- });
- assert!(matches!(
- fsm.state,
- AgentState::Turn {
- stream: StreamPhase::Connecting
- }
- ));
-
- // Continuation stream: text only, no new tools
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamChunk("Here's the file.".into()));
- let effects = fsm.handle(Event::StreamDone {
- session_id: "".into(),
- });
-
- // Should go Idle, NOT start another continuation
- assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
- assert!(effects.iter().any(|e| matches!(e, Effect::Persist)));
- assert!(
- !effects
- .iter()
- .any(|e| matches!(e, Effect::StartStream { .. }))
- );
-}
-
-#[test]
-fn tool_done_before_stream_done_stays_in_turn() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- // Tool completes but stream hasn't sent Done yet
- let effects = fsm.handle(Event::ToolExecutionDone {
- tool_id: "t1".into(),
- outcome: crate::tools::ToolOutcome::Success("contents".into()),
- preview: None,
- });
-
- // Still in Turn — stream phase is Streaming, not Done
- assert!(matches!(
- fsm.state,
- AgentState::Turn {
- stream: StreamPhase::Streaming { .. }
- }
- ));
- assert!(effects.is_empty());
-}
-
-// ============================================================================
-// Cancel
-// ============================================================================
-
-#[test]
-fn cancel_during_streaming_goes_idle() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("hello".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamChunk("partial text".into()));
-
- let effects = fsm.handle(Event::Cancel);
-
- assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
- assert!(effects.iter().any(|e| matches!(e, Effect::AbortStream)));
- assert!(effects.iter().any(|e| matches!(e, Effect::Persist)));
- // Partial text committed with cancel suffix
- assert!(fsm.ctx.events.iter().any(|e| matches!(
- e,
- ConversationEvent::Text { content } if content.contains("[User cancelled")
- )));
-}
-
-#[test]
-fn stale_permission_resolved_after_cancel_is_ignored() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
- fsm.handle(Event::StreamDone {
- session_id: "".into(),
- });
- // Tool is in CheckingPermission, cancel happens before permission resolves
- fsm.handle(Event::Cancel);
- assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
-
- // Stale permission result arrives — tool is already Completed (cancelled)
- let effects = fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- // Should NOT emit ExecuteTool — the tool was cancelled
- assert!(effects.is_empty());
-}
-
-#[test]
-fn cancel_during_turn_with_pending_tools() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("hello".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
- fsm.handle(Event::StreamDone {
- session_id: "".into(),
- });
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
- // Tool is Executing, stream is Done
-
- let effects = fsm.handle(Event::Cancel);
-
- assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
- assert!(
- effects
- .iter()
- .any(|e| matches!(e, Effect::AbortTool { .. }))
- );
- // Error ToolResult injected
- assert!(fsm.ctx.events.iter().any(|e| matches!(
- e,
- ConversationEvent::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "t1"
- )));
- // SystemContext about cancellation
- assert!(fsm.ctx.events.iter().any(|e| matches!(
- e,
- ConversationEvent::SystemContext { content } if content.contains("cancelled")
- )));
-}
-
-#[test]
-fn stale_tool_result_after_cancel_is_ignored() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("hello".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
- fsm.handle(Event::StreamDone {
- session_id: "".into(),
- });
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
- fsm.handle(Event::Cancel);
-
- // Stale event arrives
- let effects = fsm.handle(Event::ToolExecutionDone {
- tool_id: "t1".into(),
- outcome: crate::tools::ToolOutcome::Success("contents".into()),
- preview: None,
- });
-
- assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
- assert!(effects.is_empty());
-}
-
-// ============================================================================
-// Confirmation
-// ============================================================================
-
-#[test]
-fn dangerous_command_enters_confirmation() {
- let mut fsm = new_fsm();
- // Simulate a dangerous command in history
- fsm.ctx.events.push(ConversationEvent::ToolCall {
- id: "sc1".into(),
- name: "suggest_command".into(),
- input: json!({"command": "rm -rf /", "description": "bad", "confidence": "high", "danger": "high"}),
- });
-
- let effects = fsm.handle(Event::ExecuteCommand);
-
- assert!(matches!(
- fsm.state,
- AgentState::Idle {
- confirmation: Some(_)
- }
- ));
- assert!(
- effects
- .iter()
- .any(|e| matches!(e, Effect::ScheduleTimeout { .. }))
- );
-}
-
-#[test]
-fn second_execute_confirms_and_exits() {
- let mut fsm = new_fsm();
- fsm.ctx.events.push(ConversationEvent::ToolCall {
- id: "sc1".into(),
- name: "suggest_command".into(),
- input: json!({"command": "rm -rf /", "description": "bad", "confidence": "high", "danger": "high"}),
- });
- fsm.handle(Event::ExecuteCommand);
-
- let effects = fsm.handle(Event::ExecuteCommand);
-
- assert!(effects.iter().any(|e| matches!(
- e,
- Effect::ExitApp(ExitAction::Execute(cmd)) if cmd == "rm -rf /"
- )));
-}
-
-#[test]
-fn confirmation_timeout_clears_confirmation() {
- let mut fsm = new_fsm();
- fsm.ctx.events.push(ConversationEvent::ToolCall {
- id: "sc1".into(),
- name: "suggest_command".into(),
- input: json!({"command": "rm -rf /", "description": "bad", "confidence": "high", "danger": "high"}),
- });
- fsm.handle(Event::ExecuteCommand);
- let timeout_id = match &fsm.state {
- AgentState::Idle {
- confirmation: Some(c),
- } => c.timeout_id,
- _ => panic!("expected confirmation"),
- };
-
- fsm.handle(Event::ConfirmationTimeout { timeout_id });
-
- assert_eq!(fsm.state, AgentState::Idle { confirmation: None });
-}
-
-// ============================================================================
-// Error / Retry
-// ============================================================================
-
-#[test]
-fn stream_error_goes_to_error_state() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("hello".into()));
- fsm.handle(Event::StreamStarted);
-
- fsm.handle(Event::StreamError("network error".into()));
-
- assert_eq!(fsm.state, AgentState::Error("network error".to_string()));
-}
-
-#[test]
-fn retry_from_error_starts_new_stream() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("hello".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamError("fail".into()));
-
- let effects = fsm.handle(Event::Retry);
-
- assert!(matches!(
- fsm.state,
- AgentState::Turn {
- stream: StreamPhase::Connecting
- }
- ));
- assert!(
- effects
- .iter()
- .any(|e| matches!(e, Effect::StartStream { .. }))
- );
-}
-
-// ============================================================================
-// Permission choices
-// ============================================================================
-
-#[test]
-fn permission_deny_completes_turn_and_continues() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
- fsm.handle(Event::StreamDone {
- session_id: "".into(),
- });
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Ask,
- });
-
- let effects = fsm.handle(Event::PermissionUserChoice {
- tool_id: "t1".into(),
- choice: PermissionChoice::Deny,
- });
-
- // Turn should complete since all tools resolved and stream is done
- // → continuation needed (there was a tool result to send back)
- assert!(matches!(
- fsm.state,
- AgentState::Turn {
- stream: StreamPhase::Connecting
- }
- ));
- assert!(
- effects
- .iter()
- .any(|e| matches!(e, Effect::StartStream { .. }))
- );
- // Error result was injected
- assert!(fsm.ctx.events.iter().any(|e| matches!(
- e,
- ConversationEvent::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "t1"
- )));
-}
-
-// ============================================================================
-// Shell execution timeouts
-// ============================================================================
-
-fn fsm_with_shell() -> AgentFsm {
- AgentFsm::new(
- vec![
- "client_v1_read_file".to_string(),
- "client_v1_execute_shell_command".to_string(),
- ],
- "test-inv".to_string(),
- )
-}
-
-fn shell_tool_call_event(id: &str) -> Event {
- Event::StreamToolCall {
- id: id.into(),
- name: "execute_shell_command".into(),
- input: json!({
- "command": "sleep 999",
- "shell": "bash",
- "timeout": 60,
- "description": "test"
- }),
- }
-}
-
-#[test]
-fn shell_tool_schedules_execution_timeout() {
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run something".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(shell_tool_call_event("t1"));
-
- let effects = fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- // Should have ExecuteTool + ScheduleTimeout
- assert!(
- effects
- .iter()
- .any(|e| matches!(e, Effect::ExecuteTool { .. }))
- );
- assert!(effects.iter().any(|e| matches!(
- e,
- Effect::ScheduleTimeout { kind: effects::TimeoutKind::ToolExecution { tool_id }, .. }
- if tool_id == "t1"
- )));
- assert!(!fsm.ctx.tool_timeout_ids.is_empty());
-}
-
-#[test]
-fn read_tool_does_not_schedule_timeout() {
- let mut fsm = new_fsm();
- fsm.handle(Event::UserSubmit("read".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "read_file".into(),
- input: json!({"file_path": "/tmp/test.txt"}),
- });
-
- let effects = fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- assert!(
- effects
- .iter()
- .any(|e| matches!(e, Effect::ExecuteTool { .. }))
- );
- assert!(
- !effects
- .iter()
- .any(|e| matches!(e, Effect::ScheduleTimeout { .. }))
- );
- assert!(fsm.ctx.tool_timeout_ids.is_empty());
-}
-
-#[test]
-fn tool_completion_clears_timeout_mapping() {
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(shell_tool_call_event("t1"));
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
- fsm.handle(Event::StreamDone {
- session_id: "s1".into(),
- });
-
- assert!(!fsm.ctx.tool_timeout_ids.is_empty());
-
- // Tool completes naturally
- fsm.handle(Event::ToolExecutionDone {
- tool_id: "t1".into(),
- outcome: crate::tools::ToolOutcome::Success("done".into()),
- preview: None,
- });
-
- assert!(fsm.ctx.tool_timeout_ids.is_empty());
-}
-
-#[test]
-fn stale_timeout_after_natural_completion_is_ignored() {
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(shell_tool_call_event("t1"));
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
- fsm.handle(Event::StreamDone {
- session_id: "s1".into(),
- });
-
- // Tool completes naturally
- fsm.handle(Event::ToolExecutionDone {
- tool_id: "t1".into(),
- outcome: crate::tools::ToolOutcome::Success("done".into()),
- preview: None,
- });
-
- // Stale timeout fires — should be no-op
- let effects = fsm.handle(Event::ToolExecutionTimeout {
- timeout_id: 0,
- tool_id: "t1".into(),
- });
-
- assert!(effects.is_empty());
-}
-
-#[test]
-fn timeout_fires_before_completion_emits_abort() {
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(shell_tool_call_event("t1"));
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
- fsm.handle(Event::StreamDone {
- session_id: "s1".into(),
- });
-
- // Timeout fires while tool is still executing
- let effects = fsm.handle(Event::ToolExecutionTimeout {
- timeout_id: 0,
- tool_id: "t1".into(),
- });
-
- assert_eq!(effects.len(), 1);
- assert!(matches!(
- effects[0],
- Effect::AbortTool { ref tool_id } if tool_id == "t1"
- ));
- // Timeout mapping cleaned up
- assert!(fsm.ctx.tool_timeout_ids.is_empty());
-}
-
-#[test]
-fn timeout_respects_llm_specified_duration() {
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run".into()));
- fsm.handle(Event::StreamStarted);
-
- // Tool call with timeout: 120
- fsm.handle(Event::StreamToolCall {
- id: "t1".into(),
- name: "execute_shell_command".into(),
- input: json!({
- "command": "cargo build",
- "shell": "bash",
- "timeout": 120,
- "description": "build"
- }),
- });
-
- let effects = fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- let timeout_effect = effects
- .iter()
- .find(|e| matches!(e, Effect::ScheduleTimeout { .. }));
- assert!(matches!(
- timeout_effect,
- Some(Effect::ScheduleTimeout { duration, .. }) if *duration == std::time::Duration::from_secs(120)
- ));
-}
-
-#[test]
-fn cancel_clears_timeout_mappings() {
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(shell_tool_call_event("t1"));
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- assert!(!fsm.ctx.tool_timeout_ids.is_empty());
-
- fsm.handle(Event::Cancel);
-
- assert!(fsm.ctx.tool_timeout_ids.is_empty());
-}
-
-#[test]
-fn timeout_abort_propagates_timeout_reason_to_preview_and_llm() {
- use super::tools::InterruptReason;
-
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(shell_tool_call_event("t1"));
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
- fsm.handle(Event::StreamDone {
- session_id: "s1".into(),
- });
-
- // Timeout fires
- fsm.handle(Event::ToolExecutionTimeout {
- timeout_id: 0,
- tool_id: "t1".into(),
- });
-
- // Tool completes after abort (interrupted: true from execute_shell_command_streaming)
- fsm.handle(Event::ToolExecutionDone {
- tool_id: "t1".into(),
- outcome: crate::tools::ToolOutcome::Structured {
- stdout: "partial output".into(),
- stderr: String::new(),
- exit_code: None,
- duration_ms: 60000,
- interrupted: true,
- },
- preview: Some(super::tools::ToolPreviewData::Shell {
- lines: vec!["partial output".into()],
- exit_code: None,
- interrupted: None, // FSM overrides this with the reason
- }),
- });
-
- // Preview should carry Timeout reason
- let tracked = fsm.ctx.tools.get("t1").unwrap();
- let preview = tracked.shell_preview().unwrap();
- assert_eq!(preview.interrupted, Some(InterruptReason::Timeout(60)));
-
- // LLM content should say "Timed out" not "Interrupted by user"
- let tool_result = fsm.ctx.events.iter().find(
- |e| matches!(e, ConversationEvent::ToolResult { tool_use_id, .. } if tool_use_id == "t1"),
- );
- if let Some(ConversationEvent::ToolResult { content, .. }) = tool_result {
- assert!(
- content.contains("[Timed out after 60s]"),
- "Expected timeout message, got: {content}"
- );
- assert!(!content.contains("[Interrupted by user]"));
- } else {
- panic!("No ToolResult found for t1");
- }
-}
-
-#[test]
-fn user_interrupt_propagates_user_reason_to_preview_and_llm() {
- use super::tools::InterruptReason;
-
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(shell_tool_call_event("t1"));
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
- fsm.handle(Event::StreamDone {
- session_id: "s1".into(),
- });
-
- // User interrupts
- fsm.handle(Event::InterruptTools);
-
- // Tool completes after abort
- fsm.handle(Event::ToolExecutionDone {
- tool_id: "t1".into(),
- outcome: crate::tools::ToolOutcome::Structured {
- stdout: "partial".into(),
- stderr: String::new(),
- exit_code: None,
- duration_ms: 5000,
- interrupted: true,
- },
- preview: Some(super::tools::ToolPreviewData::Shell {
- lines: vec!["partial".into()],
- exit_code: None,
- interrupted: None, // FSM overrides this with the reason
- }),
- });
-
- // Preview should carry User reason
- let tracked = fsm.ctx.tools.get("t1").unwrap();
- let preview = tracked.shell_preview().unwrap();
- assert_eq!(preview.interrupted, Some(InterruptReason::User));
-
- // LLM content should say "Interrupted by user"
- let tool_result = fsm.ctx.events.iter().find(
- |e| matches!(e, ConversationEvent::ToolResult { tool_use_id, .. } if tool_use_id == "t1"),
- );
- if let Some(ConversationEvent::ToolResult { content, .. }) = tool_result {
- assert!(
- content.contains("[Interrupted by user]"),
- "Expected user interrupt message, got: {content}"
- );
- } else {
- panic!("No ToolResult found for t1");
- }
-}
-
-#[test]
-fn user_interrupt_clears_timeout_mappings_for_aborted_tools() {
- let mut fsm = fsm_with_shell();
- fsm.handle(Event::UserSubmit("run".into()));
- fsm.handle(Event::StreamStarted);
- fsm.handle(shell_tool_call_event("t1"));
- fsm.handle(Event::PermissionResolved {
- tool_id: "t1".into(),
- response: PermissionResponse::Allowed,
- });
-
- assert!(!fsm.ctx.tool_timeout_ids.is_empty());
-
- fsm.handle(Event::InterruptTools);
-
- assert!(fsm.ctx.tool_timeout_ids.is_empty());
-}