@team-agent/installer 0.5.115 → 0.5.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/mod.rs +143 -12
  4. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  5. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  6. package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
  7. package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
  8. package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
  9. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  10. package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
  11. package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
  12. package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
  13. package/crates/team-agent/src/leader/lease.rs +1 -18
  14. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
  17. package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
  18. package/crates/team-agent/src/messaging/delivery.rs +132 -27
  19. package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
  20. package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
  21. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  22. package/crates/team-agent/src/messaging/mod.rs +1 -0
  23. package/crates/team-agent/src/messaging/scheduler.rs +8 -0
  24. package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
  25. package/crates/team-agent/src/messaging/types.rs +1 -0
  26. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  27. package/crates/team-agent/src/state/persist.rs +72 -2
  28. package/crates/team-agent/src/state/projection.rs +29 -0
  29. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  30. package/crates/team-agent/src/state/repository.rs +52 -1
  31. package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
  32. package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
  33. package/crates/team-agent/src/tmux_backend.rs +49 -6
  34. package/crates/team-agent/src/transport/native_input.rs +594 -0
  35. package/crates/team-agent/src/transport.rs +19 -0
  36. package/package.json +4 -4
@@ -0,0 +1,781 @@
1
+ #![allow(clippy::unwrap_used, clippy::panic)]
2
+ use super::*;
3
+ use crate::model::enums::Provider;
4
+ use crate::transport::native_input::with_native_input;
5
+ use std::sync::atomic::{AtomicUsize, Ordering};
6
+ use std::sync::{Arc, Mutex};
7
+
8
+ #[derive(Clone, Copy)]
9
+ enum Mode {
10
+ Normal,
11
+ UnknownBefore,
12
+ UnknownAfterPaste,
13
+ UnknownAfterEnter,
14
+ DelayedReady,
15
+ Swallow,
16
+ NonceRace,
17
+ NonceSetFailed,
18
+ NonceBindingChanged,
19
+ TransientUnknown,
20
+ R6IdleNotice,
21
+ PiFolded,
22
+ PiFoldedUnstable,
23
+ }
24
+ struct State {
25
+ mode: Mode,
26
+ captures: usize,
27
+ stage_captures: [usize; 3],
28
+ pasted: usize,
29
+ entered: usize,
30
+ calls: Vec<Vec<String>>,
31
+ nonce: String,
32
+ nonce_sets: usize,
33
+ busy: bool,
34
+ provider: Provider,
35
+ folded_label: String,
36
+ }
37
+ struct Runner(Arc<Mutex<State>>);
38
+ fn pi(composer: &str, history: &str, busy: bool) -> String {
39
+ format!("{history}\n{}\n{composer}\n────────────────────────\ngpt-6-luna · max · 0%/272k · 0% · $0.00\n",
40
+ if busy {"── ⠋ Working ──────────────────"} else {"────────────────────"})
41
+ }
42
+ impl CommandRunner for Runner {
43
+ fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
44
+ let mut state = self.0.lock().unwrap();
45
+ state.calls.push(argv.to_vec());
46
+ let marker = "[team-agent-token:msg_one]";
47
+ let stdout = if argv.iter().any(|arg| arg == "display-message") {
48
+ let window = if matches!(state.mode, Mode::NonceBindingChanged) && state.nonce_sets > 0
49
+ {
50
+ "replaced"
51
+ } else {
52
+ "worker"
53
+ };
54
+ let height = if matches!(
55
+ state.mode,
56
+ Mode::R6IdleNotice | Mode::PiFolded | Mode::PiFoldedUnstable
57
+ ) {
58
+ 24
59
+ } else {
60
+ 120
61
+ };
62
+ format!(
63
+ "/tmp/owned-test.sock|%1|{}|session|{window}|{}|0|{height}\n",
64
+ std::process::id(),
65
+ state.nonce
66
+ )
67
+ } else if argv.iter().any(|arg| arg == "set-option") {
68
+ assert!(argv.iter().any(|arg| arg == "-p"));
69
+ assert!(argv.iter().any(|arg| arg == "-o"));
70
+ state.nonce_sets += 1;
71
+ if matches!(state.mode, Mode::NonceSetFailed | Mode::NonceRace) {
72
+ let stderr = if matches!(state.mode, Mode::NonceRace) {
73
+ state.nonce = "atomic-winner".into();
74
+ "already set"
75
+ } else {
76
+ "fixture nonce write failed"
77
+ };
78
+ return Ok(CommandOutput {
79
+ success: false,
80
+ code: Some(1),
81
+ stdout: String::new(),
82
+ stderr: stderr.into(),
83
+ });
84
+ }
85
+ assert!(
86
+ state.nonce.is_empty(),
87
+ "existing worker nonce must never be overwritten"
88
+ );
89
+ state.nonce = argv.last().unwrap().clone();
90
+ String::new()
91
+ } else if argv.iter().any(|arg| arg == "show-options") {
92
+ format!("{}\n", state.nonce)
93
+ } else if argv.iter().any(|arg| arg == "capture-pane") {
94
+ state.captures += 1;
95
+ let stage = if state.pasted == 0 {
96
+ 0
97
+ } else if state.entered == 0 {
98
+ 1
99
+ } else {
100
+ 2
101
+ };
102
+ state.stage_captures[stage] += 1;
103
+ if (matches!(state.mode, Mode::TransientUnknown) && state.stage_captures[stage] <= 2)
104
+ || matches!(state.mode, Mode::UnknownBefore)
105
+ || (state.pasted > 0
106
+ && state.entered == 0
107
+ && matches!(state.mode, Mode::UnknownAfterPaste))
108
+ || (state.entered > 0 && matches!(state.mode, Mode::UnknownAfterEnter))
109
+ || (matches!(state.mode, Mode::PiFoldedUnstable)
110
+ && stage == 1
111
+ && state.stage_captures[stage] > 1)
112
+ {
113
+ "startup warning/raw echo".into()
114
+ } else if matches!(state.mode, Mode::PiFolded | Mode::PiFoldedUnstable) {
115
+ let folded = include_str!("../../tests/fixtures/pi-r7-folded-24.txt");
116
+ let composer = if stage == 1 {
117
+ state.folded_label.as_str()
118
+ } else {
119
+ ""
120
+ };
121
+ let frame = folded.replace("[paste #1 +30 lines]", composer);
122
+ let frame = if state.busy {
123
+ frame.replace(
124
+ "──────────────────────────────────────────────────────────────────── pi-target ─",
125
+ "── ⠋ Working ──────────────────────────────────────────────────────────────────",
126
+ )
127
+ } else {
128
+ frame
129
+ };
130
+ if stage == 2 {
131
+ format!("{marker}\n{frame}")
132
+ } else {
133
+ frame
134
+ }
135
+ } else if matches!(state.mode, Mode::R6IdleNotice) {
136
+ let idle = include_str!("../../tests/fixtures/pi-r6-idle-24.txt");
137
+ if state.entered > 0 {
138
+ format!("{marker}\n{idle}")
139
+ } else if state.pasted > 0 {
140
+ idle.replace(
141
+ " pi-target ─\n\n",
142
+ &format!(" pi-target ─\nline\n{marker}\n"),
143
+ )
144
+ } else {
145
+ idle.into()
146
+ }
147
+ } else if state.entered > 0
148
+ && (!matches!(state.mode, Mode::Swallow) || state.entered >= 2)
149
+ {
150
+ pi("", marker, false)
151
+ } else if state.pasted > 0 {
152
+ pi(&format!("line\n{marker}"), "old", state.busy)
153
+ } else if matches!(state.mode, Mode::DelayedReady) && state.captures <= 2 {
154
+ pi("existing draft", "old", false)
155
+ } else {
156
+ pi("", "old", false)
157
+ }
158
+ } else {
159
+ if argv.iter().any(|arg| arg == "paste-buffer") {
160
+ state.pasted += 1;
161
+ }
162
+ if argv.iter().any(|arg| arg == "send-keys") {
163
+ assert_eq!(argv.last().unwrap(), "Enter");
164
+ state.entered += 1;
165
+ }
166
+ String::new()
167
+ };
168
+ let stdout = if state.provider == Provider::CursorAgent
169
+ && argv.iter().any(|arg| arg == "capture-pane")
170
+ {
171
+ let (composer, history) = if state.entered > 0 {
172
+ ("Plan, search, build anything", "[Pasted text #2 +25 lines]")
173
+ } else if state.pasted > 0 {
174
+ ("[Pasted text #2 +25 lines]", "[Pasted text #1 +25 lines]")
175
+ } else {
176
+ ("Plan, search, build anything", "[Pasted text #1 +25 lines]")
177
+ };
178
+ format!("Cursor Agent\nv2026.10.01-e373342\n{history}\n→ {composer}\nGrok 4.7 High Run Everything\n/owned/project\n")
179
+ } else {
180
+ stdout
181
+ };
182
+ Ok(CommandOutput {
183
+ success: true,
184
+ code: Some(0),
185
+ stdout,
186
+ stderr: String::new(),
187
+ })
188
+ }
189
+ }
190
+ struct Fixture {
191
+ path: std::path::PathBuf,
192
+ backend: TmuxBackend,
193
+ state: Arc<Mutex<State>>,
194
+ }
195
+ impl Fixture {
196
+ fn new(mode: Mode) -> Self {
197
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
198
+ let path = std::env::temp_dir().join(format!(
199
+ "ta-native-input-{}-{}",
200
+ std::process::id(),
201
+ NEXT.fetch_add(1, Ordering::SeqCst)
202
+ ));
203
+ std::fs::create_dir(&path).unwrap();
204
+ let state = Arc::new(Mutex::new(State {
205
+ mode,
206
+ captures: 0,
207
+ stage_captures: [0; 3],
208
+ pasted: 0,
209
+ entered: 0,
210
+ calls: vec![],
211
+ nonce: "owned-nonce".into(),
212
+ nonce_sets: 0,
213
+ busy: false,
214
+ provider: Provider::Pi,
215
+ folded_label: "[paste #1 +30 lines]".into(),
216
+ }));
217
+ let backend =
218
+ TmuxBackend::with_runner_for_workspace(Box::new(Runner(state.clone())), &path);
219
+ Self {
220
+ path,
221
+ backend,
222
+ state,
223
+ }
224
+ }
225
+ fn request(&self) -> NativeInputRequest {
226
+ NativeInputRequest {
227
+ message_id: "msg_one".into(),
228
+ owner_team_id: "team".into(),
229
+ recipient: "worker".into(),
230
+ provider: self.state.lock().unwrap().provider,
231
+ pane_id: "%1".into(),
232
+ pane_pid: std::process::id(),
233
+ session: "session".into(),
234
+ window: "worker".into(),
235
+ }
236
+ }
237
+ fn inject(
238
+ &self,
239
+ ) -> (
240
+ Result<InjectReport, TransportError>,
241
+ Option<NativeInputEvidence>,
242
+ ) {
243
+ with_native_input(Some(self.request()), || {
244
+ self.backend.inject(
245
+ &Target::Pane(PaneId::new("%1")),
246
+ &InjectPayload::Text("line\n[team-agent-token:msg_one]".into()),
247
+ Key::Enter,
248
+ true,
249
+ )
250
+ })
251
+ }
252
+ }
253
+ impl Drop for Fixture {
254
+ fn drop(&mut self) {
255
+ let _ = std::fs::remove_dir_all(&self.path);
256
+ }
257
+ }
258
+
259
+ #[test]
260
+ fn native_input_runtime_endpoint_factory_preserves_workspace_and_native_capability() {
261
+ assert!(!crate::transport::test_support::OfflineTransport::new().supports_owned_native_input());
262
+ for field in ["tmux_endpoint", "tmux_socket"] {
263
+ let mut f = Fixture::new(Mode::Normal);
264
+ let mut state = serde_json::json!({});
265
+ state[field] = "/tmp/owned-test.sock".into();
266
+ let selection =
267
+ crate::tmux_backend::tmux_backend_with_runner_for_runtime_state_or_workspace(
268
+ Box::new(Runner(f.state.clone())),
269
+ &f.path,
270
+ Some(&state),
271
+ );
272
+ assert_eq!(
273
+ selection.backend.event_workspace.as_deref(),
274
+ Some(f.path.as_path())
275
+ );
276
+ assert_eq!(
277
+ selection.tmux_endpoint_used.as_deref(),
278
+ Some("/tmp/owned-test.sock")
279
+ );
280
+ assert!(selection.backend.supports_owned_native_input());
281
+ f.backend = selection.backend;
282
+ let (report, evidence) = f.inject();
283
+ assert_eq!(
284
+ report.unwrap().submit_verification,
285
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
286
+ );
287
+ assert_eq!(evidence.unwrap().phase, NativeInputPhase::Accepted);
288
+ let observed = f.state.lock().unwrap();
289
+ assert_eq!(observed.pasted, 1);
290
+ assert_eq!(observed.entered, 1);
291
+ assert!(observed.calls.iter().all(|argv| argv
292
+ .windows(2)
293
+ .any(|pair| pair == ["-S", "/tmp/owned-test.sock"])));
294
+ }
295
+ }
296
+
297
+ #[test]
298
+ fn native_input_installs_missing_worker_nonce_once_and_preserves_atomic_winner() {
299
+ let existing = Fixture::new(Mode::Normal);
300
+ assert!(existing.inject().0.is_ok());
301
+ assert_eq!(existing.state.lock().unwrap().nonce_sets, 0);
302
+ assert_eq!(existing.state.lock().unwrap().nonce, "owned-nonce");
303
+ for mode in [Mode::Normal, Mode::NonceRace] {
304
+ let f = Fixture::new(mode);
305
+ f.state.lock().unwrap().nonce.clear();
306
+ let (report, evidence) = f.inject();
307
+ assert!(report.is_ok());
308
+ let evidence = evidence.unwrap();
309
+ assert_eq!(evidence.phase, NativeInputPhase::Accepted);
310
+ let observed = f.state.lock().unwrap();
311
+ assert_eq!(observed.nonce_sets, 1);
312
+ assert_eq!(evidence.binding.binding_nonce, observed.nonce);
313
+ assert!(!observed.nonce.is_empty());
314
+ if matches!(mode, Mode::NonceRace) {
315
+ assert_eq!(observed.nonce, "atomic-winner");
316
+ }
317
+ assert_eq!((observed.pasted, observed.entered), (1, 1));
318
+ }
319
+ }
320
+
321
+ #[test]
322
+ fn native_input_nonce_setup_requires_exact_selected_worker_before_any_write() {
323
+ for field in ["pane", "pid", "session", "window"] {
324
+ let f = Fixture::new(Mode::Normal);
325
+ f.state.lock().unwrap().nonce.clear();
326
+ let mut request = f.request();
327
+ match field {
328
+ "pane" => request.pane_id = "%2".into(),
329
+ "pid" => request.pane_pid += 1,
330
+ "session" => request.session = "other".into(),
331
+ "window" => request.window = "other".into(),
332
+ _ => unreachable!(),
333
+ }
334
+ let (report, evidence) = with_native_input(Some(request), || {
335
+ f.backend.inject(
336
+ &Target::Pane(PaneId::new("%1")),
337
+ &InjectPayload::Text("line\n[team-agent-token:msg_one]".into()),
338
+ Key::Enter,
339
+ true,
340
+ )
341
+ });
342
+ assert!(report.is_err());
343
+ assert!(evidence.is_none());
344
+ let observed = f.state.lock().unwrap();
345
+ assert_eq!(
346
+ (observed.nonce_sets, observed.pasted, observed.entered),
347
+ (0, 0, 0)
348
+ );
349
+ }
350
+ }
351
+
352
+ #[test]
353
+ fn native_input_nonce_write_failure_or_replacement_never_pastes_or_enters() {
354
+ for mode in [Mode::NonceSetFailed, Mode::NonceBindingChanged] {
355
+ let f = Fixture::new(mode);
356
+ f.state.lock().unwrap().nonce.clear();
357
+ let (report, evidence) = f.inject();
358
+ assert!(report.is_err());
359
+ assert!(evidence.is_none());
360
+ let observed = f.state.lock().unwrap();
361
+ assert_eq!(
362
+ (observed.nonce_sets, observed.pasted, observed.entered),
363
+ (1, 0, 0)
364
+ );
365
+ }
366
+ }
367
+
368
+ #[test]
369
+ fn native_input_delayed_ready_then_owned_paste_then_exactly_one_enter() {
370
+ let f = Fixture::new(Mode::DelayedReady);
371
+ let (report, evidence) = f.inject();
372
+ assert_eq!(
373
+ report.unwrap().submit_verification,
374
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
375
+ );
376
+ assert_eq!(evidence.unwrap().phase, NativeInputPhase::Accepted);
377
+ let state = f.state.lock().unwrap();
378
+ assert_eq!(state.pasted, 1);
379
+ assert_eq!(state.entered, 1);
380
+ let paste = state
381
+ .calls
382
+ .iter()
383
+ .position(|argv| argv.iter().any(|arg| arg == "paste-buffer"))
384
+ .unwrap();
385
+ assert!(
386
+ state.calls[..paste]
387
+ .iter()
388
+ .filter(|argv| argv.iter().any(|arg| arg == "capture-pane"))
389
+ .count()
390
+ >= 4
391
+ );
392
+ let submit = state
393
+ .calls
394
+ .iter()
395
+ .position(|argv| argv.iter().any(|arg| arg == "send-keys"))
396
+ .unwrap();
397
+ assert!(
398
+ state.calls[paste + 1..submit]
399
+ .iter()
400
+ .filter(|argv| argv.iter().any(|arg| arg == "capture-pane"))
401
+ .count()
402
+ >= 2
403
+ );
404
+ }
405
+
406
+ #[test]
407
+ fn native_input_r6_idle_notice_reaches_owned_paste_and_one_submit() {
408
+ let f = Fixture::new(Mode::R6IdleNotice);
409
+ let (report, evidence) = f.inject();
410
+ assert_eq!(
411
+ report.unwrap().submit_verification,
412
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
413
+ );
414
+ let evidence = evidence.unwrap();
415
+ assert_eq!(evidence.binding.pane_height, 24);
416
+ assert_eq!(evidence.phase, NativeInputPhase::Accepted);
417
+ let state = f.state.lock().unwrap();
418
+ assert_eq!((state.pasted, state.entered), (1, 1));
419
+ assert!(state.stage_captures[0] >= 2);
420
+ assert!(state.stage_captures[1] >= 2);
421
+ }
422
+
423
+ #[test]
424
+ fn native_input_capsule_baseline_exclusion_is_provider_aware() {
425
+ let history =
426
+ "old [paste #1 +30 lines] tail\n[paste #2 1234 chars]\n[Pasted text #3 +25 lines]\n";
427
+ assert_eq!(
428
+ capsule_labels(Provider::Pi, history),
429
+ vec!["[paste #1 +30 lines]", "[paste #2 1234 chars]"]
430
+ );
431
+ assert_eq!(
432
+ capsule_labels(Provider::CursorAgent, history),
433
+ vec!["[Pasted text #3 +25 lines]"]
434
+ );
435
+ }
436
+
437
+ #[test]
438
+ fn native_input_pi_folded_paste_persists_owned_latch_before_single_submit() {
439
+ let f = Fixture::new(Mode::PiFolded);
440
+ let (result, evidence) = f.inject();
441
+ assert!(result.is_ok());
442
+ let evidence = evidence.unwrap();
443
+ assert_eq!(evidence.phase, NativeInputPhase::Accepted);
444
+ assert_eq!(evidence.capsule.as_deref(), Some("[paste #1 +30 lines]"));
445
+ assert_eq!(evidence.binding.pane_height, 24);
446
+ let events: Vec<serde_json::Value> =
447
+ std::fs::read_to_string(f.path.join(".team/logs/events.jsonl"))
448
+ .unwrap()
449
+ .lines()
450
+ .map(|line| serde_json::from_str(line).unwrap())
451
+ .collect();
452
+ let latch = events.iter().position(|event| {
453
+ event["event"] == "native_input.evidence"
454
+ && event["phase"] == "pasted"
455
+ && event["capsule"] == "[paste #1 +30 lines]"
456
+ });
457
+ let seen = events.iter().position(|event| event["phase"] == "seen");
458
+ assert!(latch.unwrap() < seen.unwrap());
459
+ let state = f.state.lock().unwrap();
460
+ assert_eq!((state.pasted, state.entered), (1, 1));
461
+ }
462
+
463
+ #[test]
464
+ fn native_input_pi_rescue_retains_latch_but_never_adopts_unowned_residue() {
465
+ let f = Fixture::new(Mode::PiFoldedUnstable);
466
+ let (result, evidence) = f.inject();
467
+ assert!(matches!(
468
+ result,
469
+ Err(TransportError::NativeInputNotReady { reason }) if reason == "owned_paste_not_stable"
470
+ ));
471
+ let evidence = evidence.unwrap();
472
+ assert_eq!(evidence.phase, NativeInputPhase::Pasted);
473
+ assert_eq!(evidence.capsule.as_deref(), Some("[paste #1 +30 lines]"));
474
+ assert_eq!(f.state.lock().unwrap().entered, 0);
475
+ f.state.lock().unwrap().mode = Mode::PiFolded;
476
+ for mutation in ["missing_latch", "wrong_latch", "baseline_label"] {
477
+ let mut invalid = evidence.clone();
478
+ match mutation {
479
+ "missing_latch" => invalid.capsule = None,
480
+ "wrong_latch" => invalid.capsule = Some("[paste #2 +30 lines]".into()),
481
+ "baseline_label" => invalid
482
+ .baseline_capsules
483
+ .push("[paste #1 +30 lines]".into()),
484
+ _ => unreachable!(),
485
+ }
486
+ assert_eq!(
487
+ f.backend
488
+ .rescue_native_input(&invalid, &|| true, None)
489
+ .unwrap(),
490
+ EnterRescueOutcome::NoEffect {
491
+ reason: "residue_mismatch",
492
+ reobserve: false,
493
+ }
494
+ );
495
+ }
496
+ for label in [
497
+ "[paste #2 +30 lines]",
498
+ "[paste #1 +30 lines]\nforeign draft",
499
+ ] {
500
+ f.state.lock().unwrap().folded_label = label.into();
501
+ assert_eq!(
502
+ f.backend
503
+ .rescue_native_input(&evidence, &|| true, None)
504
+ .unwrap(),
505
+ EnterRescueOutcome::NoEffect {
506
+ reason: "residue_mismatch",
507
+ reobserve: false,
508
+ }
509
+ );
510
+ }
511
+ f.state.lock().unwrap().folded_label = "[paste #1 +30 lines]".into();
512
+ f.state.lock().unwrap().busy = true;
513
+ assert_eq!(
514
+ f.backend
515
+ .rescue_native_input(&evidence, &|| true, None)
516
+ .unwrap(),
517
+ EnterRescueOutcome::NoEffect {
518
+ reason: "busy",
519
+ reobserve: true,
520
+ }
521
+ );
522
+ f.state.lock().unwrap().busy = false;
523
+ assert_eq!(f.state.lock().unwrap().entered, 0);
524
+ assert_eq!(
525
+ f.backend
526
+ .rescue_native_input(&evidence, &|| true, None)
527
+ .unwrap(),
528
+ EnterRescueOutcome::Entered { accepted: true }
529
+ );
530
+ assert!(matches!(
531
+ f.backend
532
+ .rescue_native_input(&evidence, &|| true, None)
533
+ .unwrap(),
534
+ EnterRescueOutcome::NoEffect { .. }
535
+ ));
536
+ let state = f.state.lock().unwrap();
537
+ assert_eq!((state.pasted, state.entered), (1, 1));
538
+ }
539
+
540
+ #[test]
541
+ fn native_input_partial_redraws_only_reobserve_until_known_stable_single_submit() {
542
+ let f = Fixture::new(Mode::TransientUnknown);
543
+ let (report, evidence) = f.inject();
544
+ assert_eq!(
545
+ report.unwrap().submit_verification,
546
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
547
+ );
548
+ assert_eq!(evidence.unwrap().phase, NativeInputPhase::Accepted);
549
+ let observed = f.state.lock().unwrap();
550
+ assert!(observed.stage_captures.iter().all(|count| *count >= 3));
551
+ assert_eq!((observed.pasted, observed.entered), (1, 1));
552
+ }
553
+
554
+ #[test]
555
+ fn native_input_unknown_before_paste_is_zero_effect_after_paste_never_enters() {
556
+ for mode in [Mode::UnknownBefore, Mode::UnknownAfterPaste] {
557
+ let f = Fixture::new(mode);
558
+ let (result, evidence) = f.inject();
559
+ assert!(result.is_err());
560
+ let state = f.state.lock().unwrap();
561
+ assert_eq!(state.entered, 0);
562
+ if matches!(mode, Mode::UnknownBefore) {
563
+ assert_eq!(state.pasted, 0);
564
+ assert!(evidence.is_none());
565
+ } else {
566
+ assert_eq!(state.pasted, 1);
567
+ assert_eq!(evidence.unwrap().phase, NativeInputPhase::Pasted);
568
+ }
569
+ }
570
+ }
571
+
572
+ #[test]
573
+ fn native_input_post_enter_unknown_retains_effect_floor_without_another_key() {
574
+ let f = Fixture::new(Mode::UnknownAfterEnter);
575
+ let (result, evidence) = f.inject();
576
+ assert!(result.is_err());
577
+ assert_eq!(evidence.unwrap().phase, NativeInputPhase::Submitted);
578
+ let state = f.state.lock().unwrap();
579
+ assert_eq!(state.pasted, 1);
580
+ assert_eq!(state.entered, 1);
581
+ }
582
+
583
+ #[test]
584
+ fn native_input_swallowed_enter_only_delayed_rescue_can_press_once_no_repaste() {
585
+ let f = Fixture::new(Mode::Swallow);
586
+ let (report, evidence) = f.inject();
587
+ assert_eq!(
588
+ report.unwrap().submit_verification,
589
+ SubmitVerification::SubmitConsumptionUnverified
590
+ );
591
+ let evidence = evidence.unwrap();
592
+ assert_eq!(evidence.phase, NativeInputPhase::Submitted);
593
+ struct ObservedSubmit(AtomicUsize);
594
+ impl SubmitObserver for ObservedSubmit {
595
+ fn after_physical_submit(&self) {
596
+ self.0.fetch_add(1, Ordering::SeqCst);
597
+ }
598
+ }
599
+ let observer = ObservedSubmit(AtomicUsize::new(0));
600
+ assert_eq!(
601
+ f.backend
602
+ .rescue_enter(&evidence, &|| true, Some(&observer))
603
+ .unwrap(),
604
+ EnterRescueOutcome::Entered { accepted: true }
605
+ );
606
+ let state = f.state.lock().unwrap();
607
+ assert_eq!(state.pasted, 1);
608
+ assert_eq!(state.entered, 2);
609
+ drop(state);
610
+ assert_eq!(observer.0.load(Ordering::SeqCst), 1);
611
+ assert_eq!(
612
+ f.backend
613
+ .rescue_enter(&evidence, &|| true, Some(&observer))
614
+ .unwrap(),
615
+ EnterRescueOutcome::Consumed
616
+ );
617
+ assert_eq!(f.state.lock().unwrap().entered, 2);
618
+ assert_eq!(observer.0.load(Ordering::SeqCst), 1);
619
+ }
620
+
621
+ #[test]
622
+ fn native_input_rescue_binding_busy_and_canonical_changes_never_send_a_key() {
623
+ let f = Fixture::new(Mode::Normal);
624
+ let (_, evidence) = f.inject();
625
+ let mut evidence = evidence.unwrap();
626
+ // Controlled fixture represents the same pending draft, not a new injection.
627
+ {
628
+ let mut state = f.state.lock().unwrap();
629
+ state.entered = 0;
630
+ state.busy = true;
631
+ }
632
+ let before = f.state.lock().unwrap().calls.len();
633
+ assert_eq!(
634
+ f.backend.rescue_enter(&evidence, &|| true, None).unwrap(),
635
+ EnterRescueOutcome::NoEffect {
636
+ reason: "busy",
637
+ reobserve: true
638
+ }
639
+ );
640
+ assert!(!f.state.lock().unwrap().calls[before..]
641
+ .iter()
642
+ .any(|argv| argv.iter().any(|arg| arg == "send-keys")));
643
+ f.state.lock().unwrap().busy = false;
644
+ assert_eq!(
645
+ f.backend.rescue_enter(&evidence, &|| false, None).unwrap(),
646
+ EnterRescueOutcome::NoEffect {
647
+ reason: "canonical_changed",
648
+ reobserve: false
649
+ }
650
+ );
651
+ for field in [
652
+ "birth", "nonce", "endpoint", "pid", "pane", "session", "window",
653
+ ] {
654
+ let mut wrong = evidence.clone();
655
+ match field {
656
+ "birth" => wrong.binding.carrier_birth.push_str("-wrong"),
657
+ "nonce" => wrong.binding.binding_nonce.push_str("-wrong"),
658
+ "endpoint" => wrong.binding.endpoint.push_str("-wrong"),
659
+ "pid" => wrong.binding.pane_pid += 1,
660
+ "pane" => wrong.binding.pane_id = "%2".into(),
661
+ "session" => wrong.binding.session.push_str("-wrong"),
662
+ "window" => wrong.binding.window.push_str("-wrong"),
663
+ _ => unreachable!(),
664
+ }
665
+ assert!(
666
+ f.backend.rescue_enter(&wrong, &|| true, None).is_err(),
667
+ "{field}"
668
+ );
669
+ }
670
+ evidence.phase = NativeInputPhase::MayHavePasted;
671
+ assert_eq!(
672
+ f.backend.rescue_enter(&evidence, &|| true, None).unwrap(),
673
+ EnterRescueOutcome::NoEffect {
674
+ reason: "proof_missing",
675
+ reobserve: false
676
+ }
677
+ );
678
+ assert_eq!(f.state.lock().unwrap().entered, 0);
679
+ }
680
+
681
+ #[test]
682
+ fn native_input_cursor_owned_new_capsule_transfers_to_transcript_without_token_claim() {
683
+ let f = Fixture::new(Mode::Normal);
684
+ f.state.lock().unwrap().provider = Provider::CursorAgent;
685
+ let (report, evidence) = f.inject();
686
+ let report = report.unwrap();
687
+ let evidence = evidence.unwrap();
688
+ assert_eq!(
689
+ report.inject_verification,
690
+ InjectVerification::CaptureContainsNewPastedContentPrompt
691
+ );
692
+ assert_eq!(
693
+ report.submit_verification,
694
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
695
+ );
696
+ assert_eq!(
697
+ evidence.capsule.as_deref(),
698
+ Some("[Pasted text #2 +25 lines]")
699
+ );
700
+ assert_eq!(evidence.baseline_capsules, ["[Pasted text #1 +25 lines]"]);
701
+ let state = f.state.lock().unwrap();
702
+ assert_eq!(state.pasted, 1);
703
+ assert_eq!(state.entered, 1);
704
+ }
705
+
706
+ #[test]
707
+ fn native_input_buffer_uses_authoritative_message_not_first_quoted_marker() {
708
+ let f = Fixture::new(Mode::Normal);
709
+ let quoted = "[team-agent-token:msg_old]\nline\n[team-agent-token:msg_one]";
710
+ let (_, evidence) = with_native_input(Some(f.request()), || {
711
+ f.backend.inject(
712
+ &Target::Pane(PaneId::new("%1")),
713
+ &InjectPayload::Text(quoted.into()),
714
+ Key::Enter,
715
+ true,
716
+ )
717
+ });
718
+ let evidence = evidence.unwrap();
719
+ let buffer = owned_buffer(&evidence);
720
+ assert!(buffer.contains("msg_one"));
721
+ assert!(!buffer.contains("msg_old"));
722
+ let state = f.state.lock().unwrap();
723
+ for argv in state.calls.iter().filter(|argv| {
724
+ argv.iter()
725
+ .any(|arg| arg == "set-buffer" || arg == "paste-buffer")
726
+ }) {
727
+ let index = argv.iter().position(|arg| arg == "-b").unwrap();
728
+ assert_eq!(argv[index + 1], buffer);
729
+ }
730
+ let mut other = evidence.clone();
731
+ other.request.message_id = "msg_two".into();
732
+ assert_ne!(buffer, owned_buffer(&other));
733
+ }
734
+
735
+ #[test]
736
+ fn native_input_required_lock_contention_or_missing_workspace_is_zero_effect() {
737
+ let f = Fixture::new(Mode::Normal);
738
+ let lock = acquire_required(PaneInputLockRequest {
739
+ workspace: Some(&f.path),
740
+ target_key: "%1",
741
+ operation: "fixture",
742
+ })
743
+ .unwrap();
744
+ let (result, evidence) = f.inject();
745
+ assert!(result.is_err());
746
+ assert!(evidence.is_none());
747
+ assert!(f.state.lock().unwrap().calls.is_empty());
748
+ std::thread::scope(|scope| {
749
+ scope.spawn(|| {
750
+ assert!(f
751
+ .backend
752
+ .send_keys(&Target::Pane(PaneId::new("%1")), &[Key::Enter])
753
+ .is_err())
754
+ });
755
+ scope.spawn(|| {
756
+ assert!(f
757
+ .backend
758
+ .inject(
759
+ &Target::Pane(PaneId::new("%1")),
760
+ &InjectPayload::TextSkipConsumptionPoll("foreign command".into()),
761
+ Key::Enter,
762
+ true
763
+ )
764
+ .is_err())
765
+ });
766
+ });
767
+ assert!(f.state.lock().unwrap().calls.is_empty());
768
+ drop(lock);
769
+ let other = TmuxBackend::with_runner(Box::new(Runner(f.state.clone())));
770
+ let (result, evidence) = with_native_input(Some(f.request()), || {
771
+ other.inject(
772
+ &Target::Pane(PaneId::new("%1")),
773
+ &InjectPayload::Text("body".into()),
774
+ Key::Enter,
775
+ true,
776
+ )
777
+ });
778
+ assert!(result.is_err());
779
+ assert!(evidence.is_none());
780
+ assert!(f.state.lock().unwrap().calls.is_empty());
781
+ }