mirror of
https://github.com/herdrdev/herdr.git
synced 2026-10-02 16:00:26 +00:00
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
691 lines
25 KiB
Rust
691 lines
25 KiB
Rust
use regex::Regex;
|
|
|
|
use crate::api::schema::{
|
|
ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, Request,
|
|
Subscription, SubscriptionEventData, SubscriptionEventEnvelope, SubscriptionEventKind,
|
|
};
|
|
use crate::api::server::{dispatch_to_app_with_timeout, APP_RESPONSE_TIMEOUT};
|
|
use crate::api::{ApiRequestSender, EventHub};
|
|
|
|
pub(super) fn output_match_read_source(
|
|
source: &crate::api::schema::ReadSource,
|
|
) -> crate::api::schema::ReadSource {
|
|
match source {
|
|
crate::api::schema::ReadSource::Recent => crate::api::schema::ReadSource::RecentUnwrapped,
|
|
other => *other,
|
|
}
|
|
}
|
|
|
|
pub(super) fn match_output(
|
|
text: &str,
|
|
matcher: &crate::api::schema::OutputMatch,
|
|
regex: Option<&Regex>,
|
|
) -> Option<String> {
|
|
match matcher {
|
|
crate::api::schema::OutputMatch::Substring { value } => text
|
|
.lines()
|
|
.find(|line| line.contains(value))
|
|
.map(|line| line.to_string()),
|
|
crate::api::schema::OutputMatch::Regex { .. } => regex.and_then(|re| {
|
|
text.lines()
|
|
.find(|line| re.is_match(line))
|
|
.map(|line| line.to_string())
|
|
}),
|
|
}
|
|
}
|
|
|
|
pub(super) struct ActiveOutputMatchedSubscription {
|
|
pane_id: String,
|
|
source: crate::api::schema::ReadSource,
|
|
lines: Option<u32>,
|
|
matcher: crate::api::schema::OutputMatch,
|
|
regex: Option<Regex>,
|
|
strip_ansi: bool,
|
|
currently_matching: bool,
|
|
request_prefix: String,
|
|
}
|
|
|
|
pub(super) struct ActiveAgentStatusChangedSubscription {
|
|
pane_id: String,
|
|
status_filter: Option<crate::api::schema::AgentStatus>,
|
|
last_status: Option<crate::api::schema::AgentStatus>,
|
|
last_presentation: Option<PanePresentationSnapshot>,
|
|
last_sequence: u64,
|
|
initial_event: Option<PaneAgentStatusChangedEvent>,
|
|
request_prefix: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct PanePresentationSnapshot {
|
|
title: Option<String>,
|
|
display_agent: Option<String>,
|
|
custom_status: Option<String>,
|
|
state_labels: std::collections::HashMap<String, String>,
|
|
}
|
|
|
|
impl PanePresentationSnapshot {
|
|
fn from(pane: &crate::api::schema::PaneInfo) -> Self {
|
|
Self {
|
|
title: pane.title.clone(),
|
|
display_agent: pane.display_agent.clone(),
|
|
custom_status: pane.custom_status.clone(),
|
|
state_labels: pane.state_labels.clone(),
|
|
}
|
|
}
|
|
|
|
fn from_event(
|
|
title: &Option<String>,
|
|
display_agent: &Option<String>,
|
|
custom_status: &Option<String>,
|
|
state_labels: &std::collections::HashMap<String, String>,
|
|
) -> Self {
|
|
Self {
|
|
title: title.clone(),
|
|
display_agent: display_agent.clone(),
|
|
custom_status: custom_status.clone(),
|
|
state_labels: state_labels.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) struct ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind,
|
|
last_sequence: u64,
|
|
}
|
|
|
|
pub(super) enum ActiveSubscription {
|
|
Event(ActiveEventSubscription),
|
|
OutputMatched(ActiveOutputMatchedSubscription),
|
|
AgentStatusChanged(Box<ActiveAgentStatusChangedSubscription>),
|
|
}
|
|
|
|
impl ActiveSubscription {
|
|
pub(super) fn new(
|
|
subscription: Subscription,
|
|
request_id: &str,
|
|
index: usize,
|
|
api_tx: &ApiRequestSender,
|
|
event_hub: &EventHub,
|
|
) -> Result<Self, ErrorResponse> {
|
|
match subscription {
|
|
Subscription::WorkspaceCreated {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::WorkspaceCreated,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::WorkspaceUpdated {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::WorkspaceUpdated,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::WorkspaceRenamed {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::WorkspaceRenamed,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::WorkspaceClosed,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::WorkspaceFocused {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::WorkspaceFocused,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::WorktreeCreated {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::WorktreeCreated,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::WorktreeOpened {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::WorktreeOpened,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::WorktreeRemoved {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::WorktreeRemoved,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::TabCreated,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::TabClosed {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::TabClosed,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::TabFocused {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::TabFocused,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::TabRenamed {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::TabRenamed,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::PaneCreated {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::PaneCreated,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::PaneClosed {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::PaneClosed,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::PaneFocused {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::PaneFocused,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::PaneMoved {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::PaneMoved,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::PaneExited {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::PaneExited,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::PaneAgentDetected {} => Ok(Self::Event(ActiveEventSubscription {
|
|
event_kind: crate::api::schema::EventKind::PaneAgentDetected,
|
|
last_sequence: 0,
|
|
})),
|
|
Subscription::PaneOutputMatched {
|
|
pane_id,
|
|
source,
|
|
lines,
|
|
r#match,
|
|
strip_ansi,
|
|
} => {
|
|
let regex = match &r#match {
|
|
crate::api::schema::OutputMatch::Regex { value } => match Regex::new(value) {
|
|
Ok(regex) => Some(regex),
|
|
Err(err) => {
|
|
return Err(ErrorResponse {
|
|
id: request_id.to_string(),
|
|
error: ErrorBody {
|
|
code: "invalid_regex".into(),
|
|
message: err.to_string(),
|
|
},
|
|
});
|
|
}
|
|
},
|
|
crate::api::schema::OutputMatch::Substring { .. } => None,
|
|
};
|
|
|
|
let probe = pane_read(
|
|
format!("{request_id}:sub:{index}:probe"),
|
|
&pane_id,
|
|
source,
|
|
lines,
|
|
strip_ansi,
|
|
api_tx,
|
|
);
|
|
probe?;
|
|
|
|
Ok(Self::OutputMatched(ActiveOutputMatchedSubscription {
|
|
pane_id,
|
|
source,
|
|
lines,
|
|
matcher: r#match,
|
|
regex,
|
|
strip_ansi,
|
|
currently_matching: false,
|
|
request_prefix: format!("{request_id}:sub:{index}"),
|
|
}))
|
|
}
|
|
Subscription::PaneAgentStatusChanged {
|
|
pane_id,
|
|
agent_status,
|
|
} => {
|
|
let last_sequence = event_hub.current_sequence();
|
|
let probe = pane_get(format!("{request_id}:sub:{index}:probe"), &pane_id, api_tx)?;
|
|
let last_status = probe.agent_status;
|
|
let last_presentation = PanePresentationSnapshot::from(&probe);
|
|
let initial_event = agent_status
|
|
.is_some_and(|wanted| wanted == probe.agent_status)
|
|
.then_some(PaneAgentStatusChangedEvent {
|
|
pane_id: probe.pane_id.clone(),
|
|
workspace_id: probe.workspace_id,
|
|
agent_status: probe.agent_status,
|
|
agent: probe.agent,
|
|
title: probe.title,
|
|
display_agent: probe.display_agent,
|
|
custom_status: probe.custom_status,
|
|
state_labels: probe.state_labels,
|
|
});
|
|
|
|
Ok(Self::AgentStatusChanged(Box::new(
|
|
ActiveAgentStatusChangedSubscription {
|
|
pane_id: probe.pane_id,
|
|
status_filter: agent_status,
|
|
last_status: Some(last_status),
|
|
last_presentation: Some(last_presentation),
|
|
last_sequence,
|
|
initial_event,
|
|
request_prefix: format!("{request_id}:sub:{index}"),
|
|
},
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn poll(
|
|
&mut self,
|
|
api_tx: &ApiRequestSender,
|
|
event_hub: &EventHub,
|
|
) -> Option<serde_json::Value> {
|
|
match self {
|
|
Self::Event(subscription) => subscription.poll(event_hub),
|
|
Self::OutputMatched(subscription) => {
|
|
serde_json::to_value(subscription.poll(api_tx)?).ok()
|
|
}
|
|
Self::AgentStatusChanged(subscription) => {
|
|
serde_json::to_value(subscription.poll(api_tx, event_hub)?).ok()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ActiveEventSubscription {
|
|
fn poll(&mut self, event_hub: &EventHub) -> Option<serde_json::Value> {
|
|
for (sequence, event) in event_hub.events_after(self.last_sequence) {
|
|
self.last_sequence = sequence;
|
|
if event.event == self.event_kind {
|
|
return serde_json::to_value(event).ok();
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
impl ActiveOutputMatchedSubscription {
|
|
fn poll(&mut self, api_tx: &ApiRequestSender) -> Option<SubscriptionEventEnvelope> {
|
|
let read = pane_read(
|
|
format!("{}:read", self.request_prefix),
|
|
&self.pane_id,
|
|
output_match_read_source(&self.source),
|
|
self.lines,
|
|
self.strip_ansi,
|
|
api_tx,
|
|
)
|
|
.ok()?;
|
|
|
|
let matched_line = match_output(&read.text, &self.matcher, self.regex.as_ref());
|
|
match matched_line {
|
|
Some(matched_line) => {
|
|
if self.currently_matching {
|
|
return None;
|
|
}
|
|
self.currently_matching = true;
|
|
Some(SubscriptionEventEnvelope {
|
|
event: SubscriptionEventKind::PaneOutputMatched,
|
|
data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent {
|
|
pane_id: read.pane_id.clone(),
|
|
matched_line,
|
|
read,
|
|
}),
|
|
})
|
|
}
|
|
None => {
|
|
self.currently_matching = false;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ActiveAgentStatusChangedSubscription {
|
|
fn poll(
|
|
&mut self,
|
|
api_tx: &ApiRequestSender,
|
|
event_hub: &EventHub,
|
|
) -> Option<SubscriptionEventEnvelope> {
|
|
let mut saw_status_event = false;
|
|
for (sequence, event) in event_hub.events_after(self.last_sequence) {
|
|
self.last_sequence = sequence;
|
|
let crate::api::schema::EventData::PaneAgentStatusChanged {
|
|
pane_id,
|
|
workspace_id,
|
|
agent_status,
|
|
agent,
|
|
title,
|
|
display_agent,
|
|
custom_status,
|
|
state_labels,
|
|
} = event.data
|
|
else {
|
|
continue;
|
|
};
|
|
if event.event != crate::api::schema::EventKind::PaneAgentStatusChanged {
|
|
continue;
|
|
}
|
|
if pane_id != self.pane_id {
|
|
continue;
|
|
}
|
|
saw_status_event = true;
|
|
|
|
let current_presentation = PanePresentationSnapshot::from_event(
|
|
&title,
|
|
&display_agent,
|
|
&custom_status,
|
|
&state_labels,
|
|
);
|
|
self.last_status = Some(agent_status);
|
|
self.last_presentation = Some(current_presentation);
|
|
if self
|
|
.status_filter
|
|
.is_some_and(|wanted| wanted != agent_status)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
self.initial_event = None;
|
|
return Some(SubscriptionEventEnvelope {
|
|
event: SubscriptionEventKind::PaneAgentStatusChanged,
|
|
data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent {
|
|
pane_id,
|
|
workspace_id,
|
|
agent_status,
|
|
agent,
|
|
title,
|
|
display_agent,
|
|
custom_status,
|
|
state_labels,
|
|
}),
|
|
});
|
|
}
|
|
|
|
if saw_status_event {
|
|
self.initial_event = None;
|
|
} else if event_hub.current_sequence() != self.last_sequence {
|
|
return None;
|
|
} else if let Some(event) = self.initial_event.take() {
|
|
return Some(SubscriptionEventEnvelope {
|
|
event: SubscriptionEventKind::PaneAgentStatusChanged,
|
|
data: SubscriptionEventData::PaneAgentStatusChanged(event),
|
|
});
|
|
}
|
|
|
|
let before_snapshot_sequence = self.last_sequence;
|
|
let pane = pane_get(
|
|
format!("{}:pane", self.request_prefix),
|
|
&self.pane_id,
|
|
api_tx,
|
|
)
|
|
.ok()?;
|
|
let after_snapshot_sequence = event_hub.current_sequence();
|
|
if after_snapshot_sequence != before_snapshot_sequence {
|
|
return None;
|
|
}
|
|
|
|
let event = self.event_from_snapshot(pane);
|
|
if event.is_some() {
|
|
self.last_sequence = after_snapshot_sequence;
|
|
}
|
|
event
|
|
}
|
|
|
|
fn event_from_snapshot(
|
|
&mut self,
|
|
pane: crate::api::schema::PaneInfo,
|
|
) -> Option<SubscriptionEventEnvelope> {
|
|
let current_status = pane.agent_status;
|
|
let current_presentation = PanePresentationSnapshot::from(&pane);
|
|
let previous_status = self.last_status.replace(current_status);
|
|
let previous_presentation = self.last_presentation.replace(current_presentation.clone());
|
|
let presentation_changed = previous_presentation
|
|
.as_ref()
|
|
.is_some_and(|previous| previous != ¤t_presentation);
|
|
let status_changed = previous_status.is_some_and(|previous| previous != current_status);
|
|
if !(status_changed || presentation_changed) {
|
|
return None;
|
|
}
|
|
if self
|
|
.status_filter
|
|
.is_some_and(|wanted| wanted != current_status)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
Some(SubscriptionEventEnvelope {
|
|
event: SubscriptionEventKind::PaneAgentStatusChanged,
|
|
data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent {
|
|
pane_id: pane.pane_id,
|
|
workspace_id: pane.workspace_id,
|
|
agent_status: current_status,
|
|
agent: pane.agent,
|
|
title: pane.title,
|
|
display_agent: pane.display_agent,
|
|
custom_status: pane.custom_status,
|
|
state_labels: pane.state_labels,
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn pane_read(
|
|
request_id: String,
|
|
pane_id: &str,
|
|
source: crate::api::schema::ReadSource,
|
|
lines: Option<u32>,
|
|
strip_ansi: bool,
|
|
api_tx: &ApiRequestSender,
|
|
) -> Result<crate::api::schema::PaneReadResult, ErrorResponse> {
|
|
let response = dispatch_to_app_with_timeout(
|
|
Request {
|
|
id: request_id.clone(),
|
|
method: Method::PaneRead(crate::api::schema::PaneReadParams {
|
|
pane_id: pane_id.to_string(),
|
|
source,
|
|
lines,
|
|
format: crate::api::schema::ReadFormat::Text,
|
|
strip_ansi,
|
|
}),
|
|
},
|
|
api_tx,
|
|
Some(APP_RESPONSE_TIMEOUT),
|
|
);
|
|
let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse {
|
|
id: request_id.clone(),
|
|
error: ErrorBody {
|
|
code: "internal_error".into(),
|
|
message: "failed to decode pane read response".into(),
|
|
},
|
|
})?;
|
|
if value.get("error").is_some() {
|
|
return serde_json::from_value(value).map_err(|_| ErrorResponse {
|
|
id: request_id,
|
|
error: ErrorBody {
|
|
code: "internal_error".into(),
|
|
message: "failed to decode pane read error".into(),
|
|
},
|
|
});
|
|
}
|
|
serde_json::from_value(value["result"]["read"].clone()).map_err(|_| ErrorResponse {
|
|
id: request_id,
|
|
error: ErrorBody {
|
|
code: "internal_error".into(),
|
|
message: "failed to decode pane read result".into(),
|
|
},
|
|
})
|
|
}
|
|
|
|
fn pane_get(
|
|
request_id: String,
|
|
pane_id: &str,
|
|
api_tx: &ApiRequestSender,
|
|
) -> Result<crate::api::schema::PaneInfo, ErrorResponse> {
|
|
let response = dispatch_to_app_with_timeout(
|
|
Request {
|
|
id: request_id.clone(),
|
|
method: Method::PaneGet(crate::api::schema::PaneTarget {
|
|
pane_id: pane_id.to_string(),
|
|
}),
|
|
},
|
|
api_tx,
|
|
Some(APP_RESPONSE_TIMEOUT),
|
|
);
|
|
let value: serde_json::Value = serde_json::from_str(&response).map_err(|_| ErrorResponse {
|
|
id: request_id.clone(),
|
|
error: ErrorBody {
|
|
code: "internal_error".into(),
|
|
message: "failed to decode pane get response".into(),
|
|
},
|
|
})?;
|
|
if value.get("error").is_some() {
|
|
return serde_json::from_value(value).map_err(|_| ErrorResponse {
|
|
id: request_id,
|
|
error: ErrorBody {
|
|
code: "internal_error".into(),
|
|
message: "failed to decode pane get error".into(),
|
|
},
|
|
});
|
|
}
|
|
serde_json::from_value(value["result"]["pane"].clone()).map_err(|_| ErrorResponse {
|
|
id: request_id,
|
|
error: ErrorBody {
|
|
code: "internal_error".into(),
|
|
message: "failed to decode pane get result".into(),
|
|
},
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
|
|
use super::*;
|
|
use crate::api::schema::{AgentStatus, EventData, EventEnvelope, EventKind};
|
|
|
|
fn status_event(custom_status: Option<&str>) -> EventEnvelope {
|
|
EventEnvelope {
|
|
event: EventKind::PaneAgentStatusChanged,
|
|
data: EventData::PaneAgentStatusChanged {
|
|
pane_id: "pane_1".into(),
|
|
workspace_id: "workspace_1".into(),
|
|
agent_status: AgentStatus::Working,
|
|
agent: Some("pi".into()),
|
|
title: None,
|
|
display_agent: None,
|
|
custom_status: custom_status.map(str::to_string),
|
|
state_labels: HashMap::new(),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn agent_status_subscription_replays_queued_metadata_set_and_expiry_events() {
|
|
let event_hub = EventHub::default();
|
|
let mut subscription = ActiveAgentStatusChangedSubscription {
|
|
pane_id: "pane_1".into(),
|
|
status_filter: None,
|
|
last_status: Some(AgentStatus::Working),
|
|
last_presentation: Some(PanePresentationSnapshot {
|
|
title: None,
|
|
display_agent: None,
|
|
custom_status: None,
|
|
state_labels: HashMap::new(),
|
|
}),
|
|
last_sequence: event_hub.current_sequence(),
|
|
initial_event: None,
|
|
request_prefix: "test".into(),
|
|
};
|
|
|
|
event_hub.push(status_event(Some("short lived")));
|
|
event_hub.push(status_event(None));
|
|
|
|
let set_event = subscription
|
|
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
|
|
.expect("set event");
|
|
let SubscriptionEventData::PaneAgentStatusChanged(set_data) = set_event.data else {
|
|
panic!("wrong event data");
|
|
};
|
|
assert_eq!(set_data.custom_status.as_deref(), Some("short lived"));
|
|
|
|
let expiry_event = subscription
|
|
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
|
|
.expect("expiry event");
|
|
let SubscriptionEventData::PaneAgentStatusChanged(expiry_data) = expiry_event.data else {
|
|
panic!("wrong event data");
|
|
};
|
|
assert_eq!(expiry_data.custom_status, None);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_status_subscription_prefers_setup_window_events_over_initial_snapshot() {
|
|
let event_hub = EventHub::default();
|
|
let mut subscription = ActiveAgentStatusChangedSubscription {
|
|
pane_id: "pane_1".into(),
|
|
status_filter: Some(AgentStatus::Working),
|
|
last_status: Some(AgentStatus::Working),
|
|
last_presentation: Some(PanePresentationSnapshot {
|
|
title: None,
|
|
display_agent: None,
|
|
custom_status: None,
|
|
state_labels: HashMap::new(),
|
|
}),
|
|
last_sequence: event_hub.current_sequence(),
|
|
initial_event: Some(PaneAgentStatusChangedEvent {
|
|
pane_id: "pane_1".into(),
|
|
workspace_id: "workspace_1".into(),
|
|
agent_status: AgentStatus::Working,
|
|
agent: Some("pi".into()),
|
|
title: None,
|
|
display_agent: None,
|
|
custom_status: None,
|
|
state_labels: HashMap::new(),
|
|
}),
|
|
request_prefix: "test".into(),
|
|
};
|
|
|
|
event_hub.push(status_event(Some("short lived")));
|
|
event_hub.push(status_event(None));
|
|
|
|
let set_event = subscription
|
|
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
|
|
.expect("set event");
|
|
let SubscriptionEventData::PaneAgentStatusChanged(set_data) = set_event.data else {
|
|
panic!("wrong event data");
|
|
};
|
|
assert_eq!(set_data.custom_status.as_deref(), Some("short lived"));
|
|
|
|
let expiry_event = subscription
|
|
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
|
|
.expect("expiry event");
|
|
let SubscriptionEventData::PaneAgentStatusChanged(expiry_data) = expiry_event.data else {
|
|
panic!("wrong event data");
|
|
};
|
|
assert_eq!(expiry_data.custom_status, None);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_status_subscription_emits_setup_window_event_already_reflected_by_probe() {
|
|
let event_hub = EventHub::default();
|
|
let mut subscription = ActiveAgentStatusChangedSubscription {
|
|
pane_id: "pane_1".into(),
|
|
status_filter: Some(AgentStatus::Working),
|
|
last_status: Some(AgentStatus::Working),
|
|
last_presentation: Some(PanePresentationSnapshot {
|
|
title: None,
|
|
display_agent: None,
|
|
custom_status: Some("short lived".into()),
|
|
state_labels: HashMap::new(),
|
|
}),
|
|
last_sequence: event_hub.current_sequence(),
|
|
initial_event: Some(PaneAgentStatusChangedEvent {
|
|
pane_id: "pane_1".into(),
|
|
workspace_id: "workspace_1".into(),
|
|
agent_status: AgentStatus::Working,
|
|
agent: Some("pi".into()),
|
|
title: None,
|
|
display_agent: None,
|
|
custom_status: Some("short lived".into()),
|
|
state_labels: HashMap::new(),
|
|
}),
|
|
request_prefix: "test".into(),
|
|
};
|
|
|
|
event_hub.push(status_event(Some("short lived")));
|
|
|
|
let event = subscription
|
|
.poll(&tokio::sync::mpsc::unbounded_channel().0, &event_hub)
|
|
.expect("setup-window event");
|
|
let SubscriptionEventData::PaneAgentStatusChanged(data) = event.data else {
|
|
panic!("wrong event data");
|
|
};
|
|
assert_eq!(data.custom_status.as_deref(), Some("short lived"));
|
|
assert!(subscription.initial_event.is_none());
|
|
}
|
|
}
|