@team-agent/installer 0.3.24 → 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.
@@ -469,7 +469,19 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
469
469
  SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
470
470
  SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
471
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.
472
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,
473
485
  }
474
486
  }
475
487
 
@@ -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 {
@@ -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
+ }
@@ -572,6 +572,257 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
572
572
  );
573
573
  }
574
574
 
575
+ // ═════════════════════════════════════════════════════════════════════════════
576
+ // E46 0.3.24 task#327 P0 — submit verification false-positive / false-negative.
577
+ //
578
+ // Architect + Workflow C1: do NOT flip `EnterSentWithoutPlaceholderCheck =>
579
+ // false` (breaks MUST-10 in provider_submit_verification_red.rs:113-159).
580
+ // Instead introduce SubmitConsumptionUnverified and only emit it from the
581
+ // bracketed-paste / Enter path when post-Enter input consumption is NOT
582
+ // observed within a bounded resend cap.
583
+ //
584
+ // Real-machine truth source (macmini): a fresh claude TUI's bracketed
585
+ // paste swallows the framework's Enter as paste content; the framework
586
+ // must (a) send Escape first to exit the paste bracket and (b) verify
587
+ // post-Enter consumption by structural input-empty probe (token marker
588
+ // no longer in the bottom 5 lines of the pane = composer cleared).
589
+ // ═════════════════════════════════════════════════════════════════════════════
590
+
591
+ /// **E46 RED-1 (核心假阳)**: token-bearing Text + Enter + bracketed paste.
592
+ /// Token is visible in the pane after submit AND remains in the bottom of
593
+ /// the pane (input region — consumption was NOT observed). Submit must
594
+ /// report `SubmitConsumptionUnverified`, NOT
595
+ /// `EnterSentWithoutPlaceholderCheck`. Otherwise delivery emits delivered
596
+ /// + message.delivered (假阳) even though the worker never consumed it.
597
+ #[test]
598
+ fn e46_inject_text_with_token_unconsumed_in_tail_reports_submit_consumption_unverified() {
599
+ // Mock always returns the token in the captured tail — simulates the
600
+ // macmini fresh claude TUI where the paste lands but the input never
601
+ // clears (Enter swallowed by bracketed-paste).
602
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
603
+ let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
604
+ let report = be
605
+ .inject(
606
+ &Target::Pane(PaneId::new("%7")),
607
+ &InjectPayload::Text(token_text.to_string()),
608
+ Key::Enter,
609
+ true,
610
+ )
611
+ .expect("inject runs");
612
+ assert_eq!(
613
+ report.submit_verification,
614
+ SubmitVerification::SubmitConsumptionUnverified,
615
+ "E46 RED-1: when token remains in the pane input region after \
616
+ Enter + resend cap, transport must report SubmitConsumptionUnverified \
617
+ (not EnterSentWithoutPlaceholderCheck which delivery treats as \
618
+ delivered). Got {:?}",
619
+ report.submit_verification
620
+ );
621
+ // turn_verification stays NotYetObserved (Gap42: metadata only).
622
+ assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
623
+ }
624
+
625
+ /// **E46 RED-2 (正向消费确认 → delivered)**: token-bearing Text + Enter +
626
+ /// bracketed paste. Token VISIBLE on first capture (pre/post-submit token
627
+ /// readback) BUT then GONE from the post-submit input-consumption probe
628
+ /// (composer cleared after Enter). Submit must report
629
+ /// `EnterSentWithoutPlaceholderCheck` (MUST-10 path) — delivery proceeds
630
+ /// to delivered. Guards against regression on crd's existing-worker path.
631
+ #[test]
632
+ fn e46_inject_text_with_token_consumed_after_enter_keeps_enter_sent_without_placeholder_check() {
633
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red2]";
634
+ // First captures (token visibility probes pre/post-Enter) return text
635
+ // with token; the LAST capture (consumption probe) returns text WITHOUT
636
+ // the token in the tail (composer cleared). MockCommandRunner drains
637
+ // `queued` first then defaults — we queue the early "token visible"
638
+ // captures, then drop to the empty default for the consumption probe.
639
+ let visible = ok(token_text);
640
+ let queued = vec![
641
+ MockResp::Out(ok("")), // set-buffer
642
+ MockResp::Out(ok("")), // paste-buffer
643
+ MockResp::Out(ok("")), // delete-buffer
644
+ MockResp::Out(visible.clone()), // pasted-content prompt poll: none → continue
645
+ MockResp::Out(visible.clone()), // pre-submit token visibility: visible
646
+ MockResp::Out(ok("")), // Escape send-keys (no stdout needed)
647
+ MockResp::Out(ok("")), // Enter send-keys
648
+ // From here on, the default response is `ok("")` (empty pane tail) so
649
+ // post-Enter consumption probe sees no token in tail → consumed=true.
650
+ ];
651
+ let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
652
+ let report = be
653
+ .inject(
654
+ &Target::Pane(PaneId::new("%7")),
655
+ &InjectPayload::Text(token_text.to_string()),
656
+ Key::Enter,
657
+ true,
658
+ )
659
+ .expect("inject runs");
660
+ assert_eq!(
661
+ report.submit_verification,
662
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
663
+ "E46 RED-2: when post-Enter consumption is observed (token gone \
664
+ from input region tail), submit must report the canonical \
665
+ EnterSentWithoutPlaceholderCheck so MUST-10 delivery semantics \
666
+ hold (provider_submit_verification_red.rs:113-159). Got {:?}",
667
+ report.submit_verification
668
+ );
669
+ }
670
+
671
+ /// **E46 RED-3 (resend-to-cap, no double-submit)**: when the FIRST Enter
672
+ /// landed on the composer but our readback was slow, the re-check BEFORE
673
+ /// resend must observe the now-empty input and STOP — must NOT fire a
674
+ /// second Enter into an empty composer (would open an empty turn).
675
+ #[test]
676
+ fn e46_inject_text_resend_rechecks_input_before_resending_to_avoid_double_submit() {
677
+ // Same mock as RED-2: post-Enter consumption probe sees empty tail.
678
+ // The implementation should issue only ONE Enter (no resend) once
679
+ // consumption is observed.
680
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red3]";
681
+ let queued = vec![
682
+ MockResp::Out(ok("")), // set-buffer
683
+ MockResp::Out(ok("")), // paste-buffer
684
+ MockResp::Out(ok("")), // delete-buffer
685
+ MockResp::Out(ok(token_text)), // pasted-content prompt: not matched
686
+ MockResp::Out(ok(token_text)), // pre-submit token visibility
687
+ MockResp::Out(ok("")), // Escape
688
+ MockResp::Out(ok("")), // Enter
689
+ ];
690
+ let (be, rec) = backend_with(MockResp::Out(ok("")), queued);
691
+ let _report = be
692
+ .inject(
693
+ &Target::Pane(PaneId::new("%7")),
694
+ &InjectPayload::Text(token_text.to_string()),
695
+ Key::Enter,
696
+ true,
697
+ )
698
+ .expect("inject runs");
699
+ let calls = rec.lock().unwrap().clone();
700
+ // Count `send-keys ... Enter` invocations. There must be EXACTLY ONE
701
+ // (the canonical submit). A second one indicates the resend loop
702
+ // didn't honour C3 (re-check input before resend).
703
+ let enter_count = calls
704
+ .iter()
705
+ .filter(|argv| {
706
+ argv.get(1).map(String::as_str) == Some("send-keys")
707
+ && argv.contains(&"Enter".to_string())
708
+ })
709
+ .count();
710
+ assert_eq!(
711
+ enter_count, 1,
712
+ "E46 RED-3: when consumption is observed (or already happened \
713
+ between Enter and probe), the resend loop must STOP and NOT \
714
+ issue a second Enter. Got {enter_count} Enter sends. \
715
+ calls={calls:?}"
716
+ );
717
+ }
718
+
719
+ /// **E46 RED-5 (provider-agnostic detector)**: the consumption probe must
720
+ /// NOT hard-code provider UI strings (claude `>`, codex `╰`, ...). It uses
721
+ /// the token marker absence in the bottom of the pane — works for any
722
+ /// provider. Here we run a non-claude-shaped pane (no claude markers) and
723
+ /// confirm the same delivered semantics when consumption happens.
724
+ #[test]
725
+ fn e46_consumption_detector_works_on_codex_shaped_pane_without_provider_strings() {
726
+ // Codex-shaped fake: prompt looks like `codex>` with no claude markers.
727
+ // After Enter, the composer clears (empty default returns ok("")).
728
+ let token_text =
729
+ "Team Agent message from leader:\n\ncodex msg\n\n[team-agent-token:msg_red5]";
730
+ let queued = vec![
731
+ MockResp::Out(ok("")), // set-buffer
732
+ MockResp::Out(ok("")), // paste-buffer
733
+ MockResp::Out(ok("")), // delete-buffer
734
+ MockResp::Out(ok("codex>\n")), // pasted-content prompt: no match
735
+ MockResp::Out(ok(token_text)), // pre-submit token visibility
736
+ MockResp::Out(ok("")), // Escape
737
+ MockResp::Out(ok("")), // Enter
738
+ // post-Enter: default returns ok("") (composer cleared) → token gone
739
+ ];
740
+ let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
741
+ let report = be
742
+ .inject(
743
+ &Target::Pane(PaneId::new("%7")),
744
+ &InjectPayload::Text(token_text.to_string()),
745
+ Key::Enter,
746
+ true,
747
+ )
748
+ .expect("inject runs");
749
+ assert_eq!(
750
+ report.submit_verification,
751
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
752
+ "E46 RED-5: provider-agnostic detector — codex-shaped pane (no \
753
+ claude UI string) where composer clears post-Enter must still \
754
+ report EnterSentWithoutPlaceholderCheck via structural \
755
+ input-empty check. Got {:?}",
756
+ report.submit_verification
757
+ );
758
+ }
759
+
760
+ /// **E46 Escape pre-Enter**: when bracketed=true + Text payload + submit=Enter,
761
+ /// the inject must send Escape BEFORE the Enter (exits bracketed-paste
762
+ /// mode on stuck composer). Non-Enter submits (e.g. Key::Down for codex
763
+ /// menu) must NOT receive the Escape pre-step.
764
+ #[test]
765
+ fn e46_inject_text_enter_emits_escape_before_enter_on_bracketed_paste() {
766
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_esc]";
767
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
768
+ let _report = be
769
+ .inject(
770
+ &Target::Pane(PaneId::new("%7")),
771
+ &InjectPayload::Text(token_text.to_string()),
772
+ Key::Enter,
773
+ true,
774
+ )
775
+ .expect("inject runs");
776
+ let calls = rec.lock().unwrap().clone();
777
+ let escape_idx = calls.iter().position(|argv| {
778
+ argv.get(1).map(String::as_str) == Some("send-keys")
779
+ && argv.contains(&"Escape".to_string())
780
+ });
781
+ let enter_idx = calls.iter().position(|argv| {
782
+ argv.get(1).map(String::as_str) == Some("send-keys")
783
+ && argv.contains(&"Enter".to_string())
784
+ });
785
+ let escape_idx = escape_idx.expect("E46: bracketed text + Enter must send Escape pre-step");
786
+ let enter_idx = enter_idx.expect("E46: must still send the final Enter");
787
+ assert!(
788
+ escape_idx < enter_idx,
789
+ "E46: Escape must be sent BEFORE Enter (closes bracketed-paste \
790
+ so Enter is interpreted as submit). escape_idx={escape_idx} \
791
+ enter_idx={enter_idx} calls={calls:?}"
792
+ );
793
+ }
794
+
795
+ /// **E46 regression guard**: non-Enter submit (codex menu navigation,
796
+ /// `Key::Down`) does NOT trigger the Escape pre-step (would interfere
797
+ /// with the menu).
798
+ #[test]
799
+ fn e46_inject_text_non_enter_submit_does_not_emit_escape_prestep() {
800
+ let token_text = "menu interaction\n[team-agent-token:msg_down]";
801
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
802
+ let _report = be
803
+ .inject(
804
+ &Target::Pane(PaneId::new("%7")),
805
+ &InjectPayload::Text(token_text.to_string()),
806
+ Key::Down,
807
+ true,
808
+ )
809
+ .expect("inject runs");
810
+ let calls = rec.lock().unwrap().clone();
811
+ let escape_count = calls
812
+ .iter()
813
+ .filter(|argv| {
814
+ argv.get(1).map(String::as_str) == Some("send-keys")
815
+ && argv.contains(&"Escape".to_string())
816
+ })
817
+ .count();
818
+ assert_eq!(
819
+ escape_count, 0,
820
+ "E46 regression guard: Key::Down submits (codex menu navigation) \
821
+ must NOT receive Escape pre-step. Got {escape_count}. \
822
+ calls={calls:?}"
823
+ );
824
+ }
825
+
575
826
  #[test]
576
827
  fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
577
828
  let (be, rec) = backend_with(