@team-agent/installer 0.3.24 → 0.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +1 -0
  4. package/crates/team-agent/src/cli/mod.rs +193 -7
  5. package/crates/team-agent/src/cli/send.rs +106 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  7. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
  8. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +7 -0
  10. package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
  12. package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
  13. package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
  14. package/crates/team-agent/src/coordinator/tick.rs +55 -7
  15. package/crates/team-agent/src/leader/lease.rs +57 -0
  16. package/crates/team-agent/src/leader/start.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +217 -19
  18. package/crates/team-agent/src/lifecycle/restart/agent.rs +116 -0
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
  20. package/crates/team-agent/src/lifecycle/restart.rs +19 -2
  21. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
  22. package/crates/team-agent/src/messaging/delivery.rs +91 -1
  23. package/crates/team-agent/src/messaging/helpers.rs +64 -24
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
  25. package/crates/team-agent/src/messaging/tests/spine.rs +158 -4
  26. package/crates/team-agent/src/tmux_backend/tests.rs +414 -2
  27. package/crates/team-agent/src/tmux_backend.rs +366 -2
  28. package/crates/team-agent/src/transport/test_support.rs +1 -0
  29. package/crates/team-agent/src/transport/tests/mod.rs +1 -0
  30. package/crates/team-agent/src/transport/tests/wire.rs +2 -1
  31. package/crates/team-agent/src/transport.rs +103 -1
  32. package/npm/install.mjs +312 -39
  33. package/package.json +4 -4
@@ -348,6 +348,7 @@ pub fn deliver_pending_message(
348
348
  attempt,
349
349
  "send_inject_exhausted",
350
350
  &reason,
351
+ None,
351
352
  )?;
