@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.
@@ -298,6 +298,11 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
298
298
  team_entry_from_state(state, team_key).and_then(Value::as_object).cloned().unwrap_or_default();
299
299
  let mut p = entry_obj.clone(); // projection = deepcopy(entry)
300
300
  // setdefault session_name/team_dir ← entry.get(...)(缺则插,值可为 null)。
301
+ // **0.3.24 excision** (U1-A real-machine RED v2): the ff38ab9 root-state
302
+ // `or_else` fallback was reverted along with the wave-2 U1-A drift fallback,
303
+ // since this projection change had no other consumer. v0.3.25 will re-introduce
304
+ // this together with the writer-shape + rediscover-writer fix — see
305
+ // `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
301
306
  if !p.contains_key("session_name") {
302
307
  p.insert("session_name".to_string(), entry_obj.get("session_name").cloned().unwrap_or(Value::Null));
303
308
  }
@@ -526,6 +526,303 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
526
526
  );
527
527
  }
528
528
 
529
+ #[test]
530
+ fn inject_waits_for_token_visibility_before_enter() {
531
+ let text = "hello [team-agent-token:e31]".to_string();
532
+ let marker_visible = format!("{text}\n");
533
+ let (be, rec) = backend_with(
534
+ MockResp::Out(ok("")),
535
+ vec![
536
+ MockResp::Out(ok("")), // set-buffer
537
+ MockResp::Out(ok("")), // paste-buffer
538
+ MockResp::Out(ok("")), // delete-buffer
539
+ MockResp::Out(ok("")), // pasted-content prompt poll 1
540
+ MockResp::Out(ok("")), // pasted-content prompt poll 2
541
+ MockResp::Out(ok("")), // pasted-content prompt poll 3
542
+ MockResp::Out(ok("")), // pasted-content prompt poll 4
543
+ MockResp::Out(ok("")), // pasted-content prompt poll 5
544
+ MockResp::Out(ok("")), // token gate: not visible yet
545
+ MockResp::Out(ok("")), // token gate: still not visible
546
+ MockResp::Out(ok(&marker_visible)), // token gate: visible, Enter may fire
547
+ MockResp::Out(ok("")), // send-keys Enter
548
+ ],
549
+ );
550
+
551
+ let report = be
552
+ .inject(&Target::Pane(PaneId::new("%7")), &InjectPayload::Text(text), Key::Enter, true)
553
+ .expect("inject");
554
+ let calls = rec.lock().unwrap().clone();
555
+ let submit_index = calls
556
+ .iter()
557
+ .position(|argv| argv.get(1).map(String::as_str) == Some("send-keys"))
558
+ .expect("inject must eventually send Enter");
559
+ let captures_before_submit = calls[..submit_index]
560
+ .iter()
561
+ .filter(|argv| argv.get(1).map(String::as_str) == Some("capture-pane"))
562
+ .count();
563
+
564
+ assert!(
565
+ captures_before_submit >= super::PASTED_CONTENT_APPEAR_POLLS as usize + 3,
566
+ "Enter must wait until the pasted token is visible; calls={calls:?}"
567
+ );
568
+ assert_eq!(report.inject_verification, InjectVerification::CaptureContainsToken);
569
+ assert_eq!(
570
+ report.submit_verification,
571
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
572
+ );
573
+ }
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
+
529
826
  #[test]
530
827
  fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
531
828
  let (be, rec) = backend_with(
@@ -845,16 +845,28 @@ fn token_visible_in_capture(
845
845
  }
846
846
  }
847
847
 
848
- /// U1 #7: capture the pane just before submit and report whether the just-pasted
849
- /// token marker is actually visible. `Ok(None)` for non-token payloads (nothing to
850
- /// check). `Ok(Some(false))` means the paste silently dropped a false-positive that
851
- /// the static `inject_verification_for_payload` would have reported as success.
848
+ /// U1 #7 / E31: wait briefly for the just-pasted token marker before submitting.
849
+ /// `Ok(None)` for non-token payloads (nothing to check). `Ok(Some(false))` means the
850
+ /// paste did not become visible before the Python-parity fallback delay.
852
851
  fn pre_submit_token_visible(
853
852
  backend: &TmuxBackend,
854
853
  target: &Target,
855
854
  payload: &InjectPayload,
856
855
  ) -> Result<Option<bool>, TransportError> {
857
- token_visible_in_capture(backend, target, payload)
856
+ if payload_token_marker(payload).is_none() {
857
+ return Ok(None);
858
+ }
859
+ for attempt in 0..PASTED_CONTENT_APPEAR_POLLS {
860
+ if let Some(true) = token_visible_in_capture(backend, target, payload)? {
861
+ return Ok(Some(true));
862
+ }
863
+ if attempt + 1 < PASTED_CONTENT_APPEAR_POLLS {
864
+ std::thread::sleep(Duration::from_millis(25));
865
+ }
866
+ }
867
+ // Python waits 250ms between paste-buffer and Enter to let bracketed paste settle.
868
+ std::thread::sleep(Duration::from_millis(250));
869
+ Ok(Some(false))
858
870
  }
