@team-agent/installer 0.5.66 → 0.5.68

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 (164) hide show
  1. package/Cargo.lock +8 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/cli/adapters.rs +5 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +82 -0
  6. package/crates/team-agent/src/cli/emit.rs +3 -3
  7. package/crates/team-agent/src/cli/grok_slot.rs +299 -0
  8. package/crates/team-agent/src/cli/leader.rs +21 -7
  9. package/crates/team-agent/src/cli/leaders.rs +125 -1
  10. package/crates/team-agent/src/cli/mod.rs +20 -0
  11. package/crates/team-agent/src/cli/send/presentation.rs +7 -1
  12. package/crates/team-agent/src/cli/spec.rs +2 -0
  13. package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
  14. package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
  15. package/crates/team-agent/src/cli/tests/base.rs +26 -3
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
  17. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
  18. package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
  19. package/crates/team-agent/src/communication_mode/mod.rs +6 -4
  20. package/crates/team-agent/src/compiler.rs +59 -0
  21. package/crates/team-agent/src/coordinator/backoff.rs +55 -0
  22. package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
  23. package/crates/team-agent/src/coordinator/health.rs +173 -0
  24. package/crates/team-agent/src/coordinator/mod.rs +31 -0
  25. package/crates/team-agent/src/coordinator/orphan.rs +31 -0
  26. package/crates/team-agent/src/coordinator/runtime_detectors.rs +30 -0
  27. package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
  29. package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
  30. package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
  31. package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
  32. package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
  33. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
  34. package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
  35. package/crates/team-agent/src/coordinator/tick.rs +133 -22
  36. package/crates/team-agent/src/coordinator/types.rs +49 -0
  37. package/crates/team-agent/src/db/message_store.rs +39 -5
  38. package/crates/team-agent/src/layout/worker_env.rs +20 -3
  39. package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
  40. package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
  41. package/crates/team-agent/src/leader/registry.rs +65 -0
  42. package/crates/team-agent/src/leader/start.rs +920 -63
  43. package/crates/team-agent/src/lifecycle/display.rs +56 -0
  44. package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
  45. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +332 -12
  46. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +62 -0
  47. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +59 -0
  48. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
  49. package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
  50. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +332 -0
  51. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -456
  52. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  53. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  54. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  55. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  56. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  57. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
  58. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  59. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  60. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  61. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
  62. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  63. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  64. package/crates/team-agent/src/lifecycle/launch/spawn.rs +62 -2
  65. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +139 -0
  66. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
  67. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  68. package/crates/team-agent/src/lifecycle/launch.rs +99 -29
  69. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  70. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  71. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  72. package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
  73. package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
  74. package/crates/team-agent/src/lifecycle/restart/agent.rs +124 -0
  75. package/crates/team-agent/src/lifecycle/restart/common.rs +369 -11
  76. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  77. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  78. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
  79. package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
  80. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  81. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  82. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  83. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
  84. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  85. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
  86. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
  87. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
  88. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
  89. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
  90. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  91. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
  92. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  93. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  94. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
  95. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  96. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  97. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
  98. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
  99. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
  100. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  101. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
  102. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
  103. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +2 -0
  104. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
  105. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
  106. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  107. package/crates/team-agent/src/lifecycle/tests.rs +23 -13
  108. package/crates/team-agent/src/lifecycle/types.rs +61 -0
  109. package/crates/team-agent/src/lifecycle/worker_command_context.rs +85 -11
  110. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  111. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  112. package/crates/team-agent/src/messaging/delivery.rs +405 -40
  113. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  114. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  115. package/crates/team-agent/src/messaging/results.rs +4 -0
  116. package/crates/team-agent/src/messaging/send.rs +34 -0
  117. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  118. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  119. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  120. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  121. package/crates/team-agent/src/messaging/types.rs +4 -1
  122. package/crates/team-agent/src/model/enums.rs +14 -5
  123. package/crates/team-agent/src/model/permissions.rs +3 -0
  124. package/crates/team-agent/src/os_probe.rs +73 -2
  125. package/crates/team-agent/src/provider/adapter.rs +231 -4
  126. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
  127. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  128. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  129. package/crates/team-agent/src/provider/bypass_flags.rs +46 -2
  130. package/crates/team-agent/src/provider/classify.rs +4 -2
  131. package/crates/team-agent/src/provider/faults.rs +2 -1
  132. package/crates/team-agent/src/provider/mod.rs +11 -0
  133. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  134. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  135. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  136. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  137. package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
  138. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  139. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  140. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  141. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  142. package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
  143. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  144. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  145. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  146. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  147. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  148. package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
  149. package/crates/team-agent/src/provider/types.rs +4 -2
  150. package/crates/team-agent/src/provider/wire.rs +23 -1
  151. package/crates/team-agent/src/state/persist.rs +301 -8
  152. package/crates/team-agent/src/state/repository.rs +5 -0
  153. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  154. package/crates/team-agent/src/tmux_backend.rs +1064 -75
  155. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  156. package/crates/team-agent/src/transport.rs +248 -1
  157. package/npm/install.mjs +129 -73
  158. package/package.json +4 -4
  159. package/skills/team-agent/SKILL.md +33 -238
  160. package/skills/team-agent/command-coverage.json +31 -0
  161. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  162. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
  163. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  164. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
@@ -10,17 +10,21 @@
10
10
  use std::collections::{BTreeMap, VecDeque};
11
11
  use std::os::unix::net::UnixListener;
12
12
  use std::path::Path;
13
+ use std::sync::atomic::{AtomicBool, Ordering};
13
14
  use std::sync::{Arc, Mutex};
15
+ use std::time::{Duration, Instant};
14
16
 
15
17
  use super::{
18
+ current_paste_to_submit_floor, observe_turn_from_capture,
19
+ sleep_remaining_paste_to_submit_floor, with_cursor_single_enter, with_paste_to_submit_floor,
16
20
  CommandOutput, CommandRunner, RealCommandRunner, TmuxBackend, PANE_BINDING_NONCE_METADATA_KEY,
17
21
  };
18
22
  use crate::model::enums::PaneLiveness;
19
23
  use crate::transport::{
20
24
  normalize_capture, tmux_capture_argv, tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv,
21
- AttachOutcome, CaptureRange, InjectPayload, InjectStage, InjectVerification, Key, PaneField,
22
- PaneId, SessionName, SetEnvOutcome, SubmitVerification, Target, Transport, TransportError,
23
- TurnVerification, WindowName,
25
+ tmux_submit_key_name, AttachOutcome, CaptureRange, InjectPayload, InjectStage,
26
+ InjectVerification, Key, PaneField, PaneId, SessionName, SetEnvOutcome, SubmitVerification,
27
+ Target, Transport, TransportError, TurnVerification, WindowName,
24
28
  };
25
29
 
26
30
  type RecordedArgv = Arc<Mutex<Vec<Vec<String>>>>;
