@team-agent/installer 0.5.116 → 0.5.118

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