返回 CodeWhale
background_finished.rs
根目录 / crates / tui / src / tui / background_finished.rs
1 //! Background work that finished since the last notice (#6565).
2 //!
3 //! Every terminal transition of background work (a sub-agent, a background
4 //! shell, a durable task) lands here with the name every other surface shows.
5 //! The notice for a batch covers all of it, not only the last child to finish.
6 //! Agent notices use display names. Shells and tasks use generic labels on
7 //! lock-screen-capable transports; their commands, prompts and errors remain
8 //! in the in-app rows. Mixed batches also omit previews.
9 //!
10 //! When the notice fires is decided by [`ready_to_flush`]. In `final-only`
11 //! mode it waits only for *finite* work: running agents and queued or running
12 //! durable tasks. A background shell can run forever (a dev server, a
13 //! watcher), so a running shell never holds a notice back.
14
15 use std::time::Duration;
16
17 use codewhale_localization::{Locale, MessageId, tr};
18
19 use crate::config::SubagentCompletionNotification;
20 use crate::notify::payload::{NotificationPayload, PREVIEW_MAX_CHARS, sanitize_field};
21 use crate::tools::subagent::SubAgentStatus;
22
23 /// Finished background shells kept listed (muted) in the dock.
24 pub const MAX_FINISHED_SHELLS: usize = 8;
25 /// Names listed in a batch notice before the rest are counted.
26 const MAX_NAMED: usize = 4;
27 /// Characters of a shell command kept as its name.
28 const SHELL_NAME_CHARS: usize = 48;
29
30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31 pub enum FinishedKind {
32 Agent,
33 Shell,
34 Task,
35 }
36
37 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38 pub enum FinishedOutcome {
39 Done,
40 Failed,
41 Stopped,
42 }
43
44 impl FinishedOutcome {
45 #[must_use]
46 pub fn from_agent(status: &SubAgentStatus) -> Self {
47 match status {
48 SubAgentStatus::Completed | SubAgentStatus::Running => Self::Done,
49 SubAgentStatus::Failed(_) | SubAgentStatus::BudgetExhausted => Self::Failed,
50 SubAgentStatus::Cancelled | SubAgentStatus::Interrupted(_) => Self::Stopped,
51 }
52 }
53 }
54
55 /// One piece of background work that reached a terminal state.
56 #[derive(Debug, Clone, PartialEq)]
57 pub struct FinishedWork {
58 pub kind: FinishedKind,
59 /// The name every other surface shows: the agent's label, the shell's
60 /// command, the task's summary.
61 pub name: String,
62 pub outcome: FinishedOutcome,
63 /// The agent's exact status, so a single-agent notice keeps its
64 /// status-specific headline ("Agent cancelled", "Budget exhausted").
65 pub agent_status: Option<SubAgentStatus>,
66 /// The agent's result as reported, for the preview headline.
67 pub result: Option<String>,
68 /// One line of facts for a shell or task ("exit 0 · 12s", "killed").
69 pub summary: Option<String>,
70 pub elapsed: Duration,
71 /// Whether the parent turn was running when this finished. A shell or
72 /// task that finished while the model was working is reported by that
73 /// turn's own notice, so [`drop_reported_by_turn`] removes it when the
74 /// turn completes instead of sending a second notice.
75 pub during_turn: bool,
76 }
77
78 impl FinishedWork {
79 #[must_use]
80 pub fn agent(name: &str, status: &SubAgentStatus, result: &str, elapsed: Duration) -> Self {
81 Self {
82 kind: FinishedKind::Agent,
83 name: name.to_string(),
84 outcome: FinishedOutcome::from_agent(status),
85 agent_status: Some(status.clone()),
86 result: Some(result.to_string()),
87 summary: None,
88 elapsed,
89 during_turn: false,
90 }
91 }
92
93 #[must_use]
94 pub fn shell(
95 command: &str,
96 outcome: FinishedOutcome,
97 summary: String,
98 elapsed: Duration,
99 ) -> Self {
100 let command = command.split_whitespace().collect::<Vec<_>>().join(" ");
101 let name = if command.chars().count() > SHELL_NAME_CHARS {
102 let kept: String = command.chars().take(SHELL_NAME_CHARS - 1).collect();
103 format!("{kept}…")
104 } else {
105 command
106 };
107 Self {
108 kind: FinishedKind::Shell,
109 name,
110 outcome,
111 agent_status: None,
112 result: None,
113 summary: Some(summary),
114 elapsed,
115 during_turn: false,
116 }
117 }
118
119 #[must_use]
120 pub fn task(name: &str, outcome: FinishedOutcome, summary: String, elapsed: Duration) -> Self {
121 Self {
122 kind: FinishedKind::Task,
123 name: name.trim().to_string(),
124 outcome,
125 agent_status: None,
126 result: None,
127 summary: Some(summary),
128 elapsed,
129 during_turn: false,
130 }
131 }
132
133 /// Marks this item as finished while the parent turn was running.
134 #[must_use]
135 pub fn in_turn(mut self, parent_busy: bool) -> Self {
136 self.during_turn = parent_busy;
137 self
138 }
139
140 /// Agent result headline, reserving room for the full-result pointer
141 /// after sanitization and the optional batch label have consumed space.
142 fn preview(&self, locale: Locale, named: bool) -> Option<String> {
143 if self.kind != FinishedKind::Agent {
144 return None;
145 }
146 let result = self.result.as_deref()?;
147 let headline = crate::agent_roster::result_headline(result)?;
148 let bounded = crate::tui::notifications::text_summary(&headline)?;
149 let whole = result
150 .lines()
151 .map(str::trim)
152 .filter(|line| !line.is_empty() && !line.starts_with("<codewhale:"))
153 .collect::<Vec<_>>()
154 .join(" ");
155 let preview = sanitize_field(&if named {
156 format!("{}: {bounded}", self.name)
157 } else {
158 bounded.clone()
159 });
160 if bounded == whole && preview.chars().count() <= PREVIEW_MAX_CHARS {
161 return Some(preview);
162 }
163 let suffix = format!(
164 " … {}",
165 tr(locale, MessageId::NotificationFullResultPointer)
166 );
167 let budget = PREVIEW_MAX_CHARS.saturating_sub(suffix.chars().count());
168 let kept: String = preview.chars().take(budget).collect();
169 Some(format!(
170 "{}{suffix}",
171 kept.trim_end_matches("...").trim_end()
172 ))
173 }
174
175 fn headline_id(&self) -> MessageId {
176 match (self.kind, self.outcome) {
177 (FinishedKind::Agent, _) => crate::tui::notifications::subagent_terminal_label(
178 self.agent_status
179 .as_ref()
180 .unwrap_or(&SubAgentStatus::Completed),
181 ),
182 (FinishedKind::Shell, FinishedOutcome::Done) => MessageId::NotificationShellFinished,
183 (FinishedKind::Shell, FinishedOutcome::Failed) => MessageId::NotificationShellFailed,
184 (FinishedKind::Shell, FinishedOutcome::Stopped) => MessageId::NotificationShellStopped,
185 (FinishedKind::Task, FinishedOutcome::Done) => MessageId::NotificationTaskFinished,
186 (FinishedKind::Task, FinishedOutcome::Failed) => MessageId::NotificationTaskFailed,
187 (FinishedKind::Task, FinishedOutcome::Stopped) => MessageId::NotificationTaskStopped,
188 }
189 }
190 }
191
192 /// Drop the shells and tasks that finished while the parent turn was running.
193 ///
194 /// Called when that turn completes: its own notice covers them, so they must
195 /// not go out as a second notice. Agents keep their own notice.
196 pub fn drop_reported_by_turn(batch: &mut Vec<FinishedWork>) {
197 batch.retain(|item| item.kind == FinishedKind::Agent || !item.during_turn);
198 }
199
200 /// Whether the pending batch should be announced now.
201 ///
202 /// - `off` drains the batch without a notice.
203 /// - `always` announces every item as it arrives.
204 /// - `final-only` waits while finite work is still live (running agents, a
205 /// running workflow, queued or running durable tasks). Running shells are
206 /// open-ended and never count. A batch with no agent in it also waits for
207 /// the parent turn to go idle; when that turn completes,
208 /// [`drop_reported_by_turn`] removes the shells and tasks that finished
209 /// during it, so they are reported by the turn, not by a second notice.
210 #[must_use]
211 pub fn ready_to_flush(
212 mode: SubagentCompletionNotification,
213 batch: &[FinishedWork],
214 finite_work_live: bool,
215 parent_busy: bool,
216 ) -> bool {
217 if batch.is_empty() {
218 return false;
219 }
220 match mode {
221 SubagentCompletionNotification::Off | SubagentCompletionNotification::Always => true,
222 SubagentCompletionNotification::FinalOnly => {
223 !finite_work_live
224 && (!parent_busy || batch.iter().any(|item| item.kind == FinishedKind::Agent))
225 }
226 }
227 }
228
229 /// One notice for a batch of finished background work.
230 ///
231 /// A single item keeps its own headline ("Agent complete", "Shell failed"),
232 /// names itself in the detail and previews its result. A batch counts what
233 /// finished ("3 finished", or "2 done · 1 failed" when something did not
234 /// succeed), names each item (the first few, then `+N`), and previews the
235 /// item most worth reading: the first that did not succeed, else the first.
236 #[must_use]
237 pub fn background_finished_payload(
238 locale: Locale,
239 batch: &[FinishedWork],
240 include_summary: bool,
241 ) -> Option<NotificationPayload> {
242 let elapsed = batch.iter().map(|item| item.elapsed).max()?;
243 let headline = match batch {
244 [single] => tr(locale, single.headline_id()).into_owned(),
245 many => {
246 let done = many
247 .iter()
248 .filter(|item| item.outcome == FinishedOutcome::Done)
249 .count();
250 let failed = many
251 .iter()
252 .filter(|item| item.outcome == FinishedOutcome::Failed)
253 .count();
254 let stopped = many
255 .iter()
256 .filter(|item| item.outcome == FinishedOutcome::Stopped)
257 .count();
258 if stopped > 0 {
259 tr(locale, MessageId::NotificationBackgroundStopped)
260 .replace("{done}", &done.to_string())
261 .replace("{failed}", &failed.to_string())
262 .replace("{stopped}", &stopped.to_string())
263 } else if done == many.len() {
264 tr(locale, MessageId::NotificationBackgroundFinished)
265 .replace("{count}", &many.len().to_string())
266 } else {
267 tr(locale, MessageId::NotificationBackgroundMixed)
268 .replace("{done}", &done.to_string())
269 .replace("{failed}", &failed.to_string())
270 }
271 }
272 };
273 let headline =
274 crate::tui::notifications::completion_status(&headline, include_summary, elapsed, None);
275 let mut names = batch
276 .iter()
277 .take(MAX_NAMED)
278 .map(|item| match item.kind {
279 FinishedKind::Agent => std::borrow::Cow::Borrowed(item.name.as_str()),
280 FinishedKind::Shell => tr(locale, MessageId::AgentFocusPostureShellFull),
281 FinishedKind::Task => tr(locale, MessageId::WorkflowDispatchFallbackTask),
282 })
283 .collect::<Vec<_>>()
284 .join(" · ");
285 if batch.len() > MAX_NAMED {
286 names.push_str(&format!(" · +{}", batch.len() - MAX_NAMED));
287 }
288 if batch.iter().any(|item| item.kind != FinishedKind::Agent) {
289 return Some(NotificationPayload::background_terminal(&headline, &names));
290 }
291 let featured = batch
292 .iter()
293 .find(|item| item.outcome != FinishedOutcome::Done)
294 .unwrap_or(&batch[0]);
295 let preview = featured.preview(locale, batch.len() > 1);
296 Some(NotificationPayload::subagent_terminal(&headline, &names).with_preview(preview.as_deref()))
297 }
298
299 #[cfg(test)]
300 mod tests {
301 use super::*;
302 use crate::config::SubagentCompletionNotification as Mode;
303
304 fn agent(name: &str, status: SubAgentStatus, result: &str) -> FinishedWork {
305 FinishedWork::agent(name, &status, result, Duration::from_secs(30))
306 }
307
308 #[test]
309 fn final_only_waits_for_finite_work_but_never_for_a_running_shell() {
310 let batch = vec![agent("explore", SubAgentStatus::Completed, "Done.")];
311 // Another agent is still running: hold.
312 assert!(!ready_to_flush(Mode::FinalOnly, &batch, true, false));
313 // Only an `npm run dev` shell is still running: shells are not finite
314 // work, so the caller reports no finite work and the notice fires.
315 assert!(ready_to_flush(Mode::FinalOnly, &batch, false, false));
316 // An agent batch does not wait for the parent turn.
317 assert!(ready_to_flush(Mode::FinalOnly, &batch, false, true));
318 // A shell-only batch waits for the parent turn to go idle.
319 let shells = vec![FinishedWork::shell(
320 "cargo build",
321 FinishedOutcome::Done,
322 "exit 0 · 12s".to_string(),
323 Duration::from_secs(12),
324 )];
325 assert!(!ready_to_flush(Mode::FinalOnly, &shells, false, true));
326 assert!(ready_to_flush(Mode::FinalOnly, &shells, false, false));
327 assert!(ready_to_flush(Mode::Always, &batch, true, true));
328 assert!(ready_to_flush(Mode::Off, &batch, true, true));
329 assert!(!ready_to_flush(Mode::Always, &[], false, false));
330 }
331
332 #[test]
333 fn a_batch_names_everything_that_finished_and_counts_failures() {
334 let batch = vec![
335 agent("explore", SubAgentStatus::Completed, "Found 3 call sites."),
336 agent(
337 "review",
338 SubAgentStatus::Failed("boom".to_string()),
339 "provider returned 429",
340 ),
341 agent(
342 "audit docs",
343 SubAgentStatus::Completed,
344 "All links resolve.",
345 ),
346 ];
347 let payload = background_finished_payload(Locale::En, &batch, false).expect("payload");
348 assert_eq!(payload.headline(), "2 done · 1 failed");
349 assert_eq!(payload.detail(), Some("explore · review · audit docs"));
350 assert_eq!(payload.preview(), Some("review: provider returned 429"));
351
352 let done = vec![
353 agent("explore", SubAgentStatus::Completed, "One."),
354 agent("audit docs", SubAgentStatus::Completed, "Two."),
355 agent("review", SubAgentStatus::Completed, "Three."),
356 ];
357 let payload = background_finished_payload(Locale::En, &done, false).expect("payload");
358 assert_eq!(payload.headline(), "3 finished");
359 assert_eq!(payload.preview(), Some("explore: One."));
360
361 let many = (0..6)
362 .map(|n| agent(&format!("lane {n}"), SubAgentStatus::Completed, "ok."))
363 .collect::<Vec<_>>();
364 let payload = background_finished_payload(Locale::En, &many, false).expect("payload");
365 assert_eq!(
366 payload.detail(),
367 Some("lane 0 · lane 1 · lane 2 · lane 3 · +2")
368 );
369 assert!(background_finished_payload(Locale::En, &[], false).is_none());
370 }
371
372 #[test]
373 fn a_cut_preview_says_so_and_points_at_the_full_result() {
374 let single = vec![agent(
375 "audit docs",
376 SubAgentStatus::Completed,
377 "## Summary\n\nThree links are stale. Two are in README.md.",
378 )];
379 let payload = background_finished_payload(Locale::En, &single, false).expect("payload");
380 assert_eq!(payload.headline(), "Agent complete");
381 assert_eq!(payload.detail(), Some("audit docs"));
382 assert_eq!(
383 payload.preview(),
384 Some("Three links are stale. … open Codewhale for the full result")
385 );
386 // A result that is only its headline is shown as it is.
387 let whole = vec![agent("explore", SubAgentStatus::Completed, "Found it.")];
388 let payload = background_finished_payload(Locale::En, &whole, false).expect("payload");
389 assert_eq!(payload.preview(), Some("Found it."));
390 }
391
392 #[test]
393 fn shell_notices_keep_outcomes_but_commands_and_output_stay_in_app() {
394 for (outcome, summary, headline) in [
395 (FinishedOutcome::Done, "exit 0 · 12s", "Shell finished"),
396 (FinishedOutcome::Failed, "failed · exit 2", "Shell failed"),
397 (FinishedOutcome::Stopped, "killed", "Shell stopped"),
398 (FinishedOutcome::Stopped, "timed out", "Shell stopped"),
399 ] {
400 let batch = vec![FinishedWork::shell(
401 "npm test --\n --watch=false",
402 outcome,
403 summary.to_string(),
404 Duration::from_secs(12),
405 )];
406 let payload = background_finished_payload(Locale::En, &batch, false).expect("payload");
407 assert_eq!(payload.headline(), headline);
408 assert_eq!(payload.detail(), Some("shell"));
409 assert_eq!(payload.preview(), None);
410 }
411 }
412
413 #[test]
414 fn background_review_stopped_work_is_never_counted_as_failed() {
415 let stopped = agent("cancelled", SubAgentStatus::Cancelled, "Stopped.");
416 let stopped_shell = FinishedWork::shell(
417 "sleep 99",
418 FinishedOutcome::Stopped,
419 "killed".into(),
420 Duration::ZERO,
421 );
422 let cases = [
423 (
424 vec![stopped.clone(), stopped_shell.clone()],
425 "0 done · 0 failed · 2 stopped",
426 ),
427 (
428 vec![
429 agent("done", SubAgentStatus::Completed, "Done."),
430 stopped.clone(),
431 ],
432 "1 done · 0 failed · 1 stopped",
433 ),
434 (
435 vec![
436 agent("failed", SubAgentStatus::Failed("failed".into()), "Failed."),
437 stopped_shell,
438 ],
439 "0 done · 1 failed · 1 stopped",
440 ),
441 (
442 vec![
443 agent("done", SubAgentStatus::Completed, "Done."),
444 agent("failed", SubAgentStatus::Failed("failed".into()), "Failed."),
445 stopped,
446 ],
447 "1 done · 1 failed · 1 stopped",
448 ),
449 ];
450 for (batch, expected) in cases {
451 assert_eq!(
452 background_finished_payload(Locale::En, &batch, false)
453 .unwrap()
454 .headline(),
455 expected
456 );
457 }
458 }
459
460 #[test]
461 fn background_review_notifications_hide_commands_and_task_errors() {
462 let shell = FinishedWork::shell(
463 "mysql -psecret123 private_customer",
464 FinishedOutcome::Failed,
465 "private output".into(),
466 Duration::ZERO,
467 );
468 let task = FinishedWork::task(
469 "private account",
470 FinishedOutcome::Failed,
471 "private customer error".into(),
472 Duration::ZERO,
473 );
474 for batch in [
475 vec![shell.clone()],
476 vec![task.clone()],
477 vec![
478 agent("review", SubAgentStatus::Completed, "Done."),
479 shell,
480 task,
481 ],
482 ] {
483 let payload = background_finished_payload(Locale::En, &batch, false).unwrap();
484 assert_eq!(
485 payload.kind(),
486 crate::notify::payload::NotificationKind::BackgroundTerminal
487 );
488 assert_eq!(
489 payload
490 .clone()
491 .with_preview(Some("mysql -psecret123"))
492 .preview(),
493 None
494 );
495 for private in ["mysql", "secret123", "private", "customer", "account"] {
496 assert!(
497 !payload.render_inline().contains(private),
498 "{}",
499 payload.render_inline()
500 );
501 }
502 }
503 let batch = [agent(
504 "review https://user:shortpw@example.test",
505 SubAgentStatus::Completed,
506 "Done.",
507 )];
508 let payload = background_finished_payload(Locale::En, &batch, false).unwrap();
509 assert!(!payload.render_inline().contains("shortpw"));
510 assert!(!payload.detail().unwrap().contains("user:"));
511 assert!(payload.detail().unwrap().contains("example.test"));
512 }
513
514 #[test]
515 fn background_review_long_previews_keep_the_localized_full_result_pointer() {
516 for &locale in Locale::shipped_complete() {
517 for text in [
518 "A long first sentence ".repeat(30),
519 "詳しい結果 ".repeat(90),
520 ] {
521 for named in [false, true] {
522 let mut batch = vec![agent(
523 &"review ".repeat(40),
524 SubAgentStatus::Completed,
525 &text,
526 )];
527 if named {
528 batch.push(agent("other", SubAgentStatus::Completed, "Done."));
529 }
530 let payload = background_finished_payload(locale, &batch, false).unwrap();
531 let preview = payload.preview().unwrap();
532 assert!(preview.chars().count() <= PREVIEW_MAX_CHARS);
533 assert!(
534 preview.ends_with(
535 tr(locale, MessageId::NotificationFullResultPointer).as_ref()
536 ),
537 "{preview}"
538 );
539 }
540 }
541 }
542 }
543 }
544
544 lines RUST