@@ -761,13 +765,20 @@ fn capture_argv_and_normalizes_scrollback() {
761
765
  // ── 5a. send_keys: argv = tmux_send_keys_argv ──────────────────────────────────────────────────
762
766
  #[test]
763
767
  fn send_keys_argv_matches_builder() {
764
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
768
+ let (be, rec) = backend_with(MockResp::Out(ok("0\n")), vec![]);
765
769
  let pane = PaneId::new("%7");
766
770
  be.send_keys(&Target::Pane(pane.clone()), &[Key::Enter])
767
771
  .expect("send_keys");
772
+ let calls = rec.lock().unwrap().clone();
773
+ let submit = calls.iter().find(|argv| {
774
+ argv.get(1).map(String::as_str) == Some("send-keys")
775
+ && argv.iter().any(|a| a == "Enter")
776
+ && !argv.iter().any(|a| a == "-l")
777
+ });
768
778
  assert_eq!(
769
- rec.lock().unwrap()[0],
770
- tmux_send_keys_argv(&pane, &[Key::Enter])
779
+ submit.cloned().as_ref(),
780
+ Some(&tmux_send_keys_argv(&pane, &[Key::Enter])),
781
+ "Enter 键组仍走 tmux_send_keys_argv;prepare 只允许前缀 mode-query/201~; calls={calls:?}"
771
782
  );
772
783
  }
773
784
 
@@ -800,9 +811,9 @@ fn inject_text_runs_buffer_paste_submit_sequence_and_reports_submit() {
800
811
  "inject must bracketed-paste (-p) the buffer to the pane; got {calls:?}"
801
812
  );
802
813
  assert!(
803
- calls
804
- .iter()
805
- .any(|a| is(a, "send-keys") && a.contains(&"Enter".to_string())),
814
+ calls.iter().any(
815
+ |a| is(a, "send-keys") && a.contains(&tmux_submit_key_name(Key::Enter).to_string())
816
+ ),
806
817
  "inject must send the submit key (Enter) last; got {calls:?}"
807
818
  );
808
819
  assert_eq!(
@@ -927,6 +938,57 @@ fn inject_waits_for_token_visibility_before_enter() {
927
938
  );
928
939
  }
929
940
 
941
+ #[test]
942
+ fn paste_to_submit_floor_default_is_zero_even_if_test_tmp_is_set() {
943
+ assert_eq!(
944
+ current_paste_to_submit_floor(),
945
+ Duration::ZERO,
946
+ "default floor must be ZERO; TEAM_AGENT_TEST_TMP is not this switch"
947
+ );
948
+ }
949
+
950
+ #[test]
951
+ fn paste_to_submit_nonzero_floor_is_actually_slept() {
952
+ let start = Instant::now();
953
+ sleep_remaining_paste_to_submit_floor(start, Duration::from_millis(40));
954
+ let elapsed = start.elapsed();
955
+ assert!(
956
+ elapsed >= Duration::from_millis(40),
957
+ "non-zero floor must sleep the remaining time; elapsed={elapsed:?}"
958
+ );
959
+ }
960
+
961
+ #[test]
962
+ fn paste_to_submit_zero_floor_does_not_sleep() {
963
+ let start = Instant::now();
964
+ sleep_remaining_paste_to_submit_floor(start, Duration::ZERO);
965
+ let elapsed = start.elapsed();
966
+ assert!(
967
+ elapsed < Duration::from_millis(20),
968
+ "ZERO floor must be free for claude/codex/grok; elapsed={elapsed:?}"
969
+ );
970
+ }
971
+
972
+ #[test]
973
+ fn inject_honors_injected_nonzero_paste_to_submit_floor() {
974
+ let (be, _) = backend_with(MockResp::Out(ok("hello")), vec![]);
975
+ let start = Instant::now();
976
+ with_paste_to_submit_floor(Duration::from_millis(40), || {
977
+ be.inject(
978
+ &Target::Pane(PaneId::new("%7")),
979
+ &InjectPayload::Text("hello".to_string()),
980
+ Key::Enter,
981
+ true,
982
+ )
983
+ .expect("inject");
984
+ });
985
+ let elapsed = start.elapsed();
986
+ assert!(
987
+ elapsed >= Duration::from_millis(40),
988
+ "inject must take the injected cursor floor; elapsed={elapsed:?}"
989
+ );
990
+ }
991
+
930
992
  #[test]
931
993
  fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
932
994
  let text = "hello leader [team-agent-token:skip]";
@@ -949,7 +1011,7 @@ fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
949
1011
  assert!(
950
1012
  calls.iter().any(|argv| {
951
1013
  argv.get(1).map(String::as_str) == Some("send-keys")
952
- && argv.contains(&"Enter".to_string())
1014
+ && argv.contains(&tmux_submit_key_name(Key::Enter).to_string())
953
1015
  }),
954
1016
  "skip-consumption payload must still submit once; calls={calls:?}"
955
1017
  );
@@ -977,11 +1039,10 @@ fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
977
1039
  // no longer in the bottom 5 lines of the pane = composer cleared).
978
1040
  // ═════════════════════════════════════════════════════════════════════════════
979
1041
 
980
- /// 0.3.30 false-negative fix: token seen during post-submit consumption
981
- /// polling proves the paste landed after Enter was sent. If it never
982
- /// scrolls away, that is slow provider output, not transport failure.
1042
+ /// ledger.inject-fix: paste landing is not submit. Token still in the
1043
+ /// composer and no Working signal SubmitConsumptionUnverified.
983
1044
  #[test]
984
- fn e46_post_submit_matched_token_without_scroll_is_verified() {
1045
+ fn e46_post_submit_matched_token_without_scroll_is_unverified() {
985
1046
  let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
986
1047
  let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
987
1048
  let report = be
@@ -994,16 +1055,20 @@ fn e46_post_submit_matched_token_without_scroll_is_verified() {
994
1055
  .expect("inject runs");
995
1056
  assert_eq!(
996
1057
  report.submit_verification,
997
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
998
- "0.3.30: post-submit matched=true is delivery proof even when \
999
- the token stays in the bottom capture window. Got {:?}",
1058
+ SubmitVerification::SubmitConsumptionUnverified,
1059
+ "token still in composer and no Working signal must be unverified, \
1060
+ not EnterSentWithoutPlaceholderCheck. Got {:?}",
1000
1061
  report.submit_verification
1001
1062
  );
1002
- assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
1063
+ assert_eq!(
1064
+ report.turn_verification,
1065
+ TurnVerification::LeaderNewTurnBoundaryMissing,
1066
+ "token still in composer and no busy must report 没开跑, not unknown/started"
1067
+ );
1003
1068
  let diagnostics = report.submit_diagnostics.expect("diagnostics");
1004
1069
  assert!(
1005
1070
  diagnostics.attempts_detail.iter().any(|obs| obs.matched),
1006
- "the positive verdict must be backed by a post-submit matched observation"
1071
+ "the unverified verdict must still record that paste landed"
1007
1072
  );
1008
1073
  }
1009
1074
 
@@ -1026,6 +1091,11 @@ fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
1026
1091
  "busy provider state means the turn is being processed even if \
1027
1092
  the token has not yet scrolled out of the bottom capture"
1028
1093
  );
1094
+ assert_eq!(
1095
+ report.turn_verification,
1096
+ TurnVerification::LeaderNewTurnBoundaryVerified,
1097
+ "live Working signal is 开跑; must not stay not_yet_observed"
1098
+ );
1029
1099
  let diagnostics = report.submit_diagnostics.expect("diagnostics");
1030
1100
  assert!(
1031
1101
  diagnostics
@@ -1041,17 +1111,10 @@ fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
1041
1111
  );
1042
1112
  }
1043
1113
 
1044
- /// 0.3.27: empty pane captures consumption poll sees "no token in
1045
- /// bottom 5" → consumed=true → EnterSentWithoutPlaceholderCheck. This is
1046
- /// the generous default for panes where the capture can't distinguish
1047
- /// "token consumed" from "token never landed" (empty mock, MCP sim).
1048
- /// SubmitConsumptionUnverified only fires when the grace fallback
1049
- /// explicitly rejects (token_visible_for_report=false AND consumed=false
1050
- /// at the same time — a state that requires the token to be in the pane
1051
- /// during consumption poll but absent during Phase 1, which is a
1052
- /// contradictory mock state that doesn't arise in production).
1114
+ /// NeverSeen + 无占位符:空 pane 不得再把「token 不在底部」写成 consumed。
1115
+ /// 旧默认 EnterSentWithoutPlaceholderCheck 就是折叠恒真的同构。
1053
1116
  #[test]
1054
- fn e46_inject_text_with_empty_pane_defaults_to_consumed() {
1117
+ fn e46_inject_text_with_empty_pane_is_unverified() {
1055
1118
  let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_empty]";
1056
1119
  let (be, _rec) = backend_with(MockResp::Out(ok("")), vec![]);
1057
1120
  let report = be
@@ -1064,9 +1127,9 @@ fn e46_inject_text_with_empty_pane_defaults_to_consumed() {
1064
1127
  .expect("inject runs");
1065
1128
  assert_eq!(
1066
1129
  report.submit_verification,
1067
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
1068
- "0.3.27: empty pane consumption poll sees no token in bottom \
1069
- consumed=true EnterSentWithoutPlaceholderCheck. Got {:?}",
1130
+ SubmitVerification::SubmitConsumptionUnverified,
1131
+ "empty pane / token never visible / no pasted #N unverified, \
1132
+ not EnterSentWithoutPlaceholderCheck. Got {:?}",
1070
1133
  report.submit_verification
1071
1134
  );
1072
1135
  }
@@ -1153,7 +1216,7 @@ fn e46_inject_text_resend_rechecks_input_before_resending_to_avoid_double_submit
1153
1216
  .iter()
1154
1217
  .filter(|argv| {
1155
1218
  argv.get(1).map(String::as_str) == Some("send-keys")
1156
- && argv.contains(&"Enter".to_string())
1219
+ && argv.contains(&tmux_submit_key_name(Key::Enter).to_string())
1157
1220
  })
1158
1221
  .count();
1159
1222
  assert_eq!(
@@ -1231,7 +1294,7 @@ fn e46_inject_text_first_attempt_no_escape_retry_only() {
1231
1294
  .iter()
1232
1295
  .filter(|argv| {
1233
1296
  argv.get(1).map(String::as_str) == Some("send-keys")
1234
- && argv.contains(&"Enter".to_string())
1297
+ && argv.contains(&tmux_submit_key_name(Key::Enter).to_string())
1235
1298
  })
1236
1299
  .count();
1237
1300
  let escape_count = calls
@@ -1372,16 +1435,13 @@ fn e50_inject_paste_prompt_path_populates_submit_diagnostics_per_attempt() {
1372
1435
  true,
1373
1436
  )
1374
1437
  .expect("inject");
1375
- // Fix-B: token payload E46 token path. The submit verification is
1376
- // either EnterSentWithoutPlaceholderCheck (consumed) or
1377
- // SubmitConsumptionUnverified (not consumed). With the mock returning
1378
- // pasted-content text (no token in tail), consumed = true.
1438
+ // token 从未可见 + composer 里仍有 pasted content(无 #N)→ 不得报已消费。
1379
1439
  assert_eq!(
1380
1440
  report.submit_verification,
1381
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
1382
- "E50 PR-2 Fix-B: token payload that went through the E46 gate \
1383
- and was consumed (token not in bottom 5 lines) must report \
1384
- EnterSentWithoutPlaceholderCheck; got {:?}",
1441
+ SubmitVerification::SubmitConsumptionUnverified,
1442
+ "folded placeholder still in composer (no #N, token never seen) \
1443
+ must be unverified, not vacuous EnterSentWithoutPlaceholderCheck; \
1444
+ got {:?}",
1385
1445
  report.submit_verification
1386
1446
  );
1387
1447
  let diagnostics = report.submit_diagnostics.expect("diagnostics");
@@ -2003,3 +2063,1638 @@ fn r1_caller_target_uuid_is_first_leader_session_uuid_precedence_seam() {
2003
2063
  .list_targets()
2004
2064
  .expect("live list_targets (caller-target scan precursor)");
2005
2065
  }
2066
+
2067
+ // ═════════════════════════════════════════════════════════════════════════
2068
+ // pasted-signal: 折叠占位符正信号 + token NeverSeen 三态
2069
+ // ═════════════════════════════════════════════════════════════════════════
2070
+
2071
+ struct FoldedPasteRunner {
2072
+ recorded: RecordedArgv,
2073
+ pre_submit: String,
2074
+ post_submit: String,
2075
+ submitted: AtomicBool,
2076
+ }
2077
+
2078
+ impl CommandRunner for FoldedPasteRunner {
2079
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
2080
+ self.recorded.lock().unwrap().push(argv.to_vec());
2081
+ // 投递提交键是 C-m(tmux_send_submit_argv),不是字面量 Enter。
2082
+ if argv.get(1).map(String::as_str) == Some("send-keys")
2083
+ && argv.iter().any(|a| a == "C-m" || a == "Enter")
2084
+ {
2085
+ self.submitted.store(true, Ordering::SeqCst);
2086
+ }
2087
+ let stdout = match argv.get(1).map(String::as_str) {
2088
+ Some("capture-pane") => {
2089
+ if self.submitted.load(Ordering::SeqCst) {
2090
+ self.post_submit.clone()
2091
+ } else {
2092
+ self.pre_submit.clone()
2093
+ }
2094
+ }
2095
+ Some("display-message") => "0\n".to_string(),
2096
+ _ => String::new(),
2097
+ };
2098
+ Ok(ok(&stdout))
2099
+ }
2100
+
2101
+ fn run_with_stdin(
2102
+ &self,
2103
+ argv: &[String],
2104
+ _stdin: &str,
2105
+ ) -> Result<CommandOutput, std::io::Error> {
2106
+ self.run(argv)
2107
+ }
2108
+ }
2109
+
2110
+ fn backend_folded(pre_submit: &str, post_submit: &str) -> (TmuxBackend, RecordedArgv) {
2111
+ let recorded = Arc::new(Mutex::new(Vec::new()));
2112
+ let runner = FoldedPasteRunner {
2113
+ recorded: Arc::clone(&recorded),
2114
+ pre_submit: pre_submit.to_string(),
2115
+ post_submit: post_submit.to_string(),
2116
+ submitted: AtomicBool::new(false),
2117
+ };
2118
+ (TmuxBackend::with_runner(Box::new(runner)), recorded)
2119
+ }
2120
+
2121
+ /// 修复前尺子:token 不在底部 15 行 ⇒ consumed。折叠占位符会打中这条恒真。
2122
+ fn unfixed_vacuous_consumed(text: &str, marker: &str) -> bool {
2123
+ !super::token_in_bottom_n(text, marker, 15)
2124
+ }
2125
+
2126
+ #[test]
2127
+ fn pasted_signal_unfixed_predicate_treats_folded_placeholder_as_consumed() {
2128
+ let folded = "❯ [Pasted text #1 +8 lines]\n";
2129
+ let marker = "[team-agent-token:msg_fold]";
2130
+ assert!(
2131
+ unfixed_vacuous_consumed(folded, marker),
2132
+ "causal: pre-fix !token_in_bottom_n is vacuously true on a folded composer"
2133
+ );
2134
+ assert_eq!(
2135
+ super::consumption_from_capture(folded, marker, false, Some(super::PasteLatch::HashId(1))),
2136
+ Some(false),
2137
+ "post-fix NeverSeen + #1 still in composer must NOT be consumed"
2138
+ );
2139
+ }
2140
+
2141
+ #[test]
2142
+ fn pasted_signal_never_seen_without_placeholder_is_not_consumed() {
2143
+ let marker = "[team-agent-token:msg_empty]";
2144
+ assert_eq!(
2145
+ super::token_sighting(false, false),
2146
+ super::TokenSighting::NeverSeen
2147
+ );
2148
+ assert_eq!(
2149
+ super::consumption_from_capture("", marker, false, None),
2150
+ Some(false)
2151
+ );
2152
+ }
2153
+
2154
+ #[test]
2155
+ fn pasted_signal_never_seen_placeholder_id_gone_is_consumed() {
2156
+ let marker = "[team-agent-token:msg_fold]";
2157
+ let after = "assistant reply\n❯ \n";
2158
+ assert_eq!(
2159
+ super::consumption_from_capture(after, marker, false, Some(super::PasteLatch::HashId(7))),
2160
+ Some(true),
2161
+ "NeverSeen but latched #7 left composer ⇒ consumed (positive signal)"
2162
+ );
2163
+ }
2164
+
2165
+ #[test]
2166
+ fn pasted_signal_never_seen_wrong_id_is_consumed() {
2167
+ let marker = "[team-agent-token:msg_fold]";
2168
+ let other = "❯ [Pasted text #2 +3 lines]\n";
2169
+ assert_eq!(
2170
+ super::consumption_from_capture(other, marker, false, Some(super::PasteLatch::HashId(1))),
2171
+ Some(true),
2172
+ "our #1 gone, a later #2 in composer still means ours submitted"
2173
+ );
2174
+ }
2175
+
2176
+ #[test]
2177
+ fn pasted_signal_never_seen_no_id_cannot_claim_consumed() {
2178
+ let marker = "[team-agent-token:msg_fold]";
2179
+ let gone = "❯ \n";
2180
+ assert_eq!(
2181
+ super::consumption_from_capture(gone, marker, false, None),
2182
+ Some(false),
2183
+ "no #N latched: disappearance is unverified, not consumed"
2184
+ );
2185
+ }
2186
+
2187
+ #[test]
2188
+ fn pasted_signal_seen_token_then_gone_is_consumed() {
2189
+ let marker = "[team-agent-token:msg_short]";
2190
+ assert_eq!(
2191
+ super::token_sighting(false, true),
2192
+ super::TokenSighting::Gone
2193
+ );
2194
+ assert_eq!(
2195
+ super::consumption_from_capture("❯ \n", marker, true, None),
2196
+ Some(true)
2197
+ );
2198
+ }
2199
+
2200
+ #[test]
2201
+ fn pasted_signal_seen_token_still_present_is_not_consumed() {
2202
+ let marker = "[team-agent-token:msg_short]";
2203
+ let still = format!("❯ ping {marker}\n");
2204
+ assert_eq!(
2205
+ super::token_sighting(true, true),
2206
+ super::TokenSighting::Visible
2207
+ );
2208
+ assert_eq!(
2209
+ super::consumption_from_capture(&still, marker, true, None),
2210
+ Some(false)
2211
+ );
2212
+ }
2213
+
2214
+ #[test]
2215
+ fn pasted_signal_marker_some_observation_records_composer_placeholder() {
2216
+ use crate::transport::CapturedText;
2217
+ let cap = CapturedText {
2218
+ text: "❯ [Pasted text #4 +12 lines]\n".to_string(),
2219
+ range: CaptureRange::Tail(40),
2220
+ };
2221
+ let obs = super::submit_attempt_observation(1, &cap, Some("[team-agent-token:msg_x]"), 10);
2222
+ assert!(
2223
+ obs.matched,
2224
+ "marker Some must still walk pasted_prompt_match / composer placeholder; obs={obs:?}"
2225
+ );
2226
+ assert_eq!(obs.matched_literal.as_deref(), Some("pasted text"));
2227
+ }
2228
+
2229
+ #[test]
2230
+ fn pasted_signal_inject_folded_enter_not_effective_is_unverified() {
2231
+ let token_text = "Team Agent message from leader:\nline1\nline2\nline3\nline4\nline5\n\n[team-agent-token:msg_fold_stay]";
2232
+ let placeholder = "❯ [Pasted text #4 +6 lines]\n";
2233
+ let (be, _rec) = backend_folded(placeholder, placeholder);
2234
+ let report = be
2235
+ .inject(
2236
+ &Target::Pane(PaneId::new("%7")),
2237
+ &InjectPayload::Text(token_text.to_string()),
2238
+ Key::Enter,
2239
+ true,
2240
+ )
2241
+ .expect("inject");
2242
+ assert_eq!(
2243
+ report.submit_verification,
2244
+ SubmitVerification::SubmitConsumptionUnverified,
2245
+ "folded + Enter not effective (#4 stays) must be unverified; got {:?}",
2246
+ report.submit_verification
2247
+ );
2248
+ }
2249
+
2250
+ #[test]
2251
+ fn pasted_signal_inject_folded_enter_effective_is_consumed() {
2252
+ let token_text = "Team Agent message from leader:\nline1\nline2\nline3\nline4\nline5\n\n[team-agent-token:msg_fold_go]";
2253
+ let placeholder = "❯ [Pasted text #4 +6 lines]\n";
2254
+ // 只有 #4 离开 composer;不加 Working,否则 busy 升级会让半三不经正信号就绿。
2255
+ let after = "assistant reply\n❯ \n";
2256
+ let (be, _rec) = backend_folded(placeholder, after);
2257
+ let report = be
2258
+ .inject(
2259
+ &Target::Pane(PaneId::new("%7")),
2260
+ &InjectPayload::Text(token_text.to_string()),
2261
+ Key::Enter,
2262
+ true,
2263
+ )
2264
+ .expect("inject");
2265
+ assert_eq!(
2266
+ report.submit_verification,
2267
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
2268
+ "folded + Enter effective (#4 left composer) must be consumed; got {:?}",
2269
+ report.submit_verification
2270
+ );
2271
+ }
2272
+
2273
+ #[test]
2274
+ fn pasted_signal_inject_short_token_visible_consumed_matches_pre_fix() {
2275
+ let token_text = "ping [team-agent-token:msg_short_ok]";
2276
+ let (be, _rec) = backend_folded(token_text, "❯ \n");
2277
+ let report = be
2278
+ .inject(
2279
+ &Target::Pane(PaneId::new("%7")),
2280
+ &InjectPayload::Text(token_text.to_string()),
2281
+ Key::Enter,
2282
+ true,
2283
+ )
2284
+ .expect("inject");
2285
+ assert_eq!(
2286
+ report.submit_verification,
2287
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
2288
+ "short visible token then gone must stay consumed; got {:?}",
2289
+ report.submit_verification
2290
+ );
2291
+ }
2292
+
2293
+ #[test]
2294
+ fn pasted_signal_inject_short_token_visible_unconsumed_matches_pre_fix() {
2295
+ let token_text = "ping [team-agent-token:msg_short_stay]";
2296
+ let (be, _rec) = backend_folded(token_text, token_text);
2297
+ let report = be
2298
+ .inject(
2299
+ &Target::Pane(PaneId::new("%7")),
2300
+ &InjectPayload::Text(token_text.to_string()),
2301
+ Key::Enter,
2302
+ true,
2303
+ )
2304
+ .expect("inject");
2305
+ assert_eq!(
2306
+ report.submit_verification,
2307
+ SubmitVerification::SubmitConsumptionUnverified,
2308
+ "short visible token still in composer must stay unverified; got {:?}",
2309
+ report.submit_verification
2310
+ );
2311
+ }
2312
+
2313
+ fn count_submit_enters(calls: &[Vec<String>]) -> usize {
2314
+ calls
2315
+ .iter()
2316
+ .filter(|argv| {
2317
+ argv.get(1).map(String::as_str) == Some("send-keys")
2318
+ && argv.iter().any(|a| a == "C-m" || a == "Enter")
2319
+ })
2320
+ .count()
2321
+ }
2322
+
2323
+ fn count_paste_stage(calls: &[Vec<String>]) -> usize {
2324
+ calls
2325
+ .iter()
2326
+ .filter(|argv| {
2327
+ matches!(
2328
+ argv.get(1).map(String::as_str),
2329
+ Some("load-buffer") | Some("set-buffer") | Some("paste-buffer")
2330
+ )
2331
+ })
2332
+ .count()
2333
+ }
2334
+
2335
+ /// Inject runner that reports a fixed `#{pane_mode}` and a token that disappears after C-m.
2336
+ struct ModePasteRunner {
2337
+ recorded: RecordedArgv,
2338
+ pre_submit: String,
2339
+ post_submit: String,
2340
+ submitted: AtomicBool,
2341
+ pane_mode: String,
2342
+ }
2343
+
2344
+ impl CommandRunner for ModePasteRunner {
2345
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
2346
+ self.recorded.lock().unwrap().push(argv.to_vec());
2347
+ if argv.get(1).map(String::as_str) == Some("send-keys")
2348
+ && argv.iter().any(|a| a == "C-m" || a == "Enter")
2349
+ {
2350
+ self.submitted.store(true, Ordering::SeqCst);
2351
+ }
2352
+ let stdout = match argv.get(1).map(String::as_str) {
2353
+ Some("capture-pane") => {
2354
+ if self.submitted.load(Ordering::SeqCst) {
2355
+ self.post_submit.clone()
2356
+ } else {
2357
+ self.pre_submit.clone()
2358
+ }
2359
+ }
2360
+ Some("display-message") => format!("{}\n", self.pane_mode),
2361
+ _ => String::new(),
2362
+ };
2363
+ Ok(ok(&stdout))
2364
+ }
2365
+
2366
+ fn run_with_stdin(
2367
+ &self,
2368
+ argv: &[String],
2369
+ _stdin: &str,
2370
+ ) -> Result<CommandOutput, std::io::Error> {
2371
+ self.run(argv)
2372
+ }
2373
+ }
2374
+
2375
+ fn backend_mode(pane_mode: &str, token_text: &str) -> (TmuxBackend, RecordedArgv) {
2376
+ let recorded = Arc::new(Mutex::new(Vec::new()));
2377
+ let runner = ModePasteRunner {
2378
+ recorded: Arc::clone(&recorded),
2379
+ pre_submit: token_text.to_string(),
2380
+ post_submit: "❯ \n".to_string(),
2381
+ submitted: AtomicBool::new(false),
2382
+ pane_mode: pane_mode.to_string(),
2383
+ };
2384
+ (TmuxBackend::with_runner(Box::new(runner)), recorded)
2385
+ }
2386
+
2387
+ fn first_submit_index(calls: &[Vec<String>]) -> Option<usize> {
2388
+ calls.iter().position(|argv| {
2389
+ argv.get(1).map(String::as_str) == Some("send-keys")
2390
+ && argv.iter().any(|a| a == "C-m" || a == "Enter")
2391
+ })
2392
+ }
2393
+
2394
+ fn inject_send_keys_before_first_enter(calls: &[Vec<String>]) -> Vec<Vec<String>> {
2395
+ let Some(i) = first_submit_index(calls) else {
2396
+ return Vec::new();
2397
+ };
2398
+ calls[..i]
2399
+ .iter()
2400
+ .filter(|argv| argv.get(1).map(String::as_str) == Some("send-keys"))
2401
+ .cloned()
2402
+ .collect()
2403
+ }
2404
+
2405
+ fn assert_no_e55_keys(calls: &[Vec<String>]) {
2406
+ for argv in calls {
2407
+ if argv.get(1).map(String::as_str) != Some("send-keys") {
2408
+ continue;
2409
+ }
2410
+ assert!(
2411
+ !argv
2412
+ .iter()
2413
+ .any(|a| a == "Escape" || a == "C-c" || a == "C-C"),
2414
+ "E55: inject must not send Escape/C-c; argv={argv:?}"
2415
+ );
2416
+ }
2417
+ }
2418
+
2419
+ fn inject_token_for(tag: &str) -> String {
2420
+ format!("ping [team-agent-token:msg_{tag}]")
2421
+ }
2422
+
2423
+ /// PR-18: tree-mode 必须在第一次 Enter 前 send-keys q。修坏:只对 Copy|Unknown cancel。
2424
+ #[test]
2425
+ fn inject_cancels_tree_mode_with_q_before_enter() {
2426
+ let token = inject_token_for("tree_q");
2427
+ let (be, rec) = backend_mode("tree-mode", &token);
2428
+ with_paste_to_submit_floor(Duration::ZERO, || {
2429
+ be.inject(
2430
+ &Target::Pane(PaneId::new("%7")),
2431
+ &InjectPayload::Text(token.clone()),
2432
+ Key::Enter,
2433
+ false,
2434
+ )
2435
+ })
2436
+ .expect("inject");
2437
+ let calls = rec.lock().unwrap().clone();
2438
+ assert_no_e55_keys(&calls);
2439
+ let before = inject_send_keys_before_first_enter(&calls);
2440
+ assert!(
2441
+ before
2442
+ .iter()
2443
+ .any(|argv| argv.iter().any(|a| a == "q") && !argv.iter().any(|a| a == "-X")),
2444
+ "tree-mode must send-keys q before first Enter; before={before:?} calls={calls:?}"
2445
+ );
2446
+ assert!(
2447
+ !before.iter().any(|argv| argv.iter().any(|a| a == "-X")),
2448
+ "tree-mode must not use copy-mode -X cancel; before={before:?}"
2449
+ );
2450
+ assert_eq!(
2451
+ count_submit_enters(&calls),
2452
+ 1,
2453
+ "consumed after cancel+Enter; calls={calls:?}"
2454
+ );
2455
+ }
2456
+
2457
+ /// copy-mode 回归:仍走 -X cancel,不得改成 q。
2458
+ #[test]
2459
+ fn inject_cancels_copy_mode_with_x_cancel_before_enter() {
2460
+ let token = inject_token_for("copy_x");
2461
+ let (be, rec) = backend_mode("copy-mode", &token);
2462
+ with_paste_to_submit_floor(Duration::ZERO, || {
2463
+ be.inject(
2464
+ &Target::Pane(PaneId::new("%7")),
2465
+ &InjectPayload::Text(token.clone()),
2466
+ Key::Enter,
2467
+ false,
2468
+ )
2469
+ })
2470
+ .expect("inject");
2471
+ let calls = rec.lock().unwrap().clone();
2472
+ assert_no_e55_keys(&calls);
2473
+ let before = inject_send_keys_before_first_enter(&calls);
2474
+ assert!(
2475
+ before
2476
+ .iter()
2477
+ .any(|argv| argv.iter().any(|a| a == "-X") && argv.iter().any(|a| a == "cancel")),
2478
+ "copy-mode must keep -X cancel before Enter; before={before:?} calls={calls:?}"
2479
+ );
2480
+ }
2481
+
2482
+ /// pane_mode=0 是输入就绪,不得 cancel。
2483
+ #[test]
2484
+ fn inject_skips_cancel_when_pane_mode_is_zero() {
2485
+ let token = inject_token_for("mode0");
2486
+ let (be, rec) = backend_mode("0", &token);
2487
+ with_paste_to_submit_floor(Duration::ZERO, || {
2488
+ be.inject(
2489
+ &Target::Pane(PaneId::new("%7")),
2490
+ &InjectPayload::Text(token.clone()),
2491
+ Key::Enter,
2492
+ false,
2493
+ )
2494
+ })
2495
+ .expect("inject");
2496
+ let calls = rec.lock().unwrap().clone();
2497
+ assert_no_e55_keys(&calls);
2498
+ let before = inject_send_keys_before_first_enter(&calls);
2499
+ assert!(
2500
+ !before
2501
+ .iter()
2502
+ .any(|argv| argv.iter().any(|a| a == "q" || a == "-X")),
2503
+ "pane_mode=0 must not send mode-cancel; before={before:?}"
2504
+ );
2505
+ // 用户裁定 2026-08-23:不发 ESC,也不发 ESC 的替代品(含字面 CSI 201~)。翻面同上。
2506
+ assert!(
2507
+ !before
2508
+ .iter()
2509
+ .any(|argv| argv.iter().any(|a| a.contains('\u{1b}'))),
2510
+ "mode=0 must not send any ESC/CSI byte before Enter; before={before:?}"
2511
+ );
2512
+ }
2513
+
2514
+ #[test]
2515
+ fn inject_cancels_view_mode_with_q_before_enter() {
2516
+ let token = inject_token_for("view_q");
2517
+ let (be, rec) = backend_mode("view-mode", &token);
2518
+ with_paste_to_submit_floor(Duration::ZERO, || {
2519
+ be.inject(
2520
+ &Target::Pane(PaneId::new("%7")),
2521
+ &InjectPayload::Text(token.clone()),
2522
+ Key::Enter,
2523
+ false,
2524
+ )
2525
+ })
2526
+ .expect("inject");
2527
+ let calls = rec.lock().unwrap().clone();
2528
+ assert_no_e55_keys(&calls);
2529
+ let before = inject_send_keys_before_first_enter(&calls);
2530
+ assert!(
2531
+ before
2532
+ .iter()
2533
+ .any(|argv| argv.last().map(String::as_str) == Some("q")),
2534
+ "view-mode must send-keys q before Enter; before={before:?}"
2535
+ );
2536
+ }
2537
+
2538
+ #[test]
2539
+ fn inject_cancels_client_mode_with_d_before_enter() {
2540
+ let token = inject_token_for("client_d");
2541
+ let (be, rec) = backend_mode("client-mode", &token);
2542
+ with_paste_to_submit_floor(Duration::ZERO, || {
2543
+ be.inject(
2544
+ &Target::Pane(PaneId::new("%7")),
2545
+ &InjectPayload::Text(token.clone()),
2546
+ Key::Enter,
2547
+ false,
2548
+ )
2549
+ })
2550
+ .expect("inject");
2551
+ let calls = rec.lock().unwrap().clone();
2552
+ assert_no_e55_keys(&calls);
2553
+ let before = inject_send_keys_before_first_enter(&calls);
2554
+ assert!(
2555
+ before
2556
+ .iter()
2557
+ .any(|argv| argv.last().map(String::as_str) == Some("d")),
2558
+ "client-mode must send-keys d before Enter; before={before:?}"
2559
+ );
2560
+ }
2561
+
2562
+ /// 用户裁定 2026-08-23:不发 ESC,也不发 ESC 的替代品(含字面 CSI 201~)。
2563
+ /// 发 ESC/CSI 的唯一好处是让消息当场直接上屏;本项目只要求「下一次工具调用时能上屏」
2564
+ /// ⇒ 不存在需要它才能满足的场景,发它没有收益,只有污染 composer 输入的风险。
2565
+ /// 本判据由「必须发 201~」翻面为「Enter 前不得发任何 ESC/CSI 字节」。
2566
+ #[test]
2567
+ fn inject_sends_no_escape_bytes_before_enter() {
2568
+ let token = inject_token_for("paste201");
2569
+ let (be, rec) = backend_mode("0", &token);
2570
+ with_paste_to_submit_floor(Duration::ZERO, || {
2571
+ be.inject(
2572
+ &Target::Pane(PaneId::new("%7")),
2573
+ &InjectPayload::Text(token.clone()),
2574
+ Key::Enter,
2575
+ true,
2576
+ )
2577
+ })
2578
+ .expect("inject");
2579
+ let calls = rec.lock().unwrap().clone();
2580
+ assert_no_e55_keys(&calls);
2581
+ let before = inject_send_keys_before_first_enter(&calls);
2582
+ assert!(
2583
+ !before
2584
+ .iter()
2585
+ .any(|argv| argv.iter().any(|a| a.contains('\u{1b}'))),
2586
+ "no ESC/CSI byte may be sent before Enter; before={before:?}"
2587
+ );
2588
+ }
2589
+
2590
+ /// A1 Empty / trust 路径也必须经过 prepare。
2591
+ #[test]
2592
+ fn empty_inject_cancels_tree_mode_before_cm() {
2593
+ let (be, rec) = backend_mode("tree-mode", "");
2594
+ be.inject(
2595
+ &Target::Pane(PaneId::new("%7")),
2596
+ &InjectPayload::Empty,
2597
+ Key::Enter,
2598
+ false,
2599
+ )
2600
+ .expect("empty inject");
2601
+ let calls = rec.lock().unwrap().clone();
2602
+ assert_no_e55_keys(&calls);
2603
+ let before = inject_send_keys_before_first_enter(&calls);
2604
+ assert!(
2605
+ before
2606
+ .iter()
2607
+ .any(|argv| argv.last().map(String::as_str) == Some("q")),
2608
+ "A1 Empty must cancel tree-mode before C-m; before={before:?} calls={calls:?}"
2609
+ );
2610
+ }
2611
+
2612
+ /// A3 skip-consumption(leader 投递)也必须经过 prepare。
2613
+ #[test]
2614
+ fn skip_consumption_cancels_tree_mode_before_cm() {
2615
+ let token = inject_token_for("skip_tree");
2616
+ let (be, rec) = backend_mode("tree-mode", &token);
2617
+ with_paste_to_submit_floor(Duration::ZERO, || {
2618
+ be.inject(
2619
+ &Target::Pane(PaneId::new("%7")),
2620
+ &InjectPayload::TextSkipConsumptionPoll(token.clone()),
2621
+ Key::Enter,
2622
+ false,
2623
+ )
2624
+ })
2625
+ .expect("skip inject");
2626
+ let calls = rec.lock().unwrap().clone();
2627
+ assert_no_e55_keys(&calls);
2628
+ let before = inject_send_keys_before_first_enter(&calls);
2629
+ assert!(
2630
+ before
2631
+ .iter()
2632
+ .any(|argv| argv.last().map(String::as_str) == Some("q")),
2633
+ "A3 skip-poll must cancel tree-mode before C-m; before={before:?}"
2634
+ );
2635
+ }
2636
+
2637
+ /// A6 send_keys([Enter]) 与 C 块 8 个调用方:含 Enter 的键组先 prepare。
2638
+ #[test]
2639
+ fn send_keys_enter_cancels_tree_mode_before_enter() {
2640
+ let (be, rec) = backend_mode("tree-mode", "");
2641
+ be.send_keys(&Target::Pane(PaneId::new("%7")), &[Key::Enter])
2642
+ .expect("send_keys");
2643
+ let calls = rec.lock().unwrap().clone();
2644
+ assert_no_e55_keys(&calls);
2645
+ let before = inject_send_keys_before_first_enter(&calls);
2646
+ assert!(
2647
+ before
2648
+ .iter()
2649
+ .any(|argv| argv.last().map(String::as_str) == Some("q")),
2650
+ "send_keys(Enter) must cancel tree-mode first; before={before:?} calls={calls:?}"
2651
+ );
2652
+ }
2653
+
2654
+ #[test]
2655
+ fn send_keys_down_enter_cancels_tree_mode_before_group() {
2656
+ let (be, rec) = backend_mode("tree-mode", "");
2657
+ be.send_keys(&Target::Pane(PaneId::new("%7")), &[Key::Down, Key::Enter])
2658
+ .expect("send_keys");
2659
+ let calls = rec.lock().unwrap().clone();
2660
+ assert_no_e55_keys(&calls);
2661
+ let before = inject_send_keys_before_first_enter(&calls);
2662
+ assert!(
2663
+ before
2664
+ .iter()
2665
+ .any(|argv| argv.last().map(String::as_str) == Some("q")),
2666
+ "Down+Enter must cancel tree-mode before the key group; before={before:?}"
2667
+ );
2668
+ }
2669
+
2670
+ /// 外部队 2026-08-19 屏面原文,一字不改。
2671
+ const GROK_INCIDENT_LINE: &str = "│ ❯ [Pasted: 42 lines] │";
2672
+
2673
+ fn grok_raw_unfolded_paste(marker: &str) -> String {
2674
+ let mut lines = Vec::new();
2675
+ for i in 0..20 {
2676
+ lines.push(format!("payload line {i}"));
2677
+ }
2678
+ lines.push(format!("token {marker}"));
2679
+ lines.join("\n")
2680
+ }
2681
+
2682
+ struct RawThenFoldRunner {
2683
+ recorded: RecordedArgv,
2684
+ raw: String,
2685
+ folded: String,
2686
+ after_submit: String,
2687
+ captures: Mutex<u32>,
2688
+ fold_after: u32,
2689
+ submitted: AtomicBool,
2690
+ }
2691
+
2692
+ impl CommandRunner for RawThenFoldRunner {
2693
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
2694
+ self.recorded.lock().unwrap().push(argv.to_vec());
2695
+ if argv.get(1).map(String::as_str) == Some("send-keys")
2696
+ && argv.iter().any(|a| a == "C-m" || a == "Enter")
2697
+ {
2698
+ self.submitted.store(true, Ordering::SeqCst);
2699
+ }
2700
+ let stdout = match argv.get(1).map(String::as_str) {
2701
+ Some("capture-pane") => {
2702
+ if self.submitted.load(Ordering::SeqCst) {
2703
+ self.after_submit.clone()
2704
+ } else {
2705
+ let mut n = self.captures.lock().unwrap();
2706
+ *n = n.saturating_add(1);
2707
+ if *n > self.fold_after {
2708
+ self.folded.clone()
2709
+ } else {
2710
+ self.raw.clone()
2711
+ }
2712
+ }
2713
+ }
2714
+ Some("display-message") => "0\n".to_string(),
2715
+ _ => String::new(),
2716
+ };
2717
+ Ok(ok(&stdout))
2718
+ }
2719
+
2720
+ fn run_with_stdin(
2721
+ &self,
2722
+ argv: &[String],
2723
+ _stdin: &str,
2724
+ ) -> Result<CommandOutput, std::io::Error> {
2725
+ self.run(argv)
2726
+ }
2727
+ }
2728
+
2729
+ fn backend_raw_then_fold(
2730
+ raw: &str,
2731
+ folded: &str,
2732
+ after_submit: &str,
2733
+ fold_after: u32,
2734
+ ) -> (TmuxBackend, RecordedArgv) {
2735
+ let recorded = Arc::new(Mutex::new(Vec::new()));
2736
+ let runner = RawThenFoldRunner {
2737
+ recorded: Arc::clone(&recorded),
2738
+ raw: raw.to_string(),
2739
+ folded: folded.to_string(),
2740
+ after_submit: after_submit.to_string(),
2741
+ captures: Mutex::new(0),
2742
+ fold_after,
2743
+ submitted: AtomicBool::new(false),
2744
+ };
2745
+ (TmuxBackend::with_runner(Box::new(runner)), recorded)
2746
+ }
2747
+
2748
+ #[test]
2749
+ fn grok_fold_literal_locks_external_team_line() {
2750
+ let prompt =
2751
+ super::pasted_prompt_in_composer(GROK_INCIDENT_LINE, 15).expect("lock grok incident line");
2752
+ assert_eq!(prompt.literal, "pasted:");
2753
+ assert_eq!(prompt.id, None, "grok fold has no #N");
2754
+ assert_eq!(prompt.line_count, Some(42));
2755
+ let matched = super::pasted_prompt_match(GROK_INCIDENT_LINE).expect("match");
2756
+ assert_eq!(matched.0, "pasted:");
2757
+ }
2758
+
2759
+ #[test]
2760
+ fn grok_fold_three_tui_literals_lock() {
2761
+ let grok = super::pasted_prompt_in_composer(GROK_INCIDENT_LINE, 15).unwrap();
2762
+ let claude = super::pasted_prompt_in_composer("❯ [Pasted text #4 +12 lines]\n", 15).unwrap();
2763
+ let codex = super::pasted_prompt_in_composer("❯ [Pasted content #2 +3 lines]\n", 15).unwrap();
2764
+ assert_eq!(grok.literal, "pasted:");
2765
+ assert_eq!(claude.literal, "pasted text");
2766
+ assert_eq!(claude.id, Some(4));
2767
+ assert_eq!(codex.literal, "pasted content");
2768
+ assert_eq!(codex.id, Some(2));
2769
+ }
2770
+
2771
+ #[test]
2772
+ fn grok_fold_same_line_count_still_unconsumed() {
2773
+ let marker = "[team-agent-token:msg_grok]";
2774
+ assert_eq!(
2775
+ super::consumption_from_capture(
2776
+ GROK_INCIDENT_LINE,
2777
+ marker,
2778
+ false,
2779
+ Some(super::PasteLatch::GrokLineCount(42))
2780
+ ),
2781
+ Some(false)
2782
+ );
2783
+ }
2784
+
2785
+ #[test]
2786
+ fn grok_fold_line_count_gone_is_consumed() {
2787
+ let marker = "[team-agent-token:msg_grok]";
2788
+ assert_eq!(
2789
+ super::consumption_from_capture(
2790
+ "Waiting for response\n❯ \n",
2791
+ marker,
2792
+ false,
2793
+ Some(super::PasteLatch::GrokLineCount(42))
2794
+ ),
2795
+ Some(true)
2796
+ );
2797
+ }
2798
+
2799
+ #[test]
2800
+ fn grok_fold_different_n_is_not_same_paste() {
2801
+ let marker = "[team-agent-token:msg_grok]";
2802
+ let other = "│ ❯ [Pasted: 10 lines] │";
2803
+ assert_eq!(
2804
+ super::consumption_from_capture(
2805
+ other,
2806
+ marker,
2807
+ false,
2808
+ Some(super::PasteLatch::GrokLineCount(42))
2809
+ ),
2810
+ Some(false),
2811
+ "different N must not claim the latched paste consumed"
2812
+ );
2813
+ assert!(
2814
+ !super::should_resubmit_enter(other, marker, Some(super::PasteLatch::GrokLineCount(42))),
2815
+ "different N is not the same paste; do not hammer Enter"
2816
+ );
2817
+ }
2818
+
2819
+ #[test]
2820
+ fn grok_fold_no_latch_cannot_claim_consumed() {
2821
+ let marker = "[team-agent-token:msg_grok]";
2822
+ assert_eq!(
2823
+ super::consumption_from_capture("❯ \n", marker, false, None),
2824
+ Some(false)
2825
+ );
2826
+ }
2827
+
2828
+ #[test]
2829
+ fn grok_fold_inject_stays_retries_while_identity_present() {
2830
+ let token_text = "Team Agent message from leader:\nline1\n\n[team-agent-token:msg_grok_stay]";
2831
+ let (be, rec) = backend_folded(GROK_INCIDENT_LINE, GROK_INCIDENT_LINE);
2832
+ let report = be
2833
+ .inject(
2834
+ &Target::Pane(PaneId::new("%7")),
2835
+ &InjectPayload::Text(token_text.to_string()),
2836
+ Key::Enter,
2837
+ true,
2838
+ )
2839
+ .expect("inject");
2840
+ assert_eq!(
2841
+ report.submit_verification,
2842
+ SubmitVerification::SubmitConsumptionUnverified
2843
+ );
2844
+ let calls = rec.lock().unwrap().clone();
2845
+ assert_eq!(
2846
+ count_submit_enters(&calls),
2847
+ 3,
2848
+ "same grok N still in composer must retry Enter up to cap=3; Unverified B must not add a 4th; invert wrap-gap guard must turn this red; calls={calls:?}"
2849
+ );
2850
+ }
2851
+
2852
+ /// 2026-08-21 真机屏尾:折叠占位符还在 transcript,Working 已开。
2853
+ /// 非 cursor 路径会把 pasted #N 当成未消费而连按 Enter。
2854
+ const CURSOR_BUSY_AFTER_SUBMIT: &str = "\
2855
+ [Pasted text #1 +46 lines]\n\
2856
+ \n\
2857
+ T126S-34701-OK\n\
2858
+ \n\
2859
+ ⠰⠰ Working 37 tokens\n\
2860
+ Tip: Use /plan to plan execution and reach the right outcome faster.\n\
2861
+ \n\
2862
+ → Add a follow-up ctrl+c to stop\n\
2863
+ \n\
2864
+ Cursor Grok 4.6 Extra High Run Everything\n";
2865
+
2866
+ #[test]
2867
+ fn cursor_single_enter_busy_transcript_placeholder_does_not_retry() {
2868
+ let token_text = "Team Agent message from leader:\nline1\n\n[team-agent-token:msg_cur_busy]";
2869
+ let (be, rec) = backend_folded("[Pasted text #1 +46 lines]\n", CURSOR_BUSY_AFTER_SUBMIT);
2870
+ let report = with_cursor_single_enter(true, || {
2871
+ be.inject(
2872
+ &Target::Pane(PaneId::new("%7")),
2873
+ &InjectPayload::Text(token_text.to_string()),
2874
+ Key::Enter,
2875
+ true,
2876
+ )
2877
+ })
2878
+ .expect("inject");
2879
+ assert_eq!(
2880
+ report.submit_verification,
2881
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
2882
+ "busy after first Enter is consumption for cursor; got {:?}",
2883
+ report.submit_verification
2884
+ );
2885
+ let calls = rec.lock().unwrap().clone();
2886
+ assert_eq!(
2887
+ count_submit_enters(&calls),
2888
+ 1,
2889
+ "cursor busy: at most one Enter; extra Enter interrupts. calls={calls:?}"
2890
+ );
2891
+ }
2892
+
2893
+ #[test]
2894
+ fn cursor_single_enter_off_still_retries_on_transcript_placeholder() {
2895
+ let token_text = "Team Agent message from leader:\nline1\n\n[team-agent-token:msg_cur_ctrl]";
2896
+ let (be, rec) = backend_folded("[Pasted text #1 +46 lines]\n", CURSOR_BUSY_AFTER_SUBMIT);
2897
+ let _report = be
2898
+ .inject(
2899
+ &Target::Pane(PaneId::new("%7")),
2900
+ &InjectPayload::Text(token_text.to_string()),
2901
+ Key::Enter,
2902
+ true,
2903
+ )
2904
+ .expect("inject");
2905
+ let calls = rec.lock().unwrap().clone();
2906
+ assert_eq!(
2907
+ count_submit_enters(&calls),
2908
+ 3,
2909
+ "default path (claude/grok/codex) must keep retry cap; calls={calls:?}"
2910
+ );
2911
+ }
2912
+
2913
+ #[test]
2914
+ fn cursor_single_enter_retries_only_if_token_still_in_input() {
2915
+ let marker = "[team-agent-token:msg_cur_idle]";
2916
+ let token_text = format!("Team Agent message from leader:\nline1\n\n{marker}");
2917
+ let still_in_input = format!("{marker}\n→ \n");
2918
+ let (be, rec) = backend_folded(still_in_input.as_str(), still_in_input.as_str());
2919
+ let _report = with_cursor_single_enter(true, || {
2920
+ be.inject(
2921
+ &Target::Pane(PaneId::new("%7")),
2922
+ &InjectPayload::Text(token_text),
2923
+ Key::Enter,
2924
+ true,
2925
+ )
2926
+ })
2927
+ .expect("inject");
2928
+ let calls = rec.lock().unwrap().clone();
2929
+ let n = count_submit_enters(&calls);
2930
+ assert!(
2931
+ n >= 2,
2932
+ "token still in cursor input box (no busy) may retry Enter; got {n} calls={calls:?}"
2933
+ );
2934
+ }
2935
+
2936
+ #[test]
2937
+ fn cursor_should_resubmit_false_when_busy_even_if_paste_placeholder_visible() {
2938
+ let marker = "[team-agent-token:msg_cur_gate]";
2939
+ assert!(
2940
+ !super::should_resubmit_enter_cursor(CURSOR_BUSY_AFTER_SUBMIT, marker),
2941
+ "busy + transcript placeholder must not resubmit on cursor"
2942
+ );
2943
+ assert!(
2944
+ super::should_resubmit_enter(
2945
+ CURSOR_BUSY_AFTER_SUBMIT,
2946
+ marker,
2947
+ Some(super::PasteLatch::HashId(1))
2948
+ ),
2949
+ "non-cursor detector still sees latched #1 as resubmit"
2950
+ );
2951
+ }
2952
+
2953
+ #[test]
2954
+ fn grok_fold_inject_leaves_consumed_one_enter() {
2955
+ let token_text = "Team Agent message from leader:\nline1\n\n[team-agent-token:msg_grok_go]";
2956
+ let (be, rec) = backend_folded(GROK_INCIDENT_LINE, "Waiting for response\n❯ \n");
2957
+ let report = be
2958
+ .inject(
2959
+ &Target::Pane(PaneId::new("%7")),
2960
+ &InjectPayload::Text(token_text.to_string()),
2961
+ Key::Enter,
2962
+ true,
2963
+ )
2964
+ .expect("inject");
2965
+ assert_eq!(
2966
+ report.submit_verification,
2967
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
2968
+ );
2969
+ let calls = rec.lock().unwrap().clone();
2970
+ assert_eq!(
2971
+ count_submit_enters(&calls),
2972
+ 1,
2973
+ "fold left composer after one Enter; no empty-composer hammer; calls={calls:?}"
2974
+ );
2975
+ }
2976
+
2977
+ #[test]
2978
+ fn grok_fold_unverified_without_identity_one_enter() {
2979
+ let token_text = "Team Agent message from leader:\nhi\n\n[team-agent-token:msg_grok_empty]";
2980
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
2981
+ let report = be
2982
+ .inject(
2983
+ &Target::Pane(PaneId::new("%7")),
2984
+ &InjectPayload::Text(token_text.to_string()),
2985
+ Key::Enter,
2986
+ true,
2987
+ )
2988
+ .expect("inject");
2989
+ assert_eq!(
2990
+ report.submit_verification,
2991
+ SubmitVerification::SubmitConsumptionUnverified
2992
+ );
2993
+ let calls = rec.lock().unwrap().clone();
2994
+ assert_eq!(
2995
+ count_submit_enters(&calls),
2996
+ 1,
2997
+ "no latch / empty composer: one Enter then stop; calls={calls:?}"
2998
+ );
2999
+ }
3000
+
3001
+ #[test]
3002
+ fn grok_fold_does_not_enter_before_fold_on_raw_tall_paste() {
3003
+ let marker = "[team-agent-token:msg_grok_early]";
3004
+ let token_text = format!(
3005
+ "Team Agent message from leader:\n{}\n\n{marker}",
3006
+ "x\n".repeat(20)
3007
+ );
3008
+ let raw = grok_raw_unfolded_paste(marker);
3009
+ assert!(
3010
+ !super::paste_ready_for_enter(&raw, marker),
3011
+ "raw tall paste with token is not ready"
3012
+ );
3013
+ assert!(super::paste_ready_for_enter(GROK_INCIDENT_LINE, marker));
3014
+ let (be, rec) =
3015
+ backend_raw_then_fold(&raw, GROK_INCIDENT_LINE, "Waiting for response\n❯ \n", 2);
3016
+ let report = be
3017
+ .inject(
3018
+ &Target::Pane(PaneId::new("%7")),
3019
+ &InjectPayload::Text(token_text),
3020
+ Key::Enter,
3021
+ true,
3022
+ )
3023
+ .expect("inject");
3024
+ assert_eq!(
3025
+ report.submit_verification,
3026
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
3027
+ );
3028
+ let calls = rec.lock().unwrap().clone();
3029
+ let submit_index = calls
3030
+ .iter()
3031
+ .position(|argv| {
3032
+ argv.get(1).map(String::as_str) == Some("send-keys")
3033
+ && argv.iter().any(|a| a == "C-m" || a == "Enter")
3034
+ })
3035
+ .expect("must submit");
3036
+ let captures_before: Vec<&Vec<String>> = calls[..submit_index]
3037
+ .iter()
3038
+ .filter(|argv| argv.get(1).map(String::as_str) == Some("capture-pane"))
3039
+ .collect();
3040
+ assert!(
3041
+ captures_before.len() > 2,
3042
+ "must keep polling until fold, not Enter on first token; calls={calls:?}"
3043
+ );
3044
+ assert_eq!(count_submit_enters(&calls), 1);
3045
+ }
3046
+
3047
+ /// 悬案B 主嫌:token 见过又消失(Gone)时 composer 仍有 grok 折叠占位符。
3048
+ /// 修前 Gone 无条件 Some(true)。本测试必须先红。
3049
+ #[test]
3050
+ fn gone_with_grok_placeholder_still_in_composer_must_not_be_consumed() {
3051
+ let cap = "some transcript line\n> [Pasted: 27 lines]\nEnter:send Esc:cancel\n";
3052
+ let marker = "[team-agent-token:deadbeef]";
3053
+ assert_eq!(
3054
+ super::token_sighting(false, true),
3055
+ super::TokenSighting::Gone
3056
+ );
3057
+ let got = super::consumption_from_capture(
3058
+ cap,
3059
+ marker,
3060
+ true,
3061
+ Some(super::PasteLatch::GrokLineCount(27)),
3062
+ );
3063
+ assert_eq!(
3064
+ got,
3065
+ Some(false),
3066
+ "Gone must re-check PasteLatch: placeholder still in composer must not be consumed, got {got:?}"
3067
+ );
3068
+ }
3069
+
3070
+ #[test]
3071
+ fn gone_with_grok_kb_placeholder_still_in_composer_must_not_be_consumed() {
3072
+ let cap = "│ ❯ [Pasted: 13 KB] │\n Enter:send\n";
3073
+ let marker = "[team-agent-token:kbform]";
3074
+ let prompt = super::pasted_prompt_in_composer(cap, 15).expect("KB form is grok fold");
3075
+ assert_eq!(prompt.literal, "pasted:");
3076
+ assert_eq!(prompt.line_count, Some(13));
3077
+ let got = super::consumption_from_capture(
3078
+ cap,
3079
+ marker,
3080
+ true,
3081
+ Some(super::PasteLatch::GrokLineCount(13)),
3082
+ );
3083
+ assert_eq!(
3084
+ got,
3085
+ Some(false),
3086
+ "Gone + [Pasted: 13 KB] still in composer must not be consumed, got {got:?}"
3087
+ );
3088
+ }
3089
+
3090
+ #[test]
3091
+ fn gone_empty_composer_without_latch_stays_consumed_for_claude() {
3092
+ let marker = "[team-agent-token:msg_short]";
3093
+ assert_eq!(
3094
+ super::consumption_from_capture("❯ \n", marker, true, None),
3095
+ Some(true),
3096
+ "claude short token gone + empty composer + no latch must stay consumed"
3097
+ );
3098
+ }
3099
+
3100
+ /// 样本#5 归因钉:重试只重按 Enter,不得再 load/set/paste-buffer。
3101
+ #[test]
3102
+ fn grok_fold_raw_then_fold_placeholder_stays_retries_enter_not_repaste() {
3103
+ let marker = "[team-agent-token:msg_grok_reappear]";
3104
+ let token_text = format!(
3105
+ "Team Agent message from leader:\n{}\n\n{marker}",
3106
+ "x\n".repeat(20)
3107
+ );
3108
+ let raw = grok_raw_unfolded_paste(&marker);
3109
+ let (be, rec) = backend_raw_then_fold(&raw, GROK_INCIDENT_LINE, GROK_INCIDENT_LINE, 2);
3110
+ let report = be
3111
+ .inject(
3112
+ &Target::Pane(PaneId::new("%7")),
3113
+ &InjectPayload::Text(token_text),
3114
+ Key::Enter,
3115
+ true,
3116
+ )
3117
+ .expect("inject");
3118
+ assert_eq!(
3119
+ report.submit_verification,
3120
+ SubmitVerification::SubmitConsumptionUnverified,
3121
+ "raw visible then folded, placeholder stays: must not false-green delivered; got {:?}",
3122
+ report.submit_verification
3123
+ );
3124
+ let calls = rec.lock().unwrap().clone();
3125
+ let enters = count_submit_enters(&calls);
3126
+ assert!(
3127
+ enters >= 2,
3128
+ "placeholder still in composer must retry Enter; got {enters} calls={calls:?}"
3129
+ );
3130
+ let paste_n = count_paste_stage(&calls);
3131
+ let first_enter = calls.iter().position(|argv| {
3132
+ argv.get(1).map(String::as_str) == Some("send-keys")
3133
+ && argv.iter().any(|a| a == "C-m" || a == "Enter")
3134
+ });
3135
+ let paste_after_enter = first_enter.map(|i| {
3136
+ calls[i + 1..]
3137
+ .iter()
3138
+ .filter(|argv| {
3139
+ matches!(
3140
+ argv.get(1).map(String::as_str),
3141
+ Some("load-buffer") | Some("set-buffer") | Some("paste-buffer")
3142
+ )
3143
+ })
3144
+ .count()
3145
+ });
3146
+ assert_eq!(
3147
+ paste_after_enter,
3148
+ Some(0),
3149
+ "retry must not re-paste (inject contract); paste_n={paste_n} calls={calls:?}"
3150
+ );
3151
+ }
3152
+
3153
+ #[test]
3154
+ fn grok_fold_worker_text_payload_polls_consumption() {
3155
+ assert!(
3156
+ !InjectPayload::Text("worker [team-agent-token:x]".to_string()).skip_consumption_poll(),
3157
+ "worker Text path poll_consumption=true; submit_attempt_limit is 3 not 1"
3158
+ );
3159
+ assert!(
3160
+ InjectPayload::TextSkipConsumptionPoll("leader [team-agent-token:x]".to_string())
3161
+ .skip_consumption_poll(),
3162
+ "leader skip path is the only poll_consumption=false arm"
3163
+ );
3164
+ }
3165
+
3166
+ const G4_TOKEN: &str = "[team-agent-token:msg_g4]";
3167
+
3168
+ #[test]
3169
+ fn g4_turn_grok_unsubmitted_paste_is_missing() {
3170
+ let text = "│ ❯ [Pasted: 42 lines] │";
3171
+ assert_eq!(
3172
+ observe_turn_from_capture(text, Some(G4_TOKEN)),
3173
+ TurnVerification::LeaderNewTurnBoundaryMissing
3174
+ );
3175
+ }
3176
+
3177
+ #[test]
3178
+ fn g4_turn_claude_unsubmitted_paste_is_missing() {
3179
+ let text = "❯ pasted text #4 +12 lines";
3180
+ assert_eq!(
3181
+ observe_turn_from_capture(text, Some(G4_TOKEN)),
3182
+ TurnVerification::LeaderNewTurnBoundaryMissing
3183
+ );
3184
+ }
3185
+
3186
+ #[test]
3187
+ fn g4_turn_cursor_unsubmitted_token_is_missing() {
3188
+ let text = format!("❯ {G4_TOKEN}");
3189
+ assert_eq!(
3190
+ observe_turn_from_capture(&text, Some(G4_TOKEN)),
3191
+ TurnVerification::LeaderNewTurnBoundaryMissing
3192
+ );
3193
+ }
3194
+
3195
+ #[test]
3196
+ fn g4_turn_claude_spinner_is_verified() {
3197
+ let text = "✶ Thinking…\nesc to interrupt";
3198
+ assert_eq!(
3199
+ observe_turn_from_capture(text, Some(G4_TOKEN)),
3200
+ TurnVerification::LeaderNewTurnBoundaryVerified
3201
+ );
3202
+ }
3203
+
3204
+ #[test]
3205
+ fn g4_turn_cursor_processing_is_verified() {
3206
+ let text = "processing\n❯ ";
3207
+ assert_eq!(
3208
+ observe_turn_from_capture(text, Some(G4_TOKEN)),
3209
+ TurnVerification::LeaderNewTurnBoundaryVerified
3210
+ );
3211
+ }
3212
+
3213
+ #[test]
3214
+ fn g4_turn_grok_working_is_verified() {
3215
+ let text = "working\n❯ ";
3216
+ assert_eq!(
3217
+ observe_turn_from_capture(text, Some(G4_TOKEN)),
3218
+ TurnVerification::LeaderNewTurnBoundaryVerified
3219
+ );
3220
+ }
3221
+
3222
+ #[test]
3223
+ fn g4_turn_empty_composer_without_busy_is_unknown() {
3224
+ assert_eq!(
3225
+ observe_turn_from_capture("❯ ", Some(G4_TOKEN)),
3226
+ TurnVerification::NotYetObserved,
3227
+ "empty composer and no busy is 不知道, never 开跑"
3228
+ );
3229
+ }
3230
+
3231
+ #[test]
3232
+ fn g4_turn_inject_grok_incident_line_does_not_report_started() {
3233
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_g4_inject]";
3234
+ let (be, _rec) = backend_with(MockResp::Out(ok(GROK_INCIDENT_LINE)), vec![]);
3235
+ let report = be
3236
+ .inject(
3237
+ &Target::Pane(PaneId::new("%7")),
3238
+ &InjectPayload::Text(token_text.to_string()),
3239
+ Key::Enter,
3240
+ true,
3241
+ )
3242
+ .expect("inject runs");
3243
+ assert_ne!(
3244
+ report.turn_verification,
3245
+ TurnVerification::LeaderNewTurnBoundaryVerified,
3246
+ "unsubmitted grok paste must not report 开跑"
3247
+ );
3248
+ assert_eq!(
3249
+ report.turn_verification,
3250
+ TurnVerification::LeaderNewTurnBoundaryMissing
3251
+ );
3252
+ assert_eq!(
3253
+ report.submit_verification,
3254
+ SubmitVerification::SubmitConsumptionUnverified
3255
+ );
3256
+ }
3257
+
3258
+ #[test]
3259
+ fn g4_turn_inject_consumed_without_busy_is_unknown() {
3260
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_g4_gone]";
3261
+ let visible = ok(token_text);
3262
+ let queued = vec![
3263
+ MockResp::Out(ok("")),
3264
+ MockResp::Out(ok("")),
3265
+ MockResp::Out(ok("")),
3266
+ MockResp::Out(visible.clone()),
3267
+ MockResp::Out(visible),
3268
+ MockResp::Out(ok("")),
3269
+ MockResp::Out(ok("")),
3270
+ ];
3271
+ let (be, _rec) = backend_with(MockResp::Out(ok("❯ ")), queued);
3272
+ let report = be
3273
+ .inject(
3274
+ &Target::Pane(PaneId::new("%7")),
3275
+ &InjectPayload::Text(token_text.to_string()),
3276
+ Key::Enter,
3277
+ true,
3278
+ )
3279
+ .expect("inject runs");
3280
+ assert_eq!(
3281
+ report.submit_verification,
3282
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
3283
+ "token gone is still consumed/delivered; that is not 开跑"
3284
+ );
3285
+ assert_eq!(
3286
+ report.turn_verification,
3287
+ TurnVerification::NotYetObserved,
3288
+ "composer empty without busy is 不知道, not 开跑"
3289
+ );
3290
+ }
3291
+
3292
+ /// capture-pane 一律失败;其它 tmux 命令成功。用于「读数拿不到」而不是「读数不一样」。
3293
+ struct CapturePaneFailRunner {
3294
+ recorded: RecordedArgv,
3295
+ }
3296
+
3297
+ impl CommandRunner for CapturePaneFailRunner {
3298
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
3299
+ self.recorded.lock().unwrap().push(argv.to_vec());
3300
+ if argv.get(1).map(String::as_str) == Some("capture-pane") {
3301
+ return Err(std::io::Error::new(
3302
+ std::io::ErrorKind::Other,
3303
+ "mock capture missing",
3304
+ ));
3305
+ }
3306
+ Ok(ok(""))
3307
+ }
3308
+
3309
+ fn run_with_stdin(
3310
+ &self,
3311
+ argv: &[String],
3312
+ _stdin: &str,
3313
+ ) -> Result<CommandOutput, std::io::Error> {
3314
+ self.run(argv)
3315
+ }
3316
+ }
3317
+
3318
+ fn backend_capture_pane_fail() -> (TmuxBackend, RecordedArgv) {
3319
+ let recorded = Arc::new(Mutex::new(Vec::new()));
3320
+ let runner = CapturePaneFailRunner {
3321
+ recorded: Arc::clone(&recorded),
3322
+ };
3323
+ (TmuxBackend::with_runner(Box::new(runner)), recorded)
3324
+ }
3325
+
3326
+ /// P0 unknown-not-success: capture() Err 是「没能判断」,不得与 Some(true) 同值。
3327
+ /// 齿覆盖「读数拿不到」,不是「读数不一样」。
3328
+ #[test]
3329
+ fn p0_capture_failure_is_unverified_not_enter_sent() {
3330
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_cap_fail]";
3331
+ let (be, rec) = backend_capture_pane_fail();
3332
+ let report = be
3333
+ .inject(
3334
+ &Target::Pane(PaneId::new("%7")),
3335
+ &InjectPayload::Text(token_text.to_string()),
3336
+ Key::Enter,
3337
+ true,
3338
+ )
3339
+ .expect("inject runs");
3340
+ assert_eq!(
3341
+ report.submit_verification,
3342
+ SubmitVerification::SubmitConsumptionUnverified,
3343
+ "consumed=None (capture missing) must not share \
3344
+ EnterSentWithoutPlaceholderCheck with Some(true); got {:?}",
3345
+ report.submit_verification
3346
+ );
3347
+ assert!(
3348
+ !crate::messaging::delivery::inject_submit_verified(&report),
3349
+ "no positive consumption signal ⇒ submit_verified must be false; report={report:?}"
3350
+ );
3351
+ let calls = rec.lock().unwrap().clone();
3352
+ assert_eq!(
3353
+ count_submit_enters(&calls),
3354
+ 1,
3355
+ "capture retry must not send extra Enter; calls={calls:?}"
3356
+ );
3357
+ }
3358
+
3359
+ /// P0: capture 失败先重读再判;重试计入 attempts;不重粘文本。
3360
+ #[test]
3361
+ fn p0_capture_failure_retries_read_and_counts_attempts() {
3362
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_cap_retry]";
3363
+ let (be, rec) = backend_capture_pane_fail();
3364
+ let report = be
3365
+ .inject(
3366
+ &Target::Pane(PaneId::new("%7")),
3367
+ &InjectPayload::Text(token_text.to_string()),
3368
+ Key::Enter,
3369
+ true,
3370
+ )
3371
+ .expect("inject runs");
3372
+ assert!(
3373
+ report.attempts > 1,
3374
+ "capture failure must retry the read and count it in attempts; got attempts={}",
3375
+ report.attempts
3376
+ );
3377
+ let calls = rec.lock().unwrap().clone();
3378
+ let captures = calls
3379
+ .iter()
3380
+ .filter(|argv| argv.get(1).map(String::as_str) == Some("capture-pane"))
3381
+ .count();
3382
+ assert!(
3383
+ captures >= 2,
3384
+ "must re-read capture after failure; capture-pane count={captures} calls={calls:?}"
3385
+ );
3386
+ assert_eq!(
3387
+ count_submit_enters(&calls),
3388
+ 1,
3389
+ "retry is capture-only; extra Enter is forbidden; calls={calls:?}"
3390
+ );
3391
+ let pastes = calls
3392
+ .iter()
3393
+ .filter(|argv| argv.get(1).map(String::as_str) == Some("paste-buffer"))
3394
+ .count();
3395
+ assert_eq!(
3396
+ pastes, 1,
3397
+ "must not re-paste on capture failure; paste-buffer count={pastes}"
3398
+ );
3399
+ }
3400
+
3401
+ // ═════════════════════════════════════════════════════════════════════════
3402
+ // Unverified 之后补一颗 C-m(TUI composer 残留 / 折行),busy-shell 不补
3403
+ // ═════════════════════════════════════════════════════════════════════════
3404
+
3405
+ struct NthEnterClearRunner {
3406
+ recorded: RecordedArgv,
3407
+ pending: String,
3408
+ cleared: String,
3409
+ enters: std::sync::atomic::AtomicU32,
3410
+ clear_after: u32,
3411
+ }
3412
+
3413
+ impl CommandRunner for NthEnterClearRunner {
3414
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
3415
+ self.recorded.lock().unwrap().push(argv.to_vec());
3416
+ if argv.get(1).map(String::as_str) == Some("send-keys")
3417
+ && argv.iter().any(|a| a == "C-m" || a == "Enter")
3418
+ {
3419
+ self.enters.fetch_add(1, Ordering::SeqCst);
3420
+ }
3421
+ let stdout = match argv.get(1).map(String::as_str) {
3422
+ Some("capture-pane") => {
3423
+ if self.enters.load(Ordering::SeqCst) >= self.clear_after {
3424
+ self.cleared.clone()
3425
+ } else {
3426
+ self.pending.clone()
3427
+ }
3428
+ }
3429
+ Some("display-message") => "0\n".to_string(),
3430
+ _ => String::new(),
3431
+ };
3432
+ Ok(ok(&stdout))
3433
+ }
3434
+
3435
+ fn run_with_stdin(
3436
+ &self,
3437
+ argv: &[String],
3438
+ _stdin: &str,
3439
+ ) -> Result<CommandOutput, std::io::Error> {
3440
+ self.run(argv)
3441
+ }
3442
+ }
3443
+
3444
+ fn backend_clears_after_n_enters(
3445
+ pending: &str,
3446
+ cleared: &str,
3447
+ clear_after: u32,
3448
+ ) -> (TmuxBackend, RecordedArgv) {
3449
+ let recorded = Arc::new(Mutex::new(Vec::new()));
3450
+ let runner = NthEnterClearRunner {
3451
+ recorded: Arc::clone(&recorded),
3452
+ pending: pending.to_string(),
3453
+ cleared: cleared.to_string(),
3454
+ enters: std::sync::atomic::AtomicU32::new(0),
3455
+ clear_after,
3456
+ };
3457
+ (TmuxBackend::with_runner(Box::new(runner)), recorded)
3458
+ }
3459
+
3460
+ fn wrapped_identity_with_skin(marker: &str, skin: &str) -> String {
3461
+ // tmux 80 列折行:单行不含完整 token,拼接后才含。
3462
+ let split_at = marker.len() / 2;
3463
+ format!("{skin}\n{}\n{}\n", &marker[..split_at], &marker[split_at..])
3464
+ }
3465
+
3466
+ fn wrapped_unverified_composer(marker: &str) -> String {
3467
+ wrapped_identity_with_skin(marker, "tui-ready composer>")
3468
+ }
3469
+
3470
+ #[test]
3471
+ fn should_resend_enter_after_unverified_true_on_wrapped_tui_token() {
3472
+ let marker = "[team-agent-token:msg_wrap_gate]";
3473
+ let text = wrapped_unverified_composer(marker);
3474
+ assert!(
3475
+ !super::should_resubmit_enter(&text, marker, None),
3476
+ "per-line token_in_bottom_n must stay false on wrap"
3477
+ );
3478
+ assert!(
3479
+ super::should_resend_enter_after_unverified(&text, marker, None),
3480
+ "joined token identity must allow the one Unverified resend"
3481
+ );
3482
+ }
3483
+
3484
+ #[test]
3485
+ fn should_resend_wrapped_identity_on_three_tui_skins() {
3486
+ let marker = "[team-agent-token:msg_skins]";
3487
+ for skin in ["❯ ", "> ", "ready."] {
3488
+ let text = wrapped_identity_with_skin(marker, skin);
3489
+ assert!(
3490
+ super::should_resend_enter_after_unverified(&text, marker, None),
3491
+ "skin {skin:?} with this-paste token must resend"
3492
+ );
3493
+ }
3494
+ }
3495
+
3496
+ #[test]
3497
+ fn should_resend_false_when_payload_has_prompt_glyphs_but_identity_absent() {
3498
+ let marker = "[team-agent-token:msg_glyph_fp]";
3499
+ let text = "STARTED\nsleep holds pty\nsee composer> and ❯ in the payload\n>\n";
3500
+ assert!(
3501
+ !super::this_paste_identity_in_composer(text, marker, None),
3502
+ "glyphs in payload are not this-paste identity"
3503
+ );
3504
+ assert!(
3505
+ !super::should_resend_enter_after_unverified(text, marker, None),
3506
+ "payload composer>/❯ must not by themselves trigger resend"
3507
+ );
3508
+ }
3509
+
3510
+ #[test]
3511
+ fn should_resend_enter_after_unverified_true_when_unwrapped_token_still_in_tui() {
3512
+ let marker = "[team-agent-token:msg_still_tui]";
3513
+ let text = format!("> {marker}\n");
3514
+ assert!(super::should_resubmit_enter(&text, marker, None));
3515
+ assert!(
3516
+ super::should_resend_enter_after_unverified(&text, marker, None),
3517
+ "bare > skin with token identity still in composer must resend"
3518
+ );
3519
+ }
3520
+
3521
+ #[test]
3522
+ fn wrap_gap_false_when_a_already_sees_grok_latch() {
3523
+ let marker = "[team-agent-token:msg_grok_stay]";
3524
+ let tracked = Some(super::PasteLatch::GrokLineCount(42));
3525
+ assert!(super::should_resubmit_enter(
3526
+ GROK_INCIDENT_LINE,
3527
+ marker,
3528
+ tracked
3529
+ ));
3530
+ assert!(super::should_resend_enter_after_unverified(
3531
+ GROK_INCIDENT_LINE,
3532
+ marker,
3533
+ tracked
3534
+ ));
3535
+ assert!(
3536
+ !super::should_resend_unverified_wrap_gap(GROK_INCIDENT_LINE, marker, tracked),
3537
+ "A latch path already covers grok-fold stay; invert wrap-gap must turn this red"
3538
+ );
3539
+ }
3540
+
3541
+ #[test]
3542
+ fn wrap_gap_true_on_joined_token_a_cannot_see() {
3543
+ let marker = "[team-agent-token:msg_wrap_gate]";
3544
+ let text = wrapped_unverified_composer(marker);
3545
+ assert!(!super::should_resubmit_enter(&text, marker, None));
3546
+ assert!(
3547
+ super::should_resend_unverified_wrap_gap(&text, marker, None),
3548
+ "B must still cover the wrap gap A cannot see"
3549
+ );
3550
+ }
3551
+
3552
+ #[test]
3553
+ fn unverified_wrapped_token_resends_one_enter_then_consumes() {
3554
+ let marker = "[team-agent-token:msg_wrap_ok]";
3555
+ let token_text = format!("Team Agent message from leader:\nline1\n\n{marker}");
3556
+ let pending = wrapped_identity_with_skin(marker, "> ");
3557
+ let cleared = "> \n";
3558
+ let (be, rec) = backend_clears_after_n_enters(&pending, cleared, 2);
3559
+ let report = be
3560
+ .inject(
3561
+ &Target::Pane(PaneId::new("%7")),
3562
+ &InjectPayload::Text(token_text),
3563
+ Key::Enter,
3564
+ true,
3565
+ )
3566
+ .expect("inject");
3567
+ assert_eq!(
3568
+ report.submit_verification,
3569
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
3570
+ "Unverified + wrapped identity must resend one C-m then consume; got {:?}",
3571
+ report.submit_verification
3572
+ );
3573
+ let calls = rec.lock().unwrap().clone();
3574
+ assert_eq!(
3575
+ count_submit_enters(&calls),
3576
+ 2,
3577
+ "exactly one extra C-m after Unverified; calls={calls:?}"
3578
+ );
3579
+ let pastes = calls
3580
+ .iter()
3581
+ .filter(|argv| argv.get(1).map(String::as_str) == Some("paste-buffer"))
3582
+ .count();
3583
+ assert_eq!(pastes, 1, "must not re-paste; paste-buffer count={pastes}");
3584
+ }
3585
+
3586
+ #[test]
3587
+ fn unverified_resend_skips_when_this_message_identity_absent() {
3588
+ let marker = "[team-agent-token:msg_busy_nodup]";
3589
+ let token_text = format!("Team Agent message from leader:\nline1 composer> ❯\n\n{marker}");
3590
+ let pending = "STARTED\nsleep holds pty\ncomposer>\n❯\n>\n";
3591
+ let (be, rec) = backend_clears_after_n_enters(pending, pending, 99);
3592
+ let report = be
3593
+ .inject(
3594
+ &Target::Pane(PaneId::new("%7")),
3595
+ &InjectPayload::Text(token_text),
3596
+ Key::Enter,
3597
+ true,
3598
+ )
3599
+ .expect("inject");
3600
+ assert_eq!(
3601
+ report.submit_verification,
3602
+ SubmitVerification::SubmitConsumptionUnverified,
3603
+ "identity absent must stay Unverified, no extra Enter; got {:?}",
3604
+ report.submit_verification
3605
+ );
3606
+ let calls = rec.lock().unwrap().clone();
3607
+ assert_eq!(
3608
+ count_submit_enters(&calls),
3609
+ 1,
3610
+ "no this-paste identity ⇒ no Unverified resend; commenting the identity gate must turn this red (2+ C-m); calls={calls:?}"
3611
+ );
3612
+ }
3613
+
3614
+ #[test]
3615
+ fn unverified_identity_never_clears_hits_resend_cap_of_one() {
3616
+ let marker = "[team-agent-token:msg_cap_lock]";
3617
+ let token_text = format!("Team Agent message from leader:\nline1\n\n{marker}");
3618
+ let pending = wrapped_identity_with_skin(marker, "ready.");
3619
+ let (be, rec) = backend_clears_after_n_enters(&pending, &pending, u32::MAX);
3620
+ let report = be
3621
+ .inject(
3622
+ &Target::Pane(PaneId::new("%7")),
3623
+ &InjectPayload::Text(token_text),
3624
+ Key::Enter,
3625
+ true,
3626
+ )
3627
+ .expect("inject");
3628
+ assert_eq!(
3629
+ report.submit_verification,
3630
+ SubmitVerification::SubmitConsumptionUnverified,
3631
+ "never-clear identity must not fake-consume; got {:?}",
3632
+ report.submit_verification
3633
+ );
3634
+ let calls = rec.lock().unwrap().clone();
3635
+ assert_eq!(
3636
+ count_submit_enters(&calls),
3637
+ 2,
3638
+ "cap must be exactly one extra C-m (1 inject + 1 resend); raising UNVERIFIED_COMPOSER_RESEND_MAX must turn this red; calls={calls:?}"
3639
+ );
3640
+ let pastes = calls
3641
+ .iter()
3642
+ .filter(|argv| argv.get(1).map(String::as_str) == Some("paste-buffer"))
3643
+ .count();
3644
+ assert_eq!(pastes, 1, "must not re-paste; paste-buffer count={pastes}");
3645
+ }
3646
+
3647
+ #[test]
3648
+ fn unverified_wrapped_identity_resends_on_each_of_three_skins() {
3649
+ for skin in ["❯ ", "> ", "ready."] {
3650
+ let marker = format!(
3651
+ "[team-agent-token:msg_skin_{}]",
3652
+ skin.chars().next().unwrap() as u32
3653
+ );
3654
+ let token_text = format!("Team Agent message from leader:\nline1\n\n{marker}");
3655
+ let pending = wrapped_identity_with_skin(&marker, skin);
3656
+ let cleared = format!("{skin}\n");
3657
+ let (be, rec) = backend_clears_after_n_enters(&pending, &cleared, 2);
3658
+ let report = be
3659
+ .inject(
3660
+ &Target::Pane(PaneId::new("%7")),
3661
+ &InjectPayload::Text(token_text),
3662
+ Key::Enter,
3663
+ true,
3664
+ )
3665
+ .expect("inject");
3666
+ assert_eq!(
3667
+ report.submit_verification,
3668
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
3669
+ "skin {skin:?} must consume after one extra C-m"
3670
+ );
3671
+ let n = count_submit_enters(&rec.lock().unwrap().clone());
3672
+ assert_eq!(n, 2, "skin {skin:?} extra C-m; got {n}");
3673
+ }
3674
+ }
3675
+
3676
+ #[test]
3677
+ fn unverified_payload_glyphs_do_not_resend_without_identity() {
3678
+ let marker = "[team-agent-token:msg_payload_glyph]";
3679
+ let token_text = format!("Team Agent message from leader:\ncomposer> ❯\n\n{marker}");
3680
+ let pending = "output\ncomposer> leftover from payload\n❯ quoted\n";
3681
+ let (be, rec) = backend_clears_after_n_enters(pending, pending, 99);
3682
+ let report = be
3683
+ .inject(
3684
+ &Target::Pane(PaneId::new("%7")),
3685
+ &InjectPayload::Text(token_text),
3686
+ Key::Enter,
3687
+ true,
3688
+ )
3689
+ .expect("inject");
3690
+ let calls = rec.lock().unwrap().clone();
3691
+ assert_eq!(
3692
+ count_submit_enters(&calls),
3693
+ 1,
3694
+ "glyphs in payload must not cause extra C-m when token identity is gone; calls={calls:?}"
3695
+ );
3696
+ assert_eq!(
3697
+ report.submit_verification,
3698
+ SubmitVerification::SubmitConsumptionUnverified
3699
+ );
3700
+ }