352
353
  return Ok(DeliveryOutcome {
353
354
  ok: false,
@@ -387,6 +388,13 @@ pub fn deliver_pending_message(
387
388
  submit_verification_wire(inject_report.submit_verification)
388
389
  )
389
390
  };
391
+ // E50 PR-1 (0.3.24 P0): render forensic submit_diagnostics into the
392
+ // event so operators see per-attempt pane state without grepping. The
393
+ // legacy keys (`message_id` / `recipient` / `reason` / `attempts`)
394
+ // are preserved byte-for-byte for grep compatibility; new keys are
395
+ // ADDITIONAL.
396
+ let submit_attempts_detail =
397
+ render_submit_diagnostics(&inject_report);
390
398
  event_log.write(
391
399
  "send.unverified",
392
400
  serde_json::json!({
@@ -394,6 +402,7 @@ pub fn deliver_pending_message(
394
402
  "recipient": message.recipient,
395
403
  "reason": reason,
396
404
  "attempts": inject_report.attempts,
405
+ "submit_attempts_detail": submit_attempts_detail,
397
406
  }),
398
407
  )?;
399
408
  if inject_report.attempts >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
@@ -407,6 +416,7 @@ pub fn deliver_pending_message(
407
416
  inject_report.attempts,
408
417
  "send_unverified_exhausted",
409
418
  &reason,
419
+ Some(&inject_report),
410
420
  )?;
411
421
  return Ok(DeliveryOutcome {
412
422
  ok: false,
@@ -469,7 +479,19 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
469
479
  SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
470
480
  SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
471
481
  SubmitVerification::KeySentAfterVisibleToken { .. } => true,
482
+ // MUST-10 preserved: EnterSentWithoutPlaceholderCheck still ⇒ delivered
483
+ // (provider_submit_verification_red.rs:113-159 contract). E46 narrows
484
+ // when transport returns this variant: it is now emitted ONLY after
485
+ // post-Enter input-consumption confirmation succeeds. Fresh TUI
486
+ // (bracketed-paste-stuck) instead returns SubmitConsumptionUnverified
487
+ // so this branch keeps delivered semantics intact.
472
488
  SubmitVerification::EnterSentWithoutPlaceholderCheck => true,
489
+ // E46 (0.3.24 bug#5): Enter was sent but post-Enter consumption was
490
+ // NOT observed within the bounded resend cap. Treat as not delivered
491
+ // (submitted_unverified / failed) — prevents the macmini假阳 where
492
+ // demo-director's stuck bracketed-paste swallowed the Enter and
493
+ // delivery still reported delivered.
494
+ SubmitVerification::SubmitConsumptionUnverified => false,
473
495
  }
474
496
  }
475
497
 
@@ -492,7 +514,8 @@ fn pane_readback_verified(report: &InjectReport) -> bool {
492
514
  /// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
493
515
  /// when it sees this block + extracts the token — the bare content gives WORKING but never a report
494
516
  /// (rt-host-a loop #4). token == message_id (exactly-once correlation).
495
- fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id: &str) -> String {
517
+ /// F1 (0.3.26): promoted to `pub` for direct pane send in cli/send.rs.
518
+ pub fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id: &str) -> String {
496
519
  let mut header = format!("Team Agent message from {sender}");
497
520
  if let Some(task_id) = task_id.filter(|t| !t.is_empty()) {
498
521
  header.push_str(&format!(" for {task_id}"));
@@ -545,7 +568,23 @@ fn resolve_inject_target(
545
568
  .and_then(serde_json::Value::as_str)
546
569
  .filter(|s| !s.is_empty())
547
570
  .map(PaneId::new);
571
+ // E51 (0.3.26 P0, delivery loop guard): if the worker's cached pane_id is
572
+ // the SAME as leader_receiver.pane_id (the leader's handle), injecting into
573
+ // it would deliver the worker's message back to the leader pane — a routing
574
+ // loop that the macmini "hand-handle mapping 灾難" truth source exposed. Fail
575
+ // loud with a SessionWindow target that will surface as a structured error
576
+ // (the SessionWindow "leader" target trips the existing leader_not_attached
577
+ // guard on any subsequent delivery attempt for this message). The root fix
578
+ // is in lease.rs (E51 guard #1) which prevents the conflation; this is the
579
+ // defence-in-depth in the delivery layer.
548
580
  if let Some(pane) = cached_pane.as_ref() {
581
+ let leader_pane = leader_receiver_pane_id(state);
582
+ if leader_pane.is_some_and(|lp| lp == pane.as_str()) {
583
+ return Target::SessionWindow {
584
+ session: SessionName::new(session),
585
+ window: WindowName::new(format!("{recipient}_pane_conflicts_with_leader")),
586
+ };
587
+ }
549
588
  if live_targets
550
589
  .iter()
551
590
  .any(|target| target.pane_id.as_str() == pane.as_str())
@@ -743,6 +782,34 @@ fn leader_receiver_field_in_state<'a>(
743
782
  .filter(|value| !value.is_empty())
744
783
  }
745
784
 
785
+ /// E50 PR-1 (0.3.24 P0): render `InjectReport.submit_diagnostics` as a JSON
786
+ /// array suitable for inclusion in `send.unverified` / `send.failed` events.
787
+ /// `null` if no diagnostics were attached (e.g. the inject went through a
788
+ /// path that bypassed the paste-prompt + Enter instrumentation, or this is
789
+ /// the pre-PR-2 inject-failed path with no `InjectReport`).
790
+ fn render_submit_diagnostics(report: &InjectReport) -> serde_json::Value {
791
+ let Some(diag) = report.submit_diagnostics.as_ref() else {
792
+ return serde_json::Value::Null;
793
+ };
794
+ serde_json::Value::Array(
795
+ diag.attempts_detail
796
+ .iter()
797
+ .map(|obs| {
798
+ serde_json::json!({
799
+ "attempt_index": obs.attempt_index,
800
+ "matched": obs.matched,
801
+ "matched_literal": obs.matched_literal,
802
+ "where_in_tail": obs.where_in_tail,
803
+ "pane_tail_excerpt": obs.pane_tail_excerpt,
804
+ "pane_tail_lines": obs.pane_tail_lines,
805
+ "elapsed_ms": obs.elapsed_ms,
806
+ })
807
+ })
808
+ .collect(),
809
+ )
810
+ }
811
+
812
+ #[allow(clippy::too_many_arguments)]
746
813
  fn emit_send_failed_exhausted(
747
814
  workspace: &Path,
748
815
  state: &serde_json::Value,
@@ -752,7 +819,26 @@ fn emit_send_failed_exhausted(
752
819
  attempts: u32,
753
820
  failure_reason: &str,
754
821
  verification: &str,
822
+ inject_report: Option<&InjectReport>,
755
823
  ) -> Result<(), MessagingError> {
824
+ // E50 PR-1 (0.3.24 P0): forensic fields on send.failed. Legacy keys
825
+ // (`message_id` / `recipient` / `attempts` / `max_attempts` / `reason` /
826
+ // `verification`) preserved byte-for-byte for grep compatibility.
827
+ let submit_attempts_detail = inject_report
828
+ .map(render_submit_diagnostics)
829
+ .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
830
+ let total_elapsed_ms = inject_report
831
+ .and_then(|r| r.submit_diagnostics.as_ref())
832
+ .map(|d| d.total_elapsed_ms)
833
+ .unwrap_or(0);
834
+ let last_matched_literal = inject_report
835
+ .and_then(|r| r.submit_diagnostics.as_ref())
836
+ .and_then(|d| d.attempts_detail.last())
837
+ .and_then(|a| a.matched_literal.clone());
838
+ let last_pane_tail_excerpt = inject_report
839
+ .and_then(|r| r.submit_diagnostics.as_ref())
840
+ .and_then(|d| d.attempts_detail.last())
841
+ .map(|a| a.pane_tail_excerpt.clone());
756
842
  event_log.write(
757
843
  "send.failed",
758
844
  serde_json::json!({
@@ -762,6 +848,10 @@ fn emit_send_failed_exhausted(
762
848
  "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
763
849
  "reason": failure_reason,
764
850
  "verification": verification,
851
+ "submit_attempts_detail": submit_attempts_detail,
852
+ "total_elapsed_ms": total_elapsed_ms,
853
+ "last_matched_literal": last_matched_literal,
854
+ "last_pane_tail_excerpt": last_pane_tail_excerpt,
765
855
  }),
766
856
  )?;
767
857
  let content = format!(
@@ -112,9 +112,20 @@ pub(crate) fn parse_scheduled_kind(kind: &str) -> Result<ScheduledKind, Messagin
112
112
  }
113
113
 
114
114
  pub(crate) fn working_seconds(scrollback: &str) -> Option<u64> {
115
- let lower = scrollback.to_ascii_lowercase();
115
+ // E47 ROOT-FIX#1 (0.3.24 P0, idle/busy 假阳): the pre-fix `lower.find(...)`
116
+ // scanned the WHOLE scrollback (Tail(40) lines) and matched historical
117
+ // working markers — e.g. an old `• Working (514s · esc to interrupt)`
118
+ // still in the 40-line window after the worker idled. That stale token
119
+ // flipped a truly-idle agent to Stuck whenever the historical seconds
120
+ // exceeded 300. Real fix: only consult the LIVE composer / status line
121
+ // (last non-empty line). If the current bottom line is something like
122
+ // `❯ Run /review`, `Worked for 8m 34s`, or an empty composer prompt,
123
+ // there is no LIVE working indicator and we return None — handing off
124
+ // to the structural latest_prompt_signal / IRON LAW Uncertain path.
125
+ let last_non_empty = scrollback.lines().rev().find(|line| !line.trim().is_empty())?;
126
+ let lower = last_non_empty.to_ascii_lowercase();
116
127
  let start = lower.find("working (")?;
117
- let rest = scrollback.get(start + "Working (".len()..)?;
128
+ let rest = last_non_empty.get(start + "Working (".len()..)?;
118
129
  let seconds = rest.split_once('s')?.0;
119
130
  seconds.parse::<u64>().ok()
120
131
  }
@@ -129,32 +140,61 @@ pub(crate) fn non_provider_command(command: &str) -> Option<&str> {
129
140
  }
130
141
 
131
142
  pub(crate) fn latest_prompt_signal(scrollback: &str) -> Option<AgentActivity> {
132
- let lower = scrollback.to_ascii_lowercase();
133
- let idle_pos = latest_idle_prompt_pos(scrollback);
134
- let working_pos = [
135
- "working", "thinking", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
136
- ]
137
- .iter()
138
- .filter_map(|needle| lower.rfind(needle))
139
- .max();
140
- match (idle_pos, working_pos) {
141
- (Some(i), Some(w)) if i > w => Some(idle_activity()),
142
- (Some(_), None) => Some(idle_activity()),
143
- (_, Some(_)) => Some(AgentActivity {
143
+ // E47 ROOT-FIX#2 (0.3.24 P0, idle/busy 假阳): limit the scan to the
144
+ // BOTTOM ACTIVE REGION (last 1-3 non-empty lines = composer / status
145
+ // line). The pre-fix `rfind` across the whole Tail(40) buffer let a
146
+ // historical spinner/`Working` token out-position the live `❯`/`›`
147
+ // composer (the rfind-recency family bug — same shape as the #320
148
+ // codex prompt residue). When the composer line is `❯ Run /review`
149
+ // (idle), a scrollback `Working (514s)` 20 lines up should NOT win.
150
+ //
151
+ // IRON LAW (activity.rs:3 / bug-071/077/085): no-signal in the bottom
152
+ // region must be Uncertain (caller treats None here as "no decisive
153
+ // signal" and surfaces Uncertain), NEVER silently flipped to Idle.
154
+ let active_region: Vec<&str> = scrollback
155
+ .lines()
156
+ .rev()
157
+ .filter(|line| !line.trim().is_empty())
158
+ .take(3)
159
+ .collect();
160
+ if active_region.is_empty() {
161
+ return None;
162
+ }
163
+ let mut has_idle_prompt = false;
164
+ let mut has_live_working_indicator = false;
165
+ for line in &active_region {
166
+ let lower = line.to_ascii_lowercase();
167
+ if line.contains('❯') || line.contains('›') {
168
+ has_idle_prompt = true;
169
+ }
170
+ // codex live spinner shapes (provider/adapter.rs:875-876 markers):
171
+ // braille spinner, `• Working (`, `Thinking`; claude `✶`/`✢` per
172
+ // adapter; we look for STRUCTURAL composer signals.
173
+ if [
174
+ "working (", "thinking", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
175
+ "⠧", "⠇", "⠏", "✶", "✢",
176
+ ]
177
+ .iter()
178
+ .any(|needle| lower.contains(needle))
179
+ {
180
+ has_live_working_indicator = true;
181
+ }
182
+ }
183
+ // Working indicator IN the active region wins over a stale `❯`/`›`
184
+ // (composer may show a prompt char before the live spinner refreshes).
185
+ if has_live_working_indicator {
186
+ return Some(AgentActivity {
144
187
  status: ActivityStatus::Working,
145
188
  confidence: 0.9,
146
189
  rationale: "working_indicator".to_string(),
147
- }),
148
- (None, None) => None,
190
+ });
149
191
  }
150
- }
151
-
152
- fn latest_idle_prompt_pos(scrollback: &str) -> Option<usize> {
153
- scrollback
154
- .match_indices('❯')
155
- .map(|(idx, _)| idx)
156
- .chain(scrollback.match_indices('›').map(|(idx, _)| idx))
157
- .max()
192
+ if has_idle_prompt {
193
+ return Some(idle_activity());
194
+ }
195
+ // No structural signal in the bottom region → caller (activity.rs:184)
196
+ // gets None and treats as no-decisive-signal → Uncertain (IRON LAW).
197
+ None
158
198
  }
159
199
 
160
200
  fn idle_activity() -> AgentActivity {
@@ -1308,6 +1308,7 @@ impl Transport for UnverifiedInjectTransport {
1308
1308
  crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
1309
1309
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
1310
1310
  attempts: u32::from(SEND_RETRY_MAX_ATTEMPTS),
1311
+ submit_diagnostics: None,
1311
1312
  })
1312
1313
  }
1313
1314
  fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
@@ -1467,6 +1468,7 @@ impl Transport for ReadbackMissingTransport {
1467
1468
  },
1468
1469
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
1469
1470
  attempts: 1,
1471
+ submit_diagnostics: None,
1470
1472
  })
1471
1473
  }
1472
1474
  fn send_keys(&self, _: &Target, _: &[Key]) -> Result<(), TransportError> {
@@ -71,7 +71,14 @@ fn p2_owner_bypass_denies_on_env_agent_id_mismatch() {
71
71
  }
72
72
 
73
73
  // P1 — classify_agent_activity must read current_command, stale last_output, multiline /
74
- // Codex idle prompts, and Thinking/lowercase-working (activity_detector.py:90-146).
74
+ // Codex idle prompts, and Thinking/codex-spinner working indicators
75
+ // (activity_detector.py:90-146). **E47 amendment (0.3.24 P0)**: the
76
+ // working-indicator probe is now bottom-active-region scoped (last 1-3
77
+ // non-empty lines) and looks for STRUCTURAL spinner markers (codex
78
+ // `• Working (`/`Thinking`/braille; claude `✶`/`✢`), not bare lowercase
79
+ // `working` anywhere in the scrollback. Pre-fix rfind across the whole
80
+ // Tail(40) buffer is the macmini假阳 root cause (historical Working tokens
81
+ // out-positioning a live idle composer).
75
82
  #[test]
76
83
  fn p2_classify_activity_reads_command_stale_and_prompts() {
77
84
  let st = serde_json::json!({});
@@ -85,12 +92,28 @@ fn p2_classify_activity_reads_command_stale_and_prompts() {
85
92
  // (3) embedded multiline idle prompt (Codex ❯ not on its own trimmed line) → idle 0.9.
86
93
  let c = classify_agent_activity(&st, "some line\n❯\nmore", false, None, None);
87
94
  assert_eq!((c.status, c.confidence), (ActivityStatus::Idle, 0.9));
88
- // (4) 'Thinking' working indicator → working 0.9.
95
+ // (4) 'Thinking' working indicator in the bottom active region → working 0.9.
89
96
  let d = classify_agent_activity(&st, "Thinking about it", false, None, None);
90
97
  assert_eq!((d.status, d.confidence), (ActivityStatus::Working, 0.9));
91
- // (5) lowercase 'working' indicator working 0.9.
98
+ // (5) E47 amendment: bare lowercase 'working on it' is NOT a structural
99
+ // spinner marker; the live codex spinner is `• Working (Ns · esc to
100
+ // interrupt)`. Treat as no-decisive-signal → Uncertain. The pre-fix
101
+ // expectation here is exactly the macmini假阳 shape — a stale 'working'
102
+ // token (without the parenthesised seconds + esc-to-interrupt tail) is
103
+ // either scrollback residue or unrelated text, not a live working
104
+ // indicator. To assert the Working path use the structural codex shape.
92
105
  let e = classify_agent_activity(&st, "working on it", false, None, None);
93
- assert_eq!((e.status, e.confidence), (ActivityStatus::Working, 0.9));
106
+ assert_eq!((e.status, e.confidence), (ActivityStatus::Uncertain, 0.5));
107
+ // (5b) E47 structural Working: a real codex live spinner in the bottom
108
+ // active region must still classify Working 0.9.
109
+ let e_struct = classify_agent_activity(
110
+ &st,
111
+ "• Working (12s · esc to interrupt)",
112
+ false,
113
+ None,
114
+ None,
115
+ );
116
+ assert_eq!((e_struct.status, e_struct.confidence), (ActivityStatus::Working, 0.9));
94
117
  }
95
118
 
96
119
  // P1 — scheduler dispatch must be exhaustive: an unknown kind surfaces an error, not a
@@ -150,6 +173,7 @@ impl Transport for DeliverOkTransport {
150
173
  submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
151
174
  turn_verification: crate::transport::TurnVerification::NotYetObserved,
152
175
  attempts: 1,
176
+ submit_diagnostics: None,
153
177
  })
154
178
  }
155
179
  fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
@@ -484,3 +508,133 @@ fn spine_delivery_injects_rendered_protocol_block_not_raw_content() {
484
508
  worker builds a result_envelope; got {payload:?}"
485
509
  );
486
510
  }
511
+
512
+ // ═════════════════════════════════════════════════════════════════════════════
513
+ // E47 (0.3.24 P0, idle/busy 假阳) — TUI keyword grep is structurally bounded.
514
+ //
515
+ // Real-machine repro (macmini): a Tail(40) capture of an idle codex worker
516
+ // still carries historical `• Working (514s · esc to interrupt)` plus a
517
+ // `─ Worked for 8m 34s ─` summary plus a fresh `❯ Run /review` prompt. The
518
+ // pre-fix `working_seconds` full-buffer find matched the 514s token and
519
+ // returned ≥300 → Stuck. The pre-fix `latest_prompt_signal` rfind let the
520
+ // historical spinner out-position the bottom `❯` → Working. E47 narrows
521
+ // both probes to the bottom active region (last 1-3 non-empty lines).
522
+ //
523
+ // The authoritative provider JSONL classify is wired in coordinator/tick.rs
524
+ // (jsonl_activity_for_agent); these unit tests pin the TUI fallback layer.
525
+ // ═════════════════════════════════════════════════════════════════════════════
526
+
527
+ #[test]
528
+ fn e47_codex_idle_with_historical_working_in_scrollback_is_idle_not_stuck_or_working() {
529
+ // Exact macmini repro shape from the architect locate (E47).
530
+ let scrollback = "• Working (514s · esc to interrupt)\n\
531
+ tool call 1\n\
532
+ tool call 2\n\
533
+ ─ Worked for 8m 34s ─\n\
534
+ › Run /review\n\
535
+ ❯ ";
536
+ let st = serde_json::json!({});
537
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
538
+ assert_eq!(
539
+ a.status,
540
+ ActivityStatus::Idle,
541
+ "E47 (RED-1): codex idle worker whose scrollback still carries \
542
+ historical `• Working (514s · esc to interrupt)` must classify IDLE \
543
+ (bottom active region is `❯`+`› Run /review` = idle composer). \
544
+ pre-fix: Stuck/Working via rfind-recency. Got {a:?}"
545
+ );
546
+ }
547
+
548
+ #[test]
549
+ fn e47_past_tense_worked_for_is_not_stale_working_indicator() {
550
+ // RED-2: past-tense `Worked for` summary line must NOT trigger
551
+ // working_seconds Stuck. The pre-fix scanned the whole buffer and would
552
+ // match the embedded `8m 34s ─` against `working (` if formatted slightly
553
+ // differently — but more importantly this test pins the "past-tense
554
+ // summary is idle context" semantics.
555
+ let scrollback = "─ Worked for 8m 34s ─\n❯ ";
556
+ let st = serde_json::json!({});
557
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
558
+ assert_ne!(
559
+ a.status,
560
+ ActivityStatus::Stuck,
561
+ "E47 (RED-2): past-tense `Worked for 8m 34s` must NOT trigger \
562
+ stale_working_indicator Stuck. Got {a:?}"
563
+ );
564
+ assert_eq!(
565
+ a.status,
566
+ ActivityStatus::Idle,
567
+ "E47 (RED-2): bottom `❯` composer = idle. Got {a:?}"
568
+ );
569
+ }
570
+
571
+ #[test]
572
+ fn e47_claude_idle_with_historical_spinner_classifies_idle() {
573
+ // RED-3: claude idle worker; historical spinner shape (`✶` per
574
+ // adapter.rs:875-876) earlier in the buffer (>= 3 non-empty lines deep),
575
+ // bottom composer is idle (`›` glyph variant or `> ` claude prompt).
576
+ // The bottom active region (last 3 non-empty lines) has NO spinner.
577
+ let scrollback = "✶ Working on plan\n\
578
+ tool 1\n\
579
+ tool 2\n\
580
+ assistant reply text\n\
581
+ summary line A\n\
582
+ summary line B\n\
583
+ › Continue?";
584
+ let st = serde_json::json!({});
585
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
586
+ assert_eq!(
587
+ a.status,
588
+ ActivityStatus::Idle,
589
+ "E47 (RED-3): claude idle worker — historical ✶ above, idle `›` \
590
+ below — must classify IDLE. Got {a:?}"
591
+ );
592
+ }
593
+
594
+ #[test]
595
+ fn e47_codex_live_spinner_in_bottom_active_region_classifies_working() {
596
+ // RED-4 (defence vs over-tightening 假阴): a TRULY busy codex worker
597
+ // whose bottom active region carries the live spinner must still be
598
+ // Working. No idle composer present.
599
+ let scrollback = "tool call x\nassistant response so far\n• Working (12s · esc to interrupt)";
600
+ let st = serde_json::json!({});
601
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
602
+ assert_eq!(
603
+ a.status,
604
+ ActivityStatus::Working,
605
+ "E47 (RED-4): live codex spinner in bottom active region must \
606
+ classify Working. Got {a:?}"
607
+ );
608
+ }
609
+
610
+ #[test]
611
+ fn e47_claude_live_working_in_bottom_active_region_classifies_working() {
612
+ // RED-5: a TRULY busy claude worker showing `✶ Working` at the bottom
613
+ // active region must be Working — even without the `(Ns)` codex shape.
614
+ let scrollback = "earlier output\nmore output\n✶ Working through the request";
615
+ let st = serde_json::json!({});
616
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
617
+ assert_eq!(
618
+ a.status,
619
+ ActivityStatus::Working,
620
+ "E47 (RED-5): claude ✶ Working in bottom active region → Working. \
621
+ Got {a:?}"
622
+ );
623
+ }
624
+
625
+ #[test]
626
+ fn e47_iron_law_no_signal_in_bottom_region_stays_uncertain_not_idle() {
627
+ // Defence: IRON LAW (activity.rs:3 / bug-071/077/085). When the bottom
628
+ // active region has no spinner AND no `❯`/`›` AND no other decisive
629
+ // signal, classify_agent_activity falls through to no_decisive_signal →
630
+ // Uncertain — NEVER silently to Idle.
631
+ let scrollback = "some neutral text\nmore neutral text\nstill nothing decisive";
632
+ let st = serde_json::json!({});
633
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
634
+ assert_eq!(
635
+ a.status,
636
+ ActivityStatus::Uncertain,
637
+ "E47 IRON LAW guard: bottom region carries no spinner and no idle \
638
+ composer → Uncertain, never silently Idle. Got {a:?}"
639
+ );
640
+ }