Files
galaxy/app/src/terminal/cli_agent_sessions/mod_tests.rs
T

700 lines
24 KiB
Rust

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 { .. },
));
}