@team-agent/installer 0.5.41 → 0.5.43

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 (152) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +129 -54
  4. package/crates/team-agent/src/cli/diagnose.rs +1 -2
  5. package/crates/team-agent/src/cli/emit.rs +1 -7
  6. package/crates/team-agent/src/cli/helpers.rs +3 -1
  7. package/crates/team-agent/src/cli/leader.rs +2 -1
  8. package/crates/team-agent/src/cli/mod.rs +27 -7
  9. package/crates/team-agent/src/cli/named_address.rs +14 -5
  10. package/crates/team-agent/src/cli/profile.rs +19 -7
  11. package/crates/team-agent/src/cli/send.rs +9 -3
  12. package/crates/team-agent/src/cli/status.rs +8 -30
  13. package/crates/team-agent/src/cli/status_port.rs +1341 -1272
  14. package/crates/team-agent/src/cli/tests/base.rs +738 -660
  15. package/crates/team-agent/src/cli/tests/compile.rs +45 -18
  16. package/crates/team-agent/src/cli/tests/divergence.rs +462 -444
  17. package/crates/team-agent/src/cli/tests/lane_c.rs +365 -282
  18. package/crates/team-agent/src/cli/tests/leader_watch.rs +356 -329
  19. package/crates/team-agent/src/cli/tests/main_preserved.rs +672 -564
  20. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +284 -224
  21. package/crates/team-agent/src/cli/tests/mod.rs +17 -7
  22. package/crates/team-agent/src/cli/tests/named_address.rs +8 -2
  23. package/crates/team-agent/src/cli/tests/peer_allow.rs +10 -2
  24. package/crates/team-agent/src/cli/tests/run_delegation.rs +314 -273
  25. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +26 -15
  26. package/crates/team-agent/src/cli/tests/status_send.rs +707 -629
  27. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +20 -4
  28. package/crates/team-agent/src/cli/tests/verb_profile.rs +46 -17
  29. package/crates/team-agent/src/cli/tests/verb_validate.rs +15 -3
  30. package/crates/team-agent/src/codex_app_server.rs +2 -5
  31. package/crates/team-agent/src/compiler/tests.rs +139 -33
  32. package/crates/team-agent/src/compiler.rs +55 -22
  33. package/crates/team-agent/src/conpty/backend.rs +23 -33
  34. package/crates/team-agent/src/coordinator/backoff.rs +2 -7
  35. package/crates/team-agent/src/coordinator/conpty_shim.rs +55 -67
  36. package/crates/team-agent/src/coordinator/health.rs +46 -31
  37. package/crates/team-agent/src/coordinator/mod.rs +3 -3
  38. package/crates/team-agent/src/coordinator/orphan.rs +22 -10
  39. package/crates/team-agent/src/coordinator/steps/abnormal.rs +47 -52
  40. package/crates/team-agent/src/coordinator/tests/abnormal.rs +55 -19
  41. package/crates/team-agent/src/coordinator/tests/basics.rs +179 -41
  42. package/crates/team-agent/src/coordinator/tests/daemon.rs +53 -13
  43. package/crates/team-agent/src/coordinator/tests/health_sync.rs +78 -19
  44. package/crates/team-agent/src/coordinator/tests/main_preserved.rs +61 -11
  45. package/crates/team-agent/src/coordinator/tests/mod.rs +33 -39
  46. package/crates/team-agent/src/coordinator/tests/spine.rs +52 -12
  47. package/crates/team-agent/src/coordinator/tests/takeover.rs +73 -15
  48. package/crates/team-agent/src/coordinator/tests/tick_core.rs +50 -15
  49. package/crates/team-agent/src/coordinator/tests/watch.rs +74 -20
  50. package/crates/team-agent/src/coordinator/tick.rs +19 -1
  51. package/crates/team-agent/src/db/message_store.rs +138 -30
  52. package/crates/team-agent/src/db/migration.rs +249 -61
  53. package/crates/team-agent/src/db/schema.rs +303 -82
  54. package/crates/team-agent/src/diagnose/comms.rs +9 -2
  55. package/crates/team-agent/src/diagnose/mod.rs +1 -3
  56. package/crates/team-agent/src/diagnose/orphans.rs +79 -61
  57. package/crates/team-agent/src/event_log.rs +70 -16
  58. package/crates/team-agent/src/layout/manager.rs +15 -4
  59. package/crates/team-agent/src/layout/mod.rs +4 -4
  60. package/crates/team-agent/src/layout/overlay.rs +10 -3
  61. package/crates/team-agent/src/layout/placement.rs +5 -1
  62. package/crates/team-agent/src/layout/recovery.rs +4 -2
  63. package/crates/team-agent/src/layout/runtime_sessions.rs +7 -7
  64. package/crates/team-agent/src/layout/sessions.rs +17 -9
  65. package/crates/team-agent/src/layout/tmux_endpoint.rs +1 -1
  66. package/crates/team-agent/src/layout/worker_env.rs +52 -19
  67. package/crates/team-agent/src/leader/helpers.rs +7 -1
  68. package/crates/team-agent/src/leader/lease.rs +195 -82
  69. package/crates/team-agent/src/leader/owner_bind.rs +55 -22
  70. package/crates/team-agent/src/leader/rediscover/tests.rs +88 -24
  71. package/crates/team-agent/src/leader/rediscover.rs +74 -25
  72. package/crates/team-agent/src/leader/start.rs +75 -54
  73. package/crates/team-agent/src/leader/takeover.rs +46 -11
  74. package/crates/team-agent/src/leader/tests/basics.rs +320 -167
  75. package/crates/team-agent/src/leader/tests/byte_findings.rs +361 -219
  76. package/crates/team-agent/src/leader/tests/identity.rs +428 -356
  77. package/crates/team-agent/src/leader/tests/idle.rs +285 -254
  78. package/crates/team-agent/src/leader/tests/lease_api.rs +338 -274
  79. package/crates/team-agent/src/leader/tests/lease_claim.rs +643 -593
  80. package/crates/team-agent/src/leader/tests/mod.rs +115 -99
  81. package/crates/team-agent/src/leader/tests/rediscover.rs +74 -22
  82. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +237 -211
  83. package/crates/team-agent/src/lib.rs +4 -4
  84. package/crates/team-agent/src/lifecycle/display.rs +7 -3
  85. package/crates/team-agent/src/lifecycle/launch.rs +6 -1
  86. package/crates/team-agent/src/lifecycle/mod.rs +9 -1
  87. package/crates/team-agent/src/lifecycle/profile_launch.rs +77 -34
  88. package/crates/team-agent/src/lifecycle/profile_smoke.rs +3 -1
  89. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -6
  90. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +1 -4
  91. package/crates/team-agent/src/lifecycle/restart/preflight.rs +6 -5
  92. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -22
  93. package/crates/team-agent/src/lifecycle/restart/remove.rs +45 -35
  94. package/crates/team-agent/src/lifecycle/restart/team_state.rs +63 -17
  95. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +251 -84
  96. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +198 -48
  97. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +2 -1
  98. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +152 -32
  99. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -5
  100. package/crates/team-agent/src/lifecycle/tests.rs +2 -2
  101. package/crates/team-agent/src/main.rs +4 -4
  102. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +47 -20
  103. package/crates/team-agent/src/mcp_server/mod.rs +11 -2
  104. package/crates/team-agent/src/mcp_server/types.rs +14 -3
  105. package/crates/team-agent/src/mcp_server/wire.rs +230 -68
  106. package/crates/team-agent/src/messaging/delivery.rs +20 -18
  107. package/crates/team-agent/src/messaging/helpers.rs +25 -4
  108. package/crates/team-agent/src/messaging/leader_receiver.rs +4 -5
  109. package/crates/team-agent/src/messaging/mod.rs +2 -3
  110. package/crates/team-agent/src/messaging/selftest.rs +46 -26
  111. package/crates/team-agent/src/messaging/tests/main_preserved.rs +47 -12
  112. package/crates/team-agent/src/messaging/tests/runtime.rs +57 -22
  113. package/crates/team-agent/src/messaging/tests/spine.rs +154 -40
  114. package/crates/team-agent/src/messaging/tests/wave2.rs +31 -32
  115. package/crates/team-agent/src/messaging/trust.rs +25 -2
  116. package/crates/team-agent/src/messaging/watchers.rs +37 -9
  117. package/crates/team-agent/src/model/enums.rs +65 -16
  118. package/crates/team-agent/src/model/ids.rs +12 -3
  119. package/crates/team-agent/src/model/paths.rs +28 -7
  120. package/crates/team-agent/src/model/permissions.rs +176 -33
  121. package/crates/team-agent/src/model/routing.rs +66 -20
  122. package/crates/team-agent/src/model/spec.rs +365 -69
  123. package/crates/team-agent/src/model/task_graph.rs +36 -9
  124. package/crates/team-agent/src/model/yaml/tests.rs +24 -6
  125. package/crates/team-agent/src/model/yaml.rs +7 -6
  126. package/crates/team-agent/src/packaging/install.rs +23 -9
  127. package/crates/team-agent/src/packaging/migrate.rs +5 -7
  128. package/crates/team-agent/src/packaging/mod.rs +9 -1
  129. package/crates/team-agent/src/packaging/repair.rs +13 -6
  130. package/crates/team-agent/src/packaging/tests.rs +63 -16
  131. package/crates/team-agent/src/packaging/types.rs +22 -7
  132. package/crates/team-agent/src/platform/argv.rs +4 -1
  133. package/crates/team-agent/src/platform/file_lock.rs +22 -8
  134. package/crates/team-agent/src/platform/process.rs +21 -22
  135. package/crates/team-agent/src/provider/adapters/claude.rs +1 -3
  136. package/crates/team-agent/src/provider/approvals/parsing.rs +134 -26
  137. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +13 -3
  138. package/crates/team-agent/src/provider/classify.rs +127 -42
  139. package/crates/team-agent/src/provider/faults.rs +17 -5
  140. package/crates/team-agent/src/provider/helpers.rs +6 -5
  141. package/crates/team-agent/src/provider/startup_prompt.rs +75 -27
  142. package/crates/team-agent/src/state/repository.rs +27 -14
  143. package/crates/team-agent/src/tmux_backend/tests.rs +1637 -1398
  144. package/crates/team-agent/src/tmux_backend.rs +80 -44
  145. package/crates/team-agent/src/topology.rs +27 -20
  146. package/crates/team-agent/src/transport/test_support.rs +20 -23
  147. package/crates/team-agent/src/transport/tests/behavior.rs +292 -293
  148. package/crates/team-agent/src/transport/tests/mod.rs +178 -187
  149. package/crates/team-agent/src/transport/tests/wire.rs +561 -525
  150. package/crates/team-agent/src/transport.rs +12 -17
  151. package/crates/team-agent/src/transport_factory.rs +29 -14
  152. package/package.json +4 -4
@@ -1,294 +1,329 @@
1
- //! TMUX-BACKEND RED — every `Transport` method is `unimplemented!()` today, so these PANIC (RED)
2
- //! until the porter wires the bodies + `RealCommandRunner`. The OS edge is mocked by
3
- //! `MockCommandRunner` (records each argv; returns canned `CommandOutput`/io::Error you stage).
4
- //! Each test asserts (1) the recorded argv == the golden-locked `transport::tmux_*_argv` builder
5
- //! (or the golden command form for builder-less ops) and (2) the parsed typed return. Golden:
6
- //! runtime.py (has-session/spawn/kill), leader/__init__.py:335 (set-environment), state.py:341
7
- //! (_tmux_pane_liveness three-state, §bug-085 unknown != dead), transport.rs argv-builders.
8
- #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
9
-
10
- use std::collections::{BTreeMap, VecDeque};
11
- use std::os::unix::net::UnixListener;
12
- use std::path::Path;
13
- use std::sync::{Arc, Mutex};
14
-
15
- use super::{CommandOutput, CommandRunner, RealCommandRunner, TmuxBackend};
16
- use crate::model::enums::PaneLiveness;
17
- use crate::transport::{
18
- normalize_capture, tmux_capture_argv, tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv,
19
- AttachOutcome, CaptureRange, InjectPayload, InjectStage, InjectVerification, Key, PaneField,
20
- PaneId, SessionName, SetEnvOutcome, SubmitVerification, Target, Transport, TransportError,
21
- TurnVerification, WindowName,
22
- };
23
-
24
- type RecordedArgv = Arc<Mutex<Vec<Vec<String>>>>;
25
- type RecordedStdin = Arc<Mutex<Vec<String>>>;
26
-
27
- /// A staged runner response: a canned `CommandOutput`, or an io::Error (kind) for the error path.
28
- #[derive(Clone)]
29
- enum MockResp {
30
- Out(CommandOutput),
31
- Io(std::io::ErrorKind),
32
- }
33
-
34
- /// Records every argv it is asked to run; replays staged responses (then a default).
35
- struct MockCommandRunner {
36
- recorded: RecordedArgv,
37
- stdin_recorded: RecordedStdin,
38
- queue: Mutex<VecDeque<MockResp>>,
39
- default: MockResp,
40
- }
41
-
42
- impl CommandRunner for MockCommandRunner {
43
- fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
44
- self.recorded.lock().unwrap().push(argv.to_vec());
45
- let resp = self.queue.lock().unwrap().pop_front().unwrap_or_else(|| self.default.clone());
46
- match resp {
47
- MockResp::Out(o) => Ok(o),
48
- MockResp::Io(kind) => Err(std::io::Error::new(kind, "mock runner io error")),
49
- }
1
+ //! TMUX-BACKEND RED — every `Transport` method is `unimplemented!()` today, so these PANIC (RED)
2
+ //! until the porter wires the bodies + `RealCommandRunner`. The OS edge is mocked by
3
+ //! `MockCommandRunner` (records each argv; returns canned `CommandOutput`/io::Error you stage).
4
+ //! Each test asserts (1) the recorded argv == the golden-locked `transport::tmux_*_argv` builder
5
+ //! (or the golden command form for builder-less ops) and (2) the parsed typed return. Golden:
6
+ //! runtime.py (has-session/spawn/kill), leader/__init__.py:335 (set-environment), state.py:341
7
+ //! (_tmux_pane_liveness three-state, §bug-085 unknown != dead), transport.rs argv-builders.
8
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
9
+
10
+ use std::collections::{BTreeMap, VecDeque};
11
+ use std::os::unix::net::UnixListener;
12
+ use std::path::Path;
13
+ use std::sync::{Arc, Mutex};
14
+
15
+ use super::{CommandOutput, CommandRunner, RealCommandRunner, TmuxBackend};
16
+ use crate::model::enums::PaneLiveness;
17
+ use crate::transport::{
18
+ normalize_capture, tmux_capture_argv, tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv,
19
+ AttachOutcome, CaptureRange, InjectPayload, InjectStage, InjectVerification, Key, PaneField,
20
+ PaneId, SessionName, SetEnvOutcome, SubmitVerification, Target, Transport, TransportError,
21
+ TurnVerification, WindowName,
22
+ };
23
+
24
+ type RecordedArgv = Arc<Mutex<Vec<Vec<String>>>>;
25
+ type RecordedStdin = Arc<Mutex<Vec<String>>>;
26
+
27
+ /// A staged runner response: a canned `CommandOutput`, or an io::Error (kind) for the error path.
28
+ #[derive(Clone)]
29
+ enum MockResp {
30
+ Out(CommandOutput),
31
+ Io(std::io::ErrorKind),
32
+ }
33
+
34
+ /// Records every argv it is asked to run; replays staged responses (then a default).
35
+ struct MockCommandRunner {
36
+ recorded: RecordedArgv,
37
+ stdin_recorded: RecordedStdin,
38
+ queue: Mutex<VecDeque<MockResp>>,
39
+ default: MockResp,
40
+ }
41
+
42
+ impl CommandRunner for MockCommandRunner {
43
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
44
+ self.recorded.lock().unwrap().push(argv.to_vec());
45
+ let resp = self
46
+ .queue
47
+ .lock()
48
+ .unwrap()
49
+ .pop_front()
50
+ .unwrap_or_else(|| self.default.clone());
51
+ match resp {
52
+ MockResp::Out(o) => Ok(o),
53
+ MockResp::Io(kind) => Err(std::io::Error::new(kind, "mock runner io error")),
50
54
  }
51
-
52
- fn run_with_stdin(
53
- &self,
54
- argv: &[String],
55
- stdin: &str,
56
- ) -> Result<CommandOutput, std::io::Error> {
57
- self.stdin_recorded.lock().unwrap().push(stdin.to_string());
58
- self.run(argv)
59
- }
60
- }
61
-
62
- fn ok(stdout: &str) -> CommandOutput {
63
- CommandOutput { success: true, code: Some(0), stdout: stdout.to_string(), stderr: String::new() }
64
- }
65
- fn fail(code: i32, stderr: &str) -> CommandOutput {
66
- CommandOutput { success: false, code: Some(code), stdout: String::new(), stderr: stderr.to_string() }
67
55
  }
68
56
 
69
- /// Build a backend over a mock runner: `default` answers every un-queued call; `queued` is drained
70
- /// first. Returns the backend + the shared recorded-argv handle (read AFTER the call).
71
- fn backend_with(default: MockResp, queued: Vec<MockResp>) -> (TmuxBackend, RecordedArgv) {
72
- let recorded = Arc::new(Mutex::new(Vec::new()));
73
- let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
74
- let runner = MockCommandRunner {
75
- recorded: Arc::clone(&recorded),
76
- stdin_recorded,
77
- queue: Mutex::new(queued.into_iter().collect()),
78
- default,
79
- };
80
- (TmuxBackend::with_runner(Box::new(runner)), recorded)
81
- }
82
-
83
- fn backend_with_stdin(
84
- default: MockResp,
85
- queued: Vec<MockResp>,
86
- ) -> (TmuxBackend, RecordedArgv, RecordedStdin) {
87
- let recorded = Arc::new(Mutex::new(Vec::new()));
88
- let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
89
- let runner = MockCommandRunner {
90
- recorded: Arc::clone(&recorded),
91
- stdin_recorded: Arc::clone(&stdin_recorded),
92
- queue: Mutex::new(queued.into_iter().collect()),
93
- default,
94
- };
95
- (TmuxBackend::with_runner(Box::new(runner)), recorded, stdin_recorded)
57
+ fn run_with_stdin(
58
+ &self,
59
+ argv: &[String],
60
+ stdin: &str,
61
+ ) -> Result<CommandOutput, std::io::Error> {
62
+ self.stdin_recorded.lock().unwrap().push(stdin.to_string());
63
+ self.run(argv)
96
64
  }
97
-
98
- fn svec(items: &[&str]) -> Vec<String> {
99
- items.iter().map(|s| (*s).to_string()).collect()
65
+ }
66
+
67
+ fn ok(stdout: &str) -> CommandOutput {
68
+ CommandOutput {
69
+ success: true,
70
+ code: Some(0),
71
+ stdout: stdout.to_string(),
72
+ stderr: String::new(),
100
73
  }
101
-
102
- struct EnvGuard {
103
- saved: Vec<(String, Option<String>)>,
74
+ }
75
+ fn fail(code: i32, stderr: &str) -> CommandOutput {
76
+ CommandOutput {
77
+ success: false,
78
+ code: Some(code),
79
+ stdout: String::new(),
80
+ stderr: stderr.to_string(),
104
81
  }
105
-
106
- impl EnvGuard {
107
- fn apply(vars: &[(&str, Option<&str>)]) -> Self {
108
- let saved = vars.iter().map(|(k, _)| ((*k).to_string(), std::env::var(k).ok())).collect();
109
- for (k, v) in vars {
110
- match v {
111
- Some(val) => std::env::set_var(k, val),
112
- None => std::env::remove_var(k),
113
- }
82
+ }
83
+
84
+ /// Build a backend over a mock runner: `default` answers every un-queued call; `queued` is drained
85
+ /// first. Returns the backend + the shared recorded-argv handle (read AFTER the call).
86
+ fn backend_with(default: MockResp, queued: Vec<MockResp>) -> (TmuxBackend, RecordedArgv) {
87
+ let recorded = Arc::new(Mutex::new(Vec::new()));
88
+ let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
89
+ let runner = MockCommandRunner {
90
+ recorded: Arc::clone(&recorded),
91
+ stdin_recorded,
92
+ queue: Mutex::new(queued.into_iter().collect()),
93
+ default,
94
+ };
95
+ (TmuxBackend::with_runner(Box::new(runner)), recorded)
96
+ }
97
+
98
+ fn backend_with_stdin(
99
+ default: MockResp,
100
+ queued: Vec<MockResp>,
101
+ ) -> (TmuxBackend, RecordedArgv, RecordedStdin) {
102
+ let recorded = Arc::new(Mutex::new(Vec::new()));
103
+ let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
104
+ let runner = MockCommandRunner {
105
+ recorded: Arc::clone(&recorded),
106
+ stdin_recorded: Arc::clone(&stdin_recorded),
107
+ queue: Mutex::new(queued.into_iter().collect()),
108
+ default,
109
+ };
110
+ (
111
+ TmuxBackend::with_runner(Box::new(runner)),
112
+ recorded,
113
+ stdin_recorded,
114
+ )
115
+ }
116
+
117
+ fn svec(items: &[&str]) -> Vec<String> {
118
+ items.iter().map(|s| (*s).to_string()).collect()
119
+ }
120
+
121
+ struct EnvGuard {
122
+ saved: Vec<(String, Option<String>)>,
123
+ }
124
+
125
+ impl EnvGuard {
126
+ fn apply(vars: &[(&str, Option<&str>)]) -> Self {
127
+ let saved = vars
128
+ .iter()
129
+ .map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
130
+ .collect();
131
+ for (k, v) in vars {
132
+ match v {
133
+ Some(val) => std::env::set_var(k, val),
134
+ None => std::env::remove_var(k),
114
135
  }
115
- Self { saved }
116
136
  }
137
+ Self { saved }
117
138
  }
118
-
119
- impl Drop for EnvGuard {
120
- fn drop(&mut self) {
121
- for (k, v) in &self.saved {
122
- match v {
123
- Some(val) => std::env::set_var(k, val),
124
- None => std::env::remove_var(k),
125
- }
139
+ }
140
+
141
+ impl Drop for EnvGuard {
142
+ fn drop(&mut self) {
143
+ for (k, v) in &self.saved {
144
+ match v {
145
+ Some(val) => std::env::set_var(k, val),
146
+ None => std::env::remove_var(k),
126
147
  }
127
148
  }
128
149
  }
129
-
130
- #[test]
131
- #[serial_test::serial(env)]
132
- fn leader_receiver_endpoint_from_tmux_env_preserves_full_socket_path() {
133
- let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
134
- let _env = EnvGuard::apply(&[
135
- ("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
136
- ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
137
- ]);
138
-
139
- assert_eq!(
140
- super::socket_name_from_tmux_env().as_deref(),
141
- Some(leader_socket),
142
- "leader receivers must persist the exact tmux endpoint from $TMUX; a short -L socket \
150
+ }
151
+
152
+ #[test]
153
+ #[serial_test::serial(env)]
154
+ fn leader_receiver_endpoint_from_tmux_env_preserves_full_socket_path() {
155
+ let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
156
+ let _env = EnvGuard::apply(&[
157
+ ("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
158
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
159
+ ]);
160
+
161
+ assert_eq!(
162
+ super::socket_name_from_tmux_env().as_deref(),
163
+ Some(leader_socket),
164
+ "leader receivers must persist the exact tmux endpoint from $TMUX; a short -L socket \
143
165
  name is re-rooted under the coordinator's TMUX_TMPDIR and cannot reach an external \
144
166
  leader pane"
145
- );
146
- }
147
-
148
- #[test]
149
- #[serial_test::serial(env)]
150
- fn leader_receiver_endpoint_from_tmux_env_rejects_short_socket_name() {
151
- let _env = EnvGuard::apply(&[
152
- ("TMUX", Some("dl9aa40c88,12345,0")),
153
- ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
154
- ]);
155
-
156
- assert_eq!(
167
+ );
168
+ }
169
+
170
+ #[test]
171
+ #[serial_test::serial(env)]
172
+ fn leader_receiver_endpoint_from_tmux_env_rejects_short_socket_name() {
173
+ let _env = EnvGuard::apply(&[
174
+ ("TMUX", Some("dl9aa40c88,12345,0")),
175
+ ("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
176
+ ]);
177
+
178
+ assert_eq!(
157
179
  super::socket_name_from_tmux_env(),
158
180
  None,
159
181
  "leader_receiver.tmux_socket is a durable physical endpoint: a short socket name from \
160
182
  $TMUX must not be persisted because tmux -L <short> is re-rooted under the coordinator"
161
183
  );
162
- }
184
+ }
163
185
 
164
- #[test]
165
- fn leader_receiver_delivery_uses_full_socket_endpoint_not_short_l_reconstruction() {
166
- let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
167
- let delivery = std::fs::read_to_string(manifest.join("src/messaging/delivery.rs")).unwrap();
168
- let leader_receiver =
169
- std::fs::read_to_string(manifest.join("src/messaging/leader_receiver.rs")).unwrap();
170
- let tmux_backend = std::fs::read_to_string(manifest.join("src/tmux_backend.rs")).unwrap();
186
+ #[test]
187
+ fn leader_receiver_delivery_uses_full_socket_endpoint_not_short_l_reconstruction() {
188
+ let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
189
+ let delivery = std::fs::read_to_string(manifest.join("src/messaging/delivery.rs")).unwrap();
190
+ let leader_receiver =
191
+ std::fs::read_to_string(manifest.join("src/messaging/leader_receiver.rs")).unwrap();
192
+ let tmux_backend = std::fs::read_to_string(manifest.join("src/tmux_backend.rs")).unwrap();
171
193
 
172
- assert!(
194
+ assert!(
173
195
  tmux_backend.contains("\"-S\""),
174
196
  "tmux backend must support `tmux -S <full-socket-path>` for persisted external leader \
175
197
  endpoints; `-L <short-name>` is not enough when leader and coordinator TMUX_TMPDIR differ"
176
198
  );
177
- assert!(
178
- !delivery.contains("TmuxBackend::for_socket_name(socket)"),
179
- "worker->leader delivery must not reconstruct an external leader endpoint with \
199
+ assert!(
200
+ !delivery.contains("TmuxBackend::for_socket_name(socket)"),
201
+ "worker->leader delivery must not reconstruct an external leader endpoint with \
180
202
  `tmux -L <short-name>`; it must use the persisted full socket path endpoint"
181
- );
182
- assert!(
183
- !leader_receiver.contains("TmuxBackend::for_socket_name(socket)"),
184
- "leader_receiver live checks must verify the same full socket endpoint used by delivery, \
203
+ );
204
+ assert!(
205
+ !leader_receiver.contains("TmuxBackend::for_socket_name(socket)"),
206
+ "leader_receiver live checks must verify the same full socket endpoint used by delivery, \
185
207
  not a short socket name resolved under the coordinator's socket root"
186
- );
187
- }
188
-
189
- #[test]
190
- fn leader_receiver_full_endpoint_liveness_list_and_inject_use_s_path_command_shape() {
191
- let endpoint = "/private/tmp/tmux-501/default";
192
- let stdout = "%7\tteam-x\t0\tleader\t0\t/dev/ttys003\tbash\t1\t/Users/me/work\t1\t0\n";
193
- let (be, rec, _stdin) = {
194
- let recorded = Arc::new(Mutex::new(Vec::new()));
195
- let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
196
- let runner = MockCommandRunner {
197
- recorded: Arc::clone(&recorded),
198
- stdin_recorded: Arc::clone(&stdin_recorded),
199
- queue: Mutex::new(
200
- vec![
201
- MockResp::Out(ok(stdout)),
202
- MockResp::Out(ok("%7\n")),
203
- MockResp::Out(ok("")),
204
- MockResp::Out(ok("")),
205
- MockResp::Out(ok("")),
206
- ]
207
- .into_iter()
208
- .collect(),
209
- ),
210
- default: MockResp::Out(ok("")),
211
- };
212
- (
213
- TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
214
- recorded,
215
- stdin_recorded,
216
- )
208
+ );
209
+ }
210
+
211
+ #[test]
212
+ fn leader_receiver_full_endpoint_liveness_list_and_inject_use_s_path_command_shape() {
213
+ let endpoint = "/private/tmp/tmux-501/default";
214
+ let stdout = "%7\tteam-x\t0\tleader\t0\t/dev/ttys003\tbash\t1\t/Users/me/work\t1\t0\n";
215
+ let (be, rec, _stdin) = {
216
+ let recorded = Arc::new(Mutex::new(Vec::new()));
217
+ let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
218
+ let runner = MockCommandRunner {
219
+ recorded: Arc::clone(&recorded),
220
+ stdin_recorded: Arc::clone(&stdin_recorded),
221
+ queue: Mutex::new(
222
+ vec![
223
+ MockResp::Out(ok(stdout)),
224
+ MockResp::Out(ok("%7\n")),
225
+ MockResp::Out(ok("")),
226
+ MockResp::Out(ok("")),
227
+ MockResp::Out(ok("")),
228
+ ]
229
+ .into_iter()
230
+ .collect(),
231
+ ),
232
+ default: MockResp::Out(ok("")),
217
233
  };
234
+ (
235
+ TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
236
+ recorded,
237
+ stdin_recorded,
238
+ )
239
+ };
218
240
 
219
- let _ = be.list_targets().expect("list_targets via endpoint");
220
- let _ = be.liveness(&PaneId::new("%7")).expect("liveness via endpoint");
221
- let _ = be
222
- .inject(
223
- &Target::Pane(PaneId::new("%7")),
224
- &InjectPayload::Text("hello leader".to_string()),
225
- Key::Enter,
226
- true,
227
- )
228
- .expect("inject via endpoint");
241
+ let _ = be.list_targets().expect("list_targets via endpoint");
242
+ let _ = be
243
+ .liveness(&PaneId::new("%7"))
244
+ .expect("liveness via endpoint");
245
+ let _ = be
246
+ .inject(
247
+ &Target::Pane(PaneId::new("%7")),
248
+ &InjectPayload::Text("hello leader".to_string()),
249
+ Key::Enter,
250
+ true,
251
+ )
252
+ .expect("inject via endpoint");
229
253
 
230
- let calls = rec.lock().unwrap().clone();
231
- assert!(
254
+ let calls = rec.lock().unwrap().clone();
255
+ assert!(
232
256
  calls.len() >= 5,
233
257
  "fixture must exercise list-panes, display-message, buffer/paste, and send-keys; got {calls:?}"
234
258
  );
235
- for call in &calls {
236
- assert!(
259
+ for call in &calls {
260
+ assert!(
237
261
  call.starts_with(&["tmux".to_string(), "-S".to_string(), endpoint.to_string()]),
238
262
  "leader receiver list/liveness/inject must use tmux -S <full socket path>; got {call:?}"
239
263
  );
240
- assert!(
241
- !call.windows(2).any(|w| w == ["-L".to_string(), endpoint.to_string()]),
242
- "leader receiver full endpoint must never be reconstructed with -L; got {call:?}"
243
- );
244
- }
245
264
  assert!(
246
- calls.iter().any(|call| call.iter().any(|arg| arg == "list-panes"))
247
- && calls.iter().any(|call| call.iter().any(|arg| arg == "display-message"))
248
- && calls.iter().any(|call| call.iter().any(|arg| arg == "paste-buffer"))
249
- && calls.iter().any(|call| call.iter().any(|arg| arg == "send-keys")),
250
- "contract must cover liveness/list/inject command shapes; got {calls:?}"
265
+ !call
266
+ .windows(2)
267
+ .any(|w| w == ["-L".to_string(), endpoint.to_string()]),
268
+ "leader receiver full endpoint must never be reconstructed with -L; got {call:?}"
251
269
  );
252
270
  }
253
-
254
- #[test]
255
- #[serial_test::serial(env)]
256
- fn leader_receiver_short_endpoint_must_not_reconstruct_tmux_l_socket() {
257
- let endpoint = "dl9aa40c88";
258
- let uid = unsafe { libc::geteuid() };
259
- let tmp = std::env::temp_dir().join(format!(
260
- "ta-tmux-short-endpoint-{}",
261
- std::process::id()
262
- ));
263
- let root = tmp.join(format!("tmux-{uid}"));
264
- std::fs::create_dir_all(&root).unwrap();
265
- let socket_path = root.join(endpoint);
266
- let _listener = UnixListener::bind(&socket_path).unwrap();
267
- let socket_path = socket_path.canonicalize().unwrap();
268
- let _env = EnvGuard::apply(&[("TMPDIR", Some(tmp.to_str().unwrap()))]);
269
- let (be, rec) = {
270
- let recorded = Arc::new(Mutex::new(Vec::new()));
271
- let runner = MockCommandRunner {
272
- recorded: Arc::clone(&recorded),
273
- stdin_recorded: Arc::new(Mutex::new(Vec::new())),
274
- queue: Mutex::new(vec![MockResp::Out(ok(""))].into_iter().collect()),
275
- default: MockResp::Out(ok("")),
276
- };
277
- (
278
- TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
279
- recorded,
280
- )
271
+ assert!(
272
+ calls
273
+ .iter()
274
+ .any(|call| call.iter().any(|arg| arg == "list-panes"))
275
+ && calls
276
+ .iter()
277
+ .any(|call| call.iter().any(|arg| arg == "display-message"))
278
+ && calls
279
+ .iter()
280
+ .any(|call| call.iter().any(|arg| arg == "paste-buffer"))
281
+ && calls
282
+ .iter()
283
+ .any(|call| call.iter().any(|arg| arg == "send-keys")),
284
+ "contract must cover liveness/list/inject command shapes; got {calls:?}"
285
+ );
286
+ }
287
+
288
+ #[test]
289
+ #[serial_test::serial(env)]
290
+ fn leader_receiver_short_endpoint_must_not_reconstruct_tmux_l_socket() {
291
+ let endpoint = "dl9aa40c88";
292
+ let uid = unsafe { libc::geteuid() };
293
+ let tmp = std::env::temp_dir().join(format!("ta-tmux-short-endpoint-{}", std::process::id()));
294
+ let root = tmp.join(format!("tmux-{uid}"));
295
+ std::fs::create_dir_all(&root).unwrap();
296
+ let socket_path = root.join(endpoint);
297
+ let _listener = UnixListener::bind(&socket_path).unwrap();
298
+ let socket_path = socket_path.canonicalize().unwrap();
299
+ let _env = EnvGuard::apply(&[("TMPDIR", Some(tmp.to_str().unwrap()))]);
300
+ let (be, rec) = {
301
+ let recorded = Arc::new(Mutex::new(Vec::new()));
302
+ let runner = MockCommandRunner {
303
+ recorded: Arc::clone(&recorded),
304
+ stdin_recorded: Arc::new(Mutex::new(Vec::new())),
305
+ queue: Mutex::new(vec![MockResp::Out(ok(""))].into_iter().collect()),
306
+ default: MockResp::Out(ok("")),
281
307
  };
308
+ (
309
+ TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
310
+ recorded,
311
+ )
312
+ };
282
313
 
283
- let _ = be.list_targets().expect("short endpoint should not become -L");
314
+ let _ = be
315
+ .list_targets()
316
+ .expect("short endpoint should not become -L");
284
317
 
285
- let calls = rec.lock().unwrap().clone();
286
- assert!(
287
- calls.iter().all(|call| !call.windows(2).any(|w| w == ["-L".to_string(), endpoint.to_string()])),
288
- "non-canonical leader endpoints must be rejected or left unbound, never reconstructed as \
318
+ let calls = rec.lock().unwrap().clone();
319
+ assert!(
320
+ calls.iter().all(|call| !call
321
+ .windows(2)
322
+ .any(|w| w == ["-L".to_string(), endpoint.to_string()])),
323
+ "non-canonical leader endpoints must be rejected or left unbound, never reconstructed as \
289
324
  tmux -L <short> under the coordinator socket root; calls={calls:?}"
290
- );
291
- assert!(
325
+ );
326
+ assert!(
292
327
  calls.iter().all(|call| call.starts_with(&[
293
328
  "tmux".to_string(),
294
329
  "-S".to_string(),
@@ -296,717 +331,815 @@
296
331
  ])),
297
332
  "short leader endpoints must resolve to the existing physical socket path with -S; calls={calls:?}"
298
333
  );
299
- }
300
-
301
- // ── 1. has_session: exit 0 -> true, exit 1 -> false; argv = `tmux has-session -t <s>` ──────────
302
- #[test]
303
- fn has_session_argv_and_exit_code_maps_to_bool() {
304
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
305
- assert!(be.has_session(&SessionName::new("sess")).expect("has_session"), "exit 0 -> true");
306
- assert_eq!(rec.lock().unwrap()[0], svec(&["tmux", "has-session", "-t", "sess"]));
307
-
308
- let (be, rec) = backend_with(MockResp::Out(fail(1, "can't find session: sess")), vec![]);
309
- assert!(!be.has_session(&SessionName::new("sess")).expect("has_session"), "exit 1 -> false");
310
- assert_eq!(rec.lock().unwrap()[0], svec(&["tmux", "has-session", "-t", "sess"]));
311
- }
312
-
313
- // ── 2. spawn_first / spawn_into frame via tmux_spawn_argv; canned output parses pane id ────────
314
- #[test]
315
- fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
316
- let pane_inventory =
317
- "%3\tteamsess\t0\tw1\t0\t/dev/ttys003\tnode\t1\t/work/dir\t1\t0\t123\n";
318
- let (be, rec) = backend_with(
334
+ }
335
+
336
+ // ── 1. has_session: exit 0 -> true, exit 1 -> false; argv = `tmux has-session -t <s>` ──────────
337
+ #[test]
338
+ fn has_session_argv_and_exit_code_maps_to_bool() {
339
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
340
+ assert!(
341
+ be.has_session(&SessionName::new("sess"))
342
+ .expect("has_session"),
343
+ "exit 0 -> true"
344
+ );
345
+ assert_eq!(
346
+ rec.lock().unwrap()[0],
347
+ svec(&["tmux", "has-session", "-t", "sess"])
348
+ );
349
+
350
+ let (be, rec) = backend_with(MockResp::Out(fail(1, "can't find session: sess")), vec![]);
351
+ assert!(
352
+ !be.has_session(&SessionName::new("sess"))
353
+ .expect("has_session"),
354
+ "exit 1 -> false"
355
+ );
356
+ assert_eq!(
357
+ rec.lock().unwrap()[0],
358
+ svec(&["tmux", "has-session", "-t", "sess"])
359
+ );
360
+ }
361
+
362
+ // ── 2. spawn_first / spawn_into frame via tmux_spawn_argv; canned output parses pane id ────────
363
+ #[test]
364
+ fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
365
+ let pane_inventory = "%3\tteamsess\t0\tw1\t0\t/dev/ttys003\tnode\t1\t/work/dir\t1\t0\t123\n";
366
+ let (be, rec) = backend_with(
367
+ MockResp::Out(ok("")),
368
+ vec![
319
369
  MockResp::Out(ok("")),
320
- vec![
321
- MockResp::Out(ok("")),
322
- MockResp::Out(ok("%3")),
323
- MockResp::Out(ok(pane_inventory)),
324
- ],
325
- );
326
- let s = SessionName::new("teamsess");
327
- let w = WindowName::new("w1");
328
- let env = BTreeMap::from([("TEAM_AGENT_ID".to_string(), "w1".to_string())]);
329
- let result = be
330
- .spawn_first(&s, &w, &svec(&["provider-bin", "--flag"]), Path::new("/work/dir"), &env)
331
- .expect("spawn_first");
332
- let argv = rec.lock().unwrap()[0].clone();
333
- let cmd = argv.last().expect("the sh -lc command string").clone();
334
- assert_eq!(
335
- argv,
336
- tmux_spawn_argv(&s, &w, &cmd, true),
337
- "spawn_first must frame via tmux_spawn_argv (new-session -d -s <s> -n <w> sh -lc <cmd>)"
338
- );
339
- assert!(cmd.contains("provider-bin"), "the provider argv must be in the sh -lc command; got {cmd}");
340
- assert_eq!(result.pane_id.as_str(), "%3", "SpawnResult.pane_id must parse from the tmux output");
341
- assert_eq!(result.child_pid, Some(123));
342
- }
343
-
344
- #[test]
345
- fn spawn_into_frames_via_new_window_builder() {
346
- let pane_inventory =
347
- "%4\tteamsess\t1\tw2\t0\t/dev/ttys004\tnode\t1\t/work/dir\t1\t0\t124\n";
348
- let (be, rec) = backend_with(
370
+ MockResp::Out(ok("%3")),
371
+ MockResp::Out(ok(pane_inventory)),
372
+ ],
373
+ );
374
+ let s = SessionName::new("teamsess");
375
+ let w = WindowName::new("w1");
376
+ let env = BTreeMap::from([("TEAM_AGENT_ID".to_string(), "w1".to_string())]);
377
+ let result = be
378
+ .spawn_first(
379
+ &s,
380
+ &w,
381
+ &svec(&["provider-bin", "--flag"]),
382
+ Path::new("/work/dir"),
383
+ &env,
384
+ )
385
+ .expect("spawn_first");
386
+ let argv = rec.lock().unwrap()[0].clone();
387
+ let cmd = argv.last().expect("the sh -lc command string").clone();
388
+ assert_eq!(
389
+ argv,
390
+ tmux_spawn_argv(&s, &w, &cmd, true),
391
+ "spawn_first must frame via tmux_spawn_argv (new-session -d -s <s> -n <w> sh -lc <cmd>)"
392
+ );
393
+ assert!(
394
+ cmd.contains("provider-bin"),
395
+ "the provider argv must be in the sh -lc command; got {cmd}"
396
+ );
397
+ assert_eq!(
398
+ result.pane_id.as_str(),
399
+ "%3",
400
+ "SpawnResult.pane_id must parse from the tmux output"
401
+ );
402
+ assert_eq!(result.child_pid, Some(123));
403
+ }
404
+
405
+ #[test]
406
+ fn spawn_into_frames_via_new_window_builder() {
407
+ let pane_inventory = "%4\tteamsess\t1\tw2\t0\t/dev/ttys004\tnode\t1\t/work/dir\t1\t0\t124\n";
408
+ let (be, rec) = backend_with(
409
+ MockResp::Out(ok("")),
410
+ vec![
349
411
  MockResp::Out(ok("")),
350
- vec![
351
- MockResp::Out(ok("")),
352
- MockResp::Out(ok("%4")),
353
- MockResp::Out(ok(pane_inventory)),
354
- ],
355
- );
356
- let s = SessionName::new("teamsess");
357
- let w = WindowName::new("w2");
358
- let result = be
359
- .spawn_into(&s, &w, &svec(&["provider-bin"]), Path::new("/work/dir"), &BTreeMap::new())
360
- .expect("spawn_into");
361
- let argv = rec.lock().unwrap()[0].clone();
362
- let cmd = argv.last().expect("the sh -lc command string").clone();
363
- assert_eq!(
412
+ MockResp::Out(ok("%4")),
413
+ MockResp::Out(ok(pane_inventory)),
414
+ ],
415
+ );
416
+ let s = SessionName::new("teamsess");
417
+ let w = WindowName::new("w2");
418
+ let result = be
419
+ .spawn_into(
420
+ &s,
421
+ &w,
422
+ &svec(&["provider-bin"]),
423
+ Path::new("/work/dir"),
424
+ &BTreeMap::new(),
425
+ )
426
+ .expect("spawn_into");
427
+ let argv = rec.lock().unwrap()[0].clone();
428
+ let cmd = argv.last().expect("the sh -lc command string").clone();
429
+ assert_eq!(
364
430
  argv,
365
431
  tmux_spawn_argv(&s, &w, &cmd, false),
366
432
  "spawn_into must frame via tmux_spawn_argv first=false (new-window -t <s> -n <w> sh -lc <cmd>)"
367
433
  );
368
- assert_eq!(result.pane_id.as_str(), "%4");
369
- }
370
-
371
- #[test]
372
- fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
373
- let pane_inventory =
374
- "%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
375
- let (be, _rec) = backend_with(
376
- MockResp::Out(ok("")),
377
- vec![
378
- MockResp::Out(ok("")),
379
- MockResp::Out(ok("%5")),
380
- MockResp::Out(ok(pane_inventory)),
381
- ],
382
- );
383
- let err = be
384
- .spawn_into(
385
- &SessionName::new("teamsess"),
386
- &WindowName::new("w1"),
387
- &svec(&["provider-bin"]),
388
- Path::new("/work/dir"),
389
- &BTreeMap::new(),
390
- )
391
- .expect_err("display-message fallback to w2 pane must fail closed");
392
- let msg = err.to_string();
393
- assert!(
394
- msg.contains("requested=teamsess:w1")
395
- && msg.contains("observed_pane=%5")
396
- && msg.contains("observed=teamsess:w2"),
397
- "error must include requested/observed ownership evidence, got {msg}"
398
- );
399
- }
434
+ assert_eq!(result.pane_id.as_str(), "%4");
435
+ }
400
436
 
401
- #[test]
402
- fn spawn_split_selects_even_horizontal_not_tiled() {
403
- let (be, rec) = backend_with(
437
+ #[test]
438
+ fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
439
+ let pane_inventory = "%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
440
+ let (be, _rec) = backend_with(
441
+ MockResp::Out(ok("")),
442
+ vec![
404
443
  MockResp::Out(ok("")),
405
- vec![MockResp::Out(ok("%5")), MockResp::Out(ok(""))],
406
- );
407
- let s = SessionName::new("teamsess");
408
- let w = WindowName::new("team-w1");
409
- let result = be
410
- .spawn_split_with_env_unset(
411
- &s,
412
- &w,
413
- &svec(&["provider-bin"]),
414
- Path::new("/work/dir"),
415
- &BTreeMap::new(),
416
- &[],
417
- )
418
- .expect("spawn_split");
419
- assert_eq!(result.pane_id.as_str(), "%5");
420
- let calls = rec.lock().unwrap().clone();
421
- // E53 (0.3.26): split-window now carries `-d` (no focus steal).
422
- assert_eq!(
423
- calls[0][0..5],
424
- svec(&["tmux", "split-window", "-d", "-t", "teamsess:team-w1"])
425
- );
426
- assert_eq!(
427
- calls[1],
444
+ MockResp::Out(ok("%5")),
445
+ MockResp::Out(ok(pane_inventory)),
446
+ ],
447
+ );
448
+ let err = be
449
+ .spawn_into(
450
+ &SessionName::new("teamsess"),
451
+ &WindowName::new("w1"),
452
+ &svec(&["provider-bin"]),
453
+ Path::new("/work/dir"),
454
+ &BTreeMap::new(),
455
+ )
456
+ .expect_err("display-message fallback to w2 pane must fail closed");
457
+ let msg = err.to_string();
458
+ assert!(
459
+ msg.contains("requested=teamsess:w1")
460
+ && msg.contains("observed_pane=%5")
461
+ && msg.contains("observed=teamsess:w2"),
462
+ "error must include requested/observed ownership evidence, got {msg}"
463
+ );
464
+ }
465
+
466
+ #[test]
467
+ fn spawn_split_selects_even_horizontal_not_tiled() {
468
+ let (be, rec) = backend_with(
469
+ MockResp::Out(ok("")),
470
+ vec![MockResp::Out(ok("%5")), MockResp::Out(ok(""))],
471
+ );
472
+ let s = SessionName::new("teamsess");
473
+ let w = WindowName::new("team-w1");
474
+ let result = be
475
+ .spawn_split_with_env_unset(
476
+ &s,
477
+ &w,
478
+ &svec(&["provider-bin"]),
479
+ Path::new("/work/dir"),
480
+ &BTreeMap::new(),
481
+ &[],
482
+ )
483
+ .expect("spawn_split");
484
+ assert_eq!(result.pane_id.as_str(), "%5");
485
+ let calls = rec.lock().unwrap().clone();
486
+ // E53 (0.3.26): split-window now carries `-d` (no focus steal).
487
+ assert_eq!(
488
+ calls[0][0..5],
489
+ svec(&["tmux", "split-window", "-d", "-t", "teamsess:team-w1"])
490
+ );
491
+ assert_eq!(
492
+ calls[1],
493
+ svec(&[
494
+ "tmux",
495
+ "select-layout",
496
+ "-t",
497
+ "teamsess:team-w1",
498
+ "even-horizontal",
499
+ ])
500
+ );
501
+ assert!(
502
+ !calls.iter().flatten().any(|arg| arg == "tiled"),
503
+ "adaptive split must not leave tmux tiled layout in the command stream: {calls:?}"
504
+ );
505
+ }
506
+
507
+ #[test]
508
+ fn configure_adaptive_pane_title_sets_border_and_pane_title() {
509
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
510
+ be.configure_adaptive_pane_title(
511
+ &SessionName::new("teamsess"),
512
+ &WindowName::new("team-w1"),
513
+ &PaneId::new("%7"),
514
+ "builder",
515
+ )
516
+ .expect("configure title");
517
+ let calls = rec.lock().unwrap().clone();
518
+ assert_eq!(
519
+ calls,
520
+ vec![
428
521
  svec(&[
429
522
  "tmux",
430
- "select-layout",
523
+ "set-window-option",
431
524
  "-t",
432
525
  "teamsess:team-w1",
433
- "even-horizontal",
434
- ])
435
- );
436
- assert!(
437
- !calls.iter().flatten().any(|arg| arg == "tiled"),
438
- "adaptive split must not leave tmux tiled layout in the command stream: {calls:?}"
439
- );
440
- }
441
-
442
- #[test]
443
- fn configure_adaptive_pane_title_sets_border_and_pane_title() {
444
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
445
- be.configure_adaptive_pane_title(
446
- &SessionName::new("teamsess"),
447
- &WindowName::new("team-w1"),
448
- &PaneId::new("%7"),
449
- "builder",
526
+ "pane-border-status",
527
+ "bottom",
528
+ ]),
529
+ svec(&[
530
+ "tmux",
531
+ "set-window-option",
532
+ "-t",
533
+ "teamsess:team-w1",
534
+ "pane-border-format",
535
+ " #{pane_title} ",
536
+ ]),
537
+ svec(&["tmux", "select-pane", "-t", "%7", "-T", "builder"]),
538
+ ]
539
+ );
540
+ }
541
+
542
+ // ── 3. set_session_env: argv = `tmux set-environment -t <s> <k> <v>`; success -> Applied ───────
543
+ #[test]
544
+ fn set_session_env_argv_and_applied_outcome() {
545
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
546
+ let outcome = be
547
+ .set_session_env(&SessionName::new("sess"), "KEY", "VAL")
548
+ .expect("set env");
549
+ assert_eq!(
550
+ rec.lock().unwrap()[0],
551
+ svec(&["tmux", "set-environment", "-t", "sess", "KEY", "VAL"])
552
+ );
553
+ assert_eq!(
554
+ outcome,
555
+ SetEnvOutcome::Applied,
556
+ "tmux set-environment success -> SetEnvOutcome::Applied"
557
+ );
558
+ }
559
+
560
+ // ── 4. capture: argv = tmux_capture_argv; canned scrollback -> normalize_capture -> CapturedText ─
561
+ #[test]
562
+ fn capture_argv_and_normalizes_scrollback() {
563
+ let scroll = "line one \nbusy\u{a0}marker \n \n";
564
+ let (be, rec) = backend_with(MockResp::Out(ok(scroll)), vec![]);
565
+ let pane = PaneId::new("%7");
566
+ let captured = be
567
+ .capture(&Target::Pane(pane.clone()), CaptureRange::Tail(40))
568
+ .expect("capture");
569
+ assert_eq!(
570
+ rec.lock().unwrap()[0],
571
+ tmux_capture_argv(&pane, CaptureRange::Tail(40))
572
+ );
573
+ assert_eq!(
574
+ captured.text,
575
+ normalize_capture(scroll),
576
+ "capture output must be normalize_capture'd"
577
+ );
578
+ assert_eq!(captured.range, CaptureRange::Tail(40));
579
+ }
580
+
581
+ // ── 5a. send_keys: argv = tmux_send_keys_argv ──────────────────────────────────────────────────
582
+ #[test]
583
+ fn send_keys_argv_matches_builder() {
584
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
585
+ let pane = PaneId::new("%7");
586
+ be.send_keys(&Target::Pane(pane.clone()), &[Key::Enter])
587
+ .expect("send_keys");
588
+ assert_eq!(
589
+ rec.lock().unwrap()[0],
590
+ tmux_send_keys_argv(&pane, &[Key::Enter])
591
+ );
592
+ }
593
+
594
+ // ── 5b. inject (text): set/load-buffer(text) -> paste-buffer -p -> submit send-keys; report Submit ─
595
+ #[test]
596
+ fn inject_text_runs_buffer_paste_submit_sequence_and_reports_submit() {
597
+ let (be, rec) = backend_with(MockResp::Out(ok("hello")), vec![]);
598
+ let pane = PaneId::new("%7");
599
+ let report = be
600
+ .inject(
601
+ &Target::Pane(pane.clone()),
602
+ &InjectPayload::Text("hello".to_string()),
603
+ Key::Enter,
604
+ true,
450
605
  )
451
- .expect("configure title");
452
- let calls = rec.lock().unwrap().clone();
453
- assert_eq!(
454
- calls,
455
- vec![
456
- svec(&[
457
- "tmux",
458
- "set-window-option",
459
- "-t",
460
- "teamsess:team-w1",
461
- "pane-border-status",
462
- "bottom",
463
- ]),
464
- svec(&[
465
- "tmux",
466
- "set-window-option",
467
- "-t",
468
- "teamsess:team-w1",
469
- "pane-border-format",
470
- " #{pane_title} ",
471
- ]),
472
- svec(&["tmux", "select-pane", "-t", "%7", "-T", "builder"]),
473
- ]
474
- );
475
- }
476
-
477
- // ── 3. set_session_env: argv = `tmux set-environment -t <s> <k> <v>`; success -> Applied ───────
478
- #[test]
479
- fn set_session_env_argv_and_applied_outcome() {
480
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
481
- let outcome = be.set_session_env(&SessionName::new("sess"), "KEY", "VAL").expect("set env");
482
- assert_eq!(rec.lock().unwrap()[0], svec(&["tmux", "set-environment", "-t", "sess", "KEY", "VAL"]));
483
- assert_eq!(outcome, SetEnvOutcome::Applied, "tmux set-environment success -> SetEnvOutcome::Applied");
484
- }
485
-
486
- // ── 4. capture: argv = tmux_capture_argv; canned scrollback -> normalize_capture -> CapturedText ─
487
- #[test]
488
- fn capture_argv_and_normalizes_scrollback() {
489
- let scroll = "line one \nbusy\u{a0}marker \n \n";
490
- let (be, rec) = backend_with(MockResp::Out(ok(scroll)), vec![]);
491
- let pane = PaneId::new("%7");
492
- let captured = be
493
- .capture(&Target::Pane(pane.clone()), CaptureRange::Tail(40))
494
- .expect("capture");
495
- assert_eq!(rec.lock().unwrap()[0], tmux_capture_argv(&pane, CaptureRange::Tail(40)));
496
- assert_eq!(captured.text, normalize_capture(scroll), "capture output must be normalize_capture'd");
497
- assert_eq!(captured.range, CaptureRange::Tail(40));
498
- }
499
-
500
- // ── 5a. send_keys: argv = tmux_send_keys_argv ──────────────────────────────────────────────────
501
- #[test]
502
- fn send_keys_argv_matches_builder() {
503
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
504
- let pane = PaneId::new("%7");
505
- be.send_keys(&Target::Pane(pane.clone()), &[Key::Enter]).expect("send_keys");
506
- assert_eq!(rec.lock().unwrap()[0], tmux_send_keys_argv(&pane, &[Key::Enter]));
507
- }
508
-
509
- // ── 5b. inject (text): set/load-buffer(text) -> paste-buffer -p -> submit send-keys; report Submit ─
510
- #[test]
511
- fn inject_text_runs_buffer_paste_submit_sequence_and_reports_submit() {
512
- let (be, rec) = backend_with(MockResp::Out(ok("hello")), vec![]);
513
- let pane = PaneId::new("%7");
514
- let report = be
515
- .inject(&Target::Pane(pane.clone()), &InjectPayload::Text("hello".to_string()), Key::Enter, true)
516
- .expect("inject");
517
- let calls = rec.lock().unwrap().clone();
518
- let is = |a: &[String], sub: &str| a.get(1).map(String::as_str) == Some(sub);
519
- assert!(
520
- calls.iter().any(|a| (is(a, "set-buffer") || is(a, "load-buffer")) && a.iter().any(|x| x.contains("hello"))),
521
- "inject must stage the text into a tmux buffer (set-buffer/load-buffer); got {calls:?}"
522
- );
523
- assert!(
524
- calls.iter().any(|a| is(a, "paste-buffer") && a.contains(&"-p".to_string()) && a.contains(&"%7".to_string())),
525
- "inject must bracketed-paste (-p) the buffer to the pane; got {calls:?}"
526
- );
527
- assert!(
528
- calls.iter().any(|a| is(a, "send-keys") && a.contains(&"Enter".to_string())),
529
- "inject must send the submit key (Enter) last; got {calls:?}"
530
- );
531
- assert_eq!(report.stage_reached, InjectStage::Submit, "a fully-applied inject reaches the Submit stage");
532
- assert_eq!(report.inject_verification, InjectVerification::NoToken);
533
- assert_eq!(
534
- report.submit_verification,
535
- SubmitVerification::EnterSentWithoutPlaceholderCheck
536
- );
537
- assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
538
- }
539
-
540
- #[test]
541
- fn inject_large_text_load_buffer_writes_stdin_and_token_report() {
542
- let text = format!("{}{}", "x".repeat(16 * 1024), " [team-agent-token:abc]");
543
- // U1 #7: the pre-submit readback captures the pane; a pane that ECHOES the token
544
- // back (default capture returns the text) → CaptureContainsToken (true positive).
545
- let (be, rec, stdin_rec) = backend_with_stdin(MockResp::Out(ok(&text)), vec![]);
546
- let report = be
547
- .inject(&Target::Pane(PaneId::new("%7")), &InjectPayload::Text(text.clone()), Key::Down, true)
548
- .expect("inject large text");
549
-
550
- assert_eq!(report.inject_verification, InjectVerification::CaptureContainsToken);
551
- assert_eq!(
552
- report.submit_verification,
553
- SubmitVerification::KeySentAfterVisibleToken { key: Key::Down }
554
- );
555
- let calls = rec.lock().unwrap().clone();
556
- assert_eq!(calls[0], svec(&["tmux", "load-buffer", "-b", "team-agent-send-abc", "-"]));
557
- assert_eq!(stdin_rec.lock().unwrap()[0], text);
558
- }
559
-
560
- // U1 #7 (RED→GREEN) — pre-submit pane readback: a token payload whose token is NOT
561
- // visible in the pane before submit (paste silently dropped) must report
562
- // CaptureMissingToken, not the static false-positive CaptureContainsToken.
563
- #[test]
564
- fn inject_token_not_visible_in_pane_reports_capture_missing_token() {
565
- let text = format!("{}{}", "x".repeat(16 * 1024), " [team-agent-token:zzz]");
566
- // default capture returns empty → token not visible → readback says missing.
567
- let (be, _rec, _stdin) = backend_with_stdin(MockResp::Out(ok("")), vec![]);
568
- let report = be
569
- .inject(&Target::Pane(PaneId::new("%9")), &InjectPayload::Text(text), Key::Down, true)
570
- .expect("inject runs");
571
- assert_eq!(
572
- report.inject_verification,
573
- InjectVerification::CaptureMissingToken,
574
- "U1 #7: a token that never appeared in the pane must read back as \
575
- CaptureMissingToken, not the static CaptureContainsToken false-positive"
576
- );
577
- }
578
-
579
- #[test]
580
- fn inject_waits_for_token_visibility_before_enter() {
581
- let text = "hello [team-agent-token:e31]".to_string();
582
- let marker_visible = format!("{text}\n");
583
- let (be, rec) = backend_with(
584
- MockResp::Out(ok("")),
585
- vec![
586
- MockResp::Out(ok("")), // set-buffer
587
- MockResp::Out(ok("")), // paste-buffer
588
- MockResp::Out(ok("")), // delete-buffer
589
- MockResp::Out(ok("")), // pasted-content prompt poll 1
590
- MockResp::Out(ok("")), // pasted-content prompt poll 2
591
- MockResp::Out(ok("")), // pasted-content prompt poll 3
592
- MockResp::Out(ok("")), // pasted-content prompt poll 4
593
- MockResp::Out(ok("")), // pasted-content prompt poll 5
594
- MockResp::Out(ok("")), // token gate: not visible yet
595
- MockResp::Out(ok("")), // token gate: still not visible
596
- MockResp::Out(ok(&marker_visible)), // token gate: visible, Enter may fire
597
- MockResp::Out(ok("")), // send-keys Enter
598
- ],
599
- );
600
-
601
- let report = be
602
- .inject(&Target::Pane(PaneId::new("%7")), &InjectPayload::Text(text), Key::Enter, true)
603
- .expect("inject");
604
- let calls = rec.lock().unwrap().clone();
605
- let submit_index = calls
606
+ .expect("inject");
607
+ let calls = rec.lock().unwrap().clone();
608
+ let is = |a: &[String], sub: &str| a.get(1).map(String::as_str) == Some(sub);
609
+ assert!(
610
+ calls
606
611
  .iter()
607
- .position(|argv| argv.get(1).map(String::as_str) == Some("send-keys"))
608
- .expect("inject must eventually send Enter");
609
- let captures_before_submit = calls[..submit_index]
612
+ .any(|a| (is(a, "set-buffer") || is(a, "load-buffer"))
613
+ && a.iter().any(|x| x.contains("hello"))),
614
+ "inject must stage the text into a tmux buffer (set-buffer/load-buffer); got {calls:?}"
615
+ );
616
+ assert!(
617
+ calls.iter().any(|a| is(a, "paste-buffer")
618
+ && a.contains(&"-p".to_string())
619
+ && a.contains(&"%7".to_string())),
620
+ "inject must bracketed-paste (-p) the buffer to the pane; got {calls:?}"
621
+ );
622
+ assert!(
623
+ calls
610
624
  .iter()
611
- .filter(|argv| argv.get(1).map(String::as_str) == Some("capture-pane"))
612
- .count();
613
-
614
- assert!(
615
- captures_before_submit >= super::PASTED_CONTENT_APPEAR_POLLS as usize + 3,
616
- "Enter must wait until the pasted token is visible; calls={calls:?}"
617
- );
618
- assert_eq!(report.inject_verification, InjectVerification::CaptureContainsToken);
619
- assert_eq!(
620
- report.submit_verification,
621
- SubmitVerification::EnterSentWithoutPlaceholderCheck
622
- );
623
- }
624
-
625
- #[test]
626
- fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
627
- let text = "hello leader [team-agent-token:skip]";
628
- let (be, rec) = backend_with(MockResp::Out(ok(text)), vec![]);
629
- let report = be
630
- .inject(
631
- &Target::Pane(PaneId::new("%7")),
632
- &InjectPayload::TextSkipConsumptionPoll(text.to_string()),
633
- Key::Enter,
634
- true,
635
- )
636
- .expect("inject");
637
- let calls = rec.lock().unwrap().clone();
638
-
639
- assert_eq!(
640
- report.submit_verification,
641
- SubmitVerification::EnterSentWithoutPlaceholderCheck
642
- );
643
- assert_eq!(report.attempts, 1);
644
- assert!(
645
- calls.iter().any(|argv| {
646
- argv.get(1).map(String::as_str) == Some("send-keys")
647
- && argv.contains(&"Enter".to_string())
648
- }),
649
- "skip-consumption payload must still submit once; calls={calls:?}"
650
- );
651
- assert!(
652
- !calls.iter().any(|argv| {
653
- argv == &tmux_capture_argv(&PaneId::new("%7"), CaptureRange::Tail(40))
654
- }),
655
- "leader-bound skip payload must not run Phase 2 consumption polls; calls={calls:?}"
656
- );
657
- }
658
-
659
- // ═════════════════════════════════════════════════════════════════════════════
660
- // E46 0.3.24 task#327 P0 — submit verification false-positive / false-negative.
661
- //
662
- // Architect + Workflow C1: do NOT flip `EnterSentWithoutPlaceholderCheck =>
663
- // false` (breaks MUST-10 in provider_submit_verification_red.rs:113-159).
664
- // Instead introduce SubmitConsumptionUnverified and only emit it from the
665
- // bracketed-paste / Enter path when post-Enter input consumption is NOT
666
- // observed within a bounded resend cap.
667
- //
668
- // Real-machine truth source (macmini): a fresh claude TUI's bracketed
669
- // paste swallows the framework's Enter as paste content; the framework
670
- // must (a) send Escape first to exit the paste bracket and (b) verify
671
- // post-Enter consumption by structural input-empty probe (token marker
672
- // no longer in the bottom 5 lines of the pane = composer cleared).
673
- // ═════════════════════════════════════════════════════════════════════════════
674
-
675
- /// 0.3.30 false-negative fix: token seen during post-submit consumption
676
- /// polling proves the paste landed after Enter was sent. If it never
677
- /// scrolls away, that is slow provider output, not transport failure.
678
- #[test]
679
- fn e46_post_submit_matched_token_without_scroll_is_verified() {
680
- let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
681
- let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
682
- let report = be
683
- .inject(
684
- &Target::Pane(PaneId::new("%7")),
685
- &InjectPayload::Text(token_text.to_string()),
686
- Key::Enter,
687
- true,
688
- )
689
- .expect("inject runs");
690
- assert_eq!(
691
- report.submit_verification,
692
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
693
- "0.3.30: post-submit matched=true is delivery proof even when \
625
+ .any(|a| is(a, "send-keys") && a.contains(&"Enter".to_string())),
626
+ "inject must send the submit key (Enter) last; got {calls:?}"
627
+ );
628
+ assert_eq!(
629
+ report.stage_reached,
630
+ InjectStage::Submit,
631
+ "a fully-applied inject reaches the Submit stage"
632
+ );
633
+ assert_eq!(report.inject_verification, InjectVerification::NoToken);
634
+ assert_eq!(
635
+ report.submit_verification,
636
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
637
+ );
638
+ assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
639
+ }
640
+
641
+ #[test]
642
+ fn inject_large_text_load_buffer_writes_stdin_and_token_report() {
643
+ let text = format!("{}{}", "x".repeat(16 * 1024), " [team-agent-token:abc]");
644
+ // U1 #7: the pre-submit readback captures the pane; a pane that ECHOES the token
645
+ // back (default capture returns the text) → CaptureContainsToken (true positive).
646
+ let (be, rec, stdin_rec) = backend_with_stdin(MockResp::Out(ok(&text)), vec![]);
647
+ let report = be
648
+ .inject(
649
+ &Target::Pane(PaneId::new("%7")),
650
+ &InjectPayload::Text(text.clone()),
651
+ Key::Down,
652
+ true,
653
+ )
654
+ .expect("inject large text");
655
+
656
+ assert_eq!(
657
+ report.inject_verification,
658
+ InjectVerification::CaptureContainsToken
659
+ );
660
+ assert_eq!(
661
+ report.submit_verification,
662
+ SubmitVerification::KeySentAfterVisibleToken { key: Key::Down }
663
+ );
664
+ let calls = rec.lock().unwrap().clone();
665
+ assert_eq!(
666
+ calls[0],
667
+ svec(&["tmux", "load-buffer", "-b", "team-agent-send-abc", "-"])
668
+ );
669
+ assert_eq!(stdin_rec.lock().unwrap()[0], text);
670
+ }
671
+
672
+ // U1 #7 (RED→GREEN) — pre-submit pane readback: a token payload whose token is NOT
673
+ // visible in the pane before submit (paste silently dropped) must report
674
+ // CaptureMissingToken, not the static false-positive CaptureContainsToken.
675
+ #[test]
676
+ fn inject_token_not_visible_in_pane_reports_capture_missing_token() {
677
+ let text = format!("{}{}", "x".repeat(16 * 1024), " [team-agent-token:zzz]");
678
+ // default capture returns empty token not visible → readback says missing.
679
+ let (be, _rec, _stdin) = backend_with_stdin(MockResp::Out(ok("")), vec![]);
680
+ let report = be
681
+ .inject(
682
+ &Target::Pane(PaneId::new("%9")),
683
+ &InjectPayload::Text(text),
684
+ Key::Down,
685
+ true,
686
+ )
687
+ .expect("inject runs");
688
+ assert_eq!(
689
+ report.inject_verification,
690
+ InjectVerification::CaptureMissingToken,
691
+ "U1 #7: a token that never appeared in the pane must read back as \
692
+ CaptureMissingToken, not the static CaptureContainsToken false-positive"
693
+ );
694
+ }
695
+
696
+ #[test]
697
+ fn inject_waits_for_token_visibility_before_enter() {
698
+ let text = "hello [team-agent-token:e31]".to_string();
699
+ let marker_visible = format!("{text}\n");
700
+ let (be, rec) = backend_with(
701
+ MockResp::Out(ok("")),
702
+ vec![
703
+ MockResp::Out(ok("")), // set-buffer
704
+ MockResp::Out(ok("")), // paste-buffer
705
+ MockResp::Out(ok("")), // delete-buffer
706
+ MockResp::Out(ok("")), // pasted-content prompt poll 1
707
+ MockResp::Out(ok("")), // pasted-content prompt poll 2
708
+ MockResp::Out(ok("")), // pasted-content prompt poll 3
709
+ MockResp::Out(ok("")), // pasted-content prompt poll 4
710
+ MockResp::Out(ok("")), // pasted-content prompt poll 5
711
+ MockResp::Out(ok("")), // token gate: not visible yet
712
+ MockResp::Out(ok("")), // token gate: still not visible
713
+ MockResp::Out(ok(&marker_visible)), // token gate: visible, Enter may fire
714
+ MockResp::Out(ok("")), // send-keys Enter
715
+ ],
716
+ );
717
+
718
+ let report = be
719
+ .inject(
720
+ &Target::Pane(PaneId::new("%7")),
721
+ &InjectPayload::Text(text),
722
+ Key::Enter,
723
+ true,
724
+ )
725
+ .expect("inject");
726
+ let calls = rec.lock().unwrap().clone();
727
+ let submit_index = calls
728
+ .iter()
729
+ .position(|argv| argv.get(1).map(String::as_str) == Some("send-keys"))
730
+ .expect("inject must eventually send Enter");
731
+ let captures_before_submit = calls[..submit_index]
732
+ .iter()
733
+ .filter(|argv| argv.get(1).map(String::as_str) == Some("capture-pane"))
734
+ .count();
735
+
736
+ assert!(
737
+ captures_before_submit >= super::PASTED_CONTENT_APPEAR_POLLS as usize + 3,
738
+ "Enter must wait until the pasted token is visible; calls={calls:?}"
739
+ );
740
+ assert_eq!(
741
+ report.inject_verification,
742
+ InjectVerification::CaptureContainsToken
743
+ );
744
+ assert_eq!(
745
+ report.submit_verification,
746
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
747
+ );
748
+ }
749
+
750
+ #[test]
751
+ fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
752
+ let text = "hello leader [team-agent-token:skip]";
753
+ let (be, rec) = backend_with(MockResp::Out(ok(text)), vec![]);
754
+ let report = be
755
+ .inject(
756
+ &Target::Pane(PaneId::new("%7")),
757
+ &InjectPayload::TextSkipConsumptionPoll(text.to_string()),
758
+ Key::Enter,
759
+ true,
760
+ )
761
+ .expect("inject");
762
+ let calls = rec.lock().unwrap().clone();
763
+
764
+ assert_eq!(
765
+ report.submit_verification,
766
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
767
+ );
768
+ assert_eq!(report.attempts, 1);
769
+ assert!(
770
+ calls.iter().any(|argv| {
771
+ argv.get(1).map(String::as_str) == Some("send-keys")
772
+ && argv.contains(&"Enter".to_string())
773
+ }),
774
+ "skip-consumption payload must still submit once; calls={calls:?}"
775
+ );
776
+ assert!(
777
+ !calls
778
+ .iter()
779
+ .any(|argv| { argv == &tmux_capture_argv(&PaneId::new("%7"), CaptureRange::Tail(40)) }),
780
+ "leader-bound skip payload must not run Phase 2 consumption polls; calls={calls:?}"
781
+ );
782
+ }
783
+
784
+ // ═════════════════════════════════════════════════════════════════════════════
785
+ // E46 0.3.24 task#327 P0 — submit verification false-positive / false-negative.
786
+ //
787
+ // Architect + Workflow C1: do NOT flip `EnterSentWithoutPlaceholderCheck =>
788
+ // false` (breaks MUST-10 in provider_submit_verification_red.rs:113-159).
789
+ // Instead introduce SubmitConsumptionUnverified and only emit it from the
790
+ // bracketed-paste / Enter path when post-Enter input consumption is NOT
791
+ // observed within a bounded resend cap.
792
+ //
793
+ // Real-machine truth source (macmini): a fresh claude TUI's bracketed
794
+ // paste swallows the framework's Enter as paste content; the framework
795
+ // must (a) send Escape first to exit the paste bracket and (b) verify
796
+ // post-Enter consumption by structural input-empty probe (token marker
797
+ // no longer in the bottom 5 lines of the pane = composer cleared).
798
+ // ═════════════════════════════════════════════════════════════════════════════
799
+
800
+ /// 0.3.30 false-negative fix: token seen during post-submit consumption
801
+ /// polling proves the paste landed after Enter was sent. If it never
802
+ /// scrolls away, that is slow provider output, not transport failure.
803
+ #[test]
804
+ fn e46_post_submit_matched_token_without_scroll_is_verified() {
805
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
806
+ let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
807
+ let report = be
808
+ .inject(
809
+ &Target::Pane(PaneId::new("%7")),
810
+ &InjectPayload::Text(token_text.to_string()),
811
+ Key::Enter,
812
+ true,
813
+ )
814
+ .expect("inject runs");
815
+ assert_eq!(
816
+ report.submit_verification,
817
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
818
+ "0.3.30: post-submit matched=true is delivery proof even when \
694
819
  the token stays in the bottom capture window. Got {:?}",
695
- report.submit_verification
696
- );
697
- assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
698
- let diagnostics = report.submit_diagnostics.expect("diagnostics");
699
- assert!(
700
- diagnostics.attempts_detail.iter().any(|obs| obs.matched),
701
- "the positive verdict must be backed by a post-submit matched observation"
702
- );
703
- }
704
-
705
- #[test]
706
- fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
707
- let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_busy]";
708
- let busy_tail = format!("{token_text}\n● Working (1s · esc to interrupt)\n");
709
- let (be, _rec) = backend_with(MockResp::Out(ok(&busy_tail)), vec![]);
710
- let report = be
711
- .inject(
712
- &Target::Pane(PaneId::new("%7")),
713
- &InjectPayload::Text(token_text.to_string()),
714
- Key::Enter,
715
- true,
716
- )
717
- .expect("inject runs");
718
- assert_eq!(
719
- report.submit_verification,
720
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
721
- "busy provider state means the turn is being processed even if \
820
+ report.submit_verification
821
+ );
822
+ assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
823
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
824
+ assert!(
825
+ diagnostics.attempts_detail.iter().any(|obs| obs.matched),
826
+ "the positive verdict must be backed by a post-submit matched observation"
827
+ );
828
+ }
829
+
830
+ #[test]
831
+ fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
832
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_busy]";
833
+ let busy_tail = format!("{token_text}\n● Working (1s · esc to interrupt)\n");
834
+ let (be, _rec) = backend_with(MockResp::Out(ok(&busy_tail)), vec![]);
835
+ let report = be
836
+ .inject(
837
+ &Target::Pane(PaneId::new("%7")),
838
+ &InjectPayload::Text(token_text.to_string()),
839
+ Key::Enter,
840
+ true,
841
+ )
842
+ .expect("inject runs");
843
+ assert_eq!(
844
+ report.submit_verification,
845
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
846
+ "busy provider state means the turn is being processed even if \
722
847
  the token has not yet scrolled out of the bottom capture"
723
- );
724
- let diagnostics = report.submit_diagnostics.expect("diagnostics");
725
- assert!(
726
- diagnostics
727
- .attempts_detail
728
- .last()
729
- .map(|obs| obs.pane_tail_excerpt.to_ascii_lowercase().contains("working"))
730
- .unwrap_or(false),
731
- "busy-state capture should be recorded in attempts_detail: {:?}",
732
- diagnostics.attempts_detail
733
- );
734
- }
735
-
736
- /// 0.3.27: empty pane captures → consumption poll sees "no token in
737
- /// bottom 5" → consumed=true → EnterSentWithoutPlaceholderCheck. This is
738
- /// the generous default for panes where the capture can't distinguish
739
- /// "token consumed" from "token never landed" (empty mock, MCP sim).
740
- /// SubmitConsumptionUnverified only fires when the grace fallback
741
- /// explicitly rejects (token_visible_for_report=false AND consumed=false
742
- /// at the same time a state that requires the token to be in the pane
743
- /// during consumption poll but absent during Phase 1, which is a
744
- /// contradictory mock state that doesn't arise in production).
745
- #[test]
746
- fn e46_inject_text_with_empty_pane_defaults_to_consumed() {
747
- let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_empty]";
748
- let (be, _rec) = backend_with(MockResp::Out(ok("")), vec![]);
749
- let report = be
750
- .inject(
751
- &Target::Pane(PaneId::new("%7")),
752
- &InjectPayload::Text(token_text.to_string()),
753
- Key::Enter,
754
- true,
755
- )
756
- .expect("inject runs");
757
- assert_eq!(
758
- report.submit_verification,
759
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
760
- "0.3.27: empty pane → consumption poll sees no token in bottom → \
848
+ );
849
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
850
+ assert!(
851
+ diagnostics
852
+ .attempts_detail
853
+ .last()
854
+ .map(|obs| obs
855
+ .pane_tail_excerpt
856
+ .to_ascii_lowercase()
857
+ .contains("working"))
858
+ .unwrap_or(false),
859
+ "busy-state capture should be recorded in attempts_detail: {:?}",
860
+ diagnostics.attempts_detail
861
+ );
862
+ }
863
+
864
+ /// 0.3.27: empty pane captures consumption poll sees "no token in
865
+ /// bottom 5" consumed=true EnterSentWithoutPlaceholderCheck. This is
866
+ /// the generous default for panes where the capture can't distinguish
867
+ /// "token consumed" from "token never landed" (empty mock, MCP sim).
868
+ /// SubmitConsumptionUnverified only fires when the grace fallback
869
+ /// explicitly rejects (token_visible_for_report=false AND consumed=false
870
+ /// at the same time — a state that requires the token to be in the pane
871
+ /// during consumption poll but absent during Phase 1, which is a
872
+ /// contradictory mock state that doesn't arise in production).
873
+ #[test]
874
+ fn e46_inject_text_with_empty_pane_defaults_to_consumed() {
875
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_empty]";
876
+ let (be, _rec) = backend_with(MockResp::Out(ok("")), vec![]);
877
+ let report = be
878
+ .inject(
879
+ &Target::Pane(PaneId::new("%7")),
880
+ &InjectPayload::Text(token_text.to_string()),
881
+ Key::Enter,
882
+ true,
883
+ )
884
+ .expect("inject runs");
885
+ assert_eq!(
886
+ report.submit_verification,
887
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
888
+ "0.3.27: empty pane → consumption poll sees no token in bottom → \
761
889
  consumed=true → EnterSentWithoutPlaceholderCheck. Got {:?}",
762
- report.submit_verification
763
- );
764
- }
765
-
766
- /// **E46 RED-2 (正向消费确认 → delivered)**: token-bearing Text + Enter +
767
- /// bracketed paste. Token VISIBLE on first capture (pre/post-submit token
768
- /// readback) BUT then GONE from the post-submit input-consumption probe
769
- /// (composer cleared after Enter). Submit must report
770
- /// `EnterSentWithoutPlaceholderCheck` (MUST-10 path) — delivery proceeds
771
- /// to delivered. Guards against regression on crd's existing-worker path.
772
- #[test]
773
- fn e46_inject_text_with_token_consumed_after_enter_keeps_enter_sent_without_placeholder_check() {
774
- let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red2]";
775
- // First captures (token visibility probes pre/post-Enter) return text
776
- // with token; the LAST capture (consumption probe) returns text WITHOUT
777
- // the token in the tail (composer cleared). MockCommandRunner drains
778
- // `queued` first then defaults — we queue the early "token visible"
779
- // captures, then drop to the empty default for the consumption probe.
780
- let visible = ok(token_text);
781
- let queued = vec![
782
- MockResp::Out(ok("")), // set-buffer
783
- MockResp::Out(ok("")), // paste-buffer
784
- MockResp::Out(ok("")), // delete-buffer
785
- MockResp::Out(visible.clone()), // pasted-content prompt poll: none → continue
786
- MockResp::Out(visible.clone()), // pre-submit token visibility: visible
787
- MockResp::Out(ok("")), // Escape send-keys (no stdout needed)
788
- MockResp::Out(ok("")), // Enter send-keys
789
- // From here on, the default response is `ok("")` (empty pane tail) so
790
- // post-Enter consumption probe sees no token in tail → consumed=true.
791
- ];
792
- let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
793
- let report = be
794
- .inject(
795
- &Target::Pane(PaneId::new("%7")),
796
- &InjectPayload::Text(token_text.to_string()),
797
- Key::Enter,
798
- true,
799
- )
800
- .expect("inject runs");
801
- assert_eq!(
802
- report.submit_verification,
803
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
804
- "E46 RED-2: when post-Enter consumption is observed (token gone \
890
+ report.submit_verification
891
+ );
892
+ }
893
+
894
+ /// **E46 RED-2 (正向消费确认 → delivered)**: token-bearing Text + Enter +
895
+ /// bracketed paste. Token VISIBLE on first capture (pre/post-submit token
896
+ /// readback) BUT then GONE from the post-submit input-consumption probe
897
+ /// (composer cleared after Enter). Submit must report
898
+ /// `EnterSentWithoutPlaceholderCheck` (MUST-10 path) — delivery proceeds
899
+ /// to delivered. Guards against regression on crd's existing-worker path.
900
+ #[test]
901
+ fn e46_inject_text_with_token_consumed_after_enter_keeps_enter_sent_without_placeholder_check() {
902
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red2]";
903
+ // First captures (token visibility probes pre/post-Enter) return text
904
+ // with token; the LAST capture (consumption probe) returns text WITHOUT
905
+ // the token in the tail (composer cleared). MockCommandRunner drains
906
+ // `queued` first then defaults — we queue the early "token visible"
907
+ // captures, then drop to the empty default for the consumption probe.
908
+ let visible = ok(token_text);
909
+ let queued = vec![
910
+ MockResp::Out(ok("")), // set-buffer
911
+ MockResp::Out(ok("")), // paste-buffer
912
+ MockResp::Out(ok("")), // delete-buffer
913
+ MockResp::Out(visible.clone()), // pasted-content prompt poll: none → continue
914
+ MockResp::Out(visible.clone()), // pre-submit token visibility: visible
915
+ MockResp::Out(ok("")), // Escape send-keys (no stdout needed)
916
+ MockResp::Out(ok("")), // Enter send-keys
917
+ // From here on, the default response is `ok("")` (empty pane tail) so
918
+ // post-Enter consumption probe sees no token in tail → consumed=true.
919
+ ];
920
+ let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
921
+ let report = be
922
+ .inject(
923
+ &Target::Pane(PaneId::new("%7")),
924
+ &InjectPayload::Text(token_text.to_string()),
925
+ Key::Enter,
926
+ true,
927
+ )
928
+ .expect("inject runs");
929
+ assert_eq!(
930
+ report.submit_verification,
931
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
932
+ "E46 RED-2: when post-Enter consumption is observed (token gone \
805
933
  from input region tail), submit must report the canonical \
806
934
  EnterSentWithoutPlaceholderCheck so MUST-10 delivery semantics \
807
935
  hold (provider_submit_verification_red.rs:113-159). Got {:?}",
808
- report.submit_verification
809
- );
810
- }
811
-
812
- /// **E46 RED-3 (resend-to-cap, no double-submit)**: when the FIRST Enter
813
- /// landed on the composer but our readback was slow, the re-check BEFORE
814
- /// resend must observe the now-empty input and STOP — must NOT fire a
815
- /// second Enter into an empty composer (would open an empty turn).
816
- #[test]
817
- fn e46_inject_text_resend_rechecks_input_before_resending_to_avoid_double_submit() {
818
- // Same mock as RED-2: post-Enter consumption probe sees empty tail.
819
- // The implementation should issue only ONE Enter (no resend) once
820
- // consumption is observed.
821
- let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red3]";
822
- let queued = vec![
823
- MockResp::Out(ok("")), // set-buffer
824
- MockResp::Out(ok("")), // paste-buffer
825
- MockResp::Out(ok("")), // delete-buffer
826
- MockResp::Out(ok(token_text)), // pasted-content prompt: not matched
827
- MockResp::Out(ok(token_text)), // pre-submit token visibility
828
- MockResp::Out(ok("")), // Escape
829
- MockResp::Out(ok("")), // Enter
830
- ];
831
- let (be, rec) = backend_with(MockResp::Out(ok("")), queued);
832
- let _report = be
833
- .inject(
834
- &Target::Pane(PaneId::new("%7")),
835
- &InjectPayload::Text(token_text.to_string()),
836
- Key::Enter,
837
- true,
838
- )
839
- .expect("inject runs");
840
- let calls = rec.lock().unwrap().clone();
841
- // Count `send-keys ... Enter` invocations. There must be EXACTLY ONE
842
- // (the canonical submit). A second one indicates the resend loop
843
- // didn't honour C3 (re-check input before resend).
844
- let enter_count = calls
845
- .iter()
846
- .filter(|argv| {
847
- argv.get(1).map(String::as_str) == Some("send-keys")
848
- && argv.contains(&"Enter".to_string())
849
- })
850
- .count();
851
- assert_eq!(
852
- enter_count, 1,
853
- "E46 RED-3: when consumption is observed (or already happened \
936
+ report.submit_verification
937
+ );
938
+ }
939
+
940
+ /// **E46 RED-3 (resend-to-cap, no double-submit)**: when the FIRST Enter
941
+ /// landed on the composer but our readback was slow, the re-check BEFORE
942
+ /// resend must observe the now-empty input and STOP — must NOT fire a
943
+ /// second Enter into an empty composer (would open an empty turn).
944
+ #[test]
945
+ fn e46_inject_text_resend_rechecks_input_before_resending_to_avoid_double_submit() {
946
+ // Same mock as RED-2: post-Enter consumption probe sees empty tail.
947
+ // The implementation should issue only ONE Enter (no resend) once
948
+ // consumption is observed.
949
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red3]";
950
+ let queued = vec![
951
+ MockResp::Out(ok("")), // set-buffer
952
+ MockResp::Out(ok("")), // paste-buffer
953
+ MockResp::Out(ok("")), // delete-buffer
954
+ MockResp::Out(ok(token_text)), // pasted-content prompt: not matched
955
+ MockResp::Out(ok(token_text)), // pre-submit token visibility
956
+ MockResp::Out(ok("")), // Escape
957
+ MockResp::Out(ok("")), // Enter
958
+ ];
959
+ let (be, rec) = backend_with(MockResp::Out(ok("")), queued);
960
+ let _report = be
961
+ .inject(
962
+ &Target::Pane(PaneId::new("%7")),
963
+ &InjectPayload::Text(token_text.to_string()),
964
+ Key::Enter,
965
+ true,
966
+ )
967
+ .expect("inject runs");
968
+ let calls = rec.lock().unwrap().clone();
969
+ // Count `send-keys ... Enter` invocations. There must be EXACTLY ONE
970
+ // (the canonical submit). A second one indicates the resend loop
971
+ // didn't honour C3 (re-check input before resend).
972
+ let enter_count = calls
973
+ .iter()
974
+ .filter(|argv| {
975
+ argv.get(1).map(String::as_str) == Some("send-keys")
976
+ && argv.contains(&"Enter".to_string())
977
+ })
978
+ .count();
979
+ assert_eq!(
980
+ enter_count, 1,
981
+ "E46 RED-3: when consumption is observed (or already happened \
854
982
  between Enter and probe), the resend loop must STOP and NOT \
855
983
  issue a second Enter. Got {enter_count} Enter sends. \
856
984
  calls={calls:?}"
857
- );
858
- }
859
-
860
- /// **E46 RED-5 (provider-agnostic detector)**: the consumption probe must
861
- /// NOT hard-code provider UI strings (claude `>`, codex `╰`, ...). It uses
862
- /// the token marker absence in the bottom of the pane — works for any
863
- /// provider. Here we run a non-claude-shaped pane (no claude markers) and
864
- /// confirm the same delivered semantics when consumption happens.
865
- #[test]
866
- fn e46_consumption_detector_works_on_codex_shaped_pane_without_provider_strings() {
867
- // Codex-shaped fake: prompt looks like `codex>` with no claude markers.
868
- // After Enter, the composer clears (empty default returns ok("")).
869
- let token_text =
870
- "Team Agent message from leader:\n\ncodex msg\n\n[team-agent-token:msg_red5]";
871
- let queued = vec![
872
- MockResp::Out(ok("")), // set-buffer
873
- MockResp::Out(ok("")), // paste-buffer
874
- MockResp::Out(ok("")), // delete-buffer
875
- MockResp::Out(ok("codex>\n")), // pasted-content prompt: no match
876
- MockResp::Out(ok(token_text)), // pre-submit token visibility
877
- MockResp::Out(ok("")), // Escape
878
- MockResp::Out(ok("")), // Enter
879
- // post-Enter: default returns ok("") (composer cleared) → token gone
880
- ];
881
- let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
882
- let report = be
883
- .inject(
884
- &Target::Pane(PaneId::new("%7")),
885
- &InjectPayload::Text(token_text.to_string()),
886
- Key::Enter,
887
- true,
888
- )
889
- .expect("inject runs");
890
- assert_eq!(
891
- report.submit_verification,
892
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
893
- "E46 RED-5: provider-agnostic detector — codex-shaped pane (no \
985
+ );
986
+ }
987
+
988
+ /// **E46 RED-5 (provider-agnostic detector)**: the consumption probe must
989
+ /// NOT hard-code provider UI strings (claude `>`, codex `╰`, ...). It uses
990
+ /// the token marker absence in the bottom of the pane — works for any
991
+ /// provider. Here we run a non-claude-shaped pane (no claude markers) and
992
+ /// confirm the same delivered semantics when consumption happens.
993
+ #[test]
994
+ fn e46_consumption_detector_works_on_codex_shaped_pane_without_provider_strings() {
995
+ // Codex-shaped fake: prompt looks like `codex>` with no claude markers.
996
+ // After Enter, the composer clears (empty default returns ok("")).
997
+ let token_text = "Team Agent message from leader:\n\ncodex msg\n\n[team-agent-token:msg_red5]";
998
+ let queued = vec![
999
+ MockResp::Out(ok("")), // set-buffer
1000
+ MockResp::Out(ok("")), // paste-buffer
1001
+ MockResp::Out(ok("")), // delete-buffer
1002
+ MockResp::Out(ok("codex>\n")), // pasted-content prompt: no match
1003
+ MockResp::Out(ok(token_text)), // pre-submit token visibility
1004
+ MockResp::Out(ok("")), // Escape
1005
+ MockResp::Out(ok("")), // Enter
1006
+ // post-Enter: default returns ok("") (composer cleared) → token gone
1007
+ ];
1008
+ let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
1009
+ let report = be
1010
+ .inject(
1011
+ &Target::Pane(PaneId::new("%7")),
1012
+ &InjectPayload::Text(token_text.to_string()),
1013
+ Key::Enter,
1014
+ true,
1015
+ )
1016
+ .expect("inject runs");
1017
+ assert_eq!(
1018
+ report.submit_verification,
1019
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
1020
+ "E46 RED-5: provider-agnostic detector — codex-shaped pane (no \
894
1021
  claude UI string) where composer clears post-Enter must still \
895
1022
  report EnterSentWithoutPlaceholderCheck via structural \
896
1023
  input-empty check. Got {:?}",
897
- report.submit_verification
898
- );
899
- }
900
-
901
- /// **E46 Escape pre-Enter**: when bracketed=true + Text payload + submit=Enter,
902
- /// the inject must send Escape BEFORE the Enter (exits bracketed-paste
903
- /// mode on stuck composer). Non-Enter submits (e.g. Key::Down for codex
904
- /// menu) must NOT receive the Escape pre-step.
905
- #[test]
906
- /// 0.3.27 amendment: Escape is now RETRY-ONLY (attempt > 0). The first
907
- /// attempt sends Enter directly (Python parity — Python never sends Escape).
908
- /// Escape fires only if the first Enter failed to consume the token and a
909
- /// retry is needed. This test now asserts the first attempt has NO Escape.
910
- fn e46_inject_text_first_attempt_no_escape_retry_only() {
911
- let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_esc]";
912
- // Mock returns empty — token not in tail → consumed=true on first attempt.
913
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
914
- let _report = be
915
- .inject(
916
- &Target::Pane(PaneId::new("%7")),
917
- &InjectPayload::Text(token_text.to_string()),
918
- Key::Enter,
919
- true,
920
- )
921
- .expect("inject runs");
922
- let calls = rec.lock().unwrap().clone();
923
- let enter_count = calls.iter().filter(|argv| {
1024
+ report.submit_verification
1025
+ );
1026
+ }
1027
+
1028
+ /// **E46 Escape pre-Enter**: when bracketed=true + Text payload + submit=Enter,
1029
+ /// the inject must send Escape BEFORE the Enter (exits bracketed-paste
1030
+ /// mode on stuck composer). Non-Enter submits (e.g. Key::Down for codex
1031
+ /// menu) must NOT receive the Escape pre-step.
1032
+ #[test]
1033
+ /// 0.3.27 amendment: Escape is now RETRY-ONLY (attempt > 0). The first
1034
+ /// attempt sends Enter directly (Python parity — Python never sends Escape).
1035
+ /// Escape fires only if the first Enter failed to consume the token and a
1036
+ /// retry is needed. This test now asserts the first attempt has NO Escape.
1037
+ fn e46_inject_text_first_attempt_no_escape_retry_only() {
1038
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_esc]";
1039
+ // Mock returns empty — token not in tail → consumed=true on first attempt.
1040
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1041
+ let _report = be
1042
+ .inject(
1043
+ &Target::Pane(PaneId::new("%7")),
1044
+ &InjectPayload::Text(token_text.to_string()),
1045
+ Key::Enter,
1046
+ true,
1047
+ )
1048
+ .expect("inject runs");
1049
+ let calls = rec.lock().unwrap().clone();
1050
+ let enter_count = calls
1051
+ .iter()
1052
+ .filter(|argv| {
924
1053
  argv.get(1).map(String::as_str) == Some("send-keys")
925
1054
  && argv.contains(&"Enter".to_string())
926
- }).count();
927
- let escape_count = calls.iter().filter(|argv| {
1055
+ })
1056
+ .count();
1057
+ let escape_count = calls
1058
+ .iter()
1059
+ .filter(|argv| {
928
1060
  argv.get(1).map(String::as_str) == Some("send-keys")
929
1061
  && argv.contains(&"Escape".to_string())
930
- }).count();
931
- assert!(
932
- enter_count >= 1,
933
- "0.3.27: first attempt must send Enter; calls={calls:?}"
934
- );
935
- assert_eq!(
936
- escape_count, 0,
937
- "0.3.27: first attempt (consumed=true) must NOT send Escape \
1062
+ })
1063
+ .count();
1064
+ assert!(
1065
+ enter_count >= 1,
1066
+ "0.3.27: first attempt must send Enter; calls={calls:?}"
1067
+ );
1068
+ assert_eq!(
1069
+ escape_count, 0,
1070
+ "0.3.27: first attempt (consumed=true) must NOT send Escape \
938
1071
  (Escape is retry-only); calls={calls:?}"
939
- );
940
- }
941
-
942
- /// **E46 regression guard**: non-Enter submit (codex menu navigation,
943
- /// `Key::Down`) does NOT trigger the Escape pre-step (would interfere
944
- /// with the menu).
945
- #[test]
946
- fn e46_inject_text_non_enter_submit_does_not_emit_escape_prestep() {
947
- let token_text = "menu interaction\n[team-agent-token:msg_down]";
948
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
949
- let _report = be
950
- .inject(
951
- &Target::Pane(PaneId::new("%7")),
952
- &InjectPayload::Text(token_text.to_string()),
953
- Key::Down,
954
- true,
955
- )
956
- .expect("inject runs");
957
- let calls = rec.lock().unwrap().clone();
958
- let escape_count = calls
959
- .iter()
960
- .filter(|argv| {
961
- argv.get(1).map(String::as_str) == Some("send-keys")
962
- && argv.contains(&"Escape".to_string())
963
- })
964
- .count();
965
- assert_eq!(
966
- escape_count, 0,
967
- "E46 regression guard: Key::Down submits (codex menu navigation) \
1072
+ );
1073
+ }
1074
+
1075
+ /// **E46 regression guard**: non-Enter submit (codex menu navigation,
1076
+ /// `Key::Down`) does NOT trigger the Escape pre-step (would interfere
1077
+ /// with the menu).
1078
+ #[test]
1079
+ fn e46_inject_text_non_enter_submit_does_not_emit_escape_prestep() {
1080
+ let token_text = "menu interaction\n[team-agent-token:msg_down]";
1081
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1082
+ let _report = be
1083
+ .inject(
1084
+ &Target::Pane(PaneId::new("%7")),
1085
+ &InjectPayload::Text(token_text.to_string()),
1086
+ Key::Down,
1087
+ true,
1088
+ )
1089
+ .expect("inject runs");
1090
+ let calls = rec.lock().unwrap().clone();
1091
+ let escape_count = calls
1092
+ .iter()
1093
+ .filter(|argv| {
1094
+ argv.get(1).map(String::as_str) == Some("send-keys")
1095
+ && argv.contains(&"Escape".to_string())
1096
+ })
1097
+ .count();
1098
+ assert_eq!(
1099
+ escape_count, 0,
1100
+ "E46 regression guard: Key::Down submits (codex menu navigation) \
968
1101
  must NOT receive Escape pre-step. Got {escape_count}. \
969
1102
  calls={calls:?}"
970
- );
971
- }
972
-
973
- // ═════════════════════════════════════════════════════════════════════════
974
- // E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断) — InjectReport.submit_diagnostics
975
- // is populated for paste-prompt loops, and `pasted_prompt_match` / scrubbing
976
- // helpers are pure functions safe to test deterministically.
977
- //
978
- // Architect verdict (Workflow forensic): the pre-fix
979
- // `capture_has_pasted_content_prompt` matched ANY `pasted content` /
980
- // `pasted text` token in the full Tail(80), including scrolled-off
981
- // submitted blocks. PR-1 just adds the diagnostics; PR-2 will USE
982
- // `where_in_tail` to fix the criterion. These tests pin the data shape.
983
- // ═════════════════════════════════════════════════════════════════════════
984
-
985
- /// **E50 RED-1 (判据误判, pure function)**: `pasted_prompt_match` must
986
- /// distinguish bottom-region (composer) from scrollback (line 6+ from
987
- /// bottom). Pre-fix `capture_has_pasted_content_prompt` returned `true`
988
- /// for BOTH; the new tuple-returning helper surfaces the offset so PR-2
989
- /// can fix the criterion.
990
- #[test]
991
- fn e50_pasted_prompt_match_reports_where_in_tail_for_scrollback_vs_composer() {
992
- use super::pasted_prompt_match;
993
-
994
- // Composer (bottom) match.
995
- let composer_only = "some output\n\
1103
+ );
1104
+ }
1105
+
1106
+ // ═════════════════════════════════════════════════════════════════════════
1107
+ // E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断) — InjectReport.submit_diagnostics
1108
+ // is populated for paste-prompt loops, and `pasted_prompt_match` / scrubbing
1109
+ // helpers are pure functions safe to test deterministically.
1110
+ //
1111
+ // Architect verdict (Workflow forensic): the pre-fix
1112
+ // `capture_has_pasted_content_prompt` matched ANY `pasted content` /
1113
+ // `pasted text` token in the full Tail(80), including scrolled-off
1114
+ // submitted blocks. PR-1 just adds the diagnostics; PR-2 will USE
1115
+ // `where_in_tail` to fix the criterion. These tests pin the data shape.
1116
+ // ═════════════════════════════════════════════════════════════════════════
1117
+
1118
+ /// **E50 RED-1 (判据误判, pure function)**: `pasted_prompt_match` must
1119
+ /// distinguish bottom-region (composer) from scrollback (line 6+ from
1120
+ /// bottom). Pre-fix `capture_has_pasted_content_prompt` returned `true`
1121
+ /// for BOTH; the new tuple-returning helper surfaces the offset so PR-2
1122
+ /// can fix the criterion.
1123
+ #[test]
1124
+ fn e50_pasted_prompt_match_reports_where_in_tail_for_scrollback_vs_composer() {
1125
+ use super::pasted_prompt_match;
1126
+
1127
+ // Composer (bottom) match.
1128
+ let composer_only = "some output\n\
996
1129
  [Pasted content 1.2k]\n\
997
1130
  ❯ ";
998
- // The match is "pasted content" in the line at index 1 from bottom
999
- // among non-empty lines (`[Pasted content...]`, `❯ ` is at idx 0).
1000
- let m = pasted_prompt_match(composer_only).expect("match");
1001
- assert_eq!(m.0, "pasted content");
1002
- assert_eq!(
1003
- m.1, 1,
1004
- "E50: where_in_tail must reflect bottom offset (idx 1 = above the \
1131
+ // The match is "pasted content" in the line at index 1 from bottom
1132
+ // among non-empty lines (`[Pasted content...]`, `❯ ` is at idx 0).
1133
+ let m = pasted_prompt_match(composer_only).expect("match");
1134
+ assert_eq!(m.0, "pasted content");
1135
+ assert_eq!(
1136
+ m.1, 1,
1137
+ "E50: where_in_tail must reflect bottom offset (idx 1 = above the \
1005
1138
  `❯` composer line); got {m:?}"
1006
- );
1139
+ );
1007
1140
 
1008
- // Scrollback (top) match — 8 lines from bottom.
1009
- let scrollback_only = "[Pasted content 5.0k]\n\
1141
+ // Scrollback (top) match — 8 lines from bottom.
1142
+ let scrollback_only = "[Pasted content 5.0k]\n\
1010
1143
  assistant reply line 1\n\
1011
1144
  assistant reply line 2\n\
1012
1145
  assistant reply line 3\n\
@@ -1015,190 +1148,205 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
1015
1148
  assistant reply line 6\n\
1016
1149
  assistant reply line 7\n\
1017
1150
  ❯ ";
1018
- let m2 = pasted_prompt_match(scrollback_only).expect("match");
1019
- assert_eq!(m2.0, "pasted content");
1020
- assert!(
1021
- m2.1 >= 6,
1022
- "E50: scrollback `Pasted content` must report where_in_tail >= 6 \
1151
+ let m2 = pasted_prompt_match(scrollback_only).expect("match");
1152
+ assert_eq!(m2.0, "pasted content");
1153
+ assert!(
1154
+ m2.1 >= 6,
1155
+ "E50: scrollback `Pasted content` must report where_in_tail >= 6 \
1023
1156
  (clearly above the bottom composer region); got {m2:?}. PR-2 \
1024
1157
  will use this offset to distinguish live composer vs scrollback \
1025
1158
  residue."
1026
- );
1027
-
1028
- // No match.
1029
- assert!(pasted_prompt_match("nothing here\n❯ ").is_none());
1030
- }
1031
-
1032
- /// **E50 PR-2 Fix-B (amends PR-1 RED-2)**: with Fix-B, token-bearing
1033
- /// payloads SKIP the paste-prompt weak loop and fall through to the E46
1034
- /// token consumption gate. The `submit_diagnostics` still records the
1035
- /// appear-gate timing (was `saw_pasted_prompt` matched?), but
1036
- /// `attempts_detail` is populated by the E46 consumption gate path, not
1037
- /// the deleted weak loop. This test pins the NEW shape: `appear_gate_matched`
1038
- /// may be false (mock default returns the pasted-content text, but the
1039
- /// appear-gate polls 5 times with 25ms sleep and the mock answers the SAME
1040
- /// text every time → appear-gate DOES match). However, the `has_token` guard
1041
- /// skips the weak loop body → the token path's diagnostics fill in instead.
1042
- #[test]
1043
- fn e50_inject_paste_prompt_path_populates_submit_diagnostics_per_attempt() {
1044
- let token_text =
1045
- "Team Agent message from leader:\n\nhello\n\n[team-agent-token:msg_pr1]";
1046
- // Mock keeps returning the pasted-content placeholder. With Fix-B,
1047
- // the token path takes over: post_submit_input_consumed checks
1048
- // bottom 5 lines for the token marker. The mock returns the pasted
1049
- // placeholder text (without the token) → token NOT in tail → consumed
1050
- // = Some(true) EnterSentWithoutPlaceholderCheck.
1051
- // We test: (a) diagnostics present (b) submit verification reflects
1052
- // the E46 token path, not the deleted weak loop.
1053
- let pasted = "[Pasted content 1.2k]";
1054
- let (be, _rec) = backend_with(MockResp::Out(ok(pasted)), vec![]);
1055
- let report = be
1056
- .inject(
1057
- &Target::Pane(PaneId::new("%7")),
1058
- &InjectPayload::Text(token_text.to_string()),
1059
- Key::Enter,
1060
- true,
1061
- )
1062
- .expect("inject");
1063
- // Fix-B: token payload → E46 token path. The submit verification is
1064
- // either EnterSentWithoutPlaceholderCheck (consumed) or
1065
- // SubmitConsumptionUnverified (not consumed). With the mock returning
1066
- // pasted-content text (no token in tail), consumed = true.
1067
- assert_eq!(
1068
- report.submit_verification,
1069
- SubmitVerification::EnterSentWithoutPlaceholderCheck,
1070
- "E50 PR-2 Fix-B: token payload that went through the E46 gate \
1159
+ );
1160
+
1161
+ // No match.
1162
+ assert!(pasted_prompt_match("nothing here\n❯ ").is_none());
1163
+ }
1164
+
1165
+ /// **E50 PR-2 Fix-B (amends PR-1 RED-2)**: with Fix-B, token-bearing
1166
+ /// payloads SKIP the paste-prompt weak loop and fall through to the E46
1167
+ /// token consumption gate. The `submit_diagnostics` still records the
1168
+ /// appear-gate timing (was `saw_pasted_prompt` matched?), but
1169
+ /// `attempts_detail` is populated by the E46 consumption gate path, not
1170
+ /// the deleted weak loop. This test pins the NEW shape: `appear_gate_matched`
1171
+ /// may be false (mock default returns the pasted-content text, but the
1172
+ /// appear-gate polls 5 times with 25ms sleep and the mock answers the SAME
1173
+ /// text every time → appear-gate DOES match). However, the `has_token` guard
1174
+ /// skips the weak loop body → the token path's diagnostics fill in instead.
1175
+ #[test]
1176
+ fn e50_inject_paste_prompt_path_populates_submit_diagnostics_per_attempt() {
1177
+ let token_text = "Team Agent message from leader:\n\nhello\n\n[team-agent-token:msg_pr1]";
1178
+ // Mock keeps returning the pasted-content placeholder. With Fix-B,
1179
+ // the token path takes over: post_submit_input_consumed checks
1180
+ // bottom 5 lines for the token marker. The mock returns the pasted
1181
+ // placeholder text (without the token) token NOT in tail → consumed
1182
+ // = Some(true) → EnterSentWithoutPlaceholderCheck.
1183
+ // We test: (a) diagnostics present (b) submit verification reflects
1184
+ // the E46 token path, not the deleted weak loop.
1185
+ let pasted = "[Pasted content 1.2k]";
1186
+ let (be, _rec) = backend_with(MockResp::Out(ok(pasted)), vec![]);
1187
+ let report = be
1188
+ .inject(
1189
+ &Target::Pane(PaneId::new("%7")),
1190
+ &InjectPayload::Text(token_text.to_string()),
1191
+ Key::Enter,
1192
+ true,
1193
+ )
1194
+ .expect("inject");
1195
+ // Fix-B: token payload → E46 token path. The submit verification is
1196
+ // either EnterSentWithoutPlaceholderCheck (consumed) or
1197
+ // SubmitConsumptionUnverified (not consumed). With the mock returning
1198
+ // pasted-content text (no token in tail), consumed = true.
1199
+ assert_eq!(
1200
+ report.submit_verification,
1201
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
1202
+ "E50 PR-2 Fix-B: token payload that went through the E46 gate \
1071
1203
  and was consumed (token not in bottom 5 lines) must report \
1072
1204
  EnterSentWithoutPlaceholderCheck; got {:?}",
1073
- report.submit_verification
1074
- );
1075
- let diagnostics = report.submit_diagnostics.expect("diagnostics");
1076
- assert!(
1077
- !diagnostics.attempts_detail.is_empty(),
1078
- "E50: E46 consumption gate must preserve per-capture diagnostics"
1079
- );
1080
- assert_eq!(diagnostics.attempts_detail[0].attempt_index, 1);
1081
- assert!(
1082
- diagnostics.attempts_detail[0]
1083
- .pane_tail_excerpt
1084
- .contains("Pasted content"),
1085
- "E50: recorded pane tail should contain the post-submit capture; got {:?}",
1086
- diagnostics.attempts_detail[0]
1087
- );
1088
- }
1089
-
1090
- /// **E50 PR-1 secret scrubbing**: `scrub_pane_excerpt` must strip ANSI
1091
- /// + redact common secret shapes so emitted events.jsonl doesn't leak.
1092
- #[test]
1093
- fn e50_scrub_pane_excerpt_strips_ansi_and_redacts_secret_shapes() {
1094
- use super::scrub_pane_excerpt;
1095
- let raw = "\x1b[31merror\x1b[0m: sk-abcdef123ghi\n\
1205
+ report.submit_verification
1206
+ );
1207
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
1208
+ assert!(
1209
+ !diagnostics.attempts_detail.is_empty(),
1210
+ "E50: E46 consumption gate must preserve per-capture diagnostics"
1211
+ );
1212
+ assert_eq!(diagnostics.attempts_detail[0].attempt_index, 1);
1213
+ assert!(
1214
+ diagnostics.attempts_detail[0]
1215
+ .pane_tail_excerpt
1216
+ .contains("Pasted content"),
1217
+ "E50: recorded pane tail should contain the post-submit capture; got {:?}",
1218
+ diagnostics.attempts_detail[0]
1219
+ );
1220
+ }
1221
+
1222
+ /// **E50 PR-1 secret scrubbing**: `scrub_pane_excerpt` must strip ANSI
1223
+ /// + redact common secret shapes so emitted events.jsonl doesn't leak.
1224
+ #[test]
1225
+ fn e50_scrub_pane_excerpt_strips_ansi_and_redacts_secret_shapes() {
1226
+ use super::scrub_pane_excerpt;
1227
+ let raw = "\x1b[31merror\x1b[0m: sk-abcdef123ghi\n\
1096
1228
  token Bearer abc.def.ghi\n\
1097
1229
  key AKIAIOSFODNN7EXAMPLE\n\
1098
1230
  ghp_1234567890abcdef\n\
1099
1231
  hex deadbeefdeadbeefdeadbeefdeadbeefcafebabe\n\
1100
1232
  plain text below";
1101
- let (out, lines) = scrub_pane_excerpt(raw, 20);
1102
- assert!(lines >= 5, "E50: at least 5 non-empty lines; got {lines}");
1103
- assert!(!out.contains("\x1b"), "E50: ANSI escape stripped; got {out:?}");
1104
- assert!(
1105
- !out.contains("sk-abcdef123ghi"),
1106
- "E50: sk- secret must be redacted; got {out:?}"
1107
- );
1108
- assert!(
1109
- !out.contains("ghp_1234567890abcdef"),
1110
- "E50: ghp_ secret must be redacted; got {out:?}"
1111
- );
1112
- assert!(
1113
- !out.contains("AKIAIOSFODNN7EXAMPLE"),
1114
- "E50: AKIA secret must be redacted; got {out:?}"
1115
- );
1116
- assert!(
1117
- !out.contains("abc.def.ghi"),
1118
- "E50: Bearer secret token must be redacted; got {out:?}"
1119
- );
1120
- assert!(
1121
- !out.contains("deadbeefdeadbeefdeadbeefdeadbeefcafebabe"),
1122
- "E50: 32+ hex run must be redacted; got {out:?}"
1123
- );
1124
- assert!(
1125
- out.contains("REDACTED"),
1126
- "E50: redactions visible; got {out:?}"
1127
- );
1128
- assert!(
1129
- out.contains("plain text below"),
1130
- "E50: non-secret content preserved; got {out:?}"
1131
- );
1132
- }
1133
-
1134
- /// **E50 PR-1 byte-identical legacy matcher**: `capture_has_pasted_content_prompt`
1135
- /// wrapper must still return the same bool the 3 legacy callers depend on
1136
- /// (the bool-returning fn shape is the contract for byte-locked behaviour).
1137
- #[test]
1138
- fn e50_capture_has_pasted_content_prompt_byte_identical_wrapper() {
1139
- use super::capture_has_pasted_content_prompt;
1140
- assert!(capture_has_pasted_content_prompt("[Pasted content 1k]"));
1141
- assert!(capture_has_pasted_content_prompt("[Pasted text foo]"));
1142
- assert!(!capture_has_pasted_content_prompt("just composer text"));
1143
- assert!(!capture_has_pasted_content_prompt(""));
1144
- }
1145
-
1146
- #[test]
1147
- fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
1148
- let (be, rec) = backend_with(
1149
- MockResp::Out(ok("")),
1150
- vec![MockResp::Out(ok("tree-mode\n")), MockResp::Out(ok(""))],
1151
- );
1152
- be.send_keys(&Target::Pane(PaneId::new("%7")), &[Key::CancelMode])
1153
- .expect("cancel mode");
1154
-
1155
- let calls = rec.lock().unwrap().clone();
1156
- assert_eq!(
1157
- calls[0],
1158
- svec(&["tmux", "display-message", "-p", "-t", "%7", "#{pane_mode}"])
1159
- );
1160
- assert_eq!(calls[1], svec(&["tmux", "send-keys", "-t", "%7", "q"]));
1161
- }
1162
-
1163
- #[test]
1164
- fn cancel_mode_numeric_zero_is_input_ready_and_does_not_send_cancel() {
1165
- // Golden /tmp/transport_golden_probe.py:
1166
- // `_normalize_pane_mode("0") == ""`; `_prepare_tmux_pane_for_input` returns
1167
- // pane_input_ready and does NOT call `_pane_mode_cancel`.
1168
- // RED: pane_mode_from_raw("0") maps to Unknown, so Rust sends `-X cancel`.
1169
- let (be, rec) = backend_with(
1170
- MockResp::Out(ok("")),
1171
- vec![MockResp::Out(ok("0\n"))],
1172
- );
1173
- be.send_keys(&Target::Pane(PaneId::new("%7")), &[Key::CancelMode])
1174
- .expect("cancel mode input-ready no-op");
1175
-
1176
- let calls = rec.lock().unwrap().clone();
1177
- assert_eq!(
1233
+ let (out, lines) = scrub_pane_excerpt(raw, 20);
1234
+ assert!(lines >= 5, "E50: at least 5 non-empty lines; got {lines}");
1235
+ assert!(
1236
+ !out.contains("\x1b"),
1237
+ "E50: ANSI escape stripped; got {out:?}"
1238
+ );
1239
+ assert!(
1240
+ !out.contains("sk-abcdef123ghi"),
1241
+ "E50: sk- secret must be redacted; got {out:?}"
1242
+ );
1243
+ assert!(
1244
+ !out.contains("ghp_1234567890abcdef"),
1245
+ "E50: ghp_ secret must be redacted; got {out:?}"
1246
+ );
1247
+ assert!(
1248
+ !out.contains("AKIAIOSFODNN7EXAMPLE"),
1249
+ "E50: AKIA secret must be redacted; got {out:?}"
1250
+ );
1251
+ assert!(
1252
+ !out.contains("abc.def.ghi"),
1253
+ "E50: Bearer secret token must be redacted; got {out:?}"
1254
+ );
1255
+ assert!(
1256
+ !out.contains("deadbeefdeadbeefdeadbeefdeadbeefcafebabe"),
1257
+ "E50: 32+ hex run must be redacted; got {out:?}"
1258
+ );
1259
+ assert!(
1260
+ out.contains("REDACTED"),
1261
+ "E50: redactions visible; got {out:?}"
1262
+ );
1263
+ assert!(
1264
+ out.contains("plain text below"),
1265
+ "E50: non-secret content preserved; got {out:?}"
1266
+ );
1267
+ }
1268
+
1269
+ /// **E50 PR-1 byte-identical legacy matcher**: `capture_has_pasted_content_prompt`
1270
+ /// wrapper must still return the same bool the 3 legacy callers depend on
1271
+ /// (the bool-returning fn shape is the contract for byte-locked behaviour).
1272
+ #[test]
1273
+ fn e50_capture_has_pasted_content_prompt_byte_identical_wrapper() {
1274
+ use super::capture_has_pasted_content_prompt;
1275
+ assert!(capture_has_pasted_content_prompt("[Pasted content 1k]"));
1276
+ assert!(capture_has_pasted_content_prompt("[Pasted text foo]"));
1277
+ assert!(!capture_has_pasted_content_prompt("just composer text"));
1278
+ assert!(!capture_has_pasted_content_prompt(""));
1279
+ }
1280
+
1281
+ #[test]
1282
+ fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
1283
+ let (be, rec) = backend_with(
1284
+ MockResp::Out(ok("")),
1285
+ vec![MockResp::Out(ok("tree-mode\n")), MockResp::Out(ok(""))],
1286
+ );
1287
+ be.send_keys(&Target::Pane(PaneId::new("%7")), &[Key::CancelMode])
1288
+ .expect("cancel mode");
1289
+
1290
+ let calls = rec.lock().unwrap().clone();
1291
+ assert_eq!(
1292
+ calls[0],
1293
+ svec(&["tmux", "display-message", "-p", "-t", "%7", "#{pane_mode}"])
1294
+ );
1295
+ assert_eq!(calls[1], svec(&["tmux", "send-keys", "-t", "%7", "q"]));
1296
+ }
1297
+
1298
+ #[test]
1299
+ fn cancel_mode_numeric_zero_is_input_ready_and_does_not_send_cancel() {
1300
+ // Golden /tmp/transport_golden_probe.py:
1301
+ // `_normalize_pane_mode("0") == ""`; `_prepare_tmux_pane_for_input` returns
1302
+ // pane_input_ready and does NOT call `_pane_mode_cancel`.
1303
+ // RED: pane_mode_from_raw("0") maps to Unknown, so Rust sends `-X cancel`.
1304
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![MockResp::Out(ok("0\n"))]);
1305
+ be.send_keys(&Target::Pane(PaneId::new("%7")), &[Key::CancelMode])
1306
+ .expect("cancel mode input-ready no-op");
1307
+
1308
+ let calls = rec.lock().unwrap().clone();
1309
+ assert_eq!(
1178
1310
  calls,
1179
1311
  vec![svec(&["tmux", "display-message", "-p", "-t", "%7", "#{pane_mode}"])],
1180
1312
  "pane_mode='0' is Python input-ready; CancelMode must stop after the mode query, got {calls:?}"
1181
1313
  );
1182
- }
1183
-
1184
- #[test]
1185
- fn inject_text_uses_message_id_scoped_buffer_from_token() {
1186
- // Golden delivery.py:109-114 passes buffer_name = `team-agent-send-{message_id}` into
1187
- // `_tmux_inject_text`; tmux_io.py then uses that exact name for set/load, paste, delete.
1188
- // This prevents interleaved sends from sharing a stale global tmux buffer.
1189
- // RED: Rust currently hard-codes `team-agent-buf`.
1190
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1191
- let text = "Team Agent message from leader:\n\nhello\n\n[team-agent-token:msg_abc123]".to_string();
1192
- be.inject(&Target::Pane(PaneId::new("%7")), &InjectPayload::Text(text), Key::Enter, true)
1193
- .expect("inject");
1194
-
1195
- let calls = rec.lock().unwrap().clone();
1196
- let buffer_args: Vec<String> = calls
1197
- .iter()
1198
- .filter(|argv| matches!(argv.get(1).map(String::as_str), Some("set-buffer" | "load-buffer" | "paste-buffer" | "delete-buffer")))
1199
- .filter_map(|argv| argv.iter().position(|arg| arg == "-b").and_then(|i| argv.get(i + 1)).cloned())
1200
- .collect();
1201
- assert_eq!(
1314
+ }
1315
+
1316
+ #[test]
1317
+ fn inject_text_uses_message_id_scoped_buffer_from_token() {
1318
+ // Golden delivery.py:109-114 passes buffer_name = `team-agent-send-{message_id}` into
1319
+ // `_tmux_inject_text`; tmux_io.py then uses that exact name for set/load, paste, delete.
1320
+ // This prevents interleaved sends from sharing a stale global tmux buffer.
1321
+ // RED: Rust currently hard-codes `team-agent-buf`.
1322
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1323
+ let text =
1324
+ "Team Agent message from leader:\n\nhello\n\n[team-agent-token:msg_abc123]".to_string();
1325
+ be.inject(
1326
+ &Target::Pane(PaneId::new("%7")),
1327
+ &InjectPayload::Text(text),
1328
+ Key::Enter,
1329
+ true,
1330
+ )
1331
+ .expect("inject");
1332
+
1333
+ let calls = rec.lock().unwrap().clone();
1334
+ let buffer_args: Vec<String> = calls
1335
+ .iter()
1336
+ .filter(|argv| {
1337
+ matches!(
1338
+ argv.get(1).map(String::as_str),
1339
+ Some("set-buffer" | "load-buffer" | "paste-buffer" | "delete-buffer")
1340
+ )
1341
+ })
1342
+ .filter_map(|argv| {
1343
+ argv.iter()
1344
+ .position(|arg| arg == "-b")
1345
+ .and_then(|i| argv.get(i + 1))
1346
+ .cloned()
1347
+ })
1348
+ .collect();
1349
+ assert_eq!(
1202
1350
  buffer_args,
1203
1351
  vec![
1204
1352
  "team-agent-send-msg_abc123".to_string(),
@@ -1207,338 +1355,429 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
1207
1355
  ],
1208
1356
  "every tmux buffer operation must use the message-id-scoped golden buffer name; calls={calls:?}"
1209
1357
  );
1210
- }
1211
-
1212
- // ── 6. liveness three-state (§bug-085): exit 0 -> Live; "can't find …" -> Dead; else -> Unknown ─
1213
- #[test]
1214
- fn liveness_is_three_state_unknown_is_not_dead() {
1215
- let (be, rec) = backend_with(MockResp::Out(ok("%7")), vec![]);
1216
- assert_eq!(be.liveness(&PaneId::new("%7")).expect("liveness"), PaneLiveness::Live);
1217
- let argv0 = rec.lock().unwrap()[0].clone();
1218
- assert!(
1219
- argv0.contains(&"display-message".to_string())
1220
- && argv0.iter().any(|x| x.contains("#{pane_id}"))
1221
- && argv0.contains(&"%7".to_string()),
1222
- "liveness must probe the pane via display-message #{{pane_id}}; got {argv0:?}"
1223
- );
1224
-
1225
- let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane %7")), vec![]);
1226
- assert_eq!(
1227
- be.liveness(&PaneId::new("%7")).expect("liveness"),
1228
- PaneLiveness::Dead,
1229
- "a 'can't find pane' failure -> Dead"
1230
- );
1231
-
1232
- let (be, _r) = backend_with(MockResp::Out(fail(1, "error connecting to server: No such file or directory")), vec![]);
1233
- assert_eq!(
1234
- be.liveness(&PaneId::new("%7")).expect("liveness"),
1235
- PaneLiveness::Unknown,
1236
- "a NON-'can't find' failure is UNKNOWN, not DEAD (§bug-085 three-state)"
1237
- );
1238
- }
1239
-
1240
- #[test]
1241
- fn has_pane_is_direct_existence_probe_not_liveness_guess() {
1242
- let (be, rec) = backend_with(MockResp::Out(ok("%7")), vec![]);
1243
- assert_eq!(be.has_pane(&PaneId::new("%7")).expect("has_pane"), Some(true));
1244
- let argv0 = rec.lock().unwrap()[0].clone();
1245
- assert!(
1246
- argv0.contains(&"display-message".to_string())
1247
- && argv0.iter().any(|x| x.contains("#{pane_id}"))
1248
- && argv0.contains(&"%7".to_string()),
1249
- "has_pane must use the cheap display-message #{{pane_id}} probe; got {argv0:?}"
1250
- );
1251
-
1252
- let (be, _r) = backend_with(MockResp::Out(ok("")), vec![]);
1253
- assert_eq!(
1254
- be.has_pane(&PaneId::new("%9999")).expect("has_pane"),
1255
- Some(false),
1256
- "real tmux can report a missing pane as exit 0 with empty stdout"
1257
- );
1258
-
1259
- let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane: %9999")), vec![]);
1260
- assert_eq!(be.has_pane(&PaneId::new("%9999")).expect("has_pane"), Some(false));
1261
-
1262
- let (be, _r) = backend_with(MockResp::Out(ok("%8")), vec![]);
1263
- assert_eq!(
1264
- be.has_pane(&PaneId::new("%7")).expect("has_pane"),
1265
- None,
1266
- "a successful but mismatched pane id is not proof that the requested pane exists"
1267
- );
1268
-
1269
- let (be, _r) = backend_with(MockResp::Out(ok("not-a-pane")), vec![]);
1270
- assert_eq!(
1271
- be.has_pane(&PaneId::new("%7")).expect("has_pane"),
1272
- None,
1273
- "a successful but invalid pane id stays Unknown"
1274
- );
1275
-
1276
- let (be, _r) = backend_with(MockResp::Out(fail(1, "error connecting to server: No such file or directory")), vec![]);
1277
- assert_eq!(
1278
- be.has_pane(&PaneId::new("%7")).expect("has_pane"),
1279
- None,
1280
- "server/probe errors remain Unknown, not absent"
1281
- );
1282
- }
1283
-
1284
- // ── CP-1: per-team socket — for_workspace injects `-L ta-<hash>` at the run chokepoint; new() does NOT ─
1285
- #[test]
1286
- fn for_workspace_backend_injects_per_team_socket_but_default_backend_does_not() {
1287
- use super::socket_name_for_workspace;
1288
- let ws = Path::new("/tmp/ta-cp1-socket-test-ws");
1289
- let socket = socket_name_for_workspace(ws);
1290
- assert!(
1291
- socket.starts_with("ta-") && socket.len() == 15,
1292
- "socket name must be short + deterministic `ta-<12 hex>`; got {socket:?}"
1293
- );
1294
- // deterministic: the SAME workspace path always derives the SAME socket (CLI == daemon == ops).
1295
- assert_eq!(socket, socket_name_for_workspace(ws), "socket derivation must be deterministic");
1296
-
1297
- // workspace-bound backend: every executed `tmux` argv gets `-L <socket>` after the leading token.
1298
- let recorded = Arc::new(Mutex::new(Vec::new()));
1299
- let runner = MockCommandRunner {
1300
- recorded: Arc::clone(&recorded),
1301
- stdin_recorded: Arc::new(Mutex::new(Vec::new())),
1302
- queue: Mutex::new(VecDeque::new()),
1303
- default: MockResp::Out(ok("")),
1304
- };
1305
- let be = TmuxBackend::with_runner_for_workspace(Box::new(runner), ws);
1306
- be.has_session(&SessionName::new("sess")).expect("has_session");
1307
- let argv = recorded.lock().unwrap()[0].clone();
1308
- assert_eq!(
1309
- argv,
1310
- svec(&["tmux", "-L", &socket, "has-session", "-t", "sess"]),
1311
- "for_workspace backend must inject `-L <socket>` right after `tmux`; got {argv:?}"
1312
- );
1313
-
1314
- // default backend (new()/with_runner): NO `-L` — argv stays the golden-locked builder form.
1315
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1316
- be.has_session(&SessionName::new("sess")).expect("has_session");
1317
- assert_eq!(
1358
+ }
1359
+
1360
+ // ── 6. liveness three-state (§bug-085): exit 0 -> Live; "can't find …" -> Dead; else -> Unknown ─
1361
+ #[test]
1362
+ fn liveness_is_three_state_unknown_is_not_dead() {
1363
+ let (be, rec) = backend_with(MockResp::Out(ok("%7")), vec![]);
1364
+ assert_eq!(
1365
+ be.liveness(&PaneId::new("%7")).expect("liveness"),
1366
+ PaneLiveness::Live
1367
+ );
1368
+ let argv0 = rec.lock().unwrap()[0].clone();
1369
+ assert!(
1370
+ argv0.contains(&"display-message".to_string())
1371
+ && argv0.iter().any(|x| x.contains("#{pane_id}"))
1372
+ && argv0.contains(&"%7".to_string()),
1373
+ "liveness must probe the pane via display-message #{{pane_id}}; got {argv0:?}"
1374
+ );
1375
+
1376
+ let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane %7")), vec![]);
1377
+ assert_eq!(
1378
+ be.liveness(&PaneId::new("%7")).expect("liveness"),
1379
+ PaneLiveness::Dead,
1380
+ "a 'can't find pane' failure -> Dead"
1381
+ );
1382
+
1383
+ let (be, _r) = backend_with(
1384
+ MockResp::Out(fail(
1385
+ 1,
1386
+ "error connecting to server: No such file or directory",
1387
+ )),
1388
+ vec![],
1389
+ );
1390
+ assert_eq!(
1391
+ be.liveness(&PaneId::new("%7")).expect("liveness"),
1392
+ PaneLiveness::Unknown,
1393
+ "a NON-'can't find' failure is UNKNOWN, not DEAD (§bug-085 three-state)"
1394
+ );
1395
+ }
1396
+
1397
+ #[test]
1398
+ fn has_pane_is_direct_existence_probe_not_liveness_guess() {
1399
+ let (be, rec) = backend_with(MockResp::Out(ok("%7")), vec![]);
1400
+ assert_eq!(
1401
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
1402
+ Some(true)
1403
+ );
1404
+ let argv0 = rec.lock().unwrap()[0].clone();
1405
+ assert!(
1406
+ argv0.contains(&"display-message".to_string())
1407
+ && argv0.iter().any(|x| x.contains("#{pane_id}"))
1408
+ && argv0.contains(&"%7".to_string()),
1409
+ "has_pane must use the cheap display-message #{{pane_id}} probe; got {argv0:?}"
1410
+ );
1411
+
1412
+ let (be, _r) = backend_with(MockResp::Out(ok("")), vec![]);
1413
+ assert_eq!(
1414
+ be.has_pane(&PaneId::new("%9999")).expect("has_pane"),
1415
+ Some(false),
1416
+ "real tmux can report a missing pane as exit 0 with empty stdout"
1417
+ );
1418
+
1419
+ let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane: %9999")), vec![]);
1420
+ assert_eq!(
1421
+ be.has_pane(&PaneId::new("%9999")).expect("has_pane"),
1422
+ Some(false)
1423
+ );
1424
+
1425
+ let (be, _r) = backend_with(MockResp::Out(ok("%8")), vec![]);
1426
+ assert_eq!(
1427
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
1428
+ None,
1429
+ "a successful but mismatched pane id is not proof that the requested pane exists"
1430
+ );
1431
+
1432
+ let (be, _r) = backend_with(MockResp::Out(ok("not-a-pane")), vec![]);
1433
+ assert_eq!(
1434
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
1435
+ None,
1436
+ "a successful but invalid pane id stays Unknown"
1437
+ );
1438
+
1439
+ let (be, _r) = backend_with(
1440
+ MockResp::Out(fail(
1441
+ 1,
1442
+ "error connecting to server: No such file or directory",
1443
+ )),
1444
+ vec![],
1445
+ );
1446
+ assert_eq!(
1447
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
1448
+ None,
1449
+ "server/probe errors remain Unknown, not absent"
1450
+ );
1451
+ }
1452
+
1453
+ // ── CP-1: per-team socket — for_workspace injects `-L ta-<hash>` at the run chokepoint; new() does NOT ─
1454
+ #[test]
1455
+ fn for_workspace_backend_injects_per_team_socket_but_default_backend_does_not() {
1456
+ use super::socket_name_for_workspace;
1457
+ let ws = Path::new("/tmp/ta-cp1-socket-test-ws");
1458
+ let socket = socket_name_for_workspace(ws);
1459
+ assert!(
1460
+ socket.starts_with("ta-") && socket.len() == 15,
1461
+ "socket name must be short + deterministic `ta-<12 hex>`; got {socket:?}"
1462
+ );
1463
+ // deterministic: the SAME workspace path always derives the SAME socket (CLI == daemon == ops).
1464
+ assert_eq!(
1465
+ socket,
1466
+ socket_name_for_workspace(ws),
1467
+ "socket derivation must be deterministic"
1468
+ );
1469
+
1470
+ // workspace-bound backend: every executed `tmux` argv gets `-L <socket>` after the leading token.
1471
+ let recorded = Arc::new(Mutex::new(Vec::new()));
1472
+ let runner = MockCommandRunner {
1473
+ recorded: Arc::clone(&recorded),
1474
+ stdin_recorded: Arc::new(Mutex::new(Vec::new())),
1475
+ queue: Mutex::new(VecDeque::new()),
1476
+ default: MockResp::Out(ok("")),
1477
+ };
1478
+ let be = TmuxBackend::with_runner_for_workspace(Box::new(runner), ws);
1479
+ be.has_session(&SessionName::new("sess"))
1480
+ .expect("has_session");
1481
+ let argv = recorded.lock().unwrap()[0].clone();
1482
+ assert_eq!(
1483
+ argv,
1484
+ svec(&["tmux", "-L", &socket, "has-session", "-t", "sess"]),
1485
+ "for_workspace backend must inject `-L <socket>` right after `tmux`; got {argv:?}"
1486
+ );
1487
+
1488
+ // default backend (new()/with_runner): NO `-L` — argv stays the golden-locked builder form.
1489
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1490
+ be.has_session(&SessionName::new("sess"))
1491
+ .expect("has_session");
1492
+ assert_eq!(
1318
1493
  rec.lock().unwrap()[0],
1319
1494
  svec(&["tmux", "has-session", "-t", "sess"]),
1320
1495
  "the default-socket backend must NOT inject `-L` (existing tests + non-team callers unaffected)"
1321
1496
  );
1322
- }
1323
-
1324
- // ── 7. kill_session / kill_window: golden argv; success -> Ok(()) ───────────────────────────────
1325
- #[test]
1326
- fn kill_session_and_kill_window_argv() {
1327
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1328
- be.kill_session(&SessionName::new("sess")).expect("kill_session");
1329
- assert_eq!(rec.lock().unwrap()[0], svec(&["tmux", "kill-session", "-t", "sess"]));
1330
-
1331
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1332
- be.kill_window(&Target::Pane(PaneId::new("%7"))).expect("kill_window");
1333
- assert_eq!(rec.lock().unwrap()[0], svec(&["tmux", "kill-window", "-t", "%7"]));
1334
- }
1335
-
1336
- // ── 8. ERROR MAPPING: non-zero tmux exit -> TransportError::Subprocess; runner io::Error -> Err ──
1337
- #[test]
1338
- fn error_paths_map_to_transport_error_not_panic() {
1339
- // tmux cli non-zero exit (the Subprocess variant's documented purpose).
1340
- let (be, _r) = backend_with(MockResp::Out(fail(1, "no server running on /tmp/tmux-x/default")), vec![]);
1341
- let err = be.kill_session(&SessionName::new("sess")).expect_err("kill_session must error on non-zero exit");
1342
- assert!(
1343
- matches!(err, TransportError::Subprocess { code: Some(1), .. }),
1344
- "a non-zero tmux exit must map to TransportError::Subprocess{{code,stderr}}; got {err:?}"
1345
- );
1346
-
1347
- // a runner io::Error (e.g. tmux not on PATH) must surface as a TransportError, never a panic.
1348
- let (be, _r) = backend_with(MockResp::Io(std::io::ErrorKind::NotFound), vec![]);
1349
- let err = be
1350
- .capture(&Target::Pane(PaneId::new("%7")), CaptureRange::Full)
1351
- .expect_err("capture must surface the runner io error");
1352
- assert!(
1353
- matches!(err, TransportError::Capture { .. } | TransportError::Io(_)),
1354
- "a runner io error must map to a TransportError (not panic); got {err:?}"
1355
- );
1356
- }
1357
-
1358
- // ── 9. RealCommandRunner GOLDEN 5s TIMEOUT (rt-host-b transient-session race) ────────────────────
1359
- // GOLDEN: terminal.py:12-13 `run_cmd(args, timeout=timeout, check=False)`; runtime.py:1010-1014
1360
- // `_tmux_session_exists` runs `tmux has-session -t <s>` with timeout=5. A has-session that outlives
1361
- // 5s raises `subprocess.TimeoutExpired`, which the coordinator daemon CATCHES
1362
- // (coordinator/__main__.py:60-90 `except Exception`) and treats as a TOLERATED transient
1363
- // (exponential backoff + retry next tick) — it is NEVER read as a definitive "session gone".
1364
- // The 5s subprocess timeout is golden's ONLY tolerance for a slow/hung probe.
1365
- //
1366
- // RUST GAP (THE BUG): `RealCommandRunner::run` (tmux_backend.rs:52) calls
1367
- // `std::process::Command::output()` with NO timeout, so a slow/hung tmux blocks indefinitely.
1368
- // On the (slow) mac mini this is the ~17% single-round-trip flake: a transient slow has-session
1369
- // tears down a healthy team. This is rt-host-b's deterministic 5/5 anchor — `run` on a HUNG
1370
- // command must abandon at the golden 5s and surface `Err(TimedOut)`, NOT block on the full
1371
- // subprocess.
1372
- //
1373
- // RED today: there is no timeout, so `run(["sleep","30"])` blocks ~30s and the `< 6s` bound fails.
1374
- // #[ignore] real-machine: this is the only test here that spawns a real subprocess.
1375
- // PORTER SEAM: add a 5s timeout inside `RealCommandRunner::run` (spawn child + wait-with-timeout
1376
- // via a thread/channel + kill the child on expiry), returning `Err(io::Error, kind TimedOut)`
1377
- // NO new crate dependency. Keep the existing `CommandRunner::run(&[String]) -> Result<…, io::Error>`
1378
- // signature (the timeout is internal; do not add a parameter).
1379
- #[test]
1380
- #[ignore = "real-machine: spawns a real sleeping subprocess; asserts RealCommandRunner enforces \
1497
+ }
1498
+
1499
+ // ── 7. kill_session / kill_window: golden argv; success -> Ok(()) ───────────────────────────────
1500
+ #[test]
1501
+ fn kill_session_and_kill_window_argv() {
1502
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1503
+ be.kill_session(&SessionName::new("sess"))
1504
+ .expect("kill_session");
1505
+ assert_eq!(
1506
+ rec.lock().unwrap()[0],
1507
+ svec(&["tmux", "kill-session", "-t", "sess"])
1508
+ );
1509
+
1510
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1511
+ be.kill_window(&Target::Pane(PaneId::new("%7")))
1512
+ .expect("kill_window");
1513
+ assert_eq!(
1514
+ rec.lock().unwrap()[0],
1515
+ svec(&["tmux", "kill-window", "-t", "%7"])
1516
+ );
1517
+ }
1518
+
1519
+ // ── 8. ERROR MAPPING: non-zero tmux exit -> TransportError::Subprocess; runner io::Error -> Err ──
1520
+ #[test]
1521
+ fn error_paths_map_to_transport_error_not_panic() {
1522
+ // tmux cli non-zero exit (the Subprocess variant's documented purpose).
1523
+ let (be, _r) = backend_with(
1524
+ MockResp::Out(fail(1, "no server running on /tmp/tmux-x/default")),
1525
+ vec![],
1526
+ );
1527
+ let err = be
1528
+ .kill_session(&SessionName::new("sess"))
1529
+ .expect_err("kill_session must error on non-zero exit");
1530
+ assert!(
1531
+ matches!(err, TransportError::Subprocess { code: Some(1), .. }),
1532
+ "a non-zero tmux exit must map to TransportError::Subprocess{{code,stderr}}; got {err:?}"
1533
+ );
1534
+
1535
+ // a runner io::Error (e.g. tmux not on PATH) must surface as a TransportError, never a panic.
1536
+ let (be, _r) = backend_with(MockResp::Io(std::io::ErrorKind::NotFound), vec![]);
1537
+ let err = be
1538
+ .capture(&Target::Pane(PaneId::new("%7")), CaptureRange::Full)
1539
+ .expect_err("capture must surface the runner io error");
1540
+ assert!(
1541
+ matches!(err, TransportError::Capture { .. } | TransportError::Io(_)),
1542
+ "a runner io error must map to a TransportError (not panic); got {err:?}"
1543
+ );
1544
+ }
1545
+
1546
+ // ── 9. RealCommandRunner GOLDEN 5s TIMEOUT (rt-host-b transient-session race) ────────────────────
1547
+ // GOLDEN: terminal.py:12-13 `run_cmd(args, timeout=timeout, check=False)`; runtime.py:1010-1014
1548
+ // `_tmux_session_exists` runs `tmux has-session -t <s>` with timeout=5. A has-session that outlives
1549
+ // 5s raises `subprocess.TimeoutExpired`, which the coordinator daemon CATCHES
1550
+ // (coordinator/__main__.py:60-90 `except Exception`) and treats as a TOLERATED transient
1551
+ // (exponential backoff + retry next tick) it is NEVER read as a definitive "session gone".
1552
+ // The 5s subprocess timeout is golden's ONLY tolerance for a slow/hung probe.
1553
+ //
1554
+ // RUST GAP (THE BUG): `RealCommandRunner::run` (tmux_backend.rs:52) calls
1555
+ // `std::process::Command::output()` with NO timeout, so a slow/hung tmux blocks indefinitely.
1556
+ // On the (slow) mac mini this is the ~17% single-round-trip flake: a transient slow has-session
1557
+ // tears down a healthy team. This is rt-host-b's deterministic 5/5 anchor — `run` on a HUNG
1558
+ // command must abandon at the golden 5s and surface `Err(TimedOut)`, NOT block on the full
1559
+ // subprocess.
1560
+ //
1561
+ // RED today: there is no timeout, so `run(["sleep","30"])` blocks ~30s and the `< 6s` bound fails.
1562
+ // #[ignore] real-machine: this is the only test here that spawns a real subprocess.
1563
+ // PORTER SEAM: add a 5s timeout inside `RealCommandRunner::run` (spawn child + wait-with-timeout
1564
+ // via a thread/channel + kill the child on expiry), returning `Err(io::Error, kind TimedOut)` —
1565
+ // NO new crate dependency. Keep the existing `CommandRunner::run(&[String]) -> Result<…, io::Error>`
1566
+ // signature (the timeout is internal; do not add a parameter).
1567
+ #[test]
1568
+ #[ignore = "real-machine: spawns a real sleeping subprocess; asserts RealCommandRunner enforces \
1381
1569
  the golden 5s timeout (terminal.py run_cmd timeout / runtime.py:1013 \
1382
1570
  _tmux_session_exists timeout=5)"]
1383
- fn real_command_runner_enforces_golden_5s_timeout_on_hang() {
1384
- use std::time::{Duration, Instant};
1385
- let runner = RealCommandRunner;
1386
- let started = Instant::now();
1387
- let result = runner.run(&svec(&["sleep", "30"]));
1388
- let elapsed = started.elapsed();
1389
- assert!(
1571
+ fn real_command_runner_enforces_golden_5s_timeout_on_hang() {
1572
+ use std::time::{Duration, Instant};
1573
+ let runner = RealCommandRunner;
1574
+ let started = Instant::now();
1575
+ let result = runner.run(&svec(&["sleep", "30"]));
1576
+ let elapsed = started.elapsed();
1577
+ assert!(
1390
1578
  elapsed < Duration::from_secs(6),
1391
1579
  "RealCommandRunner::run must abandon a hung command at the golden 5s timeout, not block on \
1392
1580
  the full subprocess (terminal.py run_cmd timeout / runtime.py:1013 timeout=5); blocked {elapsed:?}"
1393
1581
  );
1394
- let err = result.expect_err(
1582
+ let err = result.expect_err(
1395
1583
  "a command outliving the 5s timeout must surface as Err (subprocess.TimeoutExpired analog) so \
1396
1584
  the daemon backoff path tolerates it, instead of yielding a bogus has-session bool",
1397
1585
  );
1398
- assert_eq!(
1586
+ assert_eq!(
1399
1587
  err.kind(),
1400
1588
  std::io::ErrorKind::TimedOut,
1401
1589
  "the timeout must be io::ErrorKind::TimedOut (golden: TimeoutExpired -> daemon except -> backoff/retry)"
1402
1590
  );
1403
- }
1404
-
1405
- // ── 10. query (TRANSPORT TRIO) — single-field display-message; nonzero -> None ──────────────────
1406
- // Golden _legacy_pane_discovery.py:35-39 _tmux_pane_info: `tmux display-message -p -t <target> -F
1407
- // <fmt>` (returncode != 0 -> None), single-field reads at state.py:346 (#{pane_id}) / delivery.py:34
1408
- // (#{pane_width}). The argv is exactly `transport::tmux_query_argv(pane, field)` (the golden-locked
1409
- // builder). RED today: `query` is unimplemented!() -> PANIC. Porter: pane_from_target(target) ->
1410
- // tmux_query_argv -> run; success => Some(stdout.trim()); nonzero => None (never Err).
1411
- #[test]
1412
- fn query_single_field_argv_and_nonzero_maps_to_none() {
1413
- // PaneId field: argv == the golden builder; present value parsed (trimmed) into Some.
1414
- let (be, rec) = backend_with(MockResp::Out(ok("%7\n")), vec![]);
1415
- let got = be.query(&Target::Pane(PaneId::new("%7")), PaneField::PaneId).expect("query ok");
1416
- assert_eq!(
1417
- rec.lock().unwrap()[0],
1418
- tmux_query_argv(&PaneId::new("%7"), PaneField::PaneId),
1419
- "query must build the golden single-field `display-message -p -t <t> -F #{{pane_id}}` argv"
1420
- );
1421
- assert_eq!(got, Some("%7".to_string()), "a present field value is parsed (stripped) into Some");
1422
-
1423
- // PaneWidth uses -F too; lock argv + the parsed numeric-as-string field.
1424
- let (be, rec) = backend_with(MockResp::Out(ok("180\n")), vec![]);
1425
- let got = be.query(&Target::Pane(PaneId::new("%7")), PaneField::PaneWidth).expect("query ok");
1426
- assert_eq!(rec.lock().unwrap()[0], tmux_query_argv(&PaneId::new("%7"), PaneField::PaneWidth));
1427
- assert_eq!(got, Some("180".to_string()));
1428
-
1429
- // nonzero exit (pane gone) -> None, NOT an Err (golden _tmux_pane_info: returncode != 0 -> None).
1430
- let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane %7")), vec![]);
1431
- assert_eq!(
1432
- be.query(&Target::Pane(PaneId::new("%7")), PaneField::PaneId).expect("query ok on nonzero"),
1433
- None,
1434
- "a nonzero / pane-gone query must map to None (not Err)"
1435
- );
1436
- }
1437
-
1438
- // ── 11. list_targets (TRANSPORT TRIO) — `list-panes -a -F TMUX_PANE_FORMAT` + per-line parse ────
1439
- // Golden _legacy_pane_discovery.py:29-33 _tmux_list_panes: `tmux list-panes -a -F <TMUX_PANE_FORMAT>`
1440
- // (returncode != 0 -> []), parse each tab line via _parse_tmux_pane_info. TMUX_PANE_FORMAT
1441
- // (runtime.py:456-460) is the byte-exact tab string locked below; P5 (C-P5-3) appends
1442
- // `#{pane_pid}` as field 12 so pane pids ride the single list-panes call (the per-pane
1443
- // display-message N+1 fallback is gone). leader_env stays the reverse-env real-machine bit.
1444
- #[test]
1445
- fn list_targets_argv_and_parses_tmux_pane_format() {
1446
- const FMT: &str = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}\t#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}\t#{pane_pid}";
1447
- let stdout = "%7\tteam-x\t0\twin0\t0\t/dev/ttys003\tcodex\t1\t/Users/me/work\t1\t0\t41001\n\
1591
+ }
1592
+
1593
+ // ── 10. query (TRANSPORT TRIO) — single-field display-message; nonzero -> None ──────────────────
1594
+ // Golden _legacy_pane_discovery.py:35-39 _tmux_pane_info: `tmux display-message -p -t <target> -F
1595
+ // <fmt>` (returncode != 0 -> None), single-field reads at state.py:346 (#{pane_id}) / delivery.py:34
1596
+ // (#{pane_width}). The argv is exactly `transport::tmux_query_argv(pane, field)` (the golden-locked
1597
+ // builder). RED today: `query` is unimplemented!() -> PANIC. Porter: pane_from_target(target) ->
1598
+ // tmux_query_argv -> run; success => Some(stdout.trim()); nonzero => None (never Err).
1599
+ #[test]
1600
+ fn query_single_field_argv_and_nonzero_maps_to_none() {
1601
+ // PaneId field: argv == the golden builder; present value parsed (trimmed) into Some.
1602
+ let (be, rec) = backend_with(MockResp::Out(ok("%7\n")), vec![]);
1603
+ let got = be
1604
+ .query(&Target::Pane(PaneId::new("%7")), PaneField::PaneId)
1605
+ .expect("query ok");
1606
+ assert_eq!(
1607
+ rec.lock().unwrap()[0],
1608
+ tmux_query_argv(&PaneId::new("%7"), PaneField::PaneId),
1609
+ "query must build the golden single-field `display-message -p -t <t> -F #{{pane_id}}` argv"
1610
+ );
1611
+ assert_eq!(
1612
+ got,
1613
+ Some("%7".to_string()),
1614
+ "a present field value is parsed (stripped) into Some"
1615
+ );
1616
+
1617
+ // PaneWidth uses -F too; lock argv + the parsed numeric-as-string field.
1618
+ let (be, rec) = backend_with(MockResp::Out(ok("180\n")), vec![]);
1619
+ let got = be
1620
+ .query(&Target::Pane(PaneId::new("%7")), PaneField::PaneWidth)
1621
+ .expect("query ok");
1622
+ assert_eq!(
1623
+ rec.lock().unwrap()[0],
1624
+ tmux_query_argv(&PaneId::new("%7"), PaneField::PaneWidth)
1625
+ );
1626
+ assert_eq!(got, Some("180".to_string()));
1627
+
1628
+ // nonzero exit (pane gone) -> None, NOT an Err (golden _tmux_pane_info: returncode != 0 -> None).
1629
+ let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane %7")), vec![]);
1630
+ assert_eq!(
1631
+ be.query(&Target::Pane(PaneId::new("%7")), PaneField::PaneId)
1632
+ .expect("query ok on nonzero"),
1633
+ None,
1634
+ "a nonzero / pane-gone query must map to None (not Err)"
1635
+ );
1636
+ }
1637
+
1638
+ // ── 11. list_targets (TRANSPORT TRIO) — `list-panes -a -F TMUX_PANE_FORMAT` + per-line parse ────
1639
+ // Golden _legacy_pane_discovery.py:29-33 _tmux_list_panes: `tmux list-panes -a -F <TMUX_PANE_FORMAT>`
1640
+ // (returncode != 0 -> []), parse each tab line via _parse_tmux_pane_info. TMUX_PANE_FORMAT
1641
+ // (runtime.py:456-460) is the byte-exact tab string locked below; P5 (C-P5-3) appends
1642
+ // `#{pane_pid}` as field 12 so pane pids ride the single list-panes call (the per-pane
1643
+ // display-message N+1 fallback is gone). leader_env stays the reverse-env real-machine bit.
1644
+ #[test]
1645
+ fn list_targets_argv_and_parses_tmux_pane_format() {
1646
+ const FMT: &str = "#{pane_id}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_index}\t#{pane_tty}\t#{pane_current_command}\t#{pane_active}\t#{pane_current_path}\t#{session_attached}\t#{pane_in_mode}\t#{pane_pid}";
1647
+ let stdout = "%7\tteam-x\t0\twin0\t0\t/dev/ttys003\tcodex\t1\t/Users/me/work\t1\t0\t41001\n\
1448
1648
  %8\tteam-x\t1\twin1\t0\t/dev/ttys004\tnode\t0\t/Users/me/other\t0\t0\t41002\n";
1449
- let (be, rec) = backend_with(MockResp::Out(ok(stdout)), vec![]);
1450
- let panes = be.list_targets().expect("list_targets ok");
1451
- assert_eq!(
1649
+ let (be, rec) = backend_with(MockResp::Out(ok(stdout)), vec![]);
1650
+ let panes = be.list_targets().expect("list_targets ok");
1651
+ assert_eq!(
1452
1652
  rec.lock().unwrap()[0],
1453
1653
  svec(&["tmux", "list-panes", "-a", "-F", FMT]),
1454
1654
  "list_targets must run `tmux list-panes -a -F <TMUX_PANE_FORMAT>` (golden _legacy_pane_discovery.py:29)"
1455
1655
  );
1456
- assert_eq!(panes.len(), 2, "one PaneInfo per output line");
1457
- let p = &panes[0];
1458
- assert_eq!(p.pane_id.as_str(), "%7", "field[0] -> pane_id");
1459
- assert_eq!(p.session.as_str(), "team-x", "field[1] -> session_name");
1460
- assert_eq!(p.window_index, Some(0), "field[2] -> window_index (parsed u32)");
1461
- assert_eq!(p.window_name.as_ref().map(|w| w.as_str().to_string()), Some("win0".to_string()), "field[3] -> window_name");
1462
- assert_eq!(p.pane_index, Some(0), "field[4] -> pane_index (parsed u32)");
1463
- assert_eq!(p.tty.as_deref(), Some("/dev/ttys003"), "field[5] -> pane_tty");
1464
- assert_eq!(p.current_command.as_deref(), Some("codex"), "field[6] -> pane_current_command");
1465
- assert!(p.active, "field[7] pane_active='1' -> active=true");
1466
- assert_eq!(
1467
- p.current_path.as_ref().map(|x| x.to_string_lossy().to_string()),
1468
- Some("/Users/me/work".to_string()),
1469
- "field[8] -> pane_current_path"
1470
- );
1471
- assert!(!panes[1].active, "field[7] pane_active='0' -> active=false");
1472
- assert_eq!(p.pane_pid, Some(41001), "field[11] -> pane_pid (P5 C-P5-3, no N+1 fallback)");
1473
- assert_eq!(panes[1].pane_pid, Some(41002), "field[11] -> pane_pid (second pane)");
1474
-
1475
- // nonzero exit -> empty vec (golden returncode != 0 -> []).
1476
- let (be, _r) = backend_with(MockResp::Out(fail(1, "no server running on /tmp/tmux-x/default")), vec![]);
1477
- assert!(
1478
- be.list_targets().expect("list_targets ok on nonzero").is_empty(),
1479
- "a nonzero list-panes must map to an EMPTY Vec (not Err)"
1480
- );
1481
- }
1482
-
1483
- // ── 12. attach_session (TRANSPORT TRIO) — `tmux attach-session -t <s>` -> Attached ──────────────
1484
- // Golden tmux attach is `tmux attach-session -t <session>`; a successful attach -> AttachOutcome::
1485
- // Attached. RED today: attach_session is unimplemented!() -> PANIC. The in-process lock asserts the
1486
- // argv + outcome via the recording runner; the REAL attach is interactive (takes over the terminal)
1487
- // that is the real-machine boundary, not unit-testable.
1488
- #[test]
1489
- fn attach_session_argv_and_attached_outcome() {
1490
- let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1491
- let outcome = be.attach_session(&SessionName::new("sess")).expect("attach ok");
1492
- assert_eq!(
1493
- rec.lock().unwrap()[0],
1494
- svec(&["tmux", "attach-session", "-t", "sess"]),
1495
- "attach_session must run `tmux attach-session -t <session>`"
1496
- );
1497
- assert_eq!(outcome, AttachOutcome::Attached, "a successful tmux attach -> AttachOutcome::Attached");
1498
- }
1499
-
1500
- // ── 13. TARGET-SCAN WIRING (a): list_targets is the LIVE pane-discovery primitive ───────────────
1501
- // WAVE-2 Lane C. `list_targets` (the `tmux list-panes -a` scan, locked argv/parse in test #11) has
1502
- // ZERO production callers today — it is dead code. Golden wires pane discovery on top of it: status
1503
- // (_capture_missing_sessions / _tmux_session_exists, queries.py:46,52) and doctor (coordinator_health)
1504
- // consume the live scan. The in-process wiring obligation is exercised at the status level by
1505
- // cli::tests::status_tmux_session_present_uses_live_tmux_probe_not_is_some (RED). This #[ignore]
1506
- // real-machine seam locks that a LIVE `list_targets` actually enumerates the running panes, proving
1507
- // the primitive is usable by the status/doctor discovery the porter must wire.
1508
- #[test]
1509
- #[ignore = "real-machine: needs a live tmux server+session; asserts list_targets() (the dangling \
1656
+ assert_eq!(panes.len(), 2, "one PaneInfo per output line");
1657
+ let p = &panes[0];
1658
+ assert_eq!(p.pane_id.as_str(), "%7", "field[0] -> pane_id");
1659
+ assert_eq!(p.session.as_str(), "team-x", "field[1] -> session_name");
1660
+ assert_eq!(
1661
+ p.window_index,
1662
+ Some(0),
1663
+ "field[2] -> window_index (parsed u32)"
1664
+ );
1665
+ assert_eq!(
1666
+ p.window_name.as_ref().map(|w| w.as_str().to_string()),
1667
+ Some("win0".to_string()),
1668
+ "field[3] -> window_name"
1669
+ );
1670
+ assert_eq!(p.pane_index, Some(0), "field[4] -> pane_index (parsed u32)");
1671
+ assert_eq!(
1672
+ p.tty.as_deref(),
1673
+ Some("/dev/ttys003"),
1674
+ "field[5] -> pane_tty"
1675
+ );
1676
+ assert_eq!(
1677
+ p.current_command.as_deref(),
1678
+ Some("codex"),
1679
+ "field[6] -> pane_current_command"
1680
+ );
1681
+ assert!(p.active, "field[7] pane_active='1' -> active=true");
1682
+ assert_eq!(
1683
+ p.current_path
1684
+ .as_ref()
1685
+ .map(|x| x.to_string_lossy().to_string()),
1686
+ Some("/Users/me/work".to_string()),
1687
+ "field[8] -> pane_current_path"
1688
+ );
1689
+ assert!(!panes[1].active, "field[7] pane_active='0' -> active=false");
1690
+ assert_eq!(
1691
+ p.pane_pid,
1692
+ Some(41001),
1693
+ "field[11] -> pane_pid (P5 C-P5-3, no N+1 fallback)"
1694
+ );
1695
+ assert_eq!(
1696
+ panes[1].pane_pid,
1697
+ Some(41002),
1698
+ "field[11] -> pane_pid (second pane)"
1699
+ );
1700
+
1701
+ // nonzero exit -> empty vec (golden returncode != 0 -> []).
1702
+ let (be, _r) = backend_with(
1703
+ MockResp::Out(fail(1, "no server running on /tmp/tmux-x/default")),
1704
+ vec![],
1705
+ );
1706
+ assert!(
1707
+ be.list_targets()
1708
+ .expect("list_targets ok on nonzero")
1709
+ .is_empty(),
1710
+ "a nonzero list-panes must map to an EMPTY Vec (not Err)"
1711
+ );
1712
+ }
1713
+
1714
+ // ── 12. attach_session (TRANSPORT TRIO) — `tmux attach-session -t <s>` -> Attached ──────────────
1715
+ // Golden tmux attach is `tmux attach-session -t <session>`; a successful attach -> AttachOutcome::
1716
+ // Attached. RED today: attach_session is unimplemented!() -> PANIC. The in-process lock asserts the
1717
+ // argv + outcome via the recording runner; the REAL attach is interactive (takes over the terminal)
1718
+ // — that is the real-machine boundary, not unit-testable.
1719
+ #[test]
1720
+ fn attach_session_argv_and_attached_outcome() {
1721
+ let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
1722
+ let outcome = be
1723
+ .attach_session(&SessionName::new("sess"))
1724
+ .expect("attach ok");
1725
+ assert_eq!(
1726
+ rec.lock().unwrap()[0],
1727
+ svec(&["tmux", "attach-session", "-t", "sess"]),
1728
+ "attach_session must run `tmux attach-session -t <session>`"
1729
+ );
1730
+ assert_eq!(
1731
+ outcome,
1732
+ AttachOutcome::Attached,
1733
+ "a successful tmux attach -> AttachOutcome::Attached"
1734
+ );
1735
+ }
1736
+
1737
+ // ── 13. TARGET-SCAN WIRING (a): list_targets is the LIVE pane-discovery primitive ───────────────
1738
+ // WAVE-2 Lane C. `list_targets` (the `tmux list-panes -a` scan, locked argv/parse in test #11) has
1739
+ // ZERO production callers today — it is dead code. Golden wires pane discovery on top of it: status
1740
+ // (_capture_missing_sessions / _tmux_session_exists, queries.py:46,52) and doctor (coordinator_health)
1741
+ // consume the live scan. The in-process wiring obligation is exercised at the status level by
1742
+ // cli::tests::status_tmux_session_present_uses_live_tmux_probe_not_is_some (RED). This #[ignore]
1743
+ // real-machine seam locks that a LIVE `list_targets` actually enumerates the running panes, proving
1744
+ // the primitive is usable by the status/doctor discovery the porter must wire.
1745
+ #[test]
1746
+ #[ignore = "real-machine: needs a live tmux server+session; asserts list_targets() (the dangling \
1510
1747
  pane-discovery primitive, zero production callers) enumerates live panes so status/doctor \
1511
1748
  discovery can consume it (golden _legacy_pane_discovery list-panes -a)"]
1512
- fn list_targets_is_live_pane_discovery_primitive_for_status_doctor() {
1513
- let be = TmuxBackend::with_runner(Box::new(RealCommandRunner));
1514
- let panes = be.list_targets().expect("live list_targets must not error");
1515
- assert!(
1516
- !panes.is_empty(),
1517
- "a live `tmux list-panes -a` must surface the running panes; status/doctor pane discovery \
1749
+ fn list_targets_is_live_pane_discovery_primitive_for_status_doctor() {
1750
+ let be = TmuxBackend::with_runner(Box::new(RealCommandRunner));
1751
+ let panes = be.list_targets().expect("live list_targets must not error");
1752
+ assert!(
1753
+ !panes.is_empty(),
1754
+ "a live `tmux list-panes -a` must surface the running panes; status/doctor pane discovery \
1518
1755
  is wired on top of this scan (currently dead code — zero production callers)"
1519
- );
1520
- }
1521
-
1522
- // ── 14. TARGET-SCAN WIRING (b): R1 — caller_target.uuid is FIRST leader_session_uuid precedence ──
1523
- // WAVE-2 Lane C / wave2-laneB-rereview PROBE-D. When the caller-target scan lands, golden
1524
- // claim_lease_no_incident threads `_target_leader_session_uuid(caller_target)` as the FIRST
1525
- // leader_session_uuid precedence (leader/__init__.py:679-684): caller_target.uuid BEFORE
1526
- // owner.uuid / receiver.uuid / derived. A DIFFERENT live pane reclaiming a DEAD owner must persist
1527
- // the CALLER's uuid, not the dead owner's (PROBE-D: PY "NEWUUID" / RUST persists "OLD"). The
1528
- // caller-target uuid is read from the caller pane's INJECTED TEAM_AGENT_LEADER_SESSION_UUID via a
1529
- // per-pane env query (NOT a TMUX_PANE_FORMAT field), so the live scan is the dependency this seam
1530
- // marks. SCOPE NOTE: the decisive IN-PROCESS claim-path R1 RED belongs in leader/tests.rs, which is
1531
- // outside this task's (cli + tmux_backend) editor scope — flagged to the leader for the
1532
- // leader-contracts agent to graduate R1 to its own claim-path RED.
1533
- #[test]
1534
- #[ignore = "real-machine + SCOPE: R1 (PROBE-D) caller_target.uuid is FIRST leader_session_uuid \
1756
+ );
1757
+ }
1758
+
1759
+ // ── 14. TARGET-SCAN WIRING (b): R1 — caller_target.uuid is FIRST leader_session_uuid precedence ──
1760
+ // WAVE-2 Lane C / wave2-laneB-rereview PROBE-D. When the caller-target scan lands, golden
1761
+ // claim_lease_no_incident threads `_target_leader_session_uuid(caller_target)` as the FIRST
1762
+ // leader_session_uuid precedence (leader/__init__.py:679-684): caller_target.uuid BEFORE
1763
+ // owner.uuid / receiver.uuid / derived. A DIFFERENT live pane reclaiming a DEAD owner must persist
1764
+ // the CALLER's uuid, not the dead owner's (PROBE-D: PY "NEWUUID" / RUST persists "OLD"). The
1765
+ // caller-target uuid is read from the caller pane's INJECTED TEAM_AGENT_LEADER_SESSION_UUID via a
1766
+ // per-pane env query (NOT a TMUX_PANE_FORMAT field), so the live scan is the dependency this seam
1767
+ // marks. SCOPE NOTE: the decisive IN-PROCESS claim-path R1 RED belongs in leader/tests.rs, which is
1768
+ // outside this task's (cli + tmux_backend) editor scope — flagged to the leader for the
1769
+ // leader-contracts agent to graduate R1 to its own claim-path RED.
1770
+ #[test]
1771
+ #[ignore = "real-machine + SCOPE: R1 (PROBE-D) caller_target.uuid is FIRST leader_session_uuid \
1535
1772
  precedence (leader/__init__.py:679-684); the in-process claim-path assertion lives in \
1536
1773
  leader/tests.rs (out of cli+tmux_backend scope) — this seam marks the live caller-target \
1537
1774
  env-scan dependency"]
1538
- fn r1_caller_target_uuid_is_first_leader_session_uuid_precedence_seam() {
1539
- // The caller-target scan (reading the caller pane's injected TEAM_AGENT_LEADER_SESSION_UUID)
1540
- // is the live precursor to R1's uuid precedence. The full uuid-persistence assertion is the
1541
- // leader claim path's obligation (see report). Here we only confirm the scan is reachable.
1542
- let be = TmuxBackend::with_runner(Box::new(RealCommandRunner));
1543
- let _panes = be.list_targets().expect("live list_targets (caller-target scan precursor)");
1544
- }
1775
+ fn r1_caller_target_uuid_is_first_leader_session_uuid_precedence_seam() {
1776
+ // The caller-target scan (reading the caller pane's injected TEAM_AGENT_LEADER_SESSION_UUID)
1777
+ // is the live precursor to R1's uuid precedence. The full uuid-persistence assertion is the
1778
+ // leader claim path's obligation (see report). Here we only confirm the scan is reachable.
1779
+ let be = TmuxBackend::with_runner(Box::new(RealCommandRunner));
1780
+ let _panes = be
1781
+ .list_targets()
1782
+ .expect("live list_targets (caller-target scan precursor)");
1783
+ }