@team-agent/installer 0.3.23 → 0.3.25

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.
@@ -203,8 +203,42 @@ pub fn deliver_pending_message(
203
203
  let delivery_transport =
204
204
  delivery_transport_for_recipient(workspace, transport, state, &message.recipient);
205
205
  let transport = delivery_transport.as_transport();
206
+ // U1-B: probe `list_targets()` ONCE per delivery and DEFER on Err. The prior
207
+ // `.unwrap_or_default()` coerced server jitter (Err = subprocess fork failed)
208
+ // to an empty vec, and the downstream cache-reuse branch
209
+ // (`live_targets.is_empty() && !known_dead`) would then inject into a
210
+ // never-validated cached pane on every tmux hiccup. Err is OBSERVED here as
211
+ // jitter and the delivery is deferred (status stays `target_resolved`, no
212
+ // inject attempted) so the next tick gets a fresh probe.
213
+ let live_targets = match transport.list_targets() {
214
+ Ok(targets) => targets,
215
+ Err(err) => {
216
+ let reason = format!("list_targets_server_jitter:{err}");
217
+ event_log.write(
218
+ "delivery.deferred_list_targets_jitter",
219
+ serde_json::json!({
220
+ "message_id": message_id,
221
+ "recipient": message.recipient,
222
+ "reason": reason,
223
+ }),
224
+ )?;
225
+ store.mark(message_id, "target_resolved", Some(&reason))?;
226
+ return Ok(DeliveryOutcome {
227
+ ok: false,
228
+ status: DeliveryStatus::Degraded,
229
+ message_status: MessageStatusShadow("target_resolved".to_string()),
230
+ message_id: Some(message_id.to_string()),
231
+ verification: Some(reason),
232
+ stage: None,
233
+ reason: None,
234
+ channel: None,
235
+ });
236
+ }
237
+ };
206
238
  // Do not inject queued leader messages into a synthetic "leader" window.
207
- if message.recipient == "leader" && !leader_receiver_pane_is_usable(transport, state) {
239
+ if message.recipient == "leader"
240
+ && !leader_receiver_pane_is_usable(transport, state, &live_targets)
241
+ {
208
242
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
209
243
  event_log.write(
210
244
  "leader_receiver.delivery_blocked",
@@ -227,7 +261,7 @@ pub fn deliver_pending_message(
227
261
  channel: Some("rebind_required".to_string()),
228
262
  });
229
263
  }
230
- let target = resolve_inject_target(state, &message.recipient, transport);
264
+ let target = resolve_inject_target(state, &message.recipient, transport, &live_targets);
231
265
  // Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
232
266
  // menu is NOT provider delivery — the menu consumes the Enter and the task text
233
267
  // is lost (PROBE-2 root-cause). Before injection, peek at the recipient's pane for
@@ -435,7 +469,19 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
435
469
  SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
436
470
  SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
437
471
  SubmitVerification::KeySentAfterVisibleToken { .. } => true,
472
+ // MUST-10 preserved: EnterSentWithoutPlaceholderCheck still ⇒ delivered
473
+ // (provider_submit_verification_red.rs:113-159 contract). E46 narrows
474
+ // when transport returns this variant: it is now emitted ONLY after
475
+ // post-Enter input-consumption confirmation succeeds. Fresh TUI
476
+ // (bracketed-paste-stuck) instead returns SubmitConsumptionUnverified
477
+ // so this branch keeps delivered semantics intact.
438
478
  SubmitVerification::EnterSentWithoutPlaceholderCheck => true,
479
+ // E46 (0.3.24 bug#5): Enter was sent but post-Enter consumption was
480
+ // NOT observed within the bounded resend cap. Treat as not delivered
481
+ // (submitted_unverified / failed) — prevents the macmini假阳 where
482
+ // demo-director's stuck bracketed-paste swallowed the Enter and
483
+ // delivery still reported delivered.
484
+ SubmitVerification::SubmitConsumptionUnverified => false,
439
485
  }
440
486
  }
441
487
 
@@ -473,12 +519,24 @@ fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id
473
519
  ///
474
520
  /// Leader delivery uses the bound leader receiver pane. The leader is not a worker agent and
475
521
  /// must not fall through to a synthetic `SessionWindow{window="leader"}` target.
