@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +129 -54
- package/crates/team-agent/src/cli/diagnose.rs +1 -2
- package/crates/team-agent/src/cli/emit.rs +1 -7
- package/crates/team-agent/src/cli/helpers.rs +3 -1
- package/crates/team-agent/src/cli/leader.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +27 -7
- package/crates/team-agent/src/cli/named_address.rs +14 -5
- package/crates/team-agent/src/cli/profile.rs +19 -7
- package/crates/team-agent/src/cli/send.rs +9 -3
- package/crates/team-agent/src/cli/status.rs +8 -30
- package/crates/team-agent/src/cli/status_port.rs +1341 -1272
- package/crates/team-agent/src/cli/tests/base.rs +738 -660
- package/crates/team-agent/src/cli/tests/compile.rs +45 -18
- package/crates/team-agent/src/cli/tests/divergence.rs +462 -444
- package/crates/team-agent/src/cli/tests/lane_c.rs +365 -282
- package/crates/team-agent/src/cli/tests/leader_watch.rs +356 -329
- package/crates/team-agent/src/cli/tests/main_preserved.rs +672 -564
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +284 -224
- package/crates/team-agent/src/cli/tests/mod.rs +17 -7
- package/crates/team-agent/src/cli/tests/named_address.rs +8 -2
- package/crates/team-agent/src/cli/tests/peer_allow.rs +10 -2
- package/crates/team-agent/src/cli/tests/run_delegation.rs +314 -273
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +26 -15
- package/crates/team-agent/src/cli/tests/status_send.rs +707 -629
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +20 -4
- package/crates/team-agent/src/cli/tests/verb_profile.rs +46 -17
- package/crates/team-agent/src/cli/tests/verb_validate.rs +15 -3
- package/crates/team-agent/src/codex_app_server.rs +2 -5
- package/crates/team-agent/src/compiler/tests.rs +139 -33
- package/crates/team-agent/src/compiler.rs +55 -22
- package/crates/team-agent/src/conpty/backend.rs +23 -33
- package/crates/team-agent/src/coordinator/backoff.rs +2 -7
- package/crates/team-agent/src/coordinator/conpty_shim.rs +55 -67
- package/crates/team-agent/src/coordinator/health.rs +46 -31
- package/crates/team-agent/src/coordinator/mod.rs +3 -3
- package/crates/team-agent/src/coordinator/orphan.rs +22 -10
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +47 -52
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +55 -19
- package/crates/team-agent/src/coordinator/tests/basics.rs +179 -41
- package/crates/team-agent/src/coordinator/tests/daemon.rs +53 -13
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +78 -19
- package/crates/team-agent/src/coordinator/tests/main_preserved.rs +61 -11
- package/crates/team-agent/src/coordinator/tests/mod.rs +33 -39
- package/crates/team-agent/src/coordinator/tests/spine.rs +52 -12
- package/crates/team-agent/src/coordinator/tests/takeover.rs +73 -15
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +50 -15
- package/crates/team-agent/src/coordinator/tests/watch.rs +74 -20
- package/crates/team-agent/src/coordinator/tick.rs +19 -1
- package/crates/team-agent/src/db/message_store.rs +138 -30
- package/crates/team-agent/src/db/migration.rs +249 -61
- package/crates/team-agent/src/db/schema.rs +303 -82
- package/crates/team-agent/src/diagnose/comms.rs +9 -2
- package/crates/team-agent/src/diagnose/mod.rs +1 -3
- package/crates/team-agent/src/diagnose/orphans.rs +79 -61
- package/crates/team-agent/src/event_log.rs +70 -16
- package/crates/team-agent/src/layout/manager.rs +15 -4
- package/crates/team-agent/src/layout/mod.rs +4 -4
- package/crates/team-agent/src/layout/overlay.rs +10 -3
- package/crates/team-agent/src/layout/placement.rs +5 -1
- package/crates/team-agent/src/layout/recovery.rs +4 -2
- package/crates/team-agent/src/layout/runtime_sessions.rs +7 -7
- package/crates/team-agent/src/layout/sessions.rs +17 -9
- package/crates/team-agent/src/layout/tmux_endpoint.rs +1 -1
- package/crates/team-agent/src/layout/worker_env.rs +52 -19
- package/crates/team-agent/src/leader/helpers.rs +7 -1
- package/crates/team-agent/src/leader/lease.rs +195 -82
- package/crates/team-agent/src/leader/owner_bind.rs +55 -22
- package/crates/team-agent/src/leader/rediscover/tests.rs +88 -24
- package/crates/team-agent/src/leader/rediscover.rs +74 -25
- package/crates/team-agent/src/leader/start.rs +75 -54
- package/crates/team-agent/src/leader/takeover.rs +46 -11
- package/crates/team-agent/src/leader/tests/basics.rs +320 -167
- package/crates/team-agent/src/leader/tests/byte_findings.rs +361 -219
- package/crates/team-agent/src/leader/tests/identity.rs +428 -356
- package/crates/team-agent/src/leader/tests/idle.rs +285 -254
- package/crates/team-agent/src/leader/tests/lease_api.rs +338 -274
- package/crates/team-agent/src/leader/tests/lease_claim.rs +643 -593
- package/crates/team-agent/src/leader/tests/mod.rs +115 -99
- package/crates/team-agent/src/leader/tests/rediscover.rs +74 -22
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +237 -211
- package/crates/team-agent/src/lib.rs +4 -4
- package/crates/team-agent/src/lifecycle/display.rs +7 -3
- package/crates/team-agent/src/lifecycle/launch.rs +6 -1
- package/crates/team-agent/src/lifecycle/mod.rs +9 -1
- package/crates/team-agent/src/lifecycle/profile_launch.rs +77 -34
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +3 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -6
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +1 -4
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +6 -5
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -22
- package/crates/team-agent/src/lifecycle/restart/remove.rs +45 -35
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +63 -17
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +251 -84
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +198 -48
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +152 -32
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -5
- package/crates/team-agent/src/lifecycle/tests.rs +2 -2
- package/crates/team-agent/src/main.rs +4 -4
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +47 -20
- package/crates/team-agent/src/mcp_server/mod.rs +11 -2
- package/crates/team-agent/src/mcp_server/types.rs +14 -3
- package/crates/team-agent/src/mcp_server/wire.rs +230 -68
- package/crates/team-agent/src/messaging/delivery.rs +20 -18
- package/crates/team-agent/src/messaging/helpers.rs +25 -4
- package/crates/team-agent/src/messaging/leader_receiver.rs +4 -5
- package/crates/team-agent/src/messaging/mod.rs +2 -3
- package/crates/team-agent/src/messaging/selftest.rs +46 -26
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +47 -12
- package/crates/team-agent/src/messaging/tests/runtime.rs +57 -22
- package/crates/team-agent/src/messaging/tests/spine.rs +154 -40
- package/crates/team-agent/src/messaging/tests/wave2.rs +31 -32
- package/crates/team-agent/src/messaging/trust.rs +25 -2
- package/crates/team-agent/src/messaging/watchers.rs +37 -9
- package/crates/team-agent/src/model/enums.rs +65 -16
- package/crates/team-agent/src/model/ids.rs +12 -3
- package/crates/team-agent/src/model/paths.rs +28 -7
- package/crates/team-agent/src/model/permissions.rs +176 -33
- package/crates/team-agent/src/model/routing.rs +66 -20
- package/crates/team-agent/src/model/spec.rs +365 -69
- package/crates/team-agent/src/model/task_graph.rs +36 -9
- package/crates/team-agent/src/model/yaml/tests.rs +24 -6
- package/crates/team-agent/src/model/yaml.rs +7 -6
- package/crates/team-agent/src/packaging/install.rs +23 -9
- package/crates/team-agent/src/packaging/migrate.rs +5 -7
- package/crates/team-agent/src/packaging/mod.rs +9 -1
- package/crates/team-agent/src/packaging/repair.rs +13 -6
- package/crates/team-agent/src/packaging/tests.rs +63 -16
- package/crates/team-agent/src/packaging/types.rs +22 -7
- package/crates/team-agent/src/platform/argv.rs +4 -1
- package/crates/team-agent/src/platform/file_lock.rs +22 -8
- package/crates/team-agent/src/platform/process.rs +21 -22
- package/crates/team-agent/src/provider/adapters/claude.rs +1 -3
- package/crates/team-agent/src/provider/approvals/parsing.rs +134 -26
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +13 -3
- package/crates/team-agent/src/provider/classify.rs +127 -42
- package/crates/team-agent/src/provider/faults.rs +17 -5
- package/crates/team-agent/src/provider/helpers.rs +6 -5
- package/crates/team-agent/src/provider/startup_prompt.rs +75 -27
- package/crates/team-agent/src/state/repository.rs +27 -14
- package/crates/team-agent/src/tmux_backend/tests.rs +1637 -1398
- package/crates/team-agent/src/tmux_backend.rs +80 -44
- package/crates/team-agent/src/topology.rs +27 -20
- package/crates/team-agent/src/transport/test_support.rs +20 -23
- package/crates/team-agent/src/transport/tests/behavior.rs +292 -293
- package/crates/team-agent/src/transport/tests/mod.rs +178 -187
- package/crates/team-agent/src/transport/tests/wire.rs +561 -525
- package/crates/team-agent/src/transport.rs +12 -17
- package/crates/team-agent/src/transport_factory.rs +29 -14
- package/package.json +4 -4
|
@@ -1,294 +1,329 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
314
|
+
let _ = be
|
|
315
|
+
.list_targets()
|
|
316
|
+
.expect("short endpoint should not become -L");
|
|
284
317
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
"
|
|
523
|
+
"set-window-option",
|
|
431
524
|
"-t",
|
|
432
525
|
"teamsess:team-w1",
|
|
433
|
-
"
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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("
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
.
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
)
|
|
636
|
-
.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
//
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
"
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
.
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
)
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
})
|
|
927
|
-
|
|
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
|
-
})
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
)
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
)
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
)
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
)
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
//
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
)
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
)
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
// (
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
//
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
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
|
+
}
|