859
871
 
860
872
  const TOKEN_POST_SUBMIT_READBACK_POLLS: u32 = 5;
@@ -915,6 +927,51 @@ fn capture_has_pasted_content_prompt(text: &str) -> bool {
915
927
  const PASTED_CONTENT_APPEAR_POLLS: u32 = 5;
916
928
  const PASTED_CONTENT_SUBMIT_ATTEMPTS: u32 = 3;
917
929
 
930
+ /// E46 (0.3.24 bug#5): bounded resend cap for post-Enter consumption probe.
931
+ /// Mirrors PASTED_CONTENT_SUBMIT_ATTEMPTS shape: try Enter then bounded
932
+ /// re-checks of the pane's input region. Each iteration first re-checks that
933
+ /// the input still has content before resending Enter — guards against double
934
+ /// submission when the first Enter was consumed but our readback was slow.
935
+ const POST_SUBMIT_CONSUMPTION_ATTEMPTS: u32 = 3;
936
+ const POST_SUBMIT_CONSUMPTION_POLL_MS: u64 = 60;
937
+
938
+ /// E46 (0.3.24 bug#5, C5 provider-agnostic detector): the pane's input region
939
+ /// is "consumed" when the token text that was just visible BEFORE the Enter
940
+ /// is no longer present in the captured tail. Structural signal — no
941
+ /// provider-specific UI string. Works across claude / codex / copilot because
942
+ /// every provider's composer clears the input area after a successful submit
943
+ /// (the content scrolls into history, leaving the prompt empty).
944
+ ///
945
+ /// Returns:
946
+ /// * `Some(true)` — token was visible BEFORE submit and is GONE from
947
+ /// the visible input area now → consumption confirmed.
948
+ /// * `Some(false)` — token still visible (or other reason to think not yet
949
+ /// consumed).
950
+ /// * `None` — payload has no token marker (peer message without token,
951
+ /// empty payload) so we can't structurally check; caller treats this as
952
+ /// non-blocking (the pre-existing `EnterSentWithoutPlaceholderCheck`
953
+ /// path).
954
+ fn post_submit_input_consumed(
955
+ backend: &TmuxBackend,
956
+ target: &Target,
957
+ payload: &InjectPayload,
958
+ ) -> Result<Option<bool>, TransportError> {
959
+ let Some(marker) = payload_token_marker(payload) else {
960
+ return Ok(None);
961
+ };
962
+ let captured = backend.capture(target, CaptureRange::Tail(30))?;
963
+ // The token may legitimately appear in scrollback (a successful submit
964
+ // pushes it into history). We only treat the BOTTOM-of-pane region (last
965
+ // few lines, where the input area lives) as the consumption signal. Tail
966
+ // 30 lines is small enough that the input area still dominates if the
967
+ // submit didn't go through, while a successful submit has pushed the
968
+ // token marker out of the bottom 5 lines by the time the response
969
+ // composer redraws.
970
+ let tail_lines: Vec<&str> = captured.text.lines().rev().take(5).collect();
971
+ let token_in_tail = tail_lines.iter().any(|line| line.contains(marker));
972
+ Ok(Some(!token_in_tail))
973
+ }
974
+
918
975
  fn shell_command(
919
976
  argv: &[String],
920
977
  cwd: &Path,
@@ -1105,11 +1162,94 @@ impl Transport for TmuxBackend {
1105
1162
  // downgrade to CaptureMissingToken.
1106
1163
  token_visible_for_report =
1107
1164
  pre_submit_token_visible(self, target, payload).unwrap_or(None);
1165
+ // E46 (0.3.24 bug#5, demo-director卡 bracketed paste root cause):
1166
+ // bracketed-paste-mode on a fresh provider TUI swallows the framework's
1167
+ // Enter as paste content. Send `Escape` first to exit the paste bracket
1168
+ // so the subsequent Enter is interpreted as submit. Safe across
1169
+ // claude/codex/copilot: Escape on an empty composer is a no-op
1170
+ // (cancel any pending mode), it only matters when bracketed-paste is
1171
+ // actually open. Architect-approved + macmini real-machine verified.
1172
+ // Only Enter benefits — explicit menu navigation (Key::Down etc.)
1173
+ // should not pre-cancel mode.
1174
+ if bracketed
1175
+ && matches!(payload, InjectPayload::Text(_))
1176
+ && matches!(submit, Key::Enter)
1177
+ {
1178
+ let escape_argv = tmux_send_keys_argv(&pane, &[Key::Escape]);
1179
+ // We don't fail the whole inject on Escape error — Escape failure
1180
+ // would surface downstream as the same SubmitConsumptionUnverified
1181
+ // when post-Enter probe still sees the token in the input region.
1182
+ let _ = self.run_inject_stage(&escape_argv, InjectStage::Submit);
1183
+ }
1108
1184
  self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
1109
1185
  if matches!(token_visible_for_report, Some(false)) {
1110
1186
  token_visible_for_report =
1111
1187
  post_submit_token_visible(self, target, payload).unwrap_or(Some(false));
1112
1188
  }
1189
+ // E46 C2 + C3: post-Enter consumption gate with bounded resend cap.
1190
+ // Only fires for:
1191
+ // - submit key == Enter (other keys like Down/Up are explicit
1192
+ // menu navigation; codex uses `Down Enter` to dismiss a
1193
+ // prompt — those carry their own verification semantics
1194
+ // via KeySentAfterVisibleToken),
1195
+ // - token-bearing Text payloads (the structural input-empty
1196
+ // signal needs a token marker to anchor on).
1197
+ // Each iteration RE-CHECKS that the input still contains the
1198
+ // token BEFORE resending Enter — guards against the C3
1199
+ // double-submit hazard when the first Enter was consumed but
1200
+ // our readback was slow to catch the empty-input state.
1201
+ let mut consumption_attempts: u32 = 1;
1202
+ let mut consumed = if matches!(submit, Key::Enter) {
1203
+ post_submit_input_consumed(self, target, payload).unwrap_or(None)
1204
+ } else {
1205
+ None
1206
+ };
1207
+ if matches!(consumed, Some(false)) {
1208
+ for _ in 1..POST_SUBMIT_CONSUMPTION_ATTEMPTS {
1209
+ std::thread::sleep(Duration::from_millis(
1210
+ POST_SUBMIT_CONSUMPTION_POLL_MS,
1211
+ ));
1212
+ // C3 critical: re-check BEFORE resending. If the input
1213
+ // is now empty (= first Enter actually consumed, just
1214
+ // observable now), DO NOT resend — bumping a spurious
1215
+ // empty Enter would open an empty turn.
1216
+ consumed = post_submit_input_consumed(self, target, payload)
1217
+ .unwrap_or(None);
1218
+ if !matches!(consumed, Some(false)) {
1219
+ break;
1220
+ }
1221
+ consumption_attempts += 1;
1222
+ let _ = self.run_inject_stage(&submit_argv, InjectStage::Submit);
1223
+ }
1224
+ }
1225
+ let submit_verification = match consumed {
1226
+ // Consumption confirmed → MUST-10 path: keep the canonical
1227
+ // EnterSentWithoutPlaceholderCheck so the existing
1228
+ // provider_submit_verification_red contract holds.
1229
+ Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
1230
+ // Consumption explicitly NOT observed (token still in tail
1231
+ // after resend cap) → E46's new variant. Delivery treats
1232
+ // as not delivered.
1233
+ Some(false) => SubmitVerification::SubmitConsumptionUnverified,
1234
+ // No structural anchor (non-token payload) → preserve the
1235
+ // historic non-checked variant; the pre-existing
1236
+ // pre/post-submit token readback + U1 #7 cover the other
1237
+ // false-positive (silent paste drop).
1238
+ None => submit_verification_for_key(submit),
1239
+ };
1240
+ return Ok(InjectReport {
1241
+ stage_reached: InjectStage::Submit,
1242
+ inject_verification: inject_verification_after_readback(
1243
+ payload,
1244
+ token_visible_for_report,
1245
+ ),
1246
+ submit_verification,
1247
+ turn_verification: match payload {
1248
+ InjectPayload::Empty => TurnVerification::NotRequired,
1249
+ InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1250
+ },
1251
+ attempts: consumption_attempts,
1252
+ });
1113
1253
  }
1114
1254
  }
1115
1255
  Ok(InjectReport {
@@ -37,6 +37,18 @@ struct OfflineState {
37
37
  inject_targets: Vec<Target>,
38
38
  inject_payloads: Vec<String>,
39
39
  tmux_endpoint: Option<String>,
40
+ /// U1-B contract: when set, `list_targets()` returns `TransportError::MuxUnavailable`
41
+ /// — used to model tmux server jitter (subprocess-fork-failed level). The whole
42
+ /// resolve must DEFER, not silently coerce to an empty vec.
43
+ list_targets_error: Option<String>,
44
+ /// U1-C / general: pre-staged `capture()` payload, keyed by target stringification.
45
+ /// `Target::Pane(p)` keys as `p.as_str()`; `Target::SessionWindow{session,window}`
46
+ /// keys as `format!("{session}:{window}")`. A miss returns empty text (current
47
+ /// default behaviour).
48
+ capture_text: BTreeMap<String, String>,
49
+ /// U1-C Tail-peek contract: every `capture()` call records its `CaptureRange`,
50
+ /// in order, so a test can prove the delivery peek site narrowed Full → Tail(80).
51
+ capture_ranges: Vec<CaptureRange>,
40
52
  }
41
53
 
42
54
  impl Default for OfflineState {
@@ -57,6 +69,9 @@ impl Default for OfflineState {
57
69
  inject_targets: Vec::new(),
58
70
  inject_payloads: Vec::new(),
59
71
  tmux_endpoint: None,
72
+ list_targets_error: None,
73
+ capture_text: BTreeMap::new(),
74
+ capture_ranges: Vec::new(),
60
75
  }
61
76
  }
62
77
  }
@@ -127,6 +142,40 @@ impl OfflineTransport {
127
142
  self
128
143
  }
129
144
 
145
+ /// U1-B: stage a `list_targets()` error to model tmux server jitter. While set,
146
+ /// every call returns `Err(TransportError::MuxUnavailable{detail})`. Pass an
147
+ /// empty string to clear via re-builder if needed.
148
+ pub fn with_list_targets_error(self, detail: impl Into<String>) -> Self {
149
+ self.with_state(|state| state.list_targets_error = Some(detail.into()));
150
+ self
151
+ }
152
+
153
+ /// Pre-stage `capture()` output for a `Target::Pane(pane_id)` key.
154
+ pub fn with_capture_for_pane(
155
+ self,
156
+ pane_id: impl Into<String>,
157
+ text: impl Into<String>,
158
+ ) -> Self {
159
+ self.with_state(|state| {
160
+ state.capture_text.insert(pane_id.into(), text.into());
161
+ });
162
+ self
163
+ }
164
+
165
+ /// Pre-stage `capture()` output for a `Target::SessionWindow{session,window}` key.
166
+ pub fn with_capture_for_session_window(
167
+ self,
168
+ session: impl Into<String>,
169
+ window: impl Into<String>,
170
+ text: impl Into<String>,
171
+ ) -> Self {
172
+ let key = format!("{}:{}", session.into(), window.into());
173
+ self.with_state(|state| {
174
+ state.capture_text.insert(key, text.into());
175
+ });
176
+ self
177
+ }
178
+
130
179
  pub fn calls(&self) -> Vec<&'static str> {
131
180
  self.with_state(|state| state.calls.clone())
132
181
  }
@@ -163,6 +212,12 @@ impl OfflineTransport {
163
212
  self.with_state(|state| state.inject_payloads.clone())
164
213
  }
165
214
 
215
+ /// All `capture()` ranges observed, in call order. Used by U1-C Tail-peek
216
+ /// contracts to prove the delivery peek site requested Tail rather than Full.
217
+ pub fn capture_ranges(&self) -> Vec<CaptureRange> {
218
+ self.with_state(|state| state.capture_ranges.clone())
219
+ }
220
+
166
221
  fn record(&self, call: &'static str) {
167
222
  self.with_state(|state| state.calls.push(call));
168
223
  }
@@ -294,11 +349,21 @@ impl Transport for OfflineTransport {
294
349
 
295
350
  fn capture(
296
351
  &self,
297
- _target: &Target,
352
+ target: &Target,
298
353
  range: CaptureRange,
299
354
  ) -> Result<CapturedText, TransportError> {
300
- self.record("capture");
301
- Ok(CapturedText { text: String::new(), range })
355
+ let key = match target {
356
+ Target::Pane(p) => p.as_str().to_string(),
357
+ Target::SessionWindow { session, window } => {
358
+ format!("{}:{}", session.as_str(), window.as_str())
359
+ }
360
+ };
361
+ let text = self.with_state(|state| {
362
+ state.calls.push("capture");
363
+ state.capture_ranges.push(range);
364
+ state.capture_text.get(&key).cloned().unwrap_or_default()
365
+ });
366
+ Ok(CapturedText { text, range })
302
367
  }
303
368
 
304
369
  fn query(
@@ -329,10 +394,18 @@ impl Transport for OfflineTransport {
329
394
  }
330
395
 
331
396
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
332
- Ok(self.with_state(|state| {
397
+ // U1-B: when `with_list_targets_error` is set, return a real Err so the
398
+ // caller sees server jitter rather than a coerced empty vec.
399
+ if let Some(detail) = self.with_state(|state| {
333
400
  state.calls.push("list_targets");
334
- state.targets.clone()
335
- }))
401
+ state.list_targets_error.clone()
402
+ }) {
403
+ return Err(TransportError::MuxUnavailable {
404
+ backend: BackendKind::Tmux,
405
+ detail,
406
+ });
407
+ }
408
+ Ok(self.with_state(|state| state.targets.clone()))
336
409
  }
337
410
 
338
411
  fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {