use super::event::{ parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, }; use super::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, }; use crate::ai::blocklist::{InputConfig, InputType}; use crate::terminal::CLIAgent; #[test] fn parse_stop_notification() { let body = r#"{"v":1,"agent":"claude","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe","transcript_path":"/tmp/t.jsonl"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.v, 1); assert_eq!(notif.agent, CLIAgent::Claude); assert_eq!(notif.event, CLIAgentEventType::Stop); assert_eq!(notif.session_id.as_deref(), Some("abc")); assert_eq!(notif.cwd.as_deref(), Some("/tmp/proj")); assert_eq!(notif.project.as_deref(), Some("proj")); assert_eq!(notif.payload.query.as_deref(), Some("write a haiku")); assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe")); assert_eq!( notif.payload.transcript_path.as_deref(), Some("/tmp/t.jsonl") ); } #[test] fn cli_agent_session_context_title_like_text_uses_trimmed_summary() { let context = CLIAgentSessionContext { summary: Some(" Reviewing changes ".to_string()), query: Some("Latest prompt".to_string()), ..Default::default() }; assert_eq!( context.title_like_text(), Some("Reviewing changes".to_string()) ); } #[test] fn cli_agent_session_context_latest_user_prompt_uses_trimmed_query() { let context = CLIAgentSessionContext { summary: Some("Reviewing changes".to_string()), query: Some(" Latest prompt ".to_string()), ..Default::default() }; assert_eq!( context.latest_user_prompt(), Some("Latest prompt".to_string()) ); } #[test] fn cli_agent_session_context_title_helpers_ignore_empty_text() { let context = CLIAgentSessionContext { summary: Some(" ".to_string()), query: Some("".to_string()), ..Default::default() }; assert_eq!(context.title_like_text(), None); assert_eq!(context.latest_user_prompt(), None); } #[test] fn parse_permission_request_notification() { let body = r#"{"v":1,"agent":"claude","event":"permission_request","session_id":"abc","cwd":"/tmp/proj","project":"proj","summary":"Wants to run Bash: rm -rf /tmp","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp"}}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.event, CLIAgentEventType::PermissionRequest); assert_eq!( notif.payload.summary.as_deref(), Some("Wants to run Bash: rm -rf /tmp") ); assert_eq!(notif.payload.tool_name.as_deref(), Some("Bash")); assert_eq!( notif.payload.tool_input_preview.as_deref(), Some("rm -rf /tmp") ); } #[test] fn parse_permission_request_with_file_path() { let body = r#"{"v":1,"agent":"claude","event":"permission_request","session_id":"abc","cwd":"/tmp","project":"tmp","tool_name":"Write","tool_input":{"file_path":"/tmp/test.py","content":"print('hi')"}}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!( notif.payload.tool_input_preview.as_deref(), Some("/tmp/test.py") ); } #[test] fn parse_idle_prompt_notification() { let body = r#"{"v":1,"agent":"claude","event":"idle_prompt","session_id":"abc","cwd":"/tmp","project":"tmp","summary":"Claude is waiting for your input"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.event, CLIAgentEventType::IdlePrompt); assert_eq!( notif.payload.summary.as_deref(), Some("Claude is waiting for your input") ); } #[test] fn parse_session_start_notification() { let body = r#"{"v":1,"agent":"claude","event":"session_start","session_id":"abc","cwd":"/tmp","project":"tmp","plugin_version":"1.1.0"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.event, CLIAgentEventType::SessionStart); assert_eq!(notif.payload.plugin_version.as_deref(), Some("1.1.0")); } #[test] fn returns_none_for_wrong_sentinel() { let body = r#"{"v":1,"agent":"claude","event":"stop"}"#; assert!(parse_event(Some("Claude Code"), body).is_none()); } #[test] fn returns_none_for_missing_title() { let body = r#"{"v":1,"agent":"claude","event":"stop"}"#; assert!(parse_event(None, body).is_none()); } #[test] fn returns_none_for_invalid_json() { assert!(parse_event(Some("warp://cli-agent"), "not json").is_none()); } #[test] fn handles_unknown_event_type() { let body = r#"{"v":1,"agent":"claude","event":"some_future_event"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!( notif.event, CLIAgentEventType::Unknown("some_future_event".to_string()) ); } #[test] fn handles_missing_optional_fields() { let body = r#"{"event":"stop"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.v, 1); assert_eq!(notif.agent, CLIAgent::Unknown); assert_eq!(notif.event, CLIAgentEventType::Stop); assert!(notif.session_id.is_none()); assert!(notif.cwd.is_none()); assert!(notif.project.is_none()); assert!(notif.payload.query.is_none()); } #[test] fn handles_special_characters_in_values() { let body = r#"{"v":1,"agent":"claude","event":"stop","query":"what does \"hello\" mean?","response":"It means greeting. Use: printf(\"hello\")"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!( notif.payload.query.as_deref(), Some("what does \"hello\" mean?") ); assert_eq!( notif.payload.response.as_deref(), Some("It means greeting. Use: printf(\"hello\")") ); } #[test] fn rejects_unsupported_schema_version() { let body = r#"{"v":2,"agent":"claude","event":"stop"}"#; assert!(parse_event(Some("warp://cli-agent"), body).is_none()); } #[test] fn defaults_to_v1_when_version_missing() { let body = r#"{"agent":"claude","event":"stop","query":"hi"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.v, 1); assert_eq!(notif.payload.query.as_deref(), Some("hi")); } #[test] fn explicit_v1_parses_correctly() { let body = r#"{"v":1,"agent":"claude","event":"stop","query":"test"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.v, 1); assert_eq!(notif.payload.query.as_deref(), Some("test")); } #[test] fn parse_prompt_submit_notification() { let body = r#"{"v":1,"agent":"claude","event":"prompt_submit","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"fix the bug"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.event, CLIAgentEventType::PromptSubmit); assert_eq!(notif.payload.query.as_deref(), Some("fix the bug")); } #[test] fn parse_tool_complete_notification() { let body = r#"{"v":1,"agent":"claude","event":"tool_complete","session_id":"abc","cwd":"/tmp/proj","project":"proj","tool_name":"Bash"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.event, CLIAgentEventType::ToolComplete); assert_eq!(notif.payload.tool_name.as_deref(), Some("Bash")); } #[test] fn parse_auggie_stop_notification() { // Mirrors what the community auggie-warp plugin emits on the Stop hook. let body = r#"{"v":1,"agent":"auggie","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.agent, CLIAgent::Auggie); assert_eq!(notif.event, CLIAgentEventType::Stop); assert_eq!(notif.payload.query.as_deref(), Some("write a haiku")); assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe")); } #[test] fn parse_pi_stop_notification() { // Mirrors what the community pi-mono plugin emits on the Stop hook — // matches the Auggie shape and uses `"agent":"pi"`, which `resolve_agent` // already maps to `CLIAgent::Pi` via `command_prefix()`. let body = r#"{"v":1,"agent":"pi","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.agent, CLIAgent::Pi); assert_eq!(notif.event, CLIAgentEventType::Stop); assert_eq!(notif.payload.query.as_deref(), Some("write a haiku")); assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe")); } #[test] fn parse_droid_stop_notification() { // Droid is already a known CLI agent, so structured OSC 777 events using // `"agent":"droid"` should resolve through the existing command prefix // parser without any Droid-specific parser logic. let body = r#"{"v":1,"agent":"droid","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#; let notif = parse_event(Some("warp://cli-agent"), body).unwrap(); assert_eq!(notif.agent, CLIAgent::Droid); assert_eq!(notif.event, CLIAgentEventType::Stop); assert_eq!(notif.payload.query.as_deref(), Some("write a haiku")); assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe")); } #[test] fn apply_event_preserves_input_session() { let input_state = CLIAgentInputState::Open { entrypoint: CLIAgentInputEntrypoint::CtrlG, previous_input_config: InputConfig { input_type: InputType::Shell, is_locked: false, }, previous_was_lock_set_with_empty_buffer: true, }; let mut session = CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state, should_auto_toggle_input: false, listener: None, remote_host: None, plugin_version: None, draft_text: None, custom_command_prefix: None, received_rich_notification: false, }; let event = CLIAgentEvent { source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, session_id: Some("abc".to_string()), cwd: Some("/tmp/proj".to_string()), project: Some("proj".to_string()), payload: CLIAgentEventPayload { summary: Some("Needs approval".to_string()), ..Default::default() }, }; session.apply_event(&event); assert_eq!(session.input_state, input_state); } #[test] fn is_remote_returns_true_when_remote_host_is_set() { let session = CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, plugin_version: None, draft_text: None, remote_host: Some("user@devbox".to_owned()), custom_command_prefix: None, received_rich_notification: false, }; assert!(session.is_remote()); } #[test] fn is_remote_returns_false_when_remote_host_is_none() { let session = CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, remote_host: None, plugin_version: None, draft_text: None, custom_command_prefix: None, received_rich_notification: false, }; assert!(!session.is_remote()); } #[test] fn local_failure_is_shared_across_local_sessions() { let mut model = CLIAgentSessionsModel::new(); model.record_plugin_auto_failure(CLIAgent::Claude, None); assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &None)); } #[test] fn local_failure_does_not_affect_remote_host() { let mut model = CLIAgentSessionsModel::new(); model.record_plugin_auto_failure(CLIAgent::Claude, None); let remote = Some("user@devbox".to_owned()); assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &remote)); } #[test] fn remote_failure_does_not_affect_local() { let mut model = CLIAgentSessionsModel::new(); model.record_plugin_auto_failure(CLIAgent::Claude, Some("user@devbox".to_owned())); assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &None)); } #[test] fn remote_failures_are_independent_per_host() { let mut model = CLIAgentSessionsModel::new(); let host_a = Some("user@host-a".to_owned()); let host_b = Some("user@host-b".to_owned()); model.record_plugin_auto_failure(CLIAgent::Claude, host_a.clone()); assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &host_a)); assert!(!model.has_plugin_auto_failed(CLIAgent::Claude, &host_b)); } #[test] fn failure_tracking_is_independent_per_agent() { let mut model = CLIAgentSessionsModel::new(); model.record_plugin_auto_failure(CLIAgent::Claude, None); assert!(model.has_plugin_auto_failed(CLIAgent::Claude, &None)); assert!(!model.has_plugin_auto_failed(CLIAgent::Gemini, &None)); } #[test] fn session_start_sets_plugin_version() { let mut session = CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, plugin_version: None, draft_text: None, remote_host: None, custom_command_prefix: None, received_rich_notification: false, }; let event = CLIAgentEvent { source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::SessionStart, session_id: Some("abc".to_owned()), cwd: Some("/tmp".to_owned()), project: Some("proj".to_owned()), payload: CLIAgentEventPayload { plugin_version: Some("1.5.0".to_owned()), ..Default::default() }, }; session.apply_event(&event); assert_eq!(session.plugin_version.as_deref(), Some("1.5.0")); } #[test] fn session_start_without_plugin_version_leaves_none() { let mut session = CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, plugin_version: None, draft_text: None, remote_host: None, custom_command_prefix: None, received_rich_notification: false, }; let event = CLIAgentEvent { source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::SessionStart, session_id: Some("abc".to_owned()), cwd: None, project: None, payload: CLIAgentEventPayload::default(), }; session.apply_event(&event); assert_eq!(session.plugin_version, None); } #[test] fn codex_session_not_rich_until_rich_notification() { // Codex's OSC 9 fallback never sets `received_rich_notification`, so the // session must not claim rich status even when a fallback listener exists. let mut session = CLIAgentSession { agent: CLIAgent::Codex, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, plugin_version: None, remote_host: None, draft_text: None, custom_command_prefix: None, received_rich_notification: false, }; assert!(!session.supports_rich_status()); // A structured OSC 777 notification latches the flag -> rich status. session.received_rich_notification = true; assert!(session.supports_rich_status()); } #[test] fn non_codex_session_rich_after_rich_notification() { let mut session = CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, plugin_version: None, remote_host: None, draft_text: None, custom_command_prefix: None, received_rich_notification: false, }; // No listener and no rich notification yet. assert!(!session.supports_rich_status()); session.received_rich_notification = true; assert!(session.supports_rich_status()); } /// Constructs a session with permission-scoped state already populated, as if /// a `PermissionRequest` had just been received and the agent is now Blocked. /// Used by the GH-9525 regression tests below. fn blocked_claude_session_with_permission_state() -> CLIAgentSession { CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::Blocked { message: Some("Wants to run bash: rm -rf /tmp".to_owned()), }, session_context: CLIAgentSessionContext { summary: Some("Wants to run bash: rm -rf /tmp".to_owned()), tool_name: Some("Bash".to_owned()), tool_input_preview: Some("rm -rf /tmp".to_owned()), ..Default::default() }, input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, plugin_version: None, draft_text: None, remote_host: None, custom_command_prefix: None, received_rich_notification: false, } } #[test] fn stop_clears_permission_scoped_state() { // GH-9525: after a PermissionRequest sets `summary`, the Stop event must // clear it. Otherwise the tab title falls back to the stale permission // text instead of reflecting the now-completed session. let mut session = blocked_claude_session_with_permission_state(); let event = CLIAgentEvent { source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::Stop, session_id: Some("abc".to_owned()), cwd: None, project: None, payload: CLIAgentEventPayload { query: Some("write a haiku".to_owned()), response: Some("Memory is safe".to_owned()), ..Default::default() }, }; session.apply_event(&event); assert_eq!(session.session_context.summary, None); assert_eq!(session.session_context.tool_name, None); assert_eq!(session.session_context.tool_input_preview, None); assert_eq!( session.session_context.query.as_deref(), Some("write a haiku"), ); assert_eq!( session.session_context.response.as_deref(), Some("Memory is safe"), ); assert!(matches!(session.status, CLIAgentSessionStatus::Success)); } #[test] fn permission_replied_clears_permission_scoped_state() { // When the user replies to a permission prompt the agent transitions back // to InProgress; the now-stale summary/tool fields must be cleared so they // don't leak into UI surfaces during the next turn. let mut session = blocked_claude_session_with_permission_state(); let event = CLIAgentEvent { source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, session_id: Some("abc".to_owned()), cwd: None, project: None, payload: CLIAgentEventPayload::default(), }; session.apply_event(&event); assert_eq!(session.session_context.summary, None); assert_eq!(session.session_context.tool_name, None); assert_eq!(session.session_context.tool_input_preview, None); assert!(matches!(session.status, CLIAgentSessionStatus::InProgress)); } #[test] fn prompt_submit_clears_permission_scoped_state() { // PromptSubmit already clears `response`; clearing the permission-scoped // fields keeps the same hygiene if the user manages to start a new turn // while permission state is still populated (e.g. an abandoned permission // flow that was not closed by an explicit PermissionReplied). let mut session = blocked_claude_session_with_permission_state(); session.session_context.response = Some("stale response".to_owned()); let event = CLIAgentEvent { source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PromptSubmit, session_id: Some("abc".to_owned()), cwd: None, project: None, payload: CLIAgentEventPayload { query: Some("next prompt".to_owned()), ..Default::default() }, }; session.apply_event(&event); assert_eq!(session.session_context.summary, None); assert_eq!(session.session_context.tool_name, None); assert_eq!(session.session_context.tool_input_preview, None); assert_eq!(session.session_context.response, None); assert_eq!( session.session_context.query.as_deref(), Some("next prompt") ); assert!(matches!(session.status, CLIAgentSessionStatus::InProgress)); } #[test] fn tool_complete_clears_permission_scoped_state() { // GH-11082: answering an AskUserQuestion emits only ToolComplete (the // plugin sends no PermissionReplied for it), so the Blocked -> InProgress // transition here must also clear the stale summary. Otherwise the tab // title keeps showing "Wants to run AskUserQuestion: ..." until the next // prompt or Stop. let mut session = blocked_claude_session_with_permission_state(); let event = CLIAgentEvent { source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::ToolComplete, session_id: Some("abc".to_owned()), cwd: None, project: None, payload: CLIAgentEventPayload::default(), }; session.apply_event(&event); assert_eq!(session.session_context.summary, None); assert_eq!(session.session_context.tool_name, None); assert_eq!(session.session_context.tool_input_preview, None); assert!(matches!(session.status, CLIAgentSessionStatus::InProgress)); } #[test] fn permission_request_still_populates_summary_and_tool_fields() { // Sanity: clearing permission-scoped state on Stop/Reply/Submit must not // also break the PermissionRequest path that initially populates them. let mut session = CLIAgentSession { agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext::default(), input_state: CLIAgentInputState::Closed, should_auto_toggle_input: false, listener: None, plugin_version: None, draft_text: None, remote_host: None, custom_command_prefix: None, received_rich_notification: false, }; let event = CLIAgentEvent { source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, session_id: Some("abc".to_owned()), cwd: None, project: None, payload: CLIAgentEventPayload { summary: Some("Wants to run bash: rm -rf /tmp".to_owned()), tool_name: Some("Bash".to_owned()), tool_input_preview: Some("rm -rf /tmp".to_owned()), ..Default::default() }, }; session.apply_event(&event); assert_eq!( session.session_context.summary.as_deref(), Some("Wants to run bash: rm -rf /tmp"), ); assert_eq!(session.session_context.tool_name.as_deref(), Some("Bash")); assert_eq!( session.session_context.tool_input_preview.as_deref(), Some("rm -rf /tmp"), ); assert!(matches!( session.status, CLIAgentSessionStatus::Blocked { .. }, )); }