feat: weak companion progress notify

This commit is contained in:
Keisuke Hirata 2026-06-13 00:18:41 +09:00
parent 05fe1f6fb3
commit a87d315471
No known key found for this signature in database
5 changed files with 562 additions and 20 deletions

View File

@ -125,6 +125,10 @@ impl PendingRun {
}
}
fn should_auto_run_notification(status: PodStatus, auto_run: bool) -> bool {
auto_run && status == PodStatus::Idle
}
// ---------------------------------------------------------------------------
// PodController — actor that owns a Pod
// ---------------------------------------------------------------------------
@ -774,7 +778,7 @@ async fn controller_loop<C, St>(
pending = Some(PendingRun::Run(input));
}
Method::Notify { message } => {
Method::Notify { message, auto_run } => {
// Client-side live echo is delivered as `Event::SystemItem`
// once the interceptor commits the corresponding
// `LogEntry::SystemItem` entry — drained out of the
@ -784,10 +788,10 @@ async fn controller_loop<C, St>(
// RUNNING / Paused: the buffer push is the entire
// operation; an in-flight turn (or the next
// Resume/Run) will drain it at its next
// pending_history_appends. IDLE: auto-start a turn so the LLM
// sees the buffered notification(s) without a human
// Run.
if shared_state.get_status() == PodStatus::Idle {
// pending_history_appends. IDLE: only `auto_run`
// notifications stage RunForNotification; weak progress
// notices stay queued until an explicit run/resume.
if should_auto_run_notification(shared_state.get_status(), auto_run) {
pending = Some(PendingRun::RunForNotification(protocol::InvokeKind::Notify));
}
}
@ -1145,7 +1149,7 @@ where
.into(),
});
}
Some(Method::Notify { message }) => {
Some(Method::Notify { message, .. }) => {
// Live echo arrives via `Event::SystemItem` once
// the in-flight turn's next `pending_history_appends`
// drains this entry through the interceptor.
@ -1337,6 +1341,14 @@ mod tests {
);
}
#[test]
fn notification_auto_run_gate_only_allows_idle_auto_run() {
assert!(should_auto_run_notification(PodStatus::Idle, true));
assert!(!should_auto_run_notification(PodStatus::Idle, false));
assert!(!should_auto_run_notification(PodStatus::Running, true));
assert!(!should_auto_run_notification(PodStatus::Paused, true));
}
struct DriveTurnEnv {
// Held to keep the channel alive; without this `method_rx.recv()`
// would observe channel-closed and confuse the select! arm.

View File

@ -913,7 +913,14 @@ where
}
async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()> {
connect_and_send(socket_path, &Method::Notify { message }).await
connect_and_send(
socket_path,
&Method::Notify {
message,
auto_run: true,
},
)
.await
}
fn json_content<T: Serialize>(value: &T) -> Result<String, ToolError> {
@ -1395,7 +1402,8 @@ mod tests {
.await
.unwrap();
let method = reader.next::<Method>().await.unwrap().unwrap();
if let Method::Notify { message } = method {
if let Method::Notify { message, auto_run } = method {
assert!(auto_run);
tx.send(message).await.unwrap();
} else {
panic!("expected Notify, got {method:?}");

View File

@ -1025,6 +1025,7 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
handle
.send(Method::Notify {
message: "turn finished".into(),
auto_run: true,
})
.await
.unwrap();
@ -1105,6 +1106,62 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
);
}
#[tokio::test]
async fn notify_while_idle_with_auto_run_false_waits_for_explicit_run() {
let client = MockClient::new(simple_text_events());
let client_for_assert = client.clone();
let pod = make_pod(client).await;
let handle = spawn_controller(pod).await;
handle
.send(Method::Notify {
message: "progress snapshot".into(),
auto_run: false,
})
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(handle.shared_state.get_status(), PodStatus::Idle);
assert!(
client_for_assert.captured_requests().is_empty(),
"weak Notify must not stage RunForNotification while idle"
);
handle.send(Method::run_text("continue")).await.unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if !client_for_assert.captured_requests().is_empty() {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"explicit run did not reach the mock LLM"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
wait_for_status(&handle, PodStatus::Idle).await;
let requests = client_for_assert.captured_requests();
assert_eq!(
requests.len(),
1,
"explicit run should drain the queued notification"
);
let notify_in_request = requests[0].items.iter().any(|i| {
i.as_text()
.is_some_and(|t| t.contains("[Notification]") && t.contains("progress snapshot"))
});
assert!(
notify_in_request,
"queued weak notification must be history-backed on the next explicit run; got items: {:?}",
requests[0]
.items
.iter()
.filter_map(|i| i.as_text())
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn pod_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_message() {
let client = MockClient::new(simple_text_events());
@ -1259,6 +1316,7 @@ async fn notify_while_running_does_not_emit_already_running_error() {
handle
.send(Method::Notify {
message: "ping".into(),
auto_run: true,
})
.await
.unwrap();

View File

@ -4,6 +4,14 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
fn default_true() -> bool {
true
}
fn is_true(value: &bool) -> bool {
*value
}
// ---------------------------------------------------------------------------
// Method (Client → Pod via Unix Socket)
// ---------------------------------------------------------------------------
@ -15,10 +23,15 @@ pub enum Method {
input: Vec<Segment>,
},
/// Human-readable text injected into the target Pod's LLM context
/// as a non-blocking system message. No side effects beyond LLM
/// context; use `PodEvent` for typed lifecycle reports.
/// as a non-blocking system message. `auto_run` controls whether an
/// idle target is kicked into `RunForNotification`; weak notifications
/// (`auto_run: false`) are only queued for the next turn/resume/run.
/// No side effects beyond LLM context; use `PodEvent` for typed
/// lifecycle reports.
Notify {
message: String,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
auto_run: bool,
},
/// Typed lifecycle report from a child Pod to its direct parent.
PodEvent(PodEvent),
@ -1027,17 +1040,28 @@ mod tests {
}
#[test]
fn method_notify_json_roundtrip() {
fn method_notify_json_roundtrip_defaults_to_auto_run() {
let json = r#"{"method":"notify","params":{"message":"turn done"}}"#;
let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!(
method,
Method::Notify { ref message } if message == "turn done"
Method::Notify { ref message, auto_run: true } if message == "turn done"
));
let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json);
}
#[test]
fn method_notify_weak_json_roundtrip_serializes_auto_run_false() {
let json = r#"{"method":"notify","params":{"message":"progress","auto_run":false}}"#;
let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!(
method,
Method::Notify { ref message, auto_run: false } if message == "progress"
));
assert_eq!(serde_json::to_string(&method).unwrap(), json);
}
#[test]
fn method_list_completions_roundtrip() {
let method = Method::ListCompletions {

View File

@ -49,6 +49,9 @@ use crate::workspace_panel::{
const MAX_ENTRIES: usize = 50;
const CLOSED_VISIBLE_ROWS: usize = 3;
const COMPANION_PROGRESS_MAX_TICKETS: usize = 5;
const COMPANION_PROGRESS_MAX_TITLE_CHARS: usize = 80;
const COMPANION_PROGRESS_MAX_MESSAGE_CHARS: usize = 1_800;
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
const MULTI_POD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
@ -126,6 +129,10 @@ pub(crate) async fn run(
loop {
if let Some(result) = pending_reload.finish_if_ready().await {
app.apply_reload_result(result);
if let Some(request) = app.prepare_companion_progress_notice() {
let result = dispatch_companion_progress_notice(request).await;
app.finish_companion_progress_notice(result);
}
}
terminal.draw(|f| draw(f, app))?;
@ -529,6 +536,50 @@ struct PanelDiagnostic {
details: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompanionProgressFreshness {
fingerprint: String,
updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompanionProgressNotice {
message: String,
fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompanionProgressNoticeRequest {
pod_name: String,
socket_path: PathBuf,
notice: CompanionProgressNotice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompanionProgressNoticeResult {
fingerprint: String,
updated_at: String,
error: Option<String>,
}
impl CompanionProgressNoticeResult {
fn sent(fingerprint: String, updated_at: String) -> Self {
Self {
fingerprint,
updated_at,
error: None,
}
}
fn failed(fingerprint: String, error: impl Into<String>) -> Self {
Self {
fingerprint,
updated_at: String::new(),
error: Some(error.into()),
}
}
}
pub(crate) struct MultiPodApp {
pub(crate) list: PodList,
pub(crate) panel: WorkspacePanelViewModel,
@ -545,6 +596,7 @@ pub(crate) struct MultiPodApp {
runtime_command: PodRuntimeCommand,
last_companion_lifecycle_failure: Option<CompanionPanelState>,
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
companion_progress: Option<CompanionProgressFreshness>,
}
impl MultiPodApp {
@ -579,6 +631,7 @@ impl MultiPodApp {
runtime_command,
last_companion_lifecycle_failure: None,
last_orchestrator_lifecycle_failure: None,
companion_progress: None,
}
}
@ -629,6 +682,50 @@ impl MultiPodApp {
self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some());
self.ensure_selection_visible();
self.ensure_composer_target_available();
self.apply_companion_progress_freshness();
}
fn prepare_companion_progress_notice(&mut self) -> Option<CompanionProgressNoticeRequest> {
let target = companion_progress_notice_target(&self.panel, &self.list)?;
let notice = companion_progress_notice(&self.panel, &self.list)?;
if self
.companion_progress
.as_ref()
.is_some_and(|freshness| freshness.fingerprint == notice.fingerprint)
{
self.apply_companion_progress_freshness();
return None;
}
Some(CompanionProgressNoticeRequest {
pod_name: target.pod_name,
socket_path: target.socket_path,
notice,
})
}
fn finish_companion_progress_notice(&mut self, result: CompanionProgressNoticeResult) {
if let Some(error) = result.error {
self.notice = Some(format!("Companion progress notice not delivered: {error}"));
return;
}
self.companion_progress = Some(CompanionProgressFreshness {
fingerprint: result.fingerprint,
updated_at: result.updated_at,
});
self.apply_companion_progress_freshness();
}
fn apply_companion_progress_freshness(&mut self) {
let Some(freshness) = self.companion_progress.as_ref() else {
return;
};
let Some(companion) = self.panel.header.companion.as_mut() else {
return;
};
companion.detail = Some(format!(
"progress context updated at {} (weak notify)",
freshness.updated_at
));
}
fn apply_companion_lifecycle_memory(&mut self, panel: &mut WorkspacePanelViewModel) {
@ -2338,6 +2435,157 @@ struct OrchestratorNotifyTarget {
socket_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CompanionProgressNoticeTarget {
pod_name: String,
socket_path: PathBuf,
}
fn companion_progress_notice_target(
panel: &WorkspacePanelViewModel,
list: &PodList,
) -> Option<CompanionProgressNoticeTarget> {
let companion = panel.header.companion.as_ref()?;
if !companion_status_is_peer_reachable(companion.status) {
return None;
}
let entry = list
.entries
.iter()
.find(|entry| entry.name == companion.pod_name)?;
let live = entry.live.as_ref()?;
if !live.reachable {
return None;
}
Some(CompanionProgressNoticeTarget {
pod_name: companion.pod_name.clone(),
socket_path: live.socket_path.clone(),
})
}
fn companion_status_is_peer_reachable(status: CompanionPanelStatus) -> bool {
matches!(
status,
CompanionPanelStatus::Live | CompanionPanelStatus::Restored | CompanionPanelStatus::Spawned
)
}
fn companion_progress_notice(
panel: &WorkspacePanelViewModel,
list: &PodList,
) -> Option<CompanionProgressNotice> {
let companion = panel.header.companion.as_ref()?;
let orchestrator = panel.header.orchestrator.as_ref()?;
let mut lines = vec![
"Orchestrator progress context (read-only weak notification; no auto-run).".to_string(),
"Reason: workspace Panel refreshed bounded orchestration progress for Companion explanation."
.to_string(),
format!(
"Roles: Companion {} is {}; Orchestrator {} is {}.",
companion.pod_name,
companion.status.label(),
orchestrator.pod_name,
orchestrator.status.label()
),
];
let mut ticket_lines = Vec::new();
for row in panel.rows.iter().take(COMPANION_PROGRESS_MAX_TICKETS) {
let ticket_id = row
.ticket
.as_ref()
.map(|ticket| ticket.id.as_str())
.unwrap_or("unknown-ticket");
ticket_lines.push(format!(
"- {} [{}] {} (ref: .yoi/tickets/{})",
ticket_id,
row.status,
bounded_progress_text(&row.title, COMPANION_PROGRESS_MAX_TITLE_CHARS),
ticket_id
));
}
if ticket_lines.is_empty() {
lines.push("Tickets: none visible in the current Panel snapshot.".to_string());
} else {
lines.push(format!(
"Tickets (first {} visible, bounded):",
ticket_lines.len()
));
lines.extend(ticket_lines);
if panel.rows.len() > COMPANION_PROGRESS_MAX_TICKETS {
lines.push(format!(
"- … {} more ticket(s) omitted from this bounded notice.",
panel.rows.len() - COMPANION_PROGRESS_MAX_TICKETS
));
}
}
let role_pod_lines = bounded_role_pod_lines(list, companion, orchestrator);
if !role_pod_lines.is_empty() {
lines.push("Role pod status snapshot:".to_string());
lines.extend(role_pod_lines);
}
let message = bounded_progress_text(&lines.join("\n"), COMPANION_PROGRESS_MAX_MESSAGE_CHARS);
let fingerprint = message.clone();
Some(CompanionProgressNotice {
message,
fingerprint,
})
}
fn bounded_role_pod_lines(
list: &PodList,
companion: &CompanionPanelState,
orchestrator: &OrchestratorPanelState,
) -> Vec<String> {
let mut lines = Vec::new();
for name in [&companion.pod_name, &orchestrator.pod_name] {
let Some(entry) = list.entries.iter().find(|entry| entry.name == *name) else {
continue;
};
lines.push(format!("- {}: {}", entry.name, row_status_label(entry).0));
}
lines
}
fn bounded_progress_text(input: &str, max_chars: usize) -> String {
let mut output = String::new();
for (idx, ch) in input.chars().enumerate() {
if idx >= max_chars {
output.push('…');
return output;
}
let sanitized = if ch.is_control() && ch != '\n' && ch != '\t' {
' '
} else {
ch
};
output.push(sanitized);
}
output
}
fn progress_notice_timestamp() -> String {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => format!("unix:{}", duration.as_secs()),
Err(_) => "unix:0".to_string(),
}
}
async fn dispatch_companion_progress_notice(
request: CompanionProgressNoticeRequest,
) -> CompanionProgressNoticeResult {
let fingerprint = request.notice.fingerprint.clone();
match send_notify_only(&request.socket_path, request.notice.message, false).await {
Ok(()) => CompanionProgressNoticeResult::sent(fingerprint, progress_notice_timestamp()),
Err(err) => CompanionProgressNoticeResult::failed(
fingerprint,
format!("{}: {}", request.pod_name, err),
),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketActionOutcome {
notice: String,
@ -3133,7 +3381,7 @@ async fn notify_workspace_orchestrator(
);
};
let message = orchestrator_queue_notification_message(ticket);
match send_notify_only(&target.socket_path, message).await {
match send_notify_only(&target.socket_path, message, true).await {
Ok(()) => OrchestratorNotificationOutcome::Sent {
pod_name: target.pod_name,
},
@ -3146,7 +3394,11 @@ async fn notify_workspace_orchestrator(
}
}
async fn send_notify_only(socket: &Path, message: String) -> Result<(), NotifySendError> {
async fn send_notify_only(
socket: &Path,
message: String,
auto_run: bool,
) -> Result<(), NotifySendError> {
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket))
.await
.map_err(|_| NotifySendError::Io("connect timed out".into()))?
@ -3175,7 +3427,10 @@ async fn send_notify_only(socket: &Path, message: String) -> Result<(), NotifySe
}
}
tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(&Method::Notify { message }))
tokio::time::timeout(
SOCKET_OP_TIMEOUT,
writer.write(&Method::Notify { message, auto_run }),
)
.await
.map_err(|_| NotifySendError::Io("write timed out".into()))?
.map_err(|e| NotifySendError::Io(format!("write: {e}")))
@ -3531,6 +3786,12 @@ fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
companion.status.label(),
companion_status_style(companion.status),
));
if let Some(detail) = companion.detail.as_deref() {
spans.push(Span::styled(
format!(" ({detail})"),
Style::default().fg(Color::DarkGray),
));
}
}
if let Some(orchestrator) = &app.panel.header.orchestrator {
spans.push(Span::styled(
@ -4692,16 +4953,194 @@ mod tests {
reader.next::<Method>().await.unwrap().unwrap()
});
send_notify_only(&socket_path, "panel Queue".to_string())
send_notify_only(&socket_path, "panel Queue".to_string(), true)
.await
.unwrap();
let method = server.await.unwrap();
assert!(matches!(
method,
Method::Notify { message } if message == "panel Queue"
Method::Notify { message, auto_run: true } if message == "panel Queue"
));
}
#[tokio::test]
async fn send_notify_only_can_deliver_weak_notification_without_auto_run() {
let temp = TempDir::new().unwrap();
let socket_path = temp.path().join("companion.sock");
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (reader, writer) = stream.into_split();
let mut reader = JsonLineReader::new(reader);
let mut writer = JsonLineWriter::new(writer);
writer
.write(&Event::Snapshot {
entries: Vec::new(),
greeting: protocol::Greeting {
pod_name: "yoi".to_string(),
cwd: temp.path().display().to_string(),
provider: "test".to_string(),
model: "test".to_string(),
scope_summary: "test".to_string(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
},
status: PodStatus::Idle,
})
.await
.unwrap();
reader.next::<Method>().await.unwrap().unwrap()
});
send_notify_only(&socket_path, "panel progress".to_string(), false)
.await
.unwrap();
let method = server.await.unwrap();
assert!(matches!(
method,
Method::Notify { message, auto_run: false } if message == "panel progress"
));
}
#[test]
fn companion_progress_notice_target_skips_missing_stopped_and_unreachable_without_spawn_restore()
{
let missing_app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]);
assert!(companion_progress_notice_target(&missing_app.panel, &missing_app.list).is_none());
let mut stopped_panel = WorkspacePanelViewModel::empty(Path::new("test"));
stopped_panel.header.companion = Some(CompanionPanelState::new(
"yoi",
CompanionPanelStatus::Stopped,
None,
));
stopped_panel.header.orchestrator = Some(OrchestratorPanelState::new(
"test-orchestrator",
OrchestratorPanelStatus::Live,
None,
));
let stopped_list = PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![stopped_info("yoi")],
vec![live_info("test-orchestrator", PodStatus::Idle)],
None,
10,
);
assert!(companion_progress_notice_target(&stopped_panel, &stopped_list).is_none());
let mut unreachable = live_info("yoi", PodStatus::Idle);
unreachable.reachable = false;
let unreachable_app = ticket_enabled_app(vec![
unreachable,
live_info("test-orchestrator", PodStatus::Idle),
]);
assert!(
companion_progress_notice_target(&unreachable_app.panel, &unreachable_app.list)
.is_none()
);
}
#[test]
fn companion_progress_notice_is_bounded_and_excludes_sensitive_unbounded_fields() {
let mut app = ticket_enabled_app(vec![
live_info("yoi", PodStatus::Idle),
live_info("test-orchestrator", PodStatus::Running),
]);
app.panel.rows = (0..12)
.map(|index| {
let mut row = panel_test_ticket_row(
&format!("TICKET-{index}"),
&format!("Visible title {index} {}", "x".repeat(140)),
ActionPriority::Background,
NextUserAction::Wait,
"inprogress",
);
if let Some(ticket) = row.ticket.as_mut() {
ticket.latest_event_excerpt = Some(
"SECRET_PROVIDER_ERROR_TOKEN should never be copied into progress notices"
.to_string(),
);
}
row.subtitle = Some("private thread excerpt should stay out".to_string());
row
})
.collect();
app.panel
.header
.diagnostics
.push("diagnostic with SECRET_PROVIDER_ERROR_TOKEN should stay out".to_string());
let notice = companion_progress_notice(&app.panel, &app.list).unwrap();
assert!(notice.message.contains("TICKET-0"));
assert!(notice.message.contains("ref: .yoi/tickets/TICKET-0"));
assert!(notice.message.contains("more ticket(s) omitted"));
assert!(notice.message.chars().count() <= COMPANION_PROGRESS_MAX_MESSAGE_CHARS + 1);
assert!(!notice.message.contains("SECRET_PROVIDER_ERROR_TOKEN"));
assert!(!notice.message.contains("private thread excerpt"));
assert_eq!(notice.fingerprint, notice.message);
}
#[test]
fn companion_progress_notice_success_sets_panel_freshness_without_persisting_snapshot() {
let mut app = ticket_enabled_app(vec![
live_info("yoi", PodStatus::Idle),
live_info("test-orchestrator", PodStatus::Idle),
]);
app.panel.rows.push(panel_test_ticket_row(
"TICKET-1",
"Implement progress notices",
ActionPriority::Background,
NextUserAction::Wait,
"inprogress",
));
let request = app.prepare_companion_progress_notice().unwrap();
assert_eq!(request.pod_name, "yoi");
app.finish_companion_progress_notice(CompanionProgressNoticeResult::sent(
request.notice.fingerprint,
"unix:42".to_string(),
));
let detail = app
.panel
.header
.companion
.as_ref()
.and_then(|companion| companion.detail.as_deref())
.unwrap();
assert!(detail.contains("unix:42"));
assert!(detail.contains("weak notify"));
assert!(app.prepare_companion_progress_notice().is_none());
}
#[test]
fn companion_progress_notice_target_accepts_live_running_companion() {
let app = ticket_enabled_app(vec![
live_info("yoi", PodStatus::Running),
live_info("test-orchestrator", PodStatus::Running),
]);
let target = companion_progress_notice_target(&app.panel, &app.list).unwrap();
assert_eq!(target.pod_name, "yoi");
assert_eq!(target.socket_path, PathBuf::from("/tmp/yoi.sock"));
}
#[test]
fn companion_progress_failure_is_best_effort_and_does_not_mark_freshness() {
let mut app = ticket_enabled_app(vec![
live_info("yoi", PodStatus::Idle),
live_info("test-orchestrator", PodStatus::Idle),
]);
let request = app.prepare_companion_progress_notice().unwrap();
app.finish_companion_progress_notice(CompanionProgressNoticeResult::failed(
request.notice.fingerprint,
"socket closed",
));
assert!(app.companion_progress.is_none());
assert!(app.notice.as_deref().unwrap().contains("not delivered"));
}
#[test]
fn no_ticket_selection_keeps_enter_pod_centric() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
@ -6103,6 +6542,7 @@ mod tests {
runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"),
last_companion_lifecycle_failure,
last_orchestrator_lifecycle_failure,
companion_progress: None,
};
app.ensure_selection_visible();
app.ensure_composer_target_available();