@team-agent/installer 0.5.66 → 0.5.68
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 +8 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +1 -0
- package/crates/team-agent/src/cli/adapters.rs +5 -0
- package/crates/team-agent/src/cli/diagnose.rs +82 -0
- package/crates/team-agent/src/cli/emit.rs +3 -3
- package/crates/team-agent/src/cli/grok_slot.rs +299 -0
- package/crates/team-agent/src/cli/leader.rs +21 -7
- package/crates/team-agent/src/cli/leaders.rs +125 -1
- package/crates/team-agent/src/cli/mod.rs +20 -0
- package/crates/team-agent/src/cli/send/presentation.rs +7 -1
- package/crates/team-agent/src/cli/spec.rs +2 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
- package/crates/team-agent/src/cli/tests/base.rs +26 -3
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
- package/crates/team-agent/src/communication_mode/mod.rs +6 -4
- package/crates/team-agent/src/compiler.rs +59 -0
- package/crates/team-agent/src/coordinator/backoff.rs +55 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
- package/crates/team-agent/src/coordinator/health.rs +173 -0
- package/crates/team-agent/src/coordinator/mod.rs +31 -0
- package/crates/team-agent/src/coordinator/orphan.rs +31 -0
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +30 -0
- package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
- package/crates/team-agent/src/coordinator/tick.rs +133 -22
- package/crates/team-agent/src/coordinator/types.rs +49 -0
- package/crates/team-agent/src/db/message_store.rs +39 -5
- package/crates/team-agent/src/layout/worker_env.rs +20 -3
- package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
- package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
- package/crates/team-agent/src/leader/registry.rs +65 -0
- package/crates/team-agent/src/leader/start.rs +920 -63
- package/crates/team-agent/src/lifecycle/display.rs +56 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +332 -12
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +62 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +59 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
- package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +332 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -456
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
- package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
- package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +62 -2
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +139 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
- package/crates/team-agent/src/lifecycle/launch.rs +99 -29
- package/crates/team-agent/src/lifecycle/lock.rs +42 -1
- package/crates/team-agent/src/lifecycle/mod.rs +11 -0
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +124 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +369 -11
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
- package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
- package/crates/team-agent/src/lifecycle/restart.rs +29 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
- package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
- package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
- package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +2 -0
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
- package/crates/team-agent/src/lifecycle/tests.rs +23 -13
- package/crates/team-agent/src/lifecycle/types.rs +61 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +85 -11
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
- package/crates/team-agent/src/mcp_server/tests.rs +196 -2
- package/crates/team-agent/src/messaging/delivery.rs +405 -40
- package/crates/team-agent/src/messaging/helpers.rs +2 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
- package/crates/team-agent/src/messaging/results.rs +4 -0
- package/crates/team-agent/src/messaging/send.rs +34 -0
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
- package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/types.rs +4 -1
- package/crates/team-agent/src/model/enums.rs +14 -5
- package/crates/team-agent/src/model/permissions.rs +3 -0
- package/crates/team-agent/src/os_probe.rs +73 -2
- package/crates/team-agent/src/provider/adapter.rs +231 -4
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
- package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/bypass_flags.rs +46 -2
- package/crates/team-agent/src/provider/classify.rs +4 -2
- package/crates/team-agent/src/provider/faults.rs +2 -1
- package/crates/team-agent/src/provider/mod.rs +11 -0
- package/crates/team-agent/src/provider/session/capture.rs +158 -20
- package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
- package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
- package/crates/team-agent/src/provider/session/mod.rs +18 -0
- package/crates/team-agent/src/provider/session/resume.rs +57 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
- package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
- package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
- package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
- package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
- package/crates/team-agent/src/provider/session_scan.rs +8 -0
- package/crates/team-agent/src/provider/submit_now.rs +94 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
- package/crates/team-agent/src/provider/types.rs +4 -2
- package/crates/team-agent/src/provider/wire.rs +23 -1
- package/crates/team-agent/src/state/persist.rs +301 -8
- package/crates/team-agent/src/state/repository.rs +5 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
- package/crates/team-agent/src/tmux_backend.rs +1064 -75
- package/crates/team-agent/src/transport/tests/wire.rs +28 -2
- package/crates/team-agent/src/transport.rs +248 -1
- package/npm/install.mjs +129 -73
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +33 -238
- package/skills/team-agent/command-coverage.json +31 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
- package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
|
@@ -10,17 +10,21 @@
|
|
|
10
10
|
use std::collections::{BTreeMap, VecDeque};
|
|
11
11
|
use std::os::unix::net::UnixListener;
|
|
12
12
|
use std::path::Path;
|
|
13
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
13
14
|
use std::sync::{Arc, Mutex};
|
|
15
|
+
use std::time::{Duration, Instant};
|
|
14
16
|
|
|
15
17
|
use super::{
|
|
18
|
+
current_paste_to_submit_floor, observe_turn_from_capture,
|
|
19
|
+
sleep_remaining_paste_to_submit_floor, with_cursor_single_enter, with_paste_to_submit_floor,
|
|
16
20
|
CommandOutput, CommandRunner, RealCommandRunner, TmuxBackend, PANE_BINDING_NONCE_METADATA_KEY,
|
|
17
21
|
};
|
|
18
22
|
use crate::model::enums::PaneLiveness;
|
|
19
23
|
use crate::transport::{
|
|
20
24
|
normalize_capture, tmux_capture_argv, tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv,
|
|
21
|
-
AttachOutcome, CaptureRange, InjectPayload, InjectStage,
|
|
22
|
-
|
|
23
|
-
TurnVerification, WindowName,
|
|
25
|
+
tmux_submit_key_name, AttachOutcome, CaptureRange, InjectPayload, InjectStage,
|
|
26
|
+
InjectVerification, Key, PaneField, PaneId, SessionName, SetEnvOutcome, SubmitVerification,
|
|
27
|
+
Target, Transport, TransportError, TurnVerification, WindowName,
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
type RecordedArgv = Arc<Mutex<Vec<Vec<String>>>>;
|
|
@@ -761,13 +765,20 @@ fn capture_argv_and_normalizes_scrollback() {
|
|
|
761
765
|
// ── 5a. send_keys: argv = tmux_send_keys_argv ──────────────────────────────────────────────────
|
|
762
766
|
#[test]
|
|
763
767
|
fn send_keys_argv_matches_builder() {
|
|
764
|
-
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
768
|
+
let (be, rec) = backend_with(MockResp::Out(ok("0\n")), vec![]);
|
|
765
769
|
let pane = PaneId::new("%7");
|
|
766
770
|
be.send_keys(&Target::Pane(pane.clone()), &[Key::Enter])
|
|
767
771
|
.expect("send_keys");
|
|
772
|
+
let calls = rec.lock().unwrap().clone();
|
|
773
|
+
let submit = calls.iter().find(|argv| {
|
|
774
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
775
|
+
&& argv.iter().any(|a| a == "Enter")
|
|
776
|
+
&& !argv.iter().any(|a| a == "-l")
|
|
777
|
+
});
|
|
768
778
|
assert_eq!(
|
|
769
|
-
|
|
770
|
-
tmux_send_keys_argv(&pane, &[Key::Enter])
|
|
779
|
+
submit.cloned().as_ref(),
|
|
780
|
+
Some(&tmux_send_keys_argv(&pane, &[Key::Enter])),
|
|
781
|
+
"Enter 键组仍走 tmux_send_keys_argv;prepare 只允许前缀 mode-query/201~; calls={calls:?}"
|
|
771
782
|
);
|
|
772
783
|
}
|
|
773
784
|
|
|
@@ -800,9 +811,9 @@ fn inject_text_runs_buffer_paste_submit_sequence_and_reports_submit() {
|
|
|
800
811
|
"inject must bracketed-paste (-p) the buffer to the pane; got {calls:?}"
|
|
801
812
|
);
|
|
802
813
|
assert!(
|
|
803
|
-
calls
|
|
804
|
-
.
|
|
805
|
-
|
|
814
|
+
calls.iter().any(
|
|
815
|
+
|a| is(a, "send-keys") && a.contains(&tmux_submit_key_name(Key::Enter).to_string())
|
|
816
|
+
),
|
|
806
817
|
"inject must send the submit key (Enter) last; got {calls:?}"
|
|
807
818
|
);
|
|
808
819
|
assert_eq!(
|
|
@@ -927,6 +938,57 @@ fn inject_waits_for_token_visibility_before_enter() {
|
|
|
927
938
|
);
|
|
928
939
|
}
|
|
929
940
|
|
|
941
|
+
#[test]
|
|
942
|
+
fn paste_to_submit_floor_default_is_zero_even_if_test_tmp_is_set() {
|
|
943
|
+
assert_eq!(
|
|
944
|
+
current_paste_to_submit_floor(),
|
|
945
|
+
Duration::ZERO,
|
|
946
|
+
"default floor must be ZERO; TEAM_AGENT_TEST_TMP is not this switch"
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
#[test]
|
|
951
|
+
fn paste_to_submit_nonzero_floor_is_actually_slept() {
|
|
952
|
+
let start = Instant::now();
|
|
953
|
+
sleep_remaining_paste_to_submit_floor(start, Duration::from_millis(40));
|
|
954
|
+
let elapsed = start.elapsed();
|
|
955
|
+
assert!(
|
|
956
|
+
elapsed >= Duration::from_millis(40),
|
|
957
|
+
"non-zero floor must sleep the remaining time; elapsed={elapsed:?}"
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
#[test]
|
|
962
|
+
fn paste_to_submit_zero_floor_does_not_sleep() {
|
|
963
|
+
let start = Instant::now();
|
|
964
|
+
sleep_remaining_paste_to_submit_floor(start, Duration::ZERO);
|
|
965
|
+
let elapsed = start.elapsed();
|
|
966
|
+
assert!(
|
|
967
|
+
elapsed < Duration::from_millis(20),
|
|
968
|
+
"ZERO floor must be free for claude/codex/grok; elapsed={elapsed:?}"
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
#[test]
|
|
973
|
+
fn inject_honors_injected_nonzero_paste_to_submit_floor() {
|
|
974
|
+
let (be, _) = backend_with(MockResp::Out(ok("hello")), vec![]);
|
|
975
|
+
let start = Instant::now();
|
|
976
|
+
with_paste_to_submit_floor(Duration::from_millis(40), || {
|
|
977
|
+
be.inject(
|
|
978
|
+
&Target::Pane(PaneId::new("%7")),
|
|
979
|
+
&InjectPayload::Text("hello".to_string()),
|
|
980
|
+
Key::Enter,
|
|
981
|
+
true,
|
|
982
|
+
)
|
|
983
|
+
.expect("inject");
|
|
984
|
+
});
|
|
985
|
+
let elapsed = start.elapsed();
|
|
986
|
+
assert!(
|
|
987
|
+
elapsed >= Duration::from_millis(40),
|
|
988
|
+
"inject must take the injected cursor floor; elapsed={elapsed:?}"
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
930
992
|
#[test]
|
|
931
993
|
fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
|
|
932
994
|
let text = "hello leader [team-agent-token:skip]";
|
|
@@ -949,7 +1011,7 @@ fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
|
|
|
949
1011
|
assert!(
|
|
950
1012
|
calls.iter().any(|argv| {
|
|
951
1013
|
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
952
|
-
&& argv.contains(&
|
|
1014
|
+
&& argv.contains(&tmux_submit_key_name(Key::Enter).to_string())
|
|
953
1015
|
}),
|
|
954
1016
|
"skip-consumption payload must still submit once; calls={calls:?}"
|
|
955
1017
|
);
|
|
@@ -977,11 +1039,10 @@ fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
|
|
|
977
1039
|
// no longer in the bottom 5 lines of the pane = composer cleared).
|
|
978
1040
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
979
1041
|
|
|
980
|
-
///
|
|
981
|
-
///
|
|
982
|
-
/// scrolls away, that is slow provider output, not transport failure.
|
|
1042
|
+
/// ledger.inject-fix: paste landing is not submit. Token still in the
|
|
1043
|
+
/// composer and no Working signal ⇒ SubmitConsumptionUnverified.
|
|
983
1044
|
#[test]
|
|
984
|
-
fn
|
|
1045
|
+
fn e46_post_submit_matched_token_without_scroll_is_unverified() {
|
|
985
1046
|
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
|
|
986
1047
|
let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
|
|
987
1048
|
let report = be
|
|
@@ -994,16 +1055,20 @@ fn e46_post_submit_matched_token_without_scroll_is_verified() {
|
|
|
994
1055
|
.expect("inject runs");
|
|
995
1056
|
assert_eq!(
|
|
996
1057
|
report.submit_verification,
|
|
997
|
-
SubmitVerification::
|
|
998
|
-
"
|
|
999
|
-
|
|
1058
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
1059
|
+
"token still in composer and no Working signal must be unverified, \
|
|
1060
|
+
not EnterSentWithoutPlaceholderCheck. Got {:?}",
|
|
1000
1061
|
report.submit_verification
|
|
1001
1062
|
);
|
|
1002
|
-
assert_eq!(
|
|
1063
|
+
assert_eq!(
|
|
1064
|
+
report.turn_verification,
|
|
1065
|
+
TurnVerification::LeaderNewTurnBoundaryMissing,
|
|
1066
|
+
"token still in composer and no busy must report 没开跑, not unknown/started"
|
|
1067
|
+
);
|
|
1003
1068
|
let diagnostics = report.submit_diagnostics.expect("diagnostics");
|
|
1004
1069
|
assert!(
|
|
1005
1070
|
diagnostics.attempts_detail.iter().any(|obs| obs.matched),
|
|
1006
|
-
"the
|
|
1071
|
+
"the unverified verdict must still record that paste landed"
|
|
1007
1072
|
);
|
|
1008
1073
|
}
|
|
1009
1074
|
|
|
@@ -1026,6 +1091,11 @@ fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
|
|
|
1026
1091
|
"busy provider state means the turn is being processed even if \
|
|
1027
1092
|
the token has not yet scrolled out of the bottom capture"
|
|
1028
1093
|
);
|
|
1094
|
+
assert_eq!(
|
|
1095
|
+
report.turn_verification,
|
|
1096
|
+
TurnVerification::LeaderNewTurnBoundaryVerified,
|
|
1097
|
+
"live Working signal is 开跑; must not stay not_yet_observed"
|
|
1098
|
+
);
|
|
1029
1099
|
let diagnostics = report.submit_diagnostics.expect("diagnostics");
|
|
1030
1100
|
assert!(
|
|
1031
1101
|
diagnostics
|
|
@@ -1041,17 +1111,10 @@ fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
|
|
|
1041
1111
|
);
|
|
1042
1112
|
}
|
|
1043
1113
|
|
|
1044
|
-
///
|
|
1045
|
-
///
|
|
1046
|
-
/// the generous default for panes where the capture can't distinguish
|
|
1047
|
-
/// "token consumed" from "token never landed" (empty mock, MCP sim).
|
|
1048
|
-
/// SubmitConsumptionUnverified only fires when the grace fallback
|
|
1049
|
-
/// explicitly rejects (token_visible_for_report=false AND consumed=false
|
|
1050
|
-
/// at the same time — a state that requires the token to be in the pane
|
|
1051
|
-
/// during consumption poll but absent during Phase 1, which is a
|
|
1052
|
-
/// contradictory mock state that doesn't arise in production).
|
|
1114
|
+
/// NeverSeen + 无占位符:空 pane 不得再把「token 不在底部」写成 consumed。
|
|
1115
|
+
/// 旧默认 EnterSentWithoutPlaceholderCheck 就是折叠恒真的同构。
|
|
1053
1116
|
#[test]
|
|
1054
|
-
fn
|
|
1117
|
+
fn e46_inject_text_with_empty_pane_is_unverified() {
|
|
1055
1118
|
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_empty]";
|
|
1056
1119
|
let (be, _rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
1057
1120
|
let report = be
|
|
@@ -1064,9 +1127,9 @@ fn e46_inject_text_with_empty_pane_defaults_to_consumed() {
|
|
|
1064
1127
|
.expect("inject runs");
|
|
1065
1128
|
assert_eq!(
|
|
1066
1129
|
report.submit_verification,
|
|
1067
|
-
SubmitVerification::
|
|
1068
|
-
"
|
|
1069
|
-
|
|
1130
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
1131
|
+
"empty pane / token never visible / no pasted #N ⇒ unverified, \
|
|
1132
|
+
not EnterSentWithoutPlaceholderCheck. Got {:?}",
|
|
1070
1133
|
report.submit_verification
|
|
1071
1134
|
);
|
|
1072
1135
|
}
|
|
@@ -1153,7 +1216,7 @@ fn e46_inject_text_resend_rechecks_input_before_resending_to_avoid_double_submit
|
|
|
1153
1216
|
.iter()
|
|
1154
1217
|
.filter(|argv| {
|
|
1155
1218
|
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
1156
|
-
&& argv.contains(&
|
|
1219
|
+
&& argv.contains(&tmux_submit_key_name(Key::Enter).to_string())
|
|
1157
1220
|
})
|
|
1158
1221
|
.count();
|
|
1159
1222
|
assert_eq!(
|
|
@@ -1231,7 +1294,7 @@ fn e46_inject_text_first_attempt_no_escape_retry_only() {
|
|
|
1231
1294
|
.iter()
|
|
1232
1295
|
.filter(|argv| {
|
|
1233
1296
|
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
1234
|
-
&& argv.contains(&
|
|
1297
|
+
&& argv.contains(&tmux_submit_key_name(Key::Enter).to_string())
|
|
1235
1298
|
})
|
|
1236
1299
|
.count();
|
|
1237
1300
|
let escape_count = calls
|
|
@@ -1372,16 +1435,13 @@ fn e50_inject_paste_prompt_path_populates_submit_diagnostics_per_attempt() {
|
|
|
1372
1435
|
true,
|
|
1373
1436
|
)
|
|
1374
1437
|
.expect("inject");
|
|
1375
|
-
//
|
|
1376
|
-
// either EnterSentWithoutPlaceholderCheck (consumed) or
|
|
1377
|
-
// SubmitConsumptionUnverified (not consumed). With the mock returning
|
|
1378
|
-
// pasted-content text (no token in tail), consumed = true.
|
|
1438
|
+
// token 从未可见 + composer 里仍有 pasted content(无 #N)→ 不得报已消费。
|
|
1379
1439
|
assert_eq!(
|
|
1380
1440
|
report.submit_verification,
|
|
1381
|
-
SubmitVerification::
|
|
1382
|
-
"
|
|
1383
|
-
|
|
1384
|
-
|
|
1441
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
1442
|
+
"folded placeholder still in composer (no #N, token never seen) \
|
|
1443
|
+
must be unverified, not vacuous EnterSentWithoutPlaceholderCheck; \
|
|
1444
|
+
got {:?}",
|
|
1385
1445
|
report.submit_verification
|
|
1386
1446
|
);
|
|
1387
1447
|
let diagnostics = report.submit_diagnostics.expect("diagnostics");
|
|
@@ -2003,3 +2063,1638 @@ fn r1_caller_target_uuid_is_first_leader_session_uuid_precedence_seam() {
|
|
|
2003
2063
|
.list_targets()
|
|
2004
2064
|
.expect("live list_targets (caller-target scan precursor)");
|
|
2005
2065
|
}
|
|
2066
|
+
|
|
2067
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
2068
|
+
// pasted-signal: 折叠占位符正信号 + token NeverSeen 三态
|
|
2069
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
2070
|
+
|
|
2071
|
+
struct FoldedPasteRunner {
|
|
2072
|
+
recorded: RecordedArgv,
|
|
2073
|
+
pre_submit: String,
|
|
2074
|
+
post_submit: String,
|
|
2075
|
+
submitted: AtomicBool,
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
impl CommandRunner for FoldedPasteRunner {
|
|
2079
|
+
fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
|
|
2080
|
+
self.recorded.lock().unwrap().push(argv.to_vec());
|
|
2081
|
+
// 投递提交键是 C-m(tmux_send_submit_argv),不是字面量 Enter。
|
|
2082
|
+
if argv.get(1).map(String::as_str) == Some("send-keys")
|
|
2083
|
+
&& argv.iter().any(|a| a == "C-m" || a == "Enter")
|
|
2084
|
+
{
|
|
2085
|
+
self.submitted.store(true, Ordering::SeqCst);
|
|
2086
|
+
}
|
|
2087
|
+
let stdout = match argv.get(1).map(String::as_str) {
|
|
2088
|
+
Some("capture-pane") => {
|
|
2089
|
+
if self.submitted.load(Ordering::SeqCst) {
|
|
2090
|
+
self.post_submit.clone()
|
|
2091
|
+
} else {
|
|
2092
|
+
self.pre_submit.clone()
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
Some("display-message") => "0\n".to_string(),
|
|
2096
|
+
_ => String::new(),
|
|
2097
|
+
};
|
|
2098
|
+
Ok(ok(&stdout))
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
fn run_with_stdin(
|
|
2102
|
+
&self,
|
|
2103
|
+
argv: &[String],
|
|
2104
|
+
_stdin: &str,
|
|
2105
|
+
) -> Result<CommandOutput, std::io::Error> {
|
|
2106
|
+
self.run(argv)
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
fn backend_folded(pre_submit: &str, post_submit: &str) -> (TmuxBackend, RecordedArgv) {
|
|
2111
|
+
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
2112
|
+
let runner = FoldedPasteRunner {
|
|
2113
|
+
recorded: Arc::clone(&recorded),
|
|
2114
|
+
pre_submit: pre_submit.to_string(),
|
|
2115
|
+
post_submit: post_submit.to_string(),
|
|
2116
|
+
submitted: AtomicBool::new(false),
|
|
2117
|
+
};
|
|
2118
|
+
(TmuxBackend::with_runner(Box::new(runner)), recorded)
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/// 修复前尺子:token 不在底部 15 行 ⇒ consumed。折叠占位符会打中这条恒真。
|
|
2122
|
+
fn unfixed_vacuous_consumed(text: &str, marker: &str) -> bool {
|
|
2123
|
+
!super::token_in_bottom_n(text, marker, 15)
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
#[test]
|
|
2127
|
+
fn pasted_signal_unfixed_predicate_treats_folded_placeholder_as_consumed() {
|
|
2128
|
+
let folded = "❯ [Pasted text #1 +8 lines]\n";
|
|
2129
|
+
let marker = "[team-agent-token:msg_fold]";
|
|
2130
|
+
assert!(
|
|
2131
|
+
unfixed_vacuous_consumed(folded, marker),
|
|
2132
|
+
"causal: pre-fix !token_in_bottom_n is vacuously true on a folded composer"
|
|
2133
|
+
);
|
|
2134
|
+
assert_eq!(
|
|
2135
|
+
super::consumption_from_capture(folded, marker, false, Some(super::PasteLatch::HashId(1))),
|
|
2136
|
+
Some(false),
|
|
2137
|
+
"post-fix NeverSeen + #1 still in composer must NOT be consumed"
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
#[test]
|
|
2142
|
+
fn pasted_signal_never_seen_without_placeholder_is_not_consumed() {
|
|
2143
|
+
let marker = "[team-agent-token:msg_empty]";
|
|
2144
|
+
assert_eq!(
|
|
2145
|
+
super::token_sighting(false, false),
|
|
2146
|
+
super::TokenSighting::NeverSeen
|
|
2147
|
+
);
|
|
2148
|
+
assert_eq!(
|
|
2149
|
+
super::consumption_from_capture("", marker, false, None),
|
|
2150
|
+
Some(false)
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
#[test]
|
|
2155
|
+
fn pasted_signal_never_seen_placeholder_id_gone_is_consumed() {
|
|
2156
|
+
let marker = "[team-agent-token:msg_fold]";
|
|
2157
|
+
let after = "assistant reply\n❯ \n";
|
|
2158
|
+
assert_eq!(
|
|
2159
|
+
super::consumption_from_capture(after, marker, false, Some(super::PasteLatch::HashId(7))),
|
|
2160
|
+
Some(true),
|
|
2161
|
+
"NeverSeen but latched #7 left composer ⇒ consumed (positive signal)"
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
#[test]
|
|
2166
|
+
fn pasted_signal_never_seen_wrong_id_is_consumed() {
|
|
2167
|
+
let marker = "[team-agent-token:msg_fold]";
|
|
2168
|
+
let other = "❯ [Pasted text #2 +3 lines]\n";
|
|
2169
|
+
assert_eq!(
|
|
2170
|
+
super::consumption_from_capture(other, marker, false, Some(super::PasteLatch::HashId(1))),
|
|
2171
|
+
Some(true),
|
|
2172
|
+
"our #1 gone, a later #2 in composer still means ours submitted"
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
#[test]
|
|
2177
|
+
fn pasted_signal_never_seen_no_id_cannot_claim_consumed() {
|
|
2178
|
+
let marker = "[team-agent-token:msg_fold]";
|
|
2179
|
+
let gone = "❯ \n";
|
|
2180
|
+
assert_eq!(
|
|
2181
|
+
super::consumption_from_capture(gone, marker, false, None),
|
|
2182
|
+
Some(false),
|
|
2183
|
+
"no #N latched: disappearance is unverified, not consumed"
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
#[test]
|
|
2188
|
+
fn pasted_signal_seen_token_then_gone_is_consumed() {
|
|
2189
|
+
let marker = "[team-agent-token:msg_short]";
|
|
2190
|
+
assert_eq!(
|
|
2191
|
+
super::token_sighting(false, true),
|
|
2192
|
+
super::TokenSighting::Gone
|
|
2193
|
+
);
|
|
2194
|
+
assert_eq!(
|
|
2195
|
+
super::consumption_from_capture("❯ \n", marker, true, None),
|
|
2196
|
+
Some(true)
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
#[test]
|
|
2201
|
+
fn pasted_signal_seen_token_still_present_is_not_consumed() {
|
|
2202
|
+
let marker = "[team-agent-token:msg_short]";
|
|
2203
|
+
let still = format!("❯ ping {marker}\n");
|
|
2204
|
+
assert_eq!(
|
|
2205
|
+
super::token_sighting(true, true),
|
|
2206
|
+
super::TokenSighting::Visible
|
|
2207
|
+
);
|
|
2208
|
+
assert_eq!(
|
|
2209
|
+
super::consumption_from_capture(&still, marker, true, None),
|
|
2210
|
+
Some(false)
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
#[test]
|
|
2215
|
+
fn pasted_signal_marker_some_observation_records_composer_placeholder() {
|
|
2216
|
+
use crate::transport::CapturedText;
|
|
2217
|
+
let cap = CapturedText {
|
|
2218
|
+
text: "❯ [Pasted text #4 +12 lines]\n".to_string(),
|
|
2219
|
+
range: CaptureRange::Tail(40),
|
|
2220
|
+
};
|
|
2221
|
+
let obs = super::submit_attempt_observation(1, &cap, Some("[team-agent-token:msg_x]"), 10);
|
|
2222
|
+
assert!(
|
|
2223
|
+
obs.matched,
|
|
2224
|
+
"marker Some must still walk pasted_prompt_match / composer placeholder; obs={obs:?}"
|
|
2225
|
+
);
|
|
2226
|
+
assert_eq!(obs.matched_literal.as_deref(), Some("pasted text"));
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
#[test]
|
|
2230
|
+
fn pasted_signal_inject_folded_enter_not_effective_is_unverified() {
|
|
2231
|
+
let token_text = "Team Agent message from leader:\nline1\nline2\nline3\nline4\nline5\n\n[team-agent-token:msg_fold_stay]";
|
|
2232
|
+
let placeholder = "❯ [Pasted text #4 +6 lines]\n";
|
|
2233
|
+
let (be, _rec) = backend_folded(placeholder, placeholder);
|
|
2234
|
+
let report = be
|
|
2235
|
+
.inject(
|
|
2236
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2237
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2238
|
+
Key::Enter,
|
|
2239
|
+
true,
|
|
2240
|
+
)
|
|
2241
|
+
.expect("inject");
|
|
2242
|
+
assert_eq!(
|
|
2243
|
+
report.submit_verification,
|
|
2244
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
2245
|
+
"folded + Enter not effective (#4 stays) must be unverified; got {:?}",
|
|
2246
|
+
report.submit_verification
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
#[test]
|
|
2251
|
+
fn pasted_signal_inject_folded_enter_effective_is_consumed() {
|
|
2252
|
+
let token_text = "Team Agent message from leader:\nline1\nline2\nline3\nline4\nline5\n\n[team-agent-token:msg_fold_go]";
|
|
2253
|
+
let placeholder = "❯ [Pasted text #4 +6 lines]\n";
|
|
2254
|
+
// 只有 #4 离开 composer;不加 Working,否则 busy 升级会让半三不经正信号就绿。
|
|
2255
|
+
let after = "assistant reply\n❯ \n";
|
|
2256
|
+
let (be, _rec) = backend_folded(placeholder, after);
|
|
2257
|
+
let report = be
|
|
2258
|
+
.inject(
|
|
2259
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2260
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2261
|
+
Key::Enter,
|
|
2262
|
+
true,
|
|
2263
|
+
)
|
|
2264
|
+
.expect("inject");
|
|
2265
|
+
assert_eq!(
|
|
2266
|
+
report.submit_verification,
|
|
2267
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
2268
|
+
"folded + Enter effective (#4 left composer) must be consumed; got {:?}",
|
|
2269
|
+
report.submit_verification
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
#[test]
|
|
2274
|
+
fn pasted_signal_inject_short_token_visible_consumed_matches_pre_fix() {
|
|
2275
|
+
let token_text = "ping [team-agent-token:msg_short_ok]";
|
|
2276
|
+
let (be, _rec) = backend_folded(token_text, "❯ \n");
|
|
2277
|
+
let report = be
|
|
2278
|
+
.inject(
|
|
2279
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2280
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2281
|
+
Key::Enter,
|
|
2282
|
+
true,
|
|
2283
|
+
)
|
|
2284
|
+
.expect("inject");
|
|
2285
|
+
assert_eq!(
|
|
2286
|
+
report.submit_verification,
|
|
2287
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
2288
|
+
"short visible token then gone must stay consumed; got {:?}",
|
|
2289
|
+
report.submit_verification
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
#[test]
|
|
2294
|
+
fn pasted_signal_inject_short_token_visible_unconsumed_matches_pre_fix() {
|
|
2295
|
+
let token_text = "ping [team-agent-token:msg_short_stay]";
|
|
2296
|
+
let (be, _rec) = backend_folded(token_text, token_text);
|
|
2297
|
+
let report = be
|
|
2298
|
+
.inject(
|
|
2299
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2300
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2301
|
+
Key::Enter,
|
|
2302
|
+
true,
|
|
2303
|
+
)
|
|
2304
|
+
.expect("inject");
|
|
2305
|
+
assert_eq!(
|
|
2306
|
+
report.submit_verification,
|
|
2307
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
2308
|
+
"short visible token still in composer must stay unverified; got {:?}",
|
|
2309
|
+
report.submit_verification
|
|
2310
|
+
);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
fn count_submit_enters(calls: &[Vec<String>]) -> usize {
|
|
2314
|
+
calls
|
|
2315
|
+
.iter()
|
|
2316
|
+
.filter(|argv| {
|
|
2317
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
2318
|
+
&& argv.iter().any(|a| a == "C-m" || a == "Enter")
|
|
2319
|
+
})
|
|
2320
|
+
.count()
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
fn count_paste_stage(calls: &[Vec<String>]) -> usize {
|
|
2324
|
+
calls
|
|
2325
|
+
.iter()
|
|
2326
|
+
.filter(|argv| {
|
|
2327
|
+
matches!(
|
|
2328
|
+
argv.get(1).map(String::as_str),
|
|
2329
|
+
Some("load-buffer") | Some("set-buffer") | Some("paste-buffer")
|
|
2330
|
+
)
|
|
2331
|
+
})
|
|
2332
|
+
.count()
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/// Inject runner that reports a fixed `#{pane_mode}` and a token that disappears after C-m.
|
|
2336
|
+
struct ModePasteRunner {
|
|
2337
|
+
recorded: RecordedArgv,
|
|
2338
|
+
pre_submit: String,
|
|
2339
|
+
post_submit: String,
|
|
2340
|
+
submitted: AtomicBool,
|
|
2341
|
+
pane_mode: String,
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
impl CommandRunner for ModePasteRunner {
|
|
2345
|
+
fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
|
|
2346
|
+
self.recorded.lock().unwrap().push(argv.to_vec());
|
|
2347
|
+
if argv.get(1).map(String::as_str) == Some("send-keys")
|
|
2348
|
+
&& argv.iter().any(|a| a == "C-m" || a == "Enter")
|
|
2349
|
+
{
|
|
2350
|
+
self.submitted.store(true, Ordering::SeqCst);
|
|
2351
|
+
}
|
|
2352
|
+
let stdout = match argv.get(1).map(String::as_str) {
|
|
2353
|
+
Some("capture-pane") => {
|
|
2354
|
+
if self.submitted.load(Ordering::SeqCst) {
|
|
2355
|
+
self.post_submit.clone()
|
|
2356
|
+
} else {
|
|
2357
|
+
self.pre_submit.clone()
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
Some("display-message") => format!("{}\n", self.pane_mode),
|
|
2361
|
+
_ => String::new(),
|
|
2362
|
+
};
|
|
2363
|
+
Ok(ok(&stdout))
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
fn run_with_stdin(
|
|
2367
|
+
&self,
|
|
2368
|
+
argv: &[String],
|
|
2369
|
+
_stdin: &str,
|
|
2370
|
+
) -> Result<CommandOutput, std::io::Error> {
|
|
2371
|
+
self.run(argv)
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
fn backend_mode(pane_mode: &str, token_text: &str) -> (TmuxBackend, RecordedArgv) {
|
|
2376
|
+
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
2377
|
+
let runner = ModePasteRunner {
|
|
2378
|
+
recorded: Arc::clone(&recorded),
|
|
2379
|
+
pre_submit: token_text.to_string(),
|
|
2380
|
+
post_submit: "❯ \n".to_string(),
|
|
2381
|
+
submitted: AtomicBool::new(false),
|
|
2382
|
+
pane_mode: pane_mode.to_string(),
|
|
2383
|
+
};
|
|
2384
|
+
(TmuxBackend::with_runner(Box::new(runner)), recorded)
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
fn first_submit_index(calls: &[Vec<String>]) -> Option<usize> {
|
|
2388
|
+
calls.iter().position(|argv| {
|
|
2389
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
2390
|
+
&& argv.iter().any(|a| a == "C-m" || a == "Enter")
|
|
2391
|
+
})
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
fn inject_send_keys_before_first_enter(calls: &[Vec<String>]) -> Vec<Vec<String>> {
|
|
2395
|
+
let Some(i) = first_submit_index(calls) else {
|
|
2396
|
+
return Vec::new();
|
|
2397
|
+
};
|
|
2398
|
+
calls[..i]
|
|
2399
|
+
.iter()
|
|
2400
|
+
.filter(|argv| argv.get(1).map(String::as_str) == Some("send-keys"))
|
|
2401
|
+
.cloned()
|
|
2402
|
+
.collect()
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
fn assert_no_e55_keys(calls: &[Vec<String>]) {
|
|
2406
|
+
for argv in calls {
|
|
2407
|
+
if argv.get(1).map(String::as_str) != Some("send-keys") {
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
assert!(
|
|
2411
|
+
!argv
|
|
2412
|
+
.iter()
|
|
2413
|
+
.any(|a| a == "Escape" || a == "C-c" || a == "C-C"),
|
|
2414
|
+
"E55: inject must not send Escape/C-c; argv={argv:?}"
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
fn inject_token_for(tag: &str) -> String {
|
|
2420
|
+
format!("ping [team-agent-token:msg_{tag}]")
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/// PR-18: tree-mode 必须在第一次 Enter 前 send-keys q。修坏:只对 Copy|Unknown cancel。
|
|
2424
|
+
#[test]
|
|
2425
|
+
fn inject_cancels_tree_mode_with_q_before_enter() {
|
|
2426
|
+
let token = inject_token_for("tree_q");
|
|
2427
|
+
let (be, rec) = backend_mode("tree-mode", &token);
|
|
2428
|
+
with_paste_to_submit_floor(Duration::ZERO, || {
|
|
2429
|
+
be.inject(
|
|
2430
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2431
|
+
&InjectPayload::Text(token.clone()),
|
|
2432
|
+
Key::Enter,
|
|
2433
|
+
false,
|
|
2434
|
+
)
|
|
2435
|
+
})
|
|
2436
|
+
.expect("inject");
|
|
2437
|
+
let calls = rec.lock().unwrap().clone();
|
|
2438
|
+
assert_no_e55_keys(&calls);
|
|
2439
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2440
|
+
assert!(
|
|
2441
|
+
before
|
|
2442
|
+
.iter()
|
|
2443
|
+
.any(|argv| argv.iter().any(|a| a == "q") && !argv.iter().any(|a| a == "-X")),
|
|
2444
|
+
"tree-mode must send-keys q before first Enter; before={before:?} calls={calls:?}"
|
|
2445
|
+
);
|
|
2446
|
+
assert!(
|
|
2447
|
+
!before.iter().any(|argv| argv.iter().any(|a| a == "-X")),
|
|
2448
|
+
"tree-mode must not use copy-mode -X cancel; before={before:?}"
|
|
2449
|
+
);
|
|
2450
|
+
assert_eq!(
|
|
2451
|
+
count_submit_enters(&calls),
|
|
2452
|
+
1,
|
|
2453
|
+
"consumed after cancel+Enter; calls={calls:?}"
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/// copy-mode 回归:仍走 -X cancel,不得改成 q。
|
|
2458
|
+
#[test]
|
|
2459
|
+
fn inject_cancels_copy_mode_with_x_cancel_before_enter() {
|
|
2460
|
+
let token = inject_token_for("copy_x");
|
|
2461
|
+
let (be, rec) = backend_mode("copy-mode", &token);
|
|
2462
|
+
with_paste_to_submit_floor(Duration::ZERO, || {
|
|
2463
|
+
be.inject(
|
|
2464
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2465
|
+
&InjectPayload::Text(token.clone()),
|
|
2466
|
+
Key::Enter,
|
|
2467
|
+
false,
|
|
2468
|
+
)
|
|
2469
|
+
})
|
|
2470
|
+
.expect("inject");
|
|
2471
|
+
let calls = rec.lock().unwrap().clone();
|
|
2472
|
+
assert_no_e55_keys(&calls);
|
|
2473
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2474
|
+
assert!(
|
|
2475
|
+
before
|
|
2476
|
+
.iter()
|
|
2477
|
+
.any(|argv| argv.iter().any(|a| a == "-X") && argv.iter().any(|a| a == "cancel")),
|
|
2478
|
+
"copy-mode must keep -X cancel before Enter; before={before:?} calls={calls:?}"
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/// pane_mode=0 是输入就绪,不得 cancel。
|
|
2483
|
+
#[test]
|
|
2484
|
+
fn inject_skips_cancel_when_pane_mode_is_zero() {
|
|
2485
|
+
let token = inject_token_for("mode0");
|
|
2486
|
+
let (be, rec) = backend_mode("0", &token);
|
|
2487
|
+
with_paste_to_submit_floor(Duration::ZERO, || {
|
|
2488
|
+
be.inject(
|
|
2489
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2490
|
+
&InjectPayload::Text(token.clone()),
|
|
2491
|
+
Key::Enter,
|
|
2492
|
+
false,
|
|
2493
|
+
)
|
|
2494
|
+
})
|
|
2495
|
+
.expect("inject");
|
|
2496
|
+
let calls = rec.lock().unwrap().clone();
|
|
2497
|
+
assert_no_e55_keys(&calls);
|
|
2498
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2499
|
+
assert!(
|
|
2500
|
+
!before
|
|
2501
|
+
.iter()
|
|
2502
|
+
.any(|argv| argv.iter().any(|a| a == "q" || a == "-X")),
|
|
2503
|
+
"pane_mode=0 must not send mode-cancel; before={before:?}"
|
|
2504
|
+
);
|
|
2505
|
+
// 用户裁定 2026-08-23:不发 ESC,也不发 ESC 的替代品(含字面 CSI 201~)。翻面同上。
|
|
2506
|
+
assert!(
|
|
2507
|
+
!before
|
|
2508
|
+
.iter()
|
|
2509
|
+
.any(|argv| argv.iter().any(|a| a.contains('\u{1b}'))),
|
|
2510
|
+
"mode=0 must not send any ESC/CSI byte before Enter; before={before:?}"
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
#[test]
|
|
2515
|
+
fn inject_cancels_view_mode_with_q_before_enter() {
|
|
2516
|
+
let token = inject_token_for("view_q");
|
|
2517
|
+
let (be, rec) = backend_mode("view-mode", &token);
|
|
2518
|
+
with_paste_to_submit_floor(Duration::ZERO, || {
|
|
2519
|
+
be.inject(
|
|
2520
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2521
|
+
&InjectPayload::Text(token.clone()),
|
|
2522
|
+
Key::Enter,
|
|
2523
|
+
false,
|
|
2524
|
+
)
|
|
2525
|
+
})
|
|
2526
|
+
.expect("inject");
|
|
2527
|
+
let calls = rec.lock().unwrap().clone();
|
|
2528
|
+
assert_no_e55_keys(&calls);
|
|
2529
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2530
|
+
assert!(
|
|
2531
|
+
before
|
|
2532
|
+
.iter()
|
|
2533
|
+
.any(|argv| argv.last().map(String::as_str) == Some("q")),
|
|
2534
|
+
"view-mode must send-keys q before Enter; before={before:?}"
|
|
2535
|
+
);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
#[test]
|
|
2539
|
+
fn inject_cancels_client_mode_with_d_before_enter() {
|
|
2540
|
+
let token = inject_token_for("client_d");
|
|
2541
|
+
let (be, rec) = backend_mode("client-mode", &token);
|
|
2542
|
+
with_paste_to_submit_floor(Duration::ZERO, || {
|
|
2543
|
+
be.inject(
|
|
2544
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2545
|
+
&InjectPayload::Text(token.clone()),
|
|
2546
|
+
Key::Enter,
|
|
2547
|
+
false,
|
|
2548
|
+
)
|
|
2549
|
+
})
|
|
2550
|
+
.expect("inject");
|
|
2551
|
+
let calls = rec.lock().unwrap().clone();
|
|
2552
|
+
assert_no_e55_keys(&calls);
|
|
2553
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2554
|
+
assert!(
|
|
2555
|
+
before
|
|
2556
|
+
.iter()
|
|
2557
|
+
.any(|argv| argv.last().map(String::as_str) == Some("d")),
|
|
2558
|
+
"client-mode must send-keys d before Enter; before={before:?}"
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
/// 用户裁定 2026-08-23:不发 ESC,也不发 ESC 的替代品(含字面 CSI 201~)。
|
|
2563
|
+
/// 发 ESC/CSI 的唯一好处是让消息当场直接上屏;本项目只要求「下一次工具调用时能上屏」
|
|
2564
|
+
/// ⇒ 不存在需要它才能满足的场景,发它没有收益,只有污染 composer 输入的风险。
|
|
2565
|
+
/// 本判据由「必须发 201~」翻面为「Enter 前不得发任何 ESC/CSI 字节」。
|
|
2566
|
+
#[test]
|
|
2567
|
+
fn inject_sends_no_escape_bytes_before_enter() {
|
|
2568
|
+
let token = inject_token_for("paste201");
|
|
2569
|
+
let (be, rec) = backend_mode("0", &token);
|
|
2570
|
+
with_paste_to_submit_floor(Duration::ZERO, || {
|
|
2571
|
+
be.inject(
|
|
2572
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2573
|
+
&InjectPayload::Text(token.clone()),
|
|
2574
|
+
Key::Enter,
|
|
2575
|
+
true,
|
|
2576
|
+
)
|
|
2577
|
+
})
|
|
2578
|
+
.expect("inject");
|
|
2579
|
+
let calls = rec.lock().unwrap().clone();
|
|
2580
|
+
assert_no_e55_keys(&calls);
|
|
2581
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2582
|
+
assert!(
|
|
2583
|
+
!before
|
|
2584
|
+
.iter()
|
|
2585
|
+
.any(|argv| argv.iter().any(|a| a.contains('\u{1b}'))),
|
|
2586
|
+
"no ESC/CSI byte may be sent before Enter; before={before:?}"
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
/// A1 Empty / trust 路径也必须经过 prepare。
|
|
2591
|
+
#[test]
|
|
2592
|
+
fn empty_inject_cancels_tree_mode_before_cm() {
|
|
2593
|
+
let (be, rec) = backend_mode("tree-mode", "");
|
|
2594
|
+
be.inject(
|
|
2595
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2596
|
+
&InjectPayload::Empty,
|
|
2597
|
+
Key::Enter,
|
|
2598
|
+
false,
|
|
2599
|
+
)
|
|
2600
|
+
.expect("empty inject");
|
|
2601
|
+
let calls = rec.lock().unwrap().clone();
|
|
2602
|
+
assert_no_e55_keys(&calls);
|
|
2603
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2604
|
+
assert!(
|
|
2605
|
+
before
|
|
2606
|
+
.iter()
|
|
2607
|
+
.any(|argv| argv.last().map(String::as_str) == Some("q")),
|
|
2608
|
+
"A1 Empty must cancel tree-mode before C-m; before={before:?} calls={calls:?}"
|
|
2609
|
+
);
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/// A3 skip-consumption(leader 投递)也必须经过 prepare。
|
|
2613
|
+
#[test]
|
|
2614
|
+
fn skip_consumption_cancels_tree_mode_before_cm() {
|
|
2615
|
+
let token = inject_token_for("skip_tree");
|
|
2616
|
+
let (be, rec) = backend_mode("tree-mode", &token);
|
|
2617
|
+
with_paste_to_submit_floor(Duration::ZERO, || {
|
|
2618
|
+
be.inject(
|
|
2619
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2620
|
+
&InjectPayload::TextSkipConsumptionPoll(token.clone()),
|
|
2621
|
+
Key::Enter,
|
|
2622
|
+
false,
|
|
2623
|
+
)
|
|
2624
|
+
})
|
|
2625
|
+
.expect("skip inject");
|
|
2626
|
+
let calls = rec.lock().unwrap().clone();
|
|
2627
|
+
assert_no_e55_keys(&calls);
|
|
2628
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2629
|
+
assert!(
|
|
2630
|
+
before
|
|
2631
|
+
.iter()
|
|
2632
|
+
.any(|argv| argv.last().map(String::as_str) == Some("q")),
|
|
2633
|
+
"A3 skip-poll must cancel tree-mode before C-m; before={before:?}"
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/// A6 send_keys([Enter]) 与 C 块 8 个调用方:含 Enter 的键组先 prepare。
|
|
2638
|
+
#[test]
|
|
2639
|
+
fn send_keys_enter_cancels_tree_mode_before_enter() {
|
|
2640
|
+
let (be, rec) = backend_mode("tree-mode", "");
|
|
2641
|
+
be.send_keys(&Target::Pane(PaneId::new("%7")), &[Key::Enter])
|
|
2642
|
+
.expect("send_keys");
|
|
2643
|
+
let calls = rec.lock().unwrap().clone();
|
|
2644
|
+
assert_no_e55_keys(&calls);
|
|
2645
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2646
|
+
assert!(
|
|
2647
|
+
before
|
|
2648
|
+
.iter()
|
|
2649
|
+
.any(|argv| argv.last().map(String::as_str) == Some("q")),
|
|
2650
|
+
"send_keys(Enter) must cancel tree-mode first; before={before:?} calls={calls:?}"
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
#[test]
|
|
2655
|
+
fn send_keys_down_enter_cancels_tree_mode_before_group() {
|
|
2656
|
+
let (be, rec) = backend_mode("tree-mode", "");
|
|
2657
|
+
be.send_keys(&Target::Pane(PaneId::new("%7")), &[Key::Down, Key::Enter])
|
|
2658
|
+
.expect("send_keys");
|
|
2659
|
+
let calls = rec.lock().unwrap().clone();
|
|
2660
|
+
assert_no_e55_keys(&calls);
|
|
2661
|
+
let before = inject_send_keys_before_first_enter(&calls);
|
|
2662
|
+
assert!(
|
|
2663
|
+
before
|
|
2664
|
+
.iter()
|
|
2665
|
+
.any(|argv| argv.last().map(String::as_str) == Some("q")),
|
|
2666
|
+
"Down+Enter must cancel tree-mode before the key group; before={before:?}"
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
/// 外部队 2026-08-19 屏面原文,一字不改。
|
|
2671
|
+
const GROK_INCIDENT_LINE: &str = "│ ❯ [Pasted: 42 lines] │";
|
|
2672
|
+
|
|
2673
|
+
fn grok_raw_unfolded_paste(marker: &str) -> String {
|
|
2674
|
+
let mut lines = Vec::new();
|
|
2675
|
+
for i in 0..20 {
|
|
2676
|
+
lines.push(format!("payload line {i}"));
|
|
2677
|
+
}
|
|
2678
|
+
lines.push(format!("token {marker}"));
|
|
2679
|
+
lines.join("\n")
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
struct RawThenFoldRunner {
|
|
2683
|
+
recorded: RecordedArgv,
|
|
2684
|
+
raw: String,
|
|
2685
|
+
folded: String,
|
|
2686
|
+
after_submit: String,
|
|
2687
|
+
captures: Mutex<u32>,
|
|
2688
|
+
fold_after: u32,
|
|
2689
|
+
submitted: AtomicBool,
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
impl CommandRunner for RawThenFoldRunner {
|
|
2693
|
+
fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
|
|
2694
|
+
self.recorded.lock().unwrap().push(argv.to_vec());
|
|
2695
|
+
if argv.get(1).map(String::as_str) == Some("send-keys")
|
|
2696
|
+
&& argv.iter().any(|a| a == "C-m" || a == "Enter")
|
|
2697
|
+
{
|
|
2698
|
+
self.submitted.store(true, Ordering::SeqCst);
|
|
2699
|
+
}
|
|
2700
|
+
let stdout = match argv.get(1).map(String::as_str) {
|
|
2701
|
+
Some("capture-pane") => {
|
|
2702
|
+
if self.submitted.load(Ordering::SeqCst) {
|
|
2703
|
+
self.after_submit.clone()
|
|
2704
|
+
} else {
|
|
2705
|
+
let mut n = self.captures.lock().unwrap();
|
|
2706
|
+
*n = n.saturating_add(1);
|
|
2707
|
+
if *n > self.fold_after {
|
|
2708
|
+
self.folded.clone()
|
|
2709
|
+
} else {
|
|
2710
|
+
self.raw.clone()
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
Some("display-message") => "0\n".to_string(),
|
|
2715
|
+
_ => String::new(),
|
|
2716
|
+
};
|
|
2717
|
+
Ok(ok(&stdout))
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
fn run_with_stdin(
|
|
2721
|
+
&self,
|
|
2722
|
+
argv: &[String],
|
|
2723
|
+
_stdin: &str,
|
|
2724
|
+
) -> Result<CommandOutput, std::io::Error> {
|
|
2725
|
+
self.run(argv)
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
fn backend_raw_then_fold(
|
|
2730
|
+
raw: &str,
|
|
2731
|
+
folded: &str,
|
|
2732
|
+
after_submit: &str,
|
|
2733
|
+
fold_after: u32,
|
|
2734
|
+
) -> (TmuxBackend, RecordedArgv) {
|
|
2735
|
+
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
2736
|
+
let runner = RawThenFoldRunner {
|
|
2737
|
+
recorded: Arc::clone(&recorded),
|
|
2738
|
+
raw: raw.to_string(),
|
|
2739
|
+
folded: folded.to_string(),
|
|
2740
|
+
after_submit: after_submit.to_string(),
|
|
2741
|
+
captures: Mutex::new(0),
|
|
2742
|
+
fold_after,
|
|
2743
|
+
submitted: AtomicBool::new(false),
|
|
2744
|
+
};
|
|
2745
|
+
(TmuxBackend::with_runner(Box::new(runner)), recorded)
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
#[test]
|
|
2749
|
+
fn grok_fold_literal_locks_external_team_line() {
|
|
2750
|
+
let prompt =
|
|
2751
|
+
super::pasted_prompt_in_composer(GROK_INCIDENT_LINE, 15).expect("lock grok incident line");
|
|
2752
|
+
assert_eq!(prompt.literal, "pasted:");
|
|
2753
|
+
assert_eq!(prompt.id, None, "grok fold has no #N");
|
|
2754
|
+
assert_eq!(prompt.line_count, Some(42));
|
|
2755
|
+
let matched = super::pasted_prompt_match(GROK_INCIDENT_LINE).expect("match");
|
|
2756
|
+
assert_eq!(matched.0, "pasted:");
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
#[test]
|
|
2760
|
+
fn grok_fold_three_tui_literals_lock() {
|
|
2761
|
+
let grok = super::pasted_prompt_in_composer(GROK_INCIDENT_LINE, 15).unwrap();
|
|
2762
|
+
let claude = super::pasted_prompt_in_composer("❯ [Pasted text #4 +12 lines]\n", 15).unwrap();
|
|
2763
|
+
let codex = super::pasted_prompt_in_composer("❯ [Pasted content #2 +3 lines]\n", 15).unwrap();
|
|
2764
|
+
assert_eq!(grok.literal, "pasted:");
|
|
2765
|
+
assert_eq!(claude.literal, "pasted text");
|
|
2766
|
+
assert_eq!(claude.id, Some(4));
|
|
2767
|
+
assert_eq!(codex.literal, "pasted content");
|
|
2768
|
+
assert_eq!(codex.id, Some(2));
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
#[test]
|
|
2772
|
+
fn grok_fold_same_line_count_still_unconsumed() {
|
|
2773
|
+
let marker = "[team-agent-token:msg_grok]";
|
|
2774
|
+
assert_eq!(
|
|
2775
|
+
super::consumption_from_capture(
|
|
2776
|
+
GROK_INCIDENT_LINE,
|
|
2777
|
+
marker,
|
|
2778
|
+
false,
|
|
2779
|
+
Some(super::PasteLatch::GrokLineCount(42))
|
|
2780
|
+
),
|
|
2781
|
+
Some(false)
|
|
2782
|
+
);
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
#[test]
|
|
2786
|
+
fn grok_fold_line_count_gone_is_consumed() {
|
|
2787
|
+
let marker = "[team-agent-token:msg_grok]";
|
|
2788
|
+
assert_eq!(
|
|
2789
|
+
super::consumption_from_capture(
|
|
2790
|
+
"Waiting for response\n❯ \n",
|
|
2791
|
+
marker,
|
|
2792
|
+
false,
|
|
2793
|
+
Some(super::PasteLatch::GrokLineCount(42))
|
|
2794
|
+
),
|
|
2795
|
+
Some(true)
|
|
2796
|
+
);
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
#[test]
|
|
2800
|
+
fn grok_fold_different_n_is_not_same_paste() {
|
|
2801
|
+
let marker = "[team-agent-token:msg_grok]";
|
|
2802
|
+
let other = "│ ❯ [Pasted: 10 lines] │";
|
|
2803
|
+
assert_eq!(
|
|
2804
|
+
super::consumption_from_capture(
|
|
2805
|
+
other,
|
|
2806
|
+
marker,
|
|
2807
|
+
false,
|
|
2808
|
+
Some(super::PasteLatch::GrokLineCount(42))
|
|
2809
|
+
),
|
|
2810
|
+
Some(false),
|
|
2811
|
+
"different N must not claim the latched paste consumed"
|
|
2812
|
+
);
|
|
2813
|
+
assert!(
|
|
2814
|
+
!super::should_resubmit_enter(other, marker, Some(super::PasteLatch::GrokLineCount(42))),
|
|
2815
|
+
"different N is not the same paste; do not hammer Enter"
|
|
2816
|
+
);
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
#[test]
|
|
2820
|
+
fn grok_fold_no_latch_cannot_claim_consumed() {
|
|
2821
|
+
let marker = "[team-agent-token:msg_grok]";
|
|
2822
|
+
assert_eq!(
|
|
2823
|
+
super::consumption_from_capture("❯ \n", marker, false, None),
|
|
2824
|
+
Some(false)
|
|
2825
|
+
);
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
#[test]
|
|
2829
|
+
fn grok_fold_inject_stays_retries_while_identity_present() {
|
|
2830
|
+
let token_text = "Team Agent message from leader:\nline1\n\n[team-agent-token:msg_grok_stay]";
|
|
2831
|
+
let (be, rec) = backend_folded(GROK_INCIDENT_LINE, GROK_INCIDENT_LINE);
|
|
2832
|
+
let report = be
|
|
2833
|
+
.inject(
|
|
2834
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2835
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2836
|
+
Key::Enter,
|
|
2837
|
+
true,
|
|
2838
|
+
)
|
|
2839
|
+
.expect("inject");
|
|
2840
|
+
assert_eq!(
|
|
2841
|
+
report.submit_verification,
|
|
2842
|
+
SubmitVerification::SubmitConsumptionUnverified
|
|
2843
|
+
);
|
|
2844
|
+
let calls = rec.lock().unwrap().clone();
|
|
2845
|
+
assert_eq!(
|
|
2846
|
+
count_submit_enters(&calls),
|
|
2847
|
+
3,
|
|
2848
|
+
"same grok N still in composer must retry Enter up to cap=3; Unverified B must not add a 4th; invert wrap-gap guard must turn this red; calls={calls:?}"
|
|
2849
|
+
);
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
/// 2026-08-21 真机屏尾:折叠占位符还在 transcript,Working 已开。
|
|
2853
|
+
/// 非 cursor 路径会把 pasted #N 当成未消费而连按 Enter。
|
|
2854
|
+
const CURSOR_BUSY_AFTER_SUBMIT: &str = "\
|
|
2855
|
+
[Pasted text #1 +46 lines]\n\
|
|
2856
|
+
\n\
|
|
2857
|
+
T126S-34701-OK\n\
|
|
2858
|
+
\n\
|
|
2859
|
+
⠰⠰ Working 37 tokens\n\
|
|
2860
|
+
Tip: Use /plan to plan execution and reach the right outcome faster.\n\
|
|
2861
|
+
\n\
|
|
2862
|
+
→ Add a follow-up ctrl+c to stop\n\
|
|
2863
|
+
\n\
|
|
2864
|
+
Cursor Grok 4.6 Extra High Run Everything\n";
|
|
2865
|
+
|
|
2866
|
+
#[test]
|
|
2867
|
+
fn cursor_single_enter_busy_transcript_placeholder_does_not_retry() {
|
|
2868
|
+
let token_text = "Team Agent message from leader:\nline1\n\n[team-agent-token:msg_cur_busy]";
|
|
2869
|
+
let (be, rec) = backend_folded("[Pasted text #1 +46 lines]\n", CURSOR_BUSY_AFTER_SUBMIT);
|
|
2870
|
+
let report = with_cursor_single_enter(true, || {
|
|
2871
|
+
be.inject(
|
|
2872
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2873
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2874
|
+
Key::Enter,
|
|
2875
|
+
true,
|
|
2876
|
+
)
|
|
2877
|
+
})
|
|
2878
|
+
.expect("inject");
|
|
2879
|
+
assert_eq!(
|
|
2880
|
+
report.submit_verification,
|
|
2881
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
2882
|
+
"busy after first Enter is consumption for cursor; got {:?}",
|
|
2883
|
+
report.submit_verification
|
|
2884
|
+
);
|
|
2885
|
+
let calls = rec.lock().unwrap().clone();
|
|
2886
|
+
assert_eq!(
|
|
2887
|
+
count_submit_enters(&calls),
|
|
2888
|
+
1,
|
|
2889
|
+
"cursor busy: at most one Enter; extra Enter interrupts. calls={calls:?}"
|
|
2890
|
+
);
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
#[test]
|
|
2894
|
+
fn cursor_single_enter_off_still_retries_on_transcript_placeholder() {
|
|
2895
|
+
let token_text = "Team Agent message from leader:\nline1\n\n[team-agent-token:msg_cur_ctrl]";
|
|
2896
|
+
let (be, rec) = backend_folded("[Pasted text #1 +46 lines]\n", CURSOR_BUSY_AFTER_SUBMIT);
|
|
2897
|
+
let _report = be
|
|
2898
|
+
.inject(
|
|
2899
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2900
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2901
|
+
Key::Enter,
|
|
2902
|
+
true,
|
|
2903
|
+
)
|
|
2904
|
+
.expect("inject");
|
|
2905
|
+
let calls = rec.lock().unwrap().clone();
|
|
2906
|
+
assert_eq!(
|
|
2907
|
+
count_submit_enters(&calls),
|
|
2908
|
+
3,
|
|
2909
|
+
"default path (claude/grok/codex) must keep retry cap; calls={calls:?}"
|
|
2910
|
+
);
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
#[test]
|
|
2914
|
+
fn cursor_single_enter_retries_only_if_token_still_in_input() {
|
|
2915
|
+
let marker = "[team-agent-token:msg_cur_idle]";
|
|
2916
|
+
let token_text = format!("Team Agent message from leader:\nline1\n\n{marker}");
|
|
2917
|
+
let still_in_input = format!("{marker}\n→ \n");
|
|
2918
|
+
let (be, rec) = backend_folded(still_in_input.as_str(), still_in_input.as_str());
|
|
2919
|
+
let _report = with_cursor_single_enter(true, || {
|
|
2920
|
+
be.inject(
|
|
2921
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2922
|
+
&InjectPayload::Text(token_text),
|
|
2923
|
+
Key::Enter,
|
|
2924
|
+
true,
|
|
2925
|
+
)
|
|
2926
|
+
})
|
|
2927
|
+
.expect("inject");
|
|
2928
|
+
let calls = rec.lock().unwrap().clone();
|
|
2929
|
+
let n = count_submit_enters(&calls);
|
|
2930
|
+
assert!(
|
|
2931
|
+
n >= 2,
|
|
2932
|
+
"token still in cursor input box (no busy) may retry Enter; got {n} calls={calls:?}"
|
|
2933
|
+
);
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
#[test]
|
|
2937
|
+
fn cursor_should_resubmit_false_when_busy_even_if_paste_placeholder_visible() {
|
|
2938
|
+
let marker = "[team-agent-token:msg_cur_gate]";
|
|
2939
|
+
assert!(
|
|
2940
|
+
!super::should_resubmit_enter_cursor(CURSOR_BUSY_AFTER_SUBMIT, marker),
|
|
2941
|
+
"busy + transcript placeholder must not resubmit on cursor"
|
|
2942
|
+
);
|
|
2943
|
+
assert!(
|
|
2944
|
+
super::should_resubmit_enter(
|
|
2945
|
+
CURSOR_BUSY_AFTER_SUBMIT,
|
|
2946
|
+
marker,
|
|
2947
|
+
Some(super::PasteLatch::HashId(1))
|
|
2948
|
+
),
|
|
2949
|
+
"non-cursor detector still sees latched #1 as resubmit"
|
|
2950
|
+
);
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
#[test]
|
|
2954
|
+
fn grok_fold_inject_leaves_consumed_one_enter() {
|
|
2955
|
+
let token_text = "Team Agent message from leader:\nline1\n\n[team-agent-token:msg_grok_go]";
|
|
2956
|
+
let (be, rec) = backend_folded(GROK_INCIDENT_LINE, "Waiting for response\n❯ \n");
|
|
2957
|
+
let report = be
|
|
2958
|
+
.inject(
|
|
2959
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2960
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2961
|
+
Key::Enter,
|
|
2962
|
+
true,
|
|
2963
|
+
)
|
|
2964
|
+
.expect("inject");
|
|
2965
|
+
assert_eq!(
|
|
2966
|
+
report.submit_verification,
|
|
2967
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
2968
|
+
);
|
|
2969
|
+
let calls = rec.lock().unwrap().clone();
|
|
2970
|
+
assert_eq!(
|
|
2971
|
+
count_submit_enters(&calls),
|
|
2972
|
+
1,
|
|
2973
|
+
"fold left composer after one Enter; no empty-composer hammer; calls={calls:?}"
|
|
2974
|
+
);
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
#[test]
|
|
2978
|
+
fn grok_fold_unverified_without_identity_one_enter() {
|
|
2979
|
+
let token_text = "Team Agent message from leader:\nhi\n\n[team-agent-token:msg_grok_empty]";
|
|
2980
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
2981
|
+
let report = be
|
|
2982
|
+
.inject(
|
|
2983
|
+
&Target::Pane(PaneId::new("%7")),
|
|
2984
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
2985
|
+
Key::Enter,
|
|
2986
|
+
true,
|
|
2987
|
+
)
|
|
2988
|
+
.expect("inject");
|
|
2989
|
+
assert_eq!(
|
|
2990
|
+
report.submit_verification,
|
|
2991
|
+
SubmitVerification::SubmitConsumptionUnverified
|
|
2992
|
+
);
|
|
2993
|
+
let calls = rec.lock().unwrap().clone();
|
|
2994
|
+
assert_eq!(
|
|
2995
|
+
count_submit_enters(&calls),
|
|
2996
|
+
1,
|
|
2997
|
+
"no latch / empty composer: one Enter then stop; calls={calls:?}"
|
|
2998
|
+
);
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
#[test]
|
|
3002
|
+
fn grok_fold_does_not_enter_before_fold_on_raw_tall_paste() {
|
|
3003
|
+
let marker = "[team-agent-token:msg_grok_early]";
|
|
3004
|
+
let token_text = format!(
|
|
3005
|
+
"Team Agent message from leader:\n{}\n\n{marker}",
|
|
3006
|
+
"x\n".repeat(20)
|
|
3007
|
+
);
|
|
3008
|
+
let raw = grok_raw_unfolded_paste(marker);
|
|
3009
|
+
assert!(
|
|
3010
|
+
!super::paste_ready_for_enter(&raw, marker),
|
|
3011
|
+
"raw tall paste with token is not ready"
|
|
3012
|
+
);
|
|
3013
|
+
assert!(super::paste_ready_for_enter(GROK_INCIDENT_LINE, marker));
|
|
3014
|
+
let (be, rec) =
|
|
3015
|
+
backend_raw_then_fold(&raw, GROK_INCIDENT_LINE, "Waiting for response\n❯ \n", 2);
|
|
3016
|
+
let report = be
|
|
3017
|
+
.inject(
|
|
3018
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3019
|
+
&InjectPayload::Text(token_text),
|
|
3020
|
+
Key::Enter,
|
|
3021
|
+
true,
|
|
3022
|
+
)
|
|
3023
|
+
.expect("inject");
|
|
3024
|
+
assert_eq!(
|
|
3025
|
+
report.submit_verification,
|
|
3026
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
3027
|
+
);
|
|
3028
|
+
let calls = rec.lock().unwrap().clone();
|
|
3029
|
+
let submit_index = calls
|
|
3030
|
+
.iter()
|
|
3031
|
+
.position(|argv| {
|
|
3032
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
3033
|
+
&& argv.iter().any(|a| a == "C-m" || a == "Enter")
|
|
3034
|
+
})
|
|
3035
|
+
.expect("must submit");
|
|
3036
|
+
let captures_before: Vec<&Vec<String>> = calls[..submit_index]
|
|
3037
|
+
.iter()
|
|
3038
|
+
.filter(|argv| argv.get(1).map(String::as_str) == Some("capture-pane"))
|
|
3039
|
+
.collect();
|
|
3040
|
+
assert!(
|
|
3041
|
+
captures_before.len() > 2,
|
|
3042
|
+
"must keep polling until fold, not Enter on first token; calls={calls:?}"
|
|
3043
|
+
);
|
|
3044
|
+
assert_eq!(count_submit_enters(&calls), 1);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
/// 悬案B 主嫌:token 见过又消失(Gone)时 composer 仍有 grok 折叠占位符。
|
|
3048
|
+
/// 修前 Gone 无条件 Some(true)。本测试必须先红。
|
|
3049
|
+
#[test]
|
|
3050
|
+
fn gone_with_grok_placeholder_still_in_composer_must_not_be_consumed() {
|
|
3051
|
+
let cap = "some transcript line\n> [Pasted: 27 lines]\nEnter:send Esc:cancel\n";
|
|
3052
|
+
let marker = "[team-agent-token:deadbeef]";
|
|
3053
|
+
assert_eq!(
|
|
3054
|
+
super::token_sighting(false, true),
|
|
3055
|
+
super::TokenSighting::Gone
|
|
3056
|
+
);
|
|
3057
|
+
let got = super::consumption_from_capture(
|
|
3058
|
+
cap,
|
|
3059
|
+
marker,
|
|
3060
|
+
true,
|
|
3061
|
+
Some(super::PasteLatch::GrokLineCount(27)),
|
|
3062
|
+
);
|
|
3063
|
+
assert_eq!(
|
|
3064
|
+
got,
|
|
3065
|
+
Some(false),
|
|
3066
|
+
"Gone must re-check PasteLatch: placeholder still in composer must not be consumed, got {got:?}"
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
#[test]
|
|
3071
|
+
fn gone_with_grok_kb_placeholder_still_in_composer_must_not_be_consumed() {
|
|
3072
|
+
let cap = "│ ❯ [Pasted: 13 KB] │\n Enter:send\n";
|
|
3073
|
+
let marker = "[team-agent-token:kbform]";
|
|
3074
|
+
let prompt = super::pasted_prompt_in_composer(cap, 15).expect("KB form is grok fold");
|
|
3075
|
+
assert_eq!(prompt.literal, "pasted:");
|
|
3076
|
+
assert_eq!(prompt.line_count, Some(13));
|
|
3077
|
+
let got = super::consumption_from_capture(
|
|
3078
|
+
cap,
|
|
3079
|
+
marker,
|
|
3080
|
+
true,
|
|
3081
|
+
Some(super::PasteLatch::GrokLineCount(13)),
|
|
3082
|
+
);
|
|
3083
|
+
assert_eq!(
|
|
3084
|
+
got,
|
|
3085
|
+
Some(false),
|
|
3086
|
+
"Gone + [Pasted: 13 KB] still in composer must not be consumed, got {got:?}"
|
|
3087
|
+
);
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
#[test]
|
|
3091
|
+
fn gone_empty_composer_without_latch_stays_consumed_for_claude() {
|
|
3092
|
+
let marker = "[team-agent-token:msg_short]";
|
|
3093
|
+
assert_eq!(
|
|
3094
|
+
super::consumption_from_capture("❯ \n", marker, true, None),
|
|
3095
|
+
Some(true),
|
|
3096
|
+
"claude short token gone + empty composer + no latch must stay consumed"
|
|
3097
|
+
);
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
/// 样本#5 归因钉:重试只重按 Enter,不得再 load/set/paste-buffer。
|
|
3101
|
+
#[test]
|
|
3102
|
+
fn grok_fold_raw_then_fold_placeholder_stays_retries_enter_not_repaste() {
|
|
3103
|
+
let marker = "[team-agent-token:msg_grok_reappear]";
|
|
3104
|
+
let token_text = format!(
|
|
3105
|
+
"Team Agent message from leader:\n{}\n\n{marker}",
|
|
3106
|
+
"x\n".repeat(20)
|
|
3107
|
+
);
|
|
3108
|
+
let raw = grok_raw_unfolded_paste(&marker);
|
|
3109
|
+
let (be, rec) = backend_raw_then_fold(&raw, GROK_INCIDENT_LINE, GROK_INCIDENT_LINE, 2);
|
|
3110
|
+
let report = be
|
|
3111
|
+
.inject(
|
|
3112
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3113
|
+
&InjectPayload::Text(token_text),
|
|
3114
|
+
Key::Enter,
|
|
3115
|
+
true,
|
|
3116
|
+
)
|
|
3117
|
+
.expect("inject");
|
|
3118
|
+
assert_eq!(
|
|
3119
|
+
report.submit_verification,
|
|
3120
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
3121
|
+
"raw visible then folded, placeholder stays: must not false-green delivered; got {:?}",
|
|
3122
|
+
report.submit_verification
|
|
3123
|
+
);
|
|
3124
|
+
let calls = rec.lock().unwrap().clone();
|
|
3125
|
+
let enters = count_submit_enters(&calls);
|
|
3126
|
+
assert!(
|
|
3127
|
+
enters >= 2,
|
|
3128
|
+
"placeholder still in composer must retry Enter; got {enters} calls={calls:?}"
|
|
3129
|
+
);
|
|
3130
|
+
let paste_n = count_paste_stage(&calls);
|
|
3131
|
+
let first_enter = calls.iter().position(|argv| {
|
|
3132
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
3133
|
+
&& argv.iter().any(|a| a == "C-m" || a == "Enter")
|
|
3134
|
+
});
|
|
3135
|
+
let paste_after_enter = first_enter.map(|i| {
|
|
3136
|
+
calls[i + 1..]
|
|
3137
|
+
.iter()
|
|
3138
|
+
.filter(|argv| {
|
|
3139
|
+
matches!(
|
|
3140
|
+
argv.get(1).map(String::as_str),
|
|
3141
|
+
Some("load-buffer") | Some("set-buffer") | Some("paste-buffer")
|
|
3142
|
+
)
|
|
3143
|
+
})
|
|
3144
|
+
.count()
|
|
3145
|
+
});
|
|
3146
|
+
assert_eq!(
|
|
3147
|
+
paste_after_enter,
|
|
3148
|
+
Some(0),
|
|
3149
|
+
"retry must not re-paste (inject contract); paste_n={paste_n} calls={calls:?}"
|
|
3150
|
+
);
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
#[test]
|
|
3154
|
+
fn grok_fold_worker_text_payload_polls_consumption() {
|
|
3155
|
+
assert!(
|
|
3156
|
+
!InjectPayload::Text("worker [team-agent-token:x]".to_string()).skip_consumption_poll(),
|
|
3157
|
+
"worker Text path poll_consumption=true; submit_attempt_limit is 3 not 1"
|
|
3158
|
+
);
|
|
3159
|
+
assert!(
|
|
3160
|
+
InjectPayload::TextSkipConsumptionPoll("leader [team-agent-token:x]".to_string())
|
|
3161
|
+
.skip_consumption_poll(),
|
|
3162
|
+
"leader skip path is the only poll_consumption=false arm"
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
const G4_TOKEN: &str = "[team-agent-token:msg_g4]";
|
|
3167
|
+
|
|
3168
|
+
#[test]
|
|
3169
|
+
fn g4_turn_grok_unsubmitted_paste_is_missing() {
|
|
3170
|
+
let text = "│ ❯ [Pasted: 42 lines] │";
|
|
3171
|
+
assert_eq!(
|
|
3172
|
+
observe_turn_from_capture(text, Some(G4_TOKEN)),
|
|
3173
|
+
TurnVerification::LeaderNewTurnBoundaryMissing
|
|
3174
|
+
);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
#[test]
|
|
3178
|
+
fn g4_turn_claude_unsubmitted_paste_is_missing() {
|
|
3179
|
+
let text = "❯ pasted text #4 +12 lines";
|
|
3180
|
+
assert_eq!(
|
|
3181
|
+
observe_turn_from_capture(text, Some(G4_TOKEN)),
|
|
3182
|
+
TurnVerification::LeaderNewTurnBoundaryMissing
|
|
3183
|
+
);
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
#[test]
|
|
3187
|
+
fn g4_turn_cursor_unsubmitted_token_is_missing() {
|
|
3188
|
+
let text = format!("❯ {G4_TOKEN}");
|
|
3189
|
+
assert_eq!(
|
|
3190
|
+
observe_turn_from_capture(&text, Some(G4_TOKEN)),
|
|
3191
|
+
TurnVerification::LeaderNewTurnBoundaryMissing
|
|
3192
|
+
);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
#[test]
|
|
3196
|
+
fn g4_turn_claude_spinner_is_verified() {
|
|
3197
|
+
let text = "✶ Thinking…\nesc to interrupt";
|
|
3198
|
+
assert_eq!(
|
|
3199
|
+
observe_turn_from_capture(text, Some(G4_TOKEN)),
|
|
3200
|
+
TurnVerification::LeaderNewTurnBoundaryVerified
|
|
3201
|
+
);
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
#[test]
|
|
3205
|
+
fn g4_turn_cursor_processing_is_verified() {
|
|
3206
|
+
let text = "processing\n❯ ";
|
|
3207
|
+
assert_eq!(
|
|
3208
|
+
observe_turn_from_capture(text, Some(G4_TOKEN)),
|
|
3209
|
+
TurnVerification::LeaderNewTurnBoundaryVerified
|
|
3210
|
+
);
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
#[test]
|
|
3214
|
+
fn g4_turn_grok_working_is_verified() {
|
|
3215
|
+
let text = "working\n❯ ";
|
|
3216
|
+
assert_eq!(
|
|
3217
|
+
observe_turn_from_capture(text, Some(G4_TOKEN)),
|
|
3218
|
+
TurnVerification::LeaderNewTurnBoundaryVerified
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
#[test]
|
|
3223
|
+
fn g4_turn_empty_composer_without_busy_is_unknown() {
|
|
3224
|
+
assert_eq!(
|
|
3225
|
+
observe_turn_from_capture("❯ ", Some(G4_TOKEN)),
|
|
3226
|
+
TurnVerification::NotYetObserved,
|
|
3227
|
+
"empty composer and no busy is 不知道, never 开跑"
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
#[test]
|
|
3232
|
+
fn g4_turn_inject_grok_incident_line_does_not_report_started() {
|
|
3233
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_g4_inject]";
|
|
3234
|
+
let (be, _rec) = backend_with(MockResp::Out(ok(GROK_INCIDENT_LINE)), vec![]);
|
|
3235
|
+
let report = be
|
|
3236
|
+
.inject(
|
|
3237
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3238
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
3239
|
+
Key::Enter,
|
|
3240
|
+
true,
|
|
3241
|
+
)
|
|
3242
|
+
.expect("inject runs");
|
|
3243
|
+
assert_ne!(
|
|
3244
|
+
report.turn_verification,
|
|
3245
|
+
TurnVerification::LeaderNewTurnBoundaryVerified,
|
|
3246
|
+
"unsubmitted grok paste must not report 开跑"
|
|
3247
|
+
);
|
|
3248
|
+
assert_eq!(
|
|
3249
|
+
report.turn_verification,
|
|
3250
|
+
TurnVerification::LeaderNewTurnBoundaryMissing
|
|
3251
|
+
);
|
|
3252
|
+
assert_eq!(
|
|
3253
|
+
report.submit_verification,
|
|
3254
|
+
SubmitVerification::SubmitConsumptionUnverified
|
|
3255
|
+
);
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
#[test]
|
|
3259
|
+
fn g4_turn_inject_consumed_without_busy_is_unknown() {
|
|
3260
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_g4_gone]";
|
|
3261
|
+
let visible = ok(token_text);
|
|
3262
|
+
let queued = vec![
|
|
3263
|
+
MockResp::Out(ok("")),
|
|
3264
|
+
MockResp::Out(ok("")),
|
|
3265
|
+
MockResp::Out(ok("")),
|
|
3266
|
+
MockResp::Out(visible.clone()),
|
|
3267
|
+
MockResp::Out(visible),
|
|
3268
|
+
MockResp::Out(ok("")),
|
|
3269
|
+
MockResp::Out(ok("")),
|
|
3270
|
+
];
|
|
3271
|
+
let (be, _rec) = backend_with(MockResp::Out(ok("❯ ")), queued);
|
|
3272
|
+
let report = be
|
|
3273
|
+
.inject(
|
|
3274
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3275
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
3276
|
+
Key::Enter,
|
|
3277
|
+
true,
|
|
3278
|
+
)
|
|
3279
|
+
.expect("inject runs");
|
|
3280
|
+
assert_eq!(
|
|
3281
|
+
report.submit_verification,
|
|
3282
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
3283
|
+
"token gone is still consumed/delivered; that is not 开跑"
|
|
3284
|
+
);
|
|
3285
|
+
assert_eq!(
|
|
3286
|
+
report.turn_verification,
|
|
3287
|
+
TurnVerification::NotYetObserved,
|
|
3288
|
+
"composer empty without busy is 不知道, not 开跑"
|
|
3289
|
+
);
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
/// capture-pane 一律失败;其它 tmux 命令成功。用于「读数拿不到」而不是「读数不一样」。
|
|
3293
|
+
struct CapturePaneFailRunner {
|
|
3294
|
+
recorded: RecordedArgv,
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
impl CommandRunner for CapturePaneFailRunner {
|
|
3298
|
+
fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
|
|
3299
|
+
self.recorded.lock().unwrap().push(argv.to_vec());
|
|
3300
|
+
if argv.get(1).map(String::as_str) == Some("capture-pane") {
|
|
3301
|
+
return Err(std::io::Error::new(
|
|
3302
|
+
std::io::ErrorKind::Other,
|
|
3303
|
+
"mock capture missing",
|
|
3304
|
+
));
|
|
3305
|
+
}
|
|
3306
|
+
Ok(ok(""))
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
fn run_with_stdin(
|
|
3310
|
+
&self,
|
|
3311
|
+
argv: &[String],
|
|
3312
|
+
_stdin: &str,
|
|
3313
|
+
) -> Result<CommandOutput, std::io::Error> {
|
|
3314
|
+
self.run(argv)
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
fn backend_capture_pane_fail() -> (TmuxBackend, RecordedArgv) {
|
|
3319
|
+
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
3320
|
+
let runner = CapturePaneFailRunner {
|
|
3321
|
+
recorded: Arc::clone(&recorded),
|
|
3322
|
+
};
|
|
3323
|
+
(TmuxBackend::with_runner(Box::new(runner)), recorded)
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
/// P0 unknown-not-success: capture() Err 是「没能判断」,不得与 Some(true) 同值。
|
|
3327
|
+
/// 齿覆盖「读数拿不到」,不是「读数不一样」。
|
|
3328
|
+
#[test]
|
|
3329
|
+
fn p0_capture_failure_is_unverified_not_enter_sent() {
|
|
3330
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_cap_fail]";
|
|
3331
|
+
let (be, rec) = backend_capture_pane_fail();
|
|
3332
|
+
let report = be
|
|
3333
|
+
.inject(
|
|
3334
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3335
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
3336
|
+
Key::Enter,
|
|
3337
|
+
true,
|
|
3338
|
+
)
|
|
3339
|
+
.expect("inject runs");
|
|
3340
|
+
assert_eq!(
|
|
3341
|
+
report.submit_verification,
|
|
3342
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
3343
|
+
"consumed=None (capture missing) must not share \
|
|
3344
|
+
EnterSentWithoutPlaceholderCheck with Some(true); got {:?}",
|
|
3345
|
+
report.submit_verification
|
|
3346
|
+
);
|
|
3347
|
+
assert!(
|
|
3348
|
+
!crate::messaging::delivery::inject_submit_verified(&report),
|
|
3349
|
+
"no positive consumption signal ⇒ submit_verified must be false; report={report:?}"
|
|
3350
|
+
);
|
|
3351
|
+
let calls = rec.lock().unwrap().clone();
|
|
3352
|
+
assert_eq!(
|
|
3353
|
+
count_submit_enters(&calls),
|
|
3354
|
+
1,
|
|
3355
|
+
"capture retry must not send extra Enter; calls={calls:?}"
|
|
3356
|
+
);
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
/// P0: capture 失败先重读再判;重试计入 attempts;不重粘文本。
|
|
3360
|
+
#[test]
|
|
3361
|
+
fn p0_capture_failure_retries_read_and_counts_attempts() {
|
|
3362
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_cap_retry]";
|
|
3363
|
+
let (be, rec) = backend_capture_pane_fail();
|
|
3364
|
+
let report = be
|
|
3365
|
+
.inject(
|
|
3366
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3367
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
3368
|
+
Key::Enter,
|
|
3369
|
+
true,
|
|
3370
|
+
)
|
|
3371
|
+
.expect("inject runs");
|
|
3372
|
+
assert!(
|
|
3373
|
+
report.attempts > 1,
|
|
3374
|
+
"capture failure must retry the read and count it in attempts; got attempts={}",
|
|
3375
|
+
report.attempts
|
|
3376
|
+
);
|
|
3377
|
+
let calls = rec.lock().unwrap().clone();
|
|
3378
|
+
let captures = calls
|
|
3379
|
+
.iter()
|
|
3380
|
+
.filter(|argv| argv.get(1).map(String::as_str) == Some("capture-pane"))
|
|
3381
|
+
.count();
|
|
3382
|
+
assert!(
|
|
3383
|
+
captures >= 2,
|
|
3384
|
+
"must re-read capture after failure; capture-pane count={captures} calls={calls:?}"
|
|
3385
|
+
);
|
|
3386
|
+
assert_eq!(
|
|
3387
|
+
count_submit_enters(&calls),
|
|
3388
|
+
1,
|
|
3389
|
+
"retry is capture-only; extra Enter is forbidden; calls={calls:?}"
|
|
3390
|
+
);
|
|
3391
|
+
let pastes = calls
|
|
3392
|
+
.iter()
|
|
3393
|
+
.filter(|argv| argv.get(1).map(String::as_str) == Some("paste-buffer"))
|
|
3394
|
+
.count();
|
|
3395
|
+
assert_eq!(
|
|
3396
|
+
pastes, 1,
|
|
3397
|
+
"must not re-paste on capture failure; paste-buffer count={pastes}"
|
|
3398
|
+
);
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
3402
|
+
// Unverified 之后补一颗 C-m(TUI composer 残留 / 折行),busy-shell 不补
|
|
3403
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
3404
|
+
|
|
3405
|
+
struct NthEnterClearRunner {
|
|
3406
|
+
recorded: RecordedArgv,
|
|
3407
|
+
pending: String,
|
|
3408
|
+
cleared: String,
|
|
3409
|
+
enters: std::sync::atomic::AtomicU32,
|
|
3410
|
+
clear_after: u32,
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
impl CommandRunner for NthEnterClearRunner {
|
|
3414
|
+
fn run(&self, argv: &[String]) -> Result<CommandOutput, std::io::Error> {
|
|
3415
|
+
self.recorded.lock().unwrap().push(argv.to_vec());
|
|
3416
|
+
if argv.get(1).map(String::as_str) == Some("send-keys")
|
|
3417
|
+
&& argv.iter().any(|a| a == "C-m" || a == "Enter")
|
|
3418
|
+
{
|
|
3419
|
+
self.enters.fetch_add(1, Ordering::SeqCst);
|
|
3420
|
+
}
|
|
3421
|
+
let stdout = match argv.get(1).map(String::as_str) {
|
|
3422
|
+
Some("capture-pane") => {
|
|
3423
|
+
if self.enters.load(Ordering::SeqCst) >= self.clear_after {
|
|
3424
|
+
self.cleared.clone()
|
|
3425
|
+
} else {
|
|
3426
|
+
self.pending.clone()
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
Some("display-message") => "0\n".to_string(),
|
|
3430
|
+
_ => String::new(),
|
|
3431
|
+
};
|
|
3432
|
+
Ok(ok(&stdout))
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
fn run_with_stdin(
|
|
3436
|
+
&self,
|
|
3437
|
+
argv: &[String],
|
|
3438
|
+
_stdin: &str,
|
|
3439
|
+
) -> Result<CommandOutput, std::io::Error> {
|
|
3440
|
+
self.run(argv)
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
fn backend_clears_after_n_enters(
|
|
3445
|
+
pending: &str,
|
|
3446
|
+
cleared: &str,
|
|
3447
|
+
clear_after: u32,
|
|
3448
|
+
) -> (TmuxBackend, RecordedArgv) {
|
|
3449
|
+
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
3450
|
+
let runner = NthEnterClearRunner {
|
|
3451
|
+
recorded: Arc::clone(&recorded),
|
|
3452
|
+
pending: pending.to_string(),
|
|
3453
|
+
cleared: cleared.to_string(),
|
|
3454
|
+
enters: std::sync::atomic::AtomicU32::new(0),
|
|
3455
|
+
clear_after,
|
|
3456
|
+
};
|
|
3457
|
+
(TmuxBackend::with_runner(Box::new(runner)), recorded)
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
fn wrapped_identity_with_skin(marker: &str, skin: &str) -> String {
|
|
3461
|
+
// tmux 80 列折行:单行不含完整 token,拼接后才含。
|
|
3462
|
+
let split_at = marker.len() / 2;
|
|
3463
|
+
format!("{skin}\n{}\n{}\n", &marker[..split_at], &marker[split_at..])
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
fn wrapped_unverified_composer(marker: &str) -> String {
|
|
3467
|
+
wrapped_identity_with_skin(marker, "tui-ready composer>")
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
#[test]
|
|
3471
|
+
fn should_resend_enter_after_unverified_true_on_wrapped_tui_token() {
|
|
3472
|
+
let marker = "[team-agent-token:msg_wrap_gate]";
|
|
3473
|
+
let text = wrapped_unverified_composer(marker);
|
|
3474
|
+
assert!(
|
|
3475
|
+
!super::should_resubmit_enter(&text, marker, None),
|
|
3476
|
+
"per-line token_in_bottom_n must stay false on wrap"
|
|
3477
|
+
);
|
|
3478
|
+
assert!(
|
|
3479
|
+
super::should_resend_enter_after_unverified(&text, marker, None),
|
|
3480
|
+
"joined token identity must allow the one Unverified resend"
|
|
3481
|
+
);
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
#[test]
|
|
3485
|
+
fn should_resend_wrapped_identity_on_three_tui_skins() {
|
|
3486
|
+
let marker = "[team-agent-token:msg_skins]";
|
|
3487
|
+
for skin in ["❯ ", "> ", "ready."] {
|
|
3488
|
+
let text = wrapped_identity_with_skin(marker, skin);
|
|
3489
|
+
assert!(
|
|
3490
|
+
super::should_resend_enter_after_unverified(&text, marker, None),
|
|
3491
|
+
"skin {skin:?} with this-paste token must resend"
|
|
3492
|
+
);
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
#[test]
|
|
3497
|
+
fn should_resend_false_when_payload_has_prompt_glyphs_but_identity_absent() {
|
|
3498
|
+
let marker = "[team-agent-token:msg_glyph_fp]";
|
|
3499
|
+
let text = "STARTED\nsleep holds pty\nsee composer> and ❯ in the payload\n>\n";
|
|
3500
|
+
assert!(
|
|
3501
|
+
!super::this_paste_identity_in_composer(text, marker, None),
|
|
3502
|
+
"glyphs in payload are not this-paste identity"
|
|
3503
|
+
);
|
|
3504
|
+
assert!(
|
|
3505
|
+
!super::should_resend_enter_after_unverified(text, marker, None),
|
|
3506
|
+
"payload composer>/❯ must not by themselves trigger resend"
|
|
3507
|
+
);
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
#[test]
|
|
3511
|
+
fn should_resend_enter_after_unverified_true_when_unwrapped_token_still_in_tui() {
|
|
3512
|
+
let marker = "[team-agent-token:msg_still_tui]";
|
|
3513
|
+
let text = format!("> {marker}\n");
|
|
3514
|
+
assert!(super::should_resubmit_enter(&text, marker, None));
|
|
3515
|
+
assert!(
|
|
3516
|
+
super::should_resend_enter_after_unverified(&text, marker, None),
|
|
3517
|
+
"bare > skin with token identity still in composer must resend"
|
|
3518
|
+
);
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
#[test]
|
|
3522
|
+
fn wrap_gap_false_when_a_already_sees_grok_latch() {
|
|
3523
|
+
let marker = "[team-agent-token:msg_grok_stay]";
|
|
3524
|
+
let tracked = Some(super::PasteLatch::GrokLineCount(42));
|
|
3525
|
+
assert!(super::should_resubmit_enter(
|
|
3526
|
+
GROK_INCIDENT_LINE,
|
|
3527
|
+
marker,
|
|
3528
|
+
tracked
|
|
3529
|
+
));
|
|
3530
|
+
assert!(super::should_resend_enter_after_unverified(
|
|
3531
|
+
GROK_INCIDENT_LINE,
|
|
3532
|
+
marker,
|
|
3533
|
+
tracked
|
|
3534
|
+
));
|
|
3535
|
+
assert!(
|
|
3536
|
+
!super::should_resend_unverified_wrap_gap(GROK_INCIDENT_LINE, marker, tracked),
|
|
3537
|
+
"A latch path already covers grok-fold stay; invert wrap-gap must turn this red"
|
|
3538
|
+
);
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
#[test]
|
|
3542
|
+
fn wrap_gap_true_on_joined_token_a_cannot_see() {
|
|
3543
|
+
let marker = "[team-agent-token:msg_wrap_gate]";
|
|
3544
|
+
let text = wrapped_unverified_composer(marker);
|
|
3545
|
+
assert!(!super::should_resubmit_enter(&text, marker, None));
|
|
3546
|
+
assert!(
|
|
3547
|
+
super::should_resend_unverified_wrap_gap(&text, marker, None),
|
|
3548
|
+
"B must still cover the wrap gap A cannot see"
|
|
3549
|
+
);
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
#[test]
|
|
3553
|
+
fn unverified_wrapped_token_resends_one_enter_then_consumes() {
|
|
3554
|
+
let marker = "[team-agent-token:msg_wrap_ok]";
|
|
3555
|
+
let token_text = format!("Team Agent message from leader:\nline1\n\n{marker}");
|
|
3556
|
+
let pending = wrapped_identity_with_skin(marker, "> ");
|
|
3557
|
+
let cleared = "> \n";
|
|
3558
|
+
let (be, rec) = backend_clears_after_n_enters(&pending, cleared, 2);
|
|
3559
|
+
let report = be
|
|
3560
|
+
.inject(
|
|
3561
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3562
|
+
&InjectPayload::Text(token_text),
|
|
3563
|
+
Key::Enter,
|
|
3564
|
+
true,
|
|
3565
|
+
)
|
|
3566
|
+
.expect("inject");
|
|
3567
|
+
assert_eq!(
|
|
3568
|
+
report.submit_verification,
|
|
3569
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
3570
|
+
"Unverified + wrapped identity must resend one C-m then consume; got {:?}",
|
|
3571
|
+
report.submit_verification
|
|
3572
|
+
);
|
|
3573
|
+
let calls = rec.lock().unwrap().clone();
|
|
3574
|
+
assert_eq!(
|
|
3575
|
+
count_submit_enters(&calls),
|
|
3576
|
+
2,
|
|
3577
|
+
"exactly one extra C-m after Unverified; calls={calls:?}"
|
|
3578
|
+
);
|
|
3579
|
+
let pastes = calls
|
|
3580
|
+
.iter()
|
|
3581
|
+
.filter(|argv| argv.get(1).map(String::as_str) == Some("paste-buffer"))
|
|
3582
|
+
.count();
|
|
3583
|
+
assert_eq!(pastes, 1, "must not re-paste; paste-buffer count={pastes}");
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
#[test]
|
|
3587
|
+
fn unverified_resend_skips_when_this_message_identity_absent() {
|
|
3588
|
+
let marker = "[team-agent-token:msg_busy_nodup]";
|
|
3589
|
+
let token_text = format!("Team Agent message from leader:\nline1 composer> ❯\n\n{marker}");
|
|
3590
|
+
let pending = "STARTED\nsleep holds pty\ncomposer>\n❯\n>\n";
|
|
3591
|
+
let (be, rec) = backend_clears_after_n_enters(pending, pending, 99);
|
|
3592
|
+
let report = be
|
|
3593
|
+
.inject(
|
|
3594
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3595
|
+
&InjectPayload::Text(token_text),
|
|
3596
|
+
Key::Enter,
|
|
3597
|
+
true,
|
|
3598
|
+
)
|
|
3599
|
+
.expect("inject");
|
|
3600
|
+
assert_eq!(
|
|
3601
|
+
report.submit_verification,
|
|
3602
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
3603
|
+
"identity absent must stay Unverified, no extra Enter; got {:?}",
|
|
3604
|
+
report.submit_verification
|
|
3605
|
+
);
|
|
3606
|
+
let calls = rec.lock().unwrap().clone();
|
|
3607
|
+
assert_eq!(
|
|
3608
|
+
count_submit_enters(&calls),
|
|
3609
|
+
1,
|
|
3610
|
+
"no this-paste identity ⇒ no Unverified resend; commenting the identity gate must turn this red (2+ C-m); calls={calls:?}"
|
|
3611
|
+
);
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
#[test]
|
|
3615
|
+
fn unverified_identity_never_clears_hits_resend_cap_of_one() {
|
|
3616
|
+
let marker = "[team-agent-token:msg_cap_lock]";
|
|
3617
|
+
let token_text = format!("Team Agent message from leader:\nline1\n\n{marker}");
|
|
3618
|
+
let pending = wrapped_identity_with_skin(marker, "ready.");
|
|
3619
|
+
let (be, rec) = backend_clears_after_n_enters(&pending, &pending, u32::MAX);
|
|
3620
|
+
let report = be
|
|
3621
|
+
.inject(
|
|
3622
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3623
|
+
&InjectPayload::Text(token_text),
|
|
3624
|
+
Key::Enter,
|
|
3625
|
+
true,
|
|
3626
|
+
)
|
|
3627
|
+
.expect("inject");
|
|
3628
|
+
assert_eq!(
|
|
3629
|
+
report.submit_verification,
|
|
3630
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
3631
|
+
"never-clear identity must not fake-consume; got {:?}",
|
|
3632
|
+
report.submit_verification
|
|
3633
|
+
);
|
|
3634
|
+
let calls = rec.lock().unwrap().clone();
|
|
3635
|
+
assert_eq!(
|
|
3636
|
+
count_submit_enters(&calls),
|
|
3637
|
+
2,
|
|
3638
|
+
"cap must be exactly one extra C-m (1 inject + 1 resend); raising UNVERIFIED_COMPOSER_RESEND_MAX must turn this red; calls={calls:?}"
|
|
3639
|
+
);
|
|
3640
|
+
let pastes = calls
|
|
3641
|
+
.iter()
|
|
3642
|
+
.filter(|argv| argv.get(1).map(String::as_str) == Some("paste-buffer"))
|
|
3643
|
+
.count();
|
|
3644
|
+
assert_eq!(pastes, 1, "must not re-paste; paste-buffer count={pastes}");
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
#[test]
|
|
3648
|
+
fn unverified_wrapped_identity_resends_on_each_of_three_skins() {
|
|
3649
|
+
for skin in ["❯ ", "> ", "ready."] {
|
|
3650
|
+
let marker = format!(
|
|
3651
|
+
"[team-agent-token:msg_skin_{}]",
|
|
3652
|
+
skin.chars().next().unwrap() as u32
|
|
3653
|
+
);
|
|
3654
|
+
let token_text = format!("Team Agent message from leader:\nline1\n\n{marker}");
|
|
3655
|
+
let pending = wrapped_identity_with_skin(&marker, skin);
|
|
3656
|
+
let cleared = format!("{skin}\n");
|
|
3657
|
+
let (be, rec) = backend_clears_after_n_enters(&pending, &cleared, 2);
|
|
3658
|
+
let report = be
|
|
3659
|
+
.inject(
|
|
3660
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3661
|
+
&InjectPayload::Text(token_text),
|
|
3662
|
+
Key::Enter,
|
|
3663
|
+
true,
|
|
3664
|
+
)
|
|
3665
|
+
.expect("inject");
|
|
3666
|
+
assert_eq!(
|
|
3667
|
+
report.submit_verification,
|
|
3668
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
3669
|
+
"skin {skin:?} must consume after one extra C-m"
|
|
3670
|
+
);
|
|
3671
|
+
let n = count_submit_enters(&rec.lock().unwrap().clone());
|
|
3672
|
+
assert_eq!(n, 2, "skin {skin:?} extra C-m; got {n}");
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
#[test]
|
|
3677
|
+
fn unverified_payload_glyphs_do_not_resend_without_identity() {
|
|
3678
|
+
let marker = "[team-agent-token:msg_payload_glyph]";
|
|
3679
|
+
let token_text = format!("Team Agent message from leader:\ncomposer> ❯\n\n{marker}");
|
|
3680
|
+
let pending = "output\ncomposer> leftover from payload\n❯ quoted\n";
|
|
3681
|
+
let (be, rec) = backend_clears_after_n_enters(pending, pending, 99);
|
|
3682
|
+
let report = be
|
|
3683
|
+
.inject(
|
|
3684
|
+
&Target::Pane(PaneId::new("%7")),
|
|
3685
|
+
&InjectPayload::Text(token_text),
|
|
3686
|
+
Key::Enter,
|
|
3687
|
+
true,
|
|
3688
|
+
)
|
|
3689
|
+
.expect("inject");
|
|
3690
|
+
let calls = rec.lock().unwrap().clone();
|
|
3691
|
+
assert_eq!(
|
|
3692
|
+
count_submit_enters(&calls),
|
|
3693
|
+
1,
|
|
3694
|
+
"glyphs in payload must not cause extra C-m when token identity is gone; calls={calls:?}"
|
|
3695
|
+
);
|
|
3696
|
+
assert_eq!(
|
|
3697
|
+
report.submit_verification,
|
|
3698
|
+
SubmitVerification::SubmitConsumptionUnverified
|
|
3699
|
+
);
|
|
3700
|
+
}
|