@team-agent/installer 0.3.23 → 0.3.25
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/coordinator/tick.rs +55 -7
- package/crates/team-agent/src/leader/lease.rs +34 -13
- package/crates/team-agent/src/leader/tests/lease_api.rs +79 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +11 -3
- package/crates/team-agent/src/lifecycle/launch.rs +219 -35
- package/crates/team-agent/src/lifecycle/restart/agent.rs +61 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
- package/crates/team-agent/src/lifecycle/restart.rs +19 -2
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
- package/crates/team-agent/src/messaging/delivery.rs +86 -27
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +157 -4
- package/crates/team-agent/src/messaging/tests/wave2.rs +689 -0
- package/crates/team-agent/src/state/projection.rs +5 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +297 -0
- package/crates/team-agent/src/tmux_backend.rs +145 -5
- package/crates/team-agent/src/transport/test_support.rs +79 -6
- package/crates/team-agent/src/transport.rs +26 -1
- package/npm/install.mjs +90 -25
- package/package.json +4 -4
|
@@ -298,6 +298,11 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
|
|
|
298
298
|
team_entry_from_state(state, team_key).and_then(Value::as_object).cloned().unwrap_or_default();
|
|
299
299
|
let mut p = entry_obj.clone(); // projection = deepcopy(entry)
|
|
300
300
|
// setdefault session_name/team_dir ← entry.get(...)(缺则插,值可为 null)。
|
|
301
|
+
// **0.3.24 excision** (U1-A real-machine RED v2): the ff38ab9 root-state
|
|
302
|
+
// `or_else` fallback was reverted along with the wave-2 U1-A drift fallback,
|
|
303
|
+
// since this projection change had no other consumer. v0.3.25 will re-introduce
|
|
304
|
+
// this together with the writer-shape + rediscover-writer fix — see
|
|
305
|
+
// `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
|
|
301
306
|
if !p.contains_key("session_name") {
|
|
302
307
|
p.insert("session_name".to_string(), entry_obj.get("session_name").cloned().unwrap_or(Value::Null));
|
|
303
308
|
}
|
|
@@ -526,6 +526,303 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
526
526
|
);
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
#[test]
|
|
530
|
+
fn inject_waits_for_token_visibility_before_enter() {
|
|
531
|
+
let text = "hello [team-agent-token:e31]".to_string();
|
|
532
|
+
let marker_visible = format!("{text}\n");
|
|
533
|
+
let (be, rec) = backend_with(
|
|
534
|
+
MockResp::Out(ok("")),
|
|
535
|
+
vec![
|
|
536
|
+
MockResp::Out(ok("")), // set-buffer
|
|
537
|
+
MockResp::Out(ok("")), // paste-buffer
|
|
538
|
+
MockResp::Out(ok("")), // delete-buffer
|
|
539
|
+
MockResp::Out(ok("")), // pasted-content prompt poll 1
|
|
540
|
+
MockResp::Out(ok("")), // pasted-content prompt poll 2
|
|
541
|
+
MockResp::Out(ok("")), // pasted-content prompt poll 3
|
|
542
|
+
MockResp::Out(ok("")), // pasted-content prompt poll 4
|
|
543
|
+
MockResp::Out(ok("")), // pasted-content prompt poll 5
|
|
544
|
+
MockResp::Out(ok("")), // token gate: not visible yet
|
|
545
|
+
MockResp::Out(ok("")), // token gate: still not visible
|
|
546
|
+
MockResp::Out(ok(&marker_visible)), // token gate: visible, Enter may fire
|
|
547
|
+
MockResp::Out(ok("")), // send-keys Enter
|
|
548
|
+
],
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
let report = be
|
|
552
|
+
.inject(&Target::Pane(PaneId::new("%7")), &InjectPayload::Text(text), Key::Enter, true)
|
|
553
|
+
.expect("inject");
|
|
554
|
+
let calls = rec.lock().unwrap().clone();
|
|
555
|
+
let submit_index = calls
|
|
556
|
+
.iter()
|
|
557
|
+
.position(|argv| argv.get(1).map(String::as_str) == Some("send-keys"))
|
|
558
|
+
.expect("inject must eventually send Enter");
|
|
559
|
+
let captures_before_submit = calls[..submit_index]
|
|
560
|
+
.iter()
|
|
561
|
+
.filter(|argv| argv.get(1).map(String::as_str) == Some("capture-pane"))
|
|
562
|
+
.count();
|
|
563
|
+
|
|
564
|
+
assert!(
|
|
565
|
+
captures_before_submit >= super::PASTED_CONTENT_APPEAR_POLLS as usize + 3,
|
|
566
|
+
"Enter must wait until the pasted token is visible; calls={calls:?}"
|
|
567
|
+
);
|
|
568
|
+
assert_eq!(report.inject_verification, InjectVerification::CaptureContainsToken);
|
|
569
|
+
assert_eq!(
|
|
570
|
+
report.submit_verification,
|
|
571
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
576
|
+
// E46 0.3.24 task#327 P0 — submit verification false-positive / false-negative.
|
|
577
|
+
//
|
|
578
|
+
// Architect + Workflow C1: do NOT flip `EnterSentWithoutPlaceholderCheck =>
|
|
579
|
+
// false` (breaks MUST-10 in provider_submit_verification_red.rs:113-159).
|
|
580
|
+
// Instead introduce SubmitConsumptionUnverified and only emit it from the
|
|
581
|
+
// bracketed-paste / Enter path when post-Enter input consumption is NOT
|
|
582
|
+
// observed within a bounded resend cap.
|
|
583
|
+
//
|
|
584
|
+
// Real-machine truth source (macmini): a fresh claude TUI's bracketed
|
|
585
|
+
// paste swallows the framework's Enter as paste content; the framework
|
|
586
|
+
// must (a) send Escape first to exit the paste bracket and (b) verify
|
|
587
|
+
// post-Enter consumption by structural input-empty probe (token marker
|
|
588
|
+
// no longer in the bottom 5 lines of the pane = composer cleared).
|
|
589
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
590
|
+
|
|
591
|
+
/// **E46 RED-1 (核心假阳)**: token-bearing Text + Enter + bracketed paste.
|
|
592
|
+
/// Token is visible in the pane after submit AND remains in the bottom of
|
|
593
|
+
/// the pane (input region — consumption was NOT observed). Submit must
|
|
594
|
+
/// report `SubmitConsumptionUnverified`, NOT
|
|
595
|
+
/// `EnterSentWithoutPlaceholderCheck`. Otherwise delivery emits delivered
|
|
596
|
+
/// + message.delivered (假阳) even though the worker never consumed it.
|
|
597
|
+
#[test]
|
|
598
|
+
fn e46_inject_text_with_token_unconsumed_in_tail_reports_submit_consumption_unverified() {
|
|
599
|
+
// Mock always returns the token in the captured tail — simulates the
|
|
600
|
+
// macmini fresh claude TUI where the paste lands but the input never
|
|
601
|
+
// clears (Enter swallowed by bracketed-paste).
|
|
602
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
|
|
603
|
+
let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
|
|
604
|
+
let report = be
|
|
605
|
+
.inject(
|
|
606
|
+
&Target::Pane(PaneId::new("%7")),
|
|
607
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
608
|
+
Key::Enter,
|
|
609
|
+
true,
|
|
610
|
+
)
|
|
611
|
+
.expect("inject runs");
|
|
612
|
+
assert_eq!(
|
|
613
|
+
report.submit_verification,
|
|
614
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
615
|
+
"E46 RED-1: when token remains in the pane input region after \
|
|
616
|
+
Enter + resend cap, transport must report SubmitConsumptionUnverified \
|
|
617
|
+
(not EnterSentWithoutPlaceholderCheck which delivery treats as \
|
|
618
|
+
delivered). Got {:?}",
|
|
619
|
+
report.submit_verification
|
|
620
|
+
);
|
|
621
|
+
// turn_verification stays NotYetObserved (Gap42: metadata only).
|
|
622
|
+
assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/// **E46 RED-2 (正向消费确认 → delivered)**: token-bearing Text + Enter +
|
|
626
|
+
/// bracketed paste. Token VISIBLE on first capture (pre/post-submit token
|
|
627
|
+
/// readback) BUT then GONE from the post-submit input-consumption probe
|
|
628
|
+
/// (composer cleared after Enter). Submit must report
|
|
629
|
+
/// `EnterSentWithoutPlaceholderCheck` (MUST-10 path) — delivery proceeds
|
|
630
|
+
/// to delivered. Guards against regression on crd's existing-worker path.
|
|
631
|
+
#[test]
|
|
632
|
+
fn e46_inject_text_with_token_consumed_after_enter_keeps_enter_sent_without_placeholder_check() {
|
|
633
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red2]";
|
|
634
|
+
// First captures (token visibility probes pre/post-Enter) return text
|
|
635
|
+
// with token; the LAST capture (consumption probe) returns text WITHOUT
|
|
636
|
+
// the token in the tail (composer cleared). MockCommandRunner drains
|
|
637
|
+
// `queued` first then defaults — we queue the early "token visible"
|
|
638
|
+
// captures, then drop to the empty default for the consumption probe.
|
|
639
|
+
let visible = ok(token_text);
|
|
640
|
+
let queued = vec![
|
|
641
|
+
MockResp::Out(ok("")), // set-buffer
|
|
642
|
+
MockResp::Out(ok("")), // paste-buffer
|
|
643
|
+
MockResp::Out(ok("")), // delete-buffer
|
|
644
|
+
MockResp::Out(visible.clone()), // pasted-content prompt poll: none → continue
|
|
645
|
+
MockResp::Out(visible.clone()), // pre-submit token visibility: visible
|
|
646
|
+
MockResp::Out(ok("")), // Escape send-keys (no stdout needed)
|
|
647
|
+
MockResp::Out(ok("")), // Enter send-keys
|
|
648
|
+
// From here on, the default response is `ok("")` (empty pane tail) so
|
|
649
|
+
// post-Enter consumption probe sees no token in tail → consumed=true.
|
|
650
|
+
];
|
|
651
|
+
let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
|
|
652
|
+
let report = be
|
|
653
|
+
.inject(
|
|
654
|
+
&Target::Pane(PaneId::new("%7")),
|
|
655
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
656
|
+
Key::Enter,
|
|
657
|
+
true,
|
|
658
|
+
)
|
|
659
|
+
.expect("inject runs");
|
|
660
|
+
assert_eq!(
|
|
661
|
+
report.submit_verification,
|
|
662
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
663
|
+
"E46 RED-2: when post-Enter consumption is observed (token gone \
|
|
664
|
+
from input region tail), submit must report the canonical \
|
|
665
|
+
EnterSentWithoutPlaceholderCheck so MUST-10 delivery semantics \
|
|
666
|
+
hold (provider_submit_verification_red.rs:113-159). Got {:?}",
|
|
667
|
+
report.submit_verification
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/// **E46 RED-3 (resend-to-cap, no double-submit)**: when the FIRST Enter
|
|
672
|
+
/// landed on the composer but our readback was slow, the re-check BEFORE
|
|
673
|
+
/// resend must observe the now-empty input and STOP — must NOT fire a
|
|
674
|
+
/// second Enter into an empty composer (would open an empty turn).
|
|
675
|
+
#[test]
|
|
676
|
+
fn e46_inject_text_resend_rechecks_input_before_resending_to_avoid_double_submit() {
|
|
677
|
+
// Same mock as RED-2: post-Enter consumption probe sees empty tail.
|
|
678
|
+
// The implementation should issue only ONE Enter (no resend) once
|
|
679
|
+
// consumption is observed.
|
|
680
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red3]";
|
|
681
|
+
let queued = vec![
|
|
682
|
+
MockResp::Out(ok("")), // set-buffer
|
|
683
|
+
MockResp::Out(ok("")), // paste-buffer
|
|
684
|
+
MockResp::Out(ok("")), // delete-buffer
|
|
685
|
+
MockResp::Out(ok(token_text)), // pasted-content prompt: not matched
|
|
686
|
+
MockResp::Out(ok(token_text)), // pre-submit token visibility
|
|
687
|
+
MockResp::Out(ok("")), // Escape
|
|
688
|
+
MockResp::Out(ok("")), // Enter
|
|
689
|
+
];
|
|
690
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), queued);
|
|
691
|
+
let _report = be
|
|
692
|
+
.inject(
|
|
693
|
+
&Target::Pane(PaneId::new("%7")),
|
|
694
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
695
|
+
Key::Enter,
|
|
696
|
+
true,
|
|
697
|
+
)
|
|
698
|
+
.expect("inject runs");
|
|
699
|
+
let calls = rec.lock().unwrap().clone();
|
|
700
|
+
// Count `send-keys ... Enter` invocations. There must be EXACTLY ONE
|
|
701
|
+
// (the canonical submit). A second one indicates the resend loop
|
|
702
|
+
// didn't honour C3 (re-check input before resend).
|
|
703
|
+
let enter_count = calls
|
|
704
|
+
.iter()
|
|
705
|
+
.filter(|argv| {
|
|
706
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
707
|
+
&& argv.contains(&"Enter".to_string())
|
|
708
|
+
})
|
|
709
|
+
.count();
|
|
710
|
+
assert_eq!(
|
|
711
|
+
enter_count, 1,
|
|
712
|
+
"E46 RED-3: when consumption is observed (or already happened \
|
|
713
|
+
between Enter and probe), the resend loop must STOP and NOT \
|
|
714
|
+
issue a second Enter. Got {enter_count} Enter sends. \
|
|
715
|
+
calls={calls:?}"
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/// **E46 RED-5 (provider-agnostic detector)**: the consumption probe must
|
|
720
|
+
/// NOT hard-code provider UI strings (claude `>`, codex `╰`, ...). It uses
|
|
721
|
+
/// the token marker absence in the bottom of the pane — works for any
|
|
722
|
+
/// provider. Here we run a non-claude-shaped pane (no claude markers) and
|
|
723
|
+
/// confirm the same delivered semantics when consumption happens.
|
|
724
|
+
#[test]
|
|
725
|
+
fn e46_consumption_detector_works_on_codex_shaped_pane_without_provider_strings() {
|
|
726
|
+
// Codex-shaped fake: prompt looks like `codex>` with no claude markers.
|
|
727
|
+
// After Enter, the composer clears (empty default returns ok("")).
|
|
728
|
+
let token_text =
|
|
729
|
+
"Team Agent message from leader:\n\ncodex msg\n\n[team-agent-token:msg_red5]";
|
|
730
|
+
let queued = vec![
|
|
731
|
+
MockResp::Out(ok("")), // set-buffer
|
|
732
|
+
MockResp::Out(ok("")), // paste-buffer
|
|
733
|
+
MockResp::Out(ok("")), // delete-buffer
|
|
734
|
+
MockResp::Out(ok("codex>\n")), // pasted-content prompt: no match
|
|
735
|
+
MockResp::Out(ok(token_text)), // pre-submit token visibility
|
|
736
|
+
MockResp::Out(ok("")), // Escape
|
|
737
|
+
MockResp::Out(ok("")), // Enter
|
|
738
|
+
// post-Enter: default returns ok("") (composer cleared) → token gone
|
|
739
|
+
];
|
|
740
|
+
let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
|
|
741
|
+
let report = be
|
|
742
|
+
.inject(
|
|
743
|
+
&Target::Pane(PaneId::new("%7")),
|
|
744
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
745
|
+
Key::Enter,
|
|
746
|
+
true,
|
|
747
|
+
)
|
|
748
|
+
.expect("inject runs");
|
|
749
|
+
assert_eq!(
|
|
750
|
+
report.submit_verification,
|
|
751
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
752
|
+
"E46 RED-5: provider-agnostic detector — codex-shaped pane (no \
|
|
753
|
+
claude UI string) where composer clears post-Enter must still \
|
|
754
|
+
report EnterSentWithoutPlaceholderCheck via structural \
|
|
755
|
+
input-empty check. Got {:?}",
|
|
756
|
+
report.submit_verification
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/// **E46 Escape pre-Enter**: when bracketed=true + Text payload + submit=Enter,
|
|
761
|
+
/// the inject must send Escape BEFORE the Enter (exits bracketed-paste
|
|
762
|
+
/// mode on stuck composer). Non-Enter submits (e.g. Key::Down for codex
|
|
763
|
+
/// menu) must NOT receive the Escape pre-step.
|
|
764
|
+
#[test]
|
|
765
|
+
fn e46_inject_text_enter_emits_escape_before_enter_on_bracketed_paste() {
|
|
766
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_esc]";
|
|
767
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
768
|
+
let _report = be
|
|
769
|
+
.inject(
|
|
770
|
+
&Target::Pane(PaneId::new("%7")),
|
|
771
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
772
|
+
Key::Enter,
|
|
773
|
+
true,
|
|
774
|
+
)
|
|
775
|
+
.expect("inject runs");
|
|
776
|
+
let calls = rec.lock().unwrap().clone();
|
|
777
|
+
let escape_idx = calls.iter().position(|argv| {
|
|
778
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
779
|
+
&& argv.contains(&"Escape".to_string())
|
|
780
|
+
});
|
|
781
|
+
let enter_idx = calls.iter().position(|argv| {
|
|
782
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
783
|
+
&& argv.contains(&"Enter".to_string())
|
|
784
|
+
});
|
|
785
|
+
let escape_idx = escape_idx.expect("E46: bracketed text + Enter must send Escape pre-step");
|
|
786
|
+
let enter_idx = enter_idx.expect("E46: must still send the final Enter");
|
|
787
|
+
assert!(
|
|
788
|
+
escape_idx < enter_idx,
|
|
789
|
+
"E46: Escape must be sent BEFORE Enter (closes bracketed-paste \
|
|
790
|
+
so Enter is interpreted as submit). escape_idx={escape_idx} \
|
|
791
|
+
enter_idx={enter_idx} calls={calls:?}"
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/// **E46 regression guard**: non-Enter submit (codex menu navigation,
|
|
796
|
+
/// `Key::Down`) does NOT trigger the Escape pre-step (would interfere
|
|
797
|
+
/// with the menu).
|
|
798
|
+
#[test]
|
|
799
|
+
fn e46_inject_text_non_enter_submit_does_not_emit_escape_prestep() {
|
|
800
|
+
let token_text = "menu interaction\n[team-agent-token:msg_down]";
|
|
801
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
802
|
+
let _report = be
|
|
803
|
+
.inject(
|
|
804
|
+
&Target::Pane(PaneId::new("%7")),
|
|
805
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
806
|
+
Key::Down,
|
|
807
|
+
true,
|
|
808
|
+
)
|
|
809
|
+
.expect("inject runs");
|
|
810
|
+
let calls = rec.lock().unwrap().clone();
|
|
811
|
+
let escape_count = calls
|
|
812
|
+
.iter()
|
|
813
|
+
.filter(|argv| {
|
|
814
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
815
|
+
&& argv.contains(&"Escape".to_string())
|
|
816
|
+
})
|
|
817
|
+
.count();
|
|
818
|
+
assert_eq!(
|
|
819
|
+
escape_count, 0,
|
|
820
|
+
"E46 regression guard: Key::Down submits (codex menu navigation) \
|
|
821
|
+
must NOT receive Escape pre-step. Got {escape_count}. \
|
|
822
|
+
calls={calls:?}"
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
529
826
|
#[test]
|
|
530
827
|
fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
|
|
531
828
|
let (be, rec) = backend_with(
|
|
@@ -845,16 +845,28 @@ fn token_visible_in_capture(
|
|
|
845
845
|
}
|
|
846
846
|
}
|
|
847
847
|
|
|
848
|
-
/// U1 #7
|
|
849
|
-
///
|
|
850
|
-
///
|
|
851
|
-
/// the static `inject_verification_for_payload` would have reported as success.
|
|
848
|
+
/// U1 #7 / E31: wait briefly for the just-pasted token marker before submitting.
|
|
849
|
+
/// `Ok(None)` for non-token payloads (nothing to check). `Ok(Some(false))` means the
|
|
850
|
+
/// paste did not become visible before the Python-parity fallback delay.
|
|
852
851
|
fn pre_submit_token_visible(
|
|
853
852
|
backend: &TmuxBackend,
|
|
854
853
|
target: &Target,
|
|
855
854
|
payload: &InjectPayload,
|
|
856
855
|
) -> Result<Option<bool>, TransportError> {
|
|
857
|
-
|
|
856
|
+
if payload_token_marker(payload).is_none() {
|
|
857
|
+
return Ok(None);
|
|
858
|
+
}
|
|
859
|
+
for attempt in 0..PASTED_CONTENT_APPEAR_POLLS {
|
|
860
|
+
if let Some(true) = token_visible_in_capture(backend, target, payload)? {
|
|
861
|
+
return Ok(Some(true));
|
|
862
|
+
}
|
|
863
|
+
if attempt + 1 < PASTED_CONTENT_APPEAR_POLLS {
|
|
864
|
+
std::thread::sleep(Duration::from_millis(25));
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// Python waits 250ms between paste-buffer and Enter to let bracketed paste settle.
|
|
868
|
+
std::thread::sleep(Duration::from_millis(250));
|
|
869
|
+
Ok(Some(false))
|
|
858
870
|
}
|
|
859
871
|
|
|
860
872
|
const TOKEN_POST_SUBMIT_READBACK_POLLS: u32 = 5;
|
|
@@ -915,6 +927,51 @@ fn capture_has_pasted_content_prompt(text: &str) -> bool {
|
|
|
915
927
|
const PASTED_CONTENT_APPEAR_POLLS: u32 = 5;
|
|
916
928
|
const PASTED_CONTENT_SUBMIT_ATTEMPTS: u32 = 3;
|
|
917
929
|
|
|
930
|
+
/// E46 (0.3.24 bug#5): bounded resend cap for post-Enter consumption probe.
|
|
931
|
+
/// Mirrors PASTED_CONTENT_SUBMIT_ATTEMPTS shape: try Enter then bounded
|
|
932
|
+
/// re-checks of the pane's input region. Each iteration first re-checks that
|
|
933
|
+
/// the input still has content before resending Enter — guards against double
|
|
934
|
+
/// submission when the first Enter was consumed but our readback was slow.
|
|
935
|
+
const POST_SUBMIT_CONSUMPTION_ATTEMPTS: u32 = 3;
|
|
936
|
+
const POST_SUBMIT_CONSUMPTION_POLL_MS: u64 = 60;
|
|
937
|
+
|
|
938
|
+
/// E46 (0.3.24 bug#5, C5 provider-agnostic detector): the pane's input region
|
|
939
|
+
/// is "consumed" when the token text that was just visible BEFORE the Enter
|
|
940
|
+
/// is no longer present in the captured tail. Structural signal — no
|
|
941
|
+
/// provider-specific UI string. Works across claude / codex / copilot because
|
|
942
|
+
/// every provider's composer clears the input area after a successful submit
|
|
943
|
+
/// (the content scrolls into history, leaving the prompt empty).
|
|
944
|
+
///
|
|
945
|
+
/// Returns:
|
|
946
|
+
/// * `Some(true)` — token was visible BEFORE submit and is GONE from
|
|
947
|
+
/// the visible input area now → consumption confirmed.
|
|
948
|
+
/// * `Some(false)` — token still visible (or other reason to think not yet
|
|
949
|
+
/// consumed).
|
|
950
|
+
/// * `None` — payload has no token marker (peer message without token,
|
|
951
|
+
/// empty payload) so we can't structurally check; caller treats this as
|
|
952
|
+
/// non-blocking (the pre-existing `EnterSentWithoutPlaceholderCheck`
|
|
953
|
+
/// path).
|
|
954
|
+
fn post_submit_input_consumed(
|
|
955
|
+
backend: &TmuxBackend,
|
|
956
|
+
target: &Target,
|
|
957
|
+
payload: &InjectPayload,
|
|
958
|
+
) -> Result<Option<bool>, TransportError> {
|
|
959
|
+
let Some(marker) = payload_token_marker(payload) else {
|
|
960
|
+
return Ok(None);
|
|
961
|
+
};
|
|
962
|
+
let captured = backend.capture(target, CaptureRange::Tail(30))?;
|
|
963
|
+
// The token may legitimately appear in scrollback (a successful submit
|
|
964
|
+
// pushes it into history). We only treat the BOTTOM-of-pane region (last
|
|
965
|
+
// few lines, where the input area lives) as the consumption signal. Tail
|
|
966
|
+
// 30 lines is small enough that the input area still dominates if the
|
|
967
|
+
// submit didn't go through, while a successful submit has pushed the
|
|
968
|
+
// token marker out of the bottom 5 lines by the time the response
|
|
969
|
+
// composer redraws.
|
|
970
|
+
let tail_lines: Vec<&str> = captured.text.lines().rev().take(5).collect();
|
|
971
|
+
let token_in_tail = tail_lines.iter().any(|line| line.contains(marker));
|
|
972
|
+
Ok(Some(!token_in_tail))
|
|
973
|
+
}
|
|
974
|
+
|
|
918
975
|
fn shell_command(
|
|
919
976
|
argv: &[String],
|
|
920
977
|
cwd: &Path,
|
|
@@ -1105,11 +1162,94 @@ impl Transport for TmuxBackend {
|
|
|
1105
1162
|
// downgrade to CaptureMissingToken.
|
|
1106
1163
|
token_visible_for_report =
|
|
1107
1164
|
pre_submit_token_visible(self, target, payload).unwrap_or(None);
|
|
1165
|
+
// E46 (0.3.24 bug#5, demo-director卡 bracketed paste root cause):
|
|
1166
|
+
// bracketed-paste-mode on a fresh provider TUI swallows the framework's
|
|
1167
|
+
// Enter as paste content. Send `Escape` first to exit the paste bracket
|
|
1168
|
+
// so the subsequent Enter is interpreted as submit. Safe across
|
|
1169
|
+
// claude/codex/copilot: Escape on an empty composer is a no-op
|
|
1170
|
+
// (cancel any pending mode), it only matters when bracketed-paste is
|
|
1171
|
+
// actually open. Architect-approved + macmini real-machine verified.
|
|
1172
|
+
// Only Enter benefits — explicit menu navigation (Key::Down etc.)
|
|
1173
|
+
// should not pre-cancel mode.
|
|
1174
|
+
if bracketed
|
|
1175
|
+
&& matches!(payload, InjectPayload::Text(_))
|
|
1176
|
+
&& matches!(submit, Key::Enter)
|
|
1177
|
+
{
|
|
1178
|
+
let escape_argv = tmux_send_keys_argv(&pane, &[Key::Escape]);
|
|
1179
|
+
// We don't fail the whole inject on Escape error — Escape failure
|
|
1180
|
+
// would surface downstream as the same SubmitConsumptionUnverified
|
|
1181
|
+
// when post-Enter probe still sees the token in the input region.
|
|
1182
|
+
let _ = self.run_inject_stage(&escape_argv, InjectStage::Submit);
|
|
1183
|
+
}
|
|
1108
1184
|
self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
|
|
1109
1185
|
if matches!(token_visible_for_report, Some(false)) {
|
|
1110
1186
|
token_visible_for_report =
|
|
1111
1187
|
post_submit_token_visible(self, target, payload).unwrap_or(Some(false));
|
|
1112
1188
|
}
|
|
1189
|
+
// E46 C2 + C3: post-Enter consumption gate with bounded resend cap.
|
|
1190
|
+
// Only fires for:
|
|
1191
|
+
// - submit key == Enter (other keys like Down/Up are explicit
|
|
1192
|
+
// menu navigation; codex uses `Down Enter` to dismiss a
|
|
1193
|
+
// prompt — those carry their own verification semantics
|
|
1194
|
+
// via KeySentAfterVisibleToken),
|
|
1195
|
+
// - token-bearing Text payloads (the structural input-empty
|
|
1196
|
+
// signal needs a token marker to anchor on).
|
|
1197
|
+
// Each iteration RE-CHECKS that the input still contains the
|
|
1198
|
+
// token BEFORE resending Enter — guards against the C3
|
|
1199
|
+
// double-submit hazard when the first Enter was consumed but
|
|
1200
|
+
// our readback was slow to catch the empty-input state.
|
|
1201
|
+
let mut consumption_attempts: u32 = 1;
|
|
1202
|
+
let mut consumed = if matches!(submit, Key::Enter) {
|
|
1203
|
+
post_submit_input_consumed(self, target, payload).unwrap_or(None)
|
|
1204
|
+
} else {
|
|
1205
|
+
None
|
|
1206
|
+
};
|
|
1207
|
+
if matches!(consumed, Some(false)) {
|
|
1208
|
+
for _ in 1..POST_SUBMIT_CONSUMPTION_ATTEMPTS {
|
|
1209
|
+
std::thread::sleep(Duration::from_millis(
|
|
1210
|
+
POST_SUBMIT_CONSUMPTION_POLL_MS,
|
|
1211
|
+
));
|
|
1212
|
+
// C3 critical: re-check BEFORE resending. If the input
|
|
1213
|
+
// is now empty (= first Enter actually consumed, just
|
|
1214
|
+
// observable now), DO NOT resend — bumping a spurious
|
|
1215
|
+
// empty Enter would open an empty turn.
|
|
1216
|
+
consumed = post_submit_input_consumed(self, target, payload)
|
|
1217
|
+
.unwrap_or(None);
|
|
1218
|
+
if !matches!(consumed, Some(false)) {
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
consumption_attempts += 1;
|
|
1222
|
+
let _ = self.run_inject_stage(&submit_argv, InjectStage::Submit);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
let submit_verification = match consumed {
|
|
1226
|
+
// Consumption confirmed → MUST-10 path: keep the canonical
|
|
1227
|
+
// EnterSentWithoutPlaceholderCheck so the existing
|
|
1228
|
+
// provider_submit_verification_red contract holds.
|
|
1229
|
+
Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
1230
|
+
// Consumption explicitly NOT observed (token still in tail
|
|
1231
|
+
// after resend cap) → E46's new variant. Delivery treats
|
|
1232
|
+
// as not delivered.
|
|
1233
|
+
Some(false) => SubmitVerification::SubmitConsumptionUnverified,
|
|
1234
|
+
// No structural anchor (non-token payload) → preserve the
|
|
1235
|
+
// historic non-checked variant; the pre-existing
|
|
1236
|
+
// pre/post-submit token readback + U1 #7 cover the other
|
|
1237
|
+
// false-positive (silent paste drop).
|
|
1238
|
+
None => submit_verification_for_key(submit),
|
|
1239
|
+
};
|
|
1240
|
+
return Ok(InjectReport {
|
|
1241
|
+
stage_reached: InjectStage::Submit,
|
|
1242
|
+
inject_verification: inject_verification_after_readback(
|
|
1243
|
+
payload,
|
|
1244
|
+
token_visible_for_report,
|
|
1245
|
+
),
|
|
1246
|
+
submit_verification,
|
|
1247
|
+
turn_verification: match payload {
|
|
1248
|
+
InjectPayload::Empty => TurnVerification::NotRequired,
|
|
1249
|
+
InjectPayload::Text(_) => TurnVerification::NotYetObserved,
|
|
1250
|
+
},
|
|
1251
|
+
attempts: consumption_attempts,
|
|
1252
|
+
});
|
|
1113
1253
|
}
|
|
1114
1254
|
}
|
|
1115
1255
|
Ok(InjectReport {
|
|
@@ -37,6 +37,18 @@ struct OfflineState {
|
|
|
37
37
|
inject_targets: Vec<Target>,
|
|
38
38
|
inject_payloads: Vec<String>,
|
|
39
39
|
tmux_endpoint: Option<String>,
|
|
40
|
+
/// U1-B contract: when set, `list_targets()` returns `TransportError::MuxUnavailable`
|
|
41
|
+
/// — used to model tmux server jitter (subprocess-fork-failed level). The whole
|
|
42
|
+
/// resolve must DEFER, not silently coerce to an empty vec.
|
|
43
|
+
list_targets_error: Option<String>,
|
|
44
|
+
/// U1-C / general: pre-staged `capture()` payload, keyed by target stringification.
|
|
45
|
+
/// `Target::Pane(p)` keys as `p.as_str()`; `Target::SessionWindow{session,window}`
|
|
46
|
+
/// keys as `format!("{session}:{window}")`. A miss returns empty text (current
|
|
47
|
+
/// default behaviour).
|
|
48
|
+
capture_text: BTreeMap<String, String>,
|
|
49
|
+
/// U1-C Tail-peek contract: every `capture()` call records its `CaptureRange`,
|
|
50
|
+
/// in order, so a test can prove the delivery peek site narrowed Full → Tail(80).
|
|
51
|
+
capture_ranges: Vec<CaptureRange>,
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
impl Default for OfflineState {
|
|
@@ -57,6 +69,9 @@ impl Default for OfflineState {
|
|
|
57
69
|
inject_targets: Vec::new(),
|
|
58
70
|
inject_payloads: Vec::new(),
|
|
59
71
|
tmux_endpoint: None,
|
|
72
|
+
list_targets_error: None,
|
|
73
|
+
capture_text: BTreeMap::new(),
|
|
74
|
+
capture_ranges: Vec::new(),
|
|
60
75
|
}
|
|
61
76
|
}
|
|
62
77
|
}
|
|
@@ -127,6 +142,40 @@ impl OfflineTransport {
|
|
|
127
142
|
self
|
|
128
143
|
}
|
|
129
144
|
|
|
145
|
+
/// U1-B: stage a `list_targets()` error to model tmux server jitter. While set,
|
|
146
|
+
/// every call returns `Err(TransportError::MuxUnavailable{detail})`. Pass an
|
|
147
|
+
/// empty string to clear via re-builder if needed.
|
|
148
|
+
pub fn with_list_targets_error(self, detail: impl Into<String>) -> Self {
|
|
149
|
+
self.with_state(|state| state.list_targets_error = Some(detail.into()));
|
|
150
|
+
self
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Pre-stage `capture()` output for a `Target::Pane(pane_id)` key.
|
|
154
|
+
pub fn with_capture_for_pane(
|
|
155
|
+
self,
|
|
156
|
+
pane_id: impl Into<String>,
|
|
157
|
+
text: impl Into<String>,
|
|
158
|
+
) -> Self {
|
|
159
|
+
self.with_state(|state| {
|
|
160
|
+
state.capture_text.insert(pane_id.into(), text.into());
|
|
161
|
+
});
|
|
162
|
+
self
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Pre-stage `capture()` output for a `Target::SessionWindow{session,window}` key.
|
|
166
|
+
pub fn with_capture_for_session_window(
|
|
167
|
+
self,
|
|
168
|
+
session: impl Into<String>,
|
|
169
|
+
window: impl Into<String>,
|
|
170
|
+
text: impl Into<String>,
|
|
171
|
+
) -> Self {
|
|
172
|
+
let key = format!("{}:{}", session.into(), window.into());
|
|
173
|
+
self.with_state(|state| {
|
|
174
|
+
state.capture_text.insert(key, text.into());
|
|
175
|
+
});
|
|
176
|
+
self
|
|
177
|
+
}
|
|
178
|
+
|
|
130
179
|
pub fn calls(&self) -> Vec<&'static str> {
|
|
131
180
|
self.with_state(|state| state.calls.clone())
|
|
132
181
|
}
|
|
@@ -163,6 +212,12 @@ impl OfflineTransport {
|
|
|
163
212
|
self.with_state(|state| state.inject_payloads.clone())
|
|
164
213
|
}
|
|
165
214
|
|
|
215
|
+
/// All `capture()` ranges observed, in call order. Used by U1-C Tail-peek
|
|
216
|
+
/// contracts to prove the delivery peek site requested Tail rather than Full.
|
|
217
|
+
pub fn capture_ranges(&self) -> Vec<CaptureRange> {
|
|
218
|
+
self.with_state(|state| state.capture_ranges.clone())
|
|
219
|
+
}
|
|
220
|
+
|
|
166
221
|
fn record(&self, call: &'static str) {
|
|
167
222
|
self.with_state(|state| state.calls.push(call));
|
|
168
223
|
}
|
|
@@ -294,11 +349,21 @@ impl Transport for OfflineTransport {
|
|
|
294
349
|
|
|
295
350
|
fn capture(
|
|
296
351
|
&self,
|
|
297
|
-
|
|
352
|
+
target: &Target,
|
|
298
353
|
range: CaptureRange,
|
|
299
354
|
) -> Result<CapturedText, TransportError> {
|
|
300
|
-
|
|
301
|
-
|
|
355
|
+
let key = match target {
|
|
356
|
+
Target::Pane(p) => p.as_str().to_string(),
|
|
357
|
+
Target::SessionWindow { session, window } => {
|
|
358
|
+
format!("{}:{}", session.as_str(), window.as_str())
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
let text = self.with_state(|state| {
|
|
362
|
+
state.calls.push("capture");
|
|
363
|
+
state.capture_ranges.push(range);
|
|
364
|
+
state.capture_text.get(&key).cloned().unwrap_or_default()
|
|
365
|
+
});
|
|
366
|
+
Ok(CapturedText { text, range })
|
|
302
367
|
}
|
|
303
368
|
|
|
304
369
|
fn query(
|
|
@@ -329,10 +394,18 @@ impl Transport for OfflineTransport {
|
|
|
329
394
|
}
|
|
330
395
|
|
|
331
396
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
332
|
-
|
|
397
|
+
// U1-B: when `with_list_targets_error` is set, return a real Err so the
|
|
398
|
+
// caller sees server jitter rather than a coerced empty vec.
|
|
399
|
+
if let Some(detail) = self.with_state(|state| {
|
|
333
400
|
state.calls.push("list_targets");
|
|
334
|
-
state.
|
|
335
|
-
})
|
|
401
|
+
state.list_targets_error.clone()
|
|
402
|
+
}) {
|
|
403
|
+
return Err(TransportError::MuxUnavailable {
|
|
404
|
+
backend: BackendKind::Tmux,
|
|
405
|
+
detail,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
Ok(self.with_state(|state| state.targets.clone()))
|
|
336
409
|
}
|
|
337
410
|
|
|
338
411
|
fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
|