522
+ ///
523
+ /// `live_targets` is passed in BY THE CALLER (probed ONCE per delivery — see U1-B); this fn is
524
+ /// pure w.r.t. transport state. The caller must DEFER on `list_targets()` Err and never coerce
525
+ /// to an empty vec.
526
+ ///
527
+ /// **0.3.24 excision (U1-A real-machine RED v2, macmini fixture res_e4b40473d36f)**:
528
+ /// the wave-2 LEADER drift fallback chain (session+window probe) was removed — see
529
+ /// `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md` for the root-cause analysis.
530
+ /// Drift now fails loudly as `leader_not_attached` rather than silently selecting a stale
531
+ /// pane. U1-A real-machine fix is deferred to v0.3.25 (writer-shape + projection +
532
+ /// rediscover-writer triad). U1-B jitter defer at try_deliver_message:213-237 and
533
+ /// U1-C-Tail at the startup-prompt peek site are unchanged.
476
534
  fn resolve_inject_target(
477
535
  state: &serde_json::Value,
478
536
  recipient: &str,
479
537
  transport: &dyn Transport,
538
+ live_targets: &[PaneInfo],
480
539
  ) -> Target {
481
- let live_targets = transport.list_targets().unwrap_or_default();
482
540
  if recipient == "leader" {
483
541
  if let Some(pane_id) = leader_receiver_pane_id(state) {
484
542
  return Target::Pane(PaneId::new(pane_id));
@@ -507,7 +565,7 @@ fn resolve_inject_target(
507
565
  return Target::Pane(pane.clone());
508
566
  }
509
567
  }
510
- if let Some(live_pane) = live_pane_for_session_window(&live_targets, session, window) {
568
+ if let Some(live_pane) = live_pane_for_session_window(live_targets, session, window) {
511
569
  return Target::Pane(live_pane);
512
570
  }
513
571
  if let Some(pane) = cached_pane {
@@ -552,16 +610,21 @@ fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
552
610
  .or_else(|| only_team_entry(state).and_then(leader_receiver_pane_id_in_state))
553
611
  }
554
612
 
555
- fn leader_receiver_pane_is_usable(transport: &dyn Transport, state: &serde_json::Value) -> bool {
613
+ /// `state` is "usable" for leader delivery when the bound `leader_receiver.pane_id`
614
+ /// is present and alive (in `live_targets` or `liveness` probe returns not-Dead).
615
+ ///
616
+ /// **0.3.24 excision (U1-A real-machine RED v2)**: the wave-2 session+window drift
617
+ /// fallback was removed. Drift now fails loudly as `leader_not_attached` —
618
+ /// see resolve_inject_target and `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
619
+ fn leader_receiver_pane_is_usable(
620
+ transport: &dyn Transport,
621
+ state: &serde_json::Value,
622
+ live_targets: &[PaneInfo],
623
+ ) -> bool {
556
624
  let Some(pane_id) = leader_receiver_pane_id(state) else {
557
625
  return false;
558
626
  };
559
- if transport
560
- .list_targets()
561
- .unwrap_or_default()
562
- .iter()
563
- .any(|target| target.pane_id.as_str() == pane_id)
564
- {
627
+ if live_targets.iter().any(|target| target.pane_id.as_str() == pane_id) {
565
628
  return true;
566
629
  }
567
630
  !matches!(
@@ -956,8 +1019,16 @@ fn recipient_pane_has_actionable_startup_prompt(
956
1019
  if matches!(startup_prompts, Some("handled" | "complete")) {
957
1020
  return false;
958
1021
  }
1022
+ // U1-C wave-2: Tail(80) instead of Full. The peek is the DELIVERY-SITE pre-check
1023
+ // (NOT the startup-prompts dismissal phase, which legitimately needs the full
1024
+ // scrollback to anchor recency). Limiting to the visible screen avoids matching
1025
+ // residual already-answered trust modals that scrolled off — while a live trust
1026
+ // modal would still appear in the last 80 lines because Codex pins it to the
1027
+ // visible region until dismissed. Pair this with the U1-C recency guard in
1028
+ // `has_actionable_trust_shape`: either fix alone covers the symptom; both
1029
+ // together defend against both scrollback-residue AND pre-render pathologies.
959
1030
  let captured = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
960
- transport.capture(target, crate::transport::CaptureRange::Full)
1031
+ transport.capture(target, crate::transport::CaptureRange::Tail(80))
961
1032
  })) {
962
1033
  Ok(Ok(captured)) => captured.text,
963
1034
  _ => return false,
@@ -1351,27 +1422,15 @@ pub(crate) fn normalize_owner_team_id_rows(
1351
1422
  if requested == canonical {
1352
1423
  return Ok(());
1353
1424
  }
1354
- let store = MessageStore::open(workspace)?;
1355
- let conn = crate::db::schema::open_db(store.db_path())?;
1356
- for table in [
1357
- "messages",
1358
- "results",
1359
- "scheduled_events",
1360
- "agent_health",
1361
- "result_watchers",
1362
- "leader_notification_log",
1363
- ] {
1364
- let sql =
1365
- format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
1366
- conn.execute(&sql, params![canonical, requested])?;
1367
- }
1368
1425
  if let Some(event_log) = event_log {
1369
1426
  event_log.write(
1370
- "owner_team_id.compatibility_alias_migrated",
1427
+ "owner_team_id.compatibility_alias_detected",
1371
1428
  serde_json::json!({
1372
1429
  "requested_owner_team_id": requested,
1373
1430
  "canonical_owner_team_id": canonical,
1374
1431
  "message_id": message_id,
1432
+ "action": "read_only_no_db_update",
1433
+ "workspace": workspace.to_string_lossy().to_string(),
1375
1434
  }),
1376
1435
  )?;
1377
1436
  }
@@ -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 {
@@ -295,3 +295,4 @@ mod e23;
295
295
  mod main_preserved;
296
296
  mod runtime;
297
297
  mod spine;
298
+ mod wave2;
@@ -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
@@ -484,3 +507,133 @@ fn spine_delivery_injects_rendered_protocol_block_not_raw_content() {
484
507
  worker builds a result_envelope; got {payload:?}"
485
508
  );
486
509
  }
510
+
511
+ // ═════════════════════════════════════════════════════════════════════════════
512
+ // E47 (0.3.24 P0, idle/busy 假阳) — TUI keyword grep is structurally bounded.
513
+ //
514
+ // Real-machine repro (macmini): a Tail(40) capture of an idle codex worker
515
+ // still carries historical `• Working (514s · esc to interrupt)` plus a
516
+ // `─ Worked for 8m 34s ─` summary plus a fresh `❯ Run /review` prompt. The
517
+ // pre-fix `working_seconds` full-buffer find matched the 514s token and
518
+ // returned ≥300 → Stuck. The pre-fix `latest_prompt_signal` rfind let the
519
+ // historical spinner out-position the bottom `❯` → Working. E47 narrows
520
+ // both probes to the bottom active region (last 1-3 non-empty lines).
521
+ //
522
+ // The authoritative provider JSONL classify is wired in coordinator/tick.rs
523
+ // (jsonl_activity_for_agent); these unit tests pin the TUI fallback layer.
524
+ // ═════════════════════════════════════════════════════════════════════════════
525
+
526
+ #[test]
527
+ fn e47_codex_idle_with_historical_working_in_scrollback_is_idle_not_stuck_or_working() {
528
+ // Exact macmini repro shape from the architect locate (E47).
529
+ let scrollback = "• Working (514s · esc to interrupt)\n\
530
+ tool call 1\n\
531
+ tool call 2\n\
532
+ ─ Worked for 8m 34s ─\n\
533
+ › Run /review\n\
534
+ ❯ ";
535
+ let st = serde_json::json!({});
536
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
537
+ assert_eq!(
538
+ a.status,
539
+ ActivityStatus::Idle,
540
+ "E47 (RED-1): codex idle worker whose scrollback still carries \
541
+ historical `• Working (514s · esc to interrupt)` must classify IDLE \
542
+ (bottom active region is `❯`+`› Run /review` = idle composer). \
543
+ pre-fix: Stuck/Working via rfind-recency. Got {a:?}"
544
+ );
545
+ }
546
+
547
+ #[test]
548
+ fn e47_past_tense_worked_for_is_not_stale_working_indicator() {
549
+ // RED-2: past-tense `Worked for` summary line must NOT trigger
550
+ // working_seconds Stuck. The pre-fix scanned the whole buffer and would
551
+ // match the embedded `8m 34s ─` against `working (` if formatted slightly
552
+ // differently — but more importantly this test pins the "past-tense
553
+ // summary is idle context" semantics.
554
+ let scrollback = "─ Worked for 8m 34s ─\n❯ ";
555
+ let st = serde_json::json!({});
556
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
557
+ assert_ne!(
558
+ a.status,
559
+ ActivityStatus::Stuck,
560
+ "E47 (RED-2): past-tense `Worked for 8m 34s` must NOT trigger \
561
+ stale_working_indicator Stuck. Got {a:?}"
562
+ );
563
+ assert_eq!(
564
+ a.status,
565
+ ActivityStatus::Idle,
566
+ "E47 (RED-2): bottom `❯` composer = idle. Got {a:?}"
567
+ );
568
+ }
569
+
570
+ #[test]
571
+ fn e47_claude_idle_with_historical_spinner_classifies_idle() {
572
+ // RED-3: claude idle worker; historical spinner shape (`✶` per
573
+ // adapter.rs:875-876) earlier in the buffer (>= 3 non-empty lines deep),
574
+ // bottom composer is idle (`›` glyph variant or `> ` claude prompt).
575
+ // The bottom active region (last 3 non-empty lines) has NO spinner.
576
+ let scrollback = "✶ Working on plan\n\
577
+ tool 1\n\
578
+ tool 2\n\
579
+ assistant reply text\n\
580
+ summary line A\n\
581
+ summary line B\n\
582
+ › Continue?";
583
+ let st = serde_json::json!({});
584
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
585
+ assert_eq!(
586
+ a.status,
587
+ ActivityStatus::Idle,
588
+ "E47 (RED-3): claude idle worker — historical ✶ above, idle `›` \
589
+ below — must classify IDLE. Got {a:?}"
590
+ );
591
+ }
592
+
593
+ #[test]
594
+ fn e47_codex_live_spinner_in_bottom_active_region_classifies_working() {
595
+ // RED-4 (defence vs over-tightening 假阴): a TRULY busy codex worker
596
+ // whose bottom active region carries the live spinner must still be
597
+ // Working. No idle composer present.
598
+ let scrollback = "tool call x\nassistant response so far\n• Working (12s · esc to interrupt)";
599
+ let st = serde_json::json!({});
600
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
601
+ assert_eq!(
602
+ a.status,
603
+ ActivityStatus::Working,
604
+ "E47 (RED-4): live codex spinner in bottom active region must \
605
+ classify Working. Got {a:?}"
606
+ );
607
+ }
608
+
609
+ #[test]
610
+ fn e47_claude_live_working_in_bottom_active_region_classifies_working() {
611
+ // RED-5: a TRULY busy claude worker showing `✶ Working` at the bottom
612
+ // active region must be Working — even without the `(Ns)` codex shape.
613
+ let scrollback = "earlier output\nmore output\n✶ Working through the request";
614
+ let st = serde_json::json!({});
615
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
616
+ assert_eq!(
617
+ a.status,
618
+ ActivityStatus::Working,
619
+ "E47 (RED-5): claude ✶ Working in bottom active region → Working. \
620
+ Got {a:?}"
621
+ );
622
+ }
623
+
624
+ #[test]
625
+ fn e47_iron_law_no_signal_in_bottom_region_stays_uncertain_not_idle() {
626
+ // Defence: IRON LAW (activity.rs:3 / bug-071/077/085). When the bottom
627
+ // active region has no spinner AND no `❯`/`›` AND no other decisive
628
+ // signal, classify_agent_activity falls through to no_decisive_signal →
629
+ // Uncertain — NEVER silently to Idle.
630
+ let scrollback = "some neutral text\nmore neutral text\nstill nothing decisive";
631
+ let st = serde_json::json!({});
632
+ let a = classify_agent_activity(&st, scrollback, false, None, None);
633
+ assert_eq!(
634
+ a.status,
635
+ ActivityStatus::Uncertain,
636
+ "E47 IRON LAW guard: bottom region carries no spinner and no idle \
637
+ composer → Uncertain, never silently Idle. Got {a:?}"
638
+ );
639
+ }