Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,7 @@ hoa_onboarding_flow = []
git_operations_in_code_review = []
hoa_remote_control = []
codex_notifications = []
codex_plugin = []
cloud_mode_setup_v2 = ["cloud_mode"]
cloud_mode_input_v2 = ["cloud_mode"]
configurable_context_window = []
Expand Down
19 changes: 7 additions & 12 deletions app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ use crate::settings::{
AISettings, AISettingsChangedEvent, PrivacySettings, PrivacySettingsChangedEvent,
};
use crate::settings_view::SettingsSection;
use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status;
#[cfg(not(target_family = "wasm"))]
use crate::terminal::cli_agent_sessions::plugin_manager::{
compare_versions, plugin_manager_for, plugin_manager_for_with_shell, CliAgentPluginManager,
Expand Down Expand Up @@ -507,12 +506,12 @@ impl AgentInputFooter {
me.plugin_chip_ready = false;
}

// When a listener connects for an agent with rich status,
// the plugin is verified installed — hide the chip.
// (Codex always has a listener but no actual plugin to install.)
// When a structured plugin connects, the plugin is verified
// installed — hide the chip. Codex's OSC 9 fallback is not a
// structured plugin, so its chip stays until the plugin connects.
if CLIAgentSessionsModel::as_ref(ctx)
.session(me.terminal_view_id)
.is_some_and(|s| s.listener.is_some() && agent_supports_rich_status(&s.agent))
.is_some_and(|s| s.supports_rich_status())
{
me.plugin_chip_ready = false;
}
Expand All @@ -533,10 +532,7 @@ impl AgentInputFooter {
|me, _, ctx: &mut ViewContext<Self>| {
let suppress = CLIAgentSessionsModel::as_ref(ctx)
.session(me.terminal_view_id)
.is_some_and(|s| {
s.listener.is_some()
&& agent_supports_rich_status(&s.agent)
});
.is_some_and(|s| s.supports_rich_status());
if !suppress {
me.plugin_chip_ready = true;
ctx.notify();
Expand Down Expand Up @@ -1084,10 +1080,9 @@ impl AgentInputFooter {
let manager = plugin_manager_for(session.agent)?;
let min_version = manager.minimum_plugin_version();
let chip_key = plugin_chip_key(session.agent.command_prefix(), &session.remote_host);

// If the plugin is connected (listener present) and this agent supports
// If a structured plugin is connected and this agent supports
// version-based updates, check the reported version.
if session.listener.is_some() && manager.supports_update() {
if session.supports_rich_status() && manager.supports_update() {
let needs_update = match &session.plugin_version {
// No version reported = pre-versioning plugin, definitely outdated.
None => true,
Expand Down
2 changes: 2 additions & 0 deletions app/src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@ fn enabled_features() -> HashSet<FeatureFlag> {
FeatureFlag::HOARemoteControl,
#[cfg(feature = "codex_notifications")]
FeatureFlag::CodexNotifications,
#[cfg(feature = "codex_plugin")]
FeatureFlag::CodexPlugin,
#[cfg(feature = "trim_trailing_blank_lines")]
FeatureFlag::TrimTrailingBlankLines,
#[cfg(feature = "cloud_mode_setup_v2")]
Expand Down
10 changes: 10 additions & 0 deletions app/src/terminal/cli_agent_sessions/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ pub enum CLIAgentEventType {
Unknown(String),
}

/// How a CLI agent event reached Warp.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CLIAgentEventSource {
/// Structured OSC 777 notification from a rich plugin.
RichPlugin,
/// Native Codex OSC 9 fallback notification.
CodexOsc9Fallback,
}

/// Event-specific fields that vary by event type.
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
Expand All @@ -49,6 +58,7 @@ pub struct CLIAgentEvent {
pub cwd: Option<String>,
pub project: Option<String>,
pub payload: CLIAgentEventPayload,
pub source: CLIAgentEventSource,
}

/// Version-specific parsers, indexed by (version - 1).
Expand Down
3 changes: 2 additions & 1 deletion app/src/terminal/cli_agent_sessions/event/v1.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use serde::Deserialize;

use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType};
use crate::terminal::CLIAgent;

/// Resolves a CLI agent from the `"agent"` string in a CLI agent event.
Expand Down Expand Up @@ -54,6 +54,7 @@ pub(super) fn parse(body: &str) -> Option<CLIAgentEvent> {
tool_input_preview,
plugin_version: raw.plugin_version,
},
source: CLIAgentEventSource::RichPlugin,
})
}

Expand Down
85 changes: 47 additions & 38 deletions app/src/terminal/cli_agent_sessions/listener/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use warpui::{EntityId, ModelContext, ModelHandle, SingletonEntity};

use super::{CLIAgentEvent, CLIAgentSessionsModel};
use crate::features::FeatureFlag;
use crate::terminal::cli_agent_sessions::event::{
parse_event, CLIAgentEventPayload, CLIAgentEventType,
parse_event, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType,
};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::CLIAgent;
Expand All @@ -15,27 +16,23 @@ trait CLIAgentSessionHandler {
/// The default implementation delegates to the structured JSON parser
/// (`parse_event`); agents with non-JSON notification formats (e.g. Codex
/// OSC 9 plain text) should override this.
fn try_parse(&self, title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
///
/// `plugin_already_active` is true when the session has already received a
/// structured OSC 777 notification; Codex uses it to drop OSC 9 fallback
/// once the rich plugin is active. Other handlers ignore it.
fn try_parse(
&mut self,
title: Option<&str>,
body: &str,
plugin_already_active: bool,
) -> Option<CLIAgentEvent> {
let _ = plugin_already_active;
parse_event(title, body)
}

/// Decide whether a parsed event should be forwarded to the sessions model.
/// Returns the event (possibly transformed) if it should be processed.
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent>;

/// Whether this handler provides meaningful, fine-grained status
/// (e.g. in-progress / blocked / success) that should be shown in the UI.
/// Handlers backed by the structured plugin protocol report rich status;
/// handlers that only forward opaque OS notifications (e.g. Codex) do not.
fn supports_rich_status(&self) -> bool {
true
}
}

/// Whether the listener for the given agent provides rich status.
/// Returns `false` for agents without a handler or whose handler opts out.
pub fn agent_supports_rich_status(agent: &CLIAgent) -> bool {
create_handler(agent).is_some_and(|h| h.supports_rich_status())
}

/// Returns `true` if the given CLI agent has a supported session handler.
Expand Down Expand Up @@ -91,14 +88,11 @@ impl CLIAgentSessionHandler for DefaultSessionListener {
}
}

/// Codex-specific handler that parses plain-text OSC 9 desktop notifications
/// into CLI agent events.
/// Codex-specific handler that supports both native OSC 9 fallback and structured plugin events.
///
/// Codex sends notifications via OSC 9 (`\x1b]9;message\x07`) with
/// human-readable text. Since there's no way to distinguish notification types
/// from the raw text, all OSC 9 notifications are treated as `Stop` (success).
/// The notification body becomes the event's `query` so it surfaces as the
/// notification title in the UI.
/// human-readable text. Since there's no way to distinguish notification types from the raw text,
/// OSC 9 fallback notifications are treated as `Stop` (success).
struct CodexSessionHandler;

impl CodexSessionHandler {
Expand All @@ -121,22 +115,34 @@ impl CodexSessionHandler {
query: Some(body.to_owned()),
..Default::default()
},
source: CLIAgentEventSource::CodexOsc9Fallback,
})
}
}

impl CLIAgentSessionHandler for CodexSessionHandler {
/// Codex sends plain-text OSC 9 notifications (title = `None`) instead of
/// the structured OSC 777 JSON used by Claude Code / OpenCode.
fn try_parse(&self, title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
// If the notification carries the structured sentinel, try the normal
// JSON parser first (future-proofing in case Codex adds plugin
// support later).
if let Some(parsed) = parse_event(title, body) {
return Some(parsed);
/// Before Codex enabled support for hooks, we relied on OSC 9 to trigger notifications in Warp.
/// Here, we try to parse an OSC 777 event if we can, and remember when we've seen one.
/// This lets us ignore OSC 9 notifications if we are working with a client that is using
/// the new plugin, but keeps them intact for legacy clients.
fn try_parse(
&mut self,
title: Option<&str>,
body: &str,
plugin_already_active: bool,
) -> Option<CLIAgentEvent> {
if let Some(event) = parse_event(title, body) {
if event.agent == CLIAgent::Codex {
if !FeatureFlag::CodexPlugin.is_enabled() {
return None;
}
return Some(event);
}
return None;
}
// OSC 9 notifications have no title.
if title.is_some() {
// OSC 9 notifications have no title. Skip OSC 9 once the rich plugin is
// active, otherwise we'd process both OSC 777 and OSC 9 notifications.
if title.is_some() || plugin_already_active {
return None;
}
Self::parse_osc9_text(body)
Expand All @@ -145,10 +151,6 @@ impl CLIAgentSessionHandler for CodexSessionHandler {
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent> {
Some(event)
}

fn supports_rich_status(&self) -> bool {
false
}
}

/// Per-agent listener that subscribes to PTY events and forwards them to the
Expand Down Expand Up @@ -178,12 +180,19 @@ impl CLIAgentSessionListener {
// `handle_event` then filters/transforms the result.
ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| {
if let ModelEvent::PluggableNotification { title, body } = event {
let Some(parsed) = me.inner.try_parse(title.as_deref(), body) else {
let view_id = me.terminal_view_id;
let plugin_already_active = CLIAgentSessionsModel::as_ref(ctx)
.session(view_id)
.is_some_and(|session| session.received_rich_notification);
let Some(parsed) =
me.inner
.try_parse(title.as_deref(), body, plugin_already_active)
else {
return;
};
if let Some(event) = me.inner.handle_event(parsed) {
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
sessions_model.update_from_event(me.terminal_view_id, &event, ctx);
sessions_model.update_from_event(view_id, &event, ctx);
});
}
}
Expand Down
70 changes: 56 additions & 14 deletions app/src/terminal/cli_agent_sessions/listener/mod_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use super::*;
use crate::terminal::cli_agent_sessions::event::CLIAgentEventType;
use crate::terminal::cli_agent_sessions::event::{
CLIAgentEventSource, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL,
};

#[test]
fn codex_parses_any_text_as_stop() {
Expand Down Expand Up @@ -40,33 +42,75 @@ fn codex_ignores_empty_body() {

#[test]
fn codex_try_parse_ignores_titled_notifications() {
let handler = CodexSessionHandler;
let mut handler = CodexSessionHandler;
assert!(handler
.try_parse(Some("some-title"), "Agent turn complete")
.try_parse(Some("some-title"), "Agent turn complete", false)
.is_none());
}

#[test]
fn codex_try_parse_handles_osc9() {
let handler = CodexSessionHandler;
let event = handler.try_parse(None, "Agent turn complete").unwrap();
let mut handler = CodexSessionHandler;
let event = handler
.try_parse(None, "Agent turn complete", false)
.unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
}

#[test]
fn auggie_is_supported() {
assert!(is_agent_supported(&CLIAgent::Auggie));
fn codex_try_parse_ignores_osc9_when_plugin_already_active() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let mut handler = CodexSessionHandler;
let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#;

let event = handler
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
.unwrap();

assert_eq!(event.event, CLIAgentEventType::PermissionRequest);
// Once the session is rich, OSC 9 fallback is dropped.
assert!(handler
.try_parse(None, "Agent turn complete", true)
.is_none());
}

#[test]
fn auggie_uses_default_handler_with_rich_status() {
assert!(agent_supports_rich_status(&CLIAgent::Auggie));
fn codex_try_parse_ignores_structured_event_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let mut handler = CodexSessionHandler;
let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#;

assert!(handler
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
.is_none());
assert!(handler
.try_parse(None, "Agent turn complete", false)
.is_some());
}

#[test]
fn codex_try_parse_ignores_other_structured_agents() {
let mut handler = CodexSessionHandler;
let body = r#"{"v":1,"agent":"claude","event":"stop"}"#;

assert!(handler
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
.is_none());
assert!(handler
.try_parse(None, "Agent turn complete", false)
.is_some());
}

#[test]
fn auggie_is_supported() {
assert!(is_agent_supported(&CLIAgent::Auggie));
}

#[test]
fn auggie_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::SessionStart,
Expand All @@ -82,6 +126,7 @@ fn auggie_default_handler_skips_session_start() {
fn auggie_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::Stop,
Expand All @@ -98,15 +143,11 @@ fn pi_is_supported() {
assert!(is_agent_supported(&CLIAgent::Pi));
}

#[test]
fn pi_uses_default_handler_with_rich_status() {
assert!(agent_supports_rich_status(&CLIAgent::Pi));
}

#[test]
fn pi_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Pi,
event: CLIAgentEventType::SessionStart,
Expand All @@ -122,6 +163,7 @@ fn pi_default_handler_skips_session_start() {
fn pi_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Pi,
event: CLIAgentEventType::Stop,
Expand Down
Loading
Loading