@team-agent/installer 0.3.21 → 0.3.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +9 -1
- package/crates/team-agent/src/cli/mod.rs +158 -2
- package/crates/team-agent/src/cli/status.rs +61 -0
- package/crates/team-agent/src/cli/status_port.rs +2 -2
- package/crates/team-agent/src/cli/tests/base.rs +26 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
- package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +452 -17
- package/crates/team-agent/src/lifecycle/display.rs +23 -302
- package/crates/team-agent/src/lifecycle/launch.rs +38 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
- package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
- package/crates/team-agent/src/messaging/delivery.rs +324 -95
- package/crates/team-agent/src/messaging/scheduler.rs +95 -73
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
- package/crates/team-agent/src/session_capture.rs +261 -1
- package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
- package/crates/team-agent/src/tmux_backend.rs +138 -2
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +11 -0
- package/package.json +4 -4
|
@@ -107,8 +107,21 @@ fn p2_scheduler_unknown_kind_surfaces_error() {
|
|
|
107
107
|
)
|
|
108
108
|
.unwrap();
|
|
109
109
|
let log = EventLog::new(&ws);
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// U1 #5: unknown kind is isolated to its own row (marked terminal 'failed' +
|
|
111
|
+
// scheduler.event_failed), NOT propagated as a pass-level Err that would halt the
|
|
112
|
+
// batch. Failure stays loud (evented) but does not take the whole scheduler down.
|
|
113
|
+
let fired = fire_due_scheduled_events(&ws, &store, &NoopTransport, &log)
|
|
114
|
+
.expect("an unknown scheduled event kind must be isolated to its row, not halt the pass");
|
|
115
|
+
assert_eq!(fired.len(), 1);
|
|
116
|
+
assert_eq!(scheduled_status_of(&store, fired[0]), "failed");
|
|
117
|
+
let events = log.tail(0).unwrap();
|
|
118
|
+
assert!(
|
|
119
|
+
events.iter().any(|event| {
|
|
120
|
+
event.get("event").and_then(serde_json::Value::as_str) == Some("scheduler.event_failed")
|
|
121
|
+
&& event.get("event_id").and_then(serde_json::Value::as_i64) == Some(fired[0])
|
|
122
|
+
}),
|
|
123
|
+
"unknown scheduled event kind must leave scheduler.event_failed; events={events:?}"
|
|
124
|
+
);
|
|
112
125
|
}
|
|
113
126
|
|
|
114
127
|
// ═════════════════════════════════════════════════════════════════════════
|
|
@@ -313,6 +326,42 @@ fn spine_scheduler_marks_failed_when_result_not_ok() {
|
|
|
313
326
|
);
|
|
314
327
|
}
|
|
315
328
|
|
|
329
|
+
// U1 #5 (RED) — a single poison scheduled event (malformed payload_json that errors at
|
|
330
|
+
// `serde_json::from_str?`) must NOT halt the whole pass: it must be marked terminal 'failed'
|
|
331
|
+
// + emit `scheduler.event_failed`, and a healthy event seeded AFTER it must still fire.
|
|
332
|
+
// Today the bare `?` aborts `fire_due_scheduled_events`, the healthy event never fires → RED.
|
|
333
|
+
#[test]
|
|
334
|
+
fn spine_scheduler_poison_event_does_not_halt_batch() {
|
|
335
|
+
let ws = tmp_ws("sched-poison");
|
|
336
|
+
let store = store_for(&ws);
|
|
337
|
+
let log = EventLog::new(&ws);
|
|
338
|
+
// earlier due_at → selected first; malformed JSON payload errors at from_str.
|
|
339
|
+
let poison = seed_event_due(&store, "send", "2000-01-01T00:00:00+00:00", "{not valid json");
|
|
340
|
+
// later due_at → selected after poison; healthy.
|
|
341
|
+
let healthy = seed_event_due(&store, "health_ping", "2000-01-02T00:00:00+00:00", "{}");
|
|
342
|
+
|
|
343
|
+
let fired = fire_due_scheduled_events(&ws, &store, &NoopTransport, &log)
|
|
344
|
+
.expect("a poison event must not propagate as a pass-level Err (must isolate + continue)");
|
|
345
|
+
|
|
346
|
+
// poison marked terminal failed (not re-fired every tick).
|
|
347
|
+
assert_eq!(
|
|
348
|
+
scheduled_status_of(&store, poison),
|
|
349
|
+
"failed",
|
|
350
|
+
"U1 #5: a poison scheduled event must be marked terminal 'failed', not halt the pass"
|
|
351
|
+
);
|
|
352
|
+
// healthy event AFTER the poison still fired.
|
|
353
|
+
assert!(
|
|
354
|
+
fired.contains(&healthy),
|
|
355
|
+
"U1 #5: a healthy event after a poison one must still fire (batch not halted); fired={fired:?}"
|
|
356
|
+
);
|
|
357
|
+
// failure is loud: scheduler.event_failed event emitted for the poison.
|
|
358
|
+
let events = read_event_log(&ws);
|
|
359
|
+
assert!(
|
|
360
|
+
events.iter().any(|e| e.get("event").and_then(|v| v.as_str()) == Some("scheduler.event_failed")),
|
|
361
|
+
"U1 #5: a poison event must emit scheduler.event_failed (failure must be loud, not silent)"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
316
365
|
// #6b — due events fire in (due_at, id) order (core.py due_scheduled_events), not id order.
|
|
317
366
|
#[test]
|
|
318
367
|
fn spine_scheduler_orders_due_events_by_due_at_then_id() {
|
|
@@ -19,9 +19,16 @@ pub struct CapturePassReport {
|
|
|
19
19
|
pub pending: Vec<String>,
|
|
20
20
|
pub assigned: Vec<String>,
|
|
21
21
|
pub ambiguous: Vec<AmbiguousSessionCapture>,
|
|
22
|
+
pub capture_failures: Vec<SessionCaptureFailure>,
|
|
22
23
|
pub candidate_count_by_agent: BTreeMap<String, usize>,
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
27
|
+
pub struct SessionCaptureFailure {
|
|
28
|
+
pub agent_id: String,
|
|
29
|
+
pub error: String,
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
26
33
|
pub struct AmbiguousSessionCapture {
|
|
27
34
|
pub agent_id: String,
|
|
@@ -140,13 +147,24 @@ where
|
|
|
140
147
|
return Ok(CapturePassReport::default());
|
|
141
148
|
};
|
|
142
149
|
let mut pending = Vec::new();
|
|
150
|
+
let mut capture_failures = Vec::new();
|
|
143
151
|
let mut candidates_by_agent = BTreeMap::new();
|
|
144
152
|
for (agent_id, agent) in agent_map {
|
|
145
153
|
let Some(capture) = pending_session_capture(agent_id, agent, adapter_for) else {
|
|
146
154
|
continue;
|
|
147
155
|
};
|
|
148
156
|
let adapter = adapter_for(capture.provider);
|
|
149
|
-
let candidates = adapter.capture_session_candidates(&capture.context, timeout_s)
|
|
157
|
+
let candidates = match adapter.capture_session_candidates(&capture.context, timeout_s) {
|
|
158
|
+
Ok(candidates) => candidates,
|
|
159
|
+
Err(error) => {
|
|
160
|
+
capture_failures.push(SessionCaptureFailure {
|
|
161
|
+
agent_id: capture.agent_id.clone(),
|
|
162
|
+
error: error.to_string(),
|
|
163
|
+
});
|
|
164
|
+
pending.push(capture);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
150
168
|
candidates_by_agent.insert(capture.agent_id.clone(), candidates);
|
|
151
169
|
pending.push(capture);
|
|
152
170
|
}
|
|
@@ -164,6 +182,7 @@ where
|
|
|
164
182
|
};
|
|
165
183
|
let mut report = CapturePassReport {
|
|
166
184
|
pending: pending.iter().map(|item| item.agent_id.clone()).collect(),
|
|
185
|
+
capture_failures,
|
|
167
186
|
candidate_count_by_agent: candidates_by_agent
|
|
168
187
|
.iter()
|
|
169
188
|
.map(|(agent_id, candidates)| (agent_id.clone(), candidates.len()))
|
|
@@ -615,3 +634,244 @@ fn parse_provider(raw: &str) -> Option<Provider> {
|
|
|
615
634
|
_ => None,
|
|
616
635
|
}
|
|
617
636
|
}
|
|
637
|
+
|
|
638
|
+
#[cfg(test)]
|
|
639
|
+
pub(crate) mod test_support {
|
|
640
|
+
use super::*;
|
|
641
|
+
|
|
642
|
+
#[derive(Clone)]
|
|
643
|
+
pub(crate) struct CaptureCandidatesAdapter {
|
|
644
|
+
provider: Provider,
|
|
645
|
+
fail_agent_id: Option<String>,
|
|
646
|
+
error: String,
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
impl CaptureCandidatesAdapter {
|
|
650
|
+
pub(crate) fn new(provider: Provider, fail_agent_id: Option<&str>, error: &str) -> Self {
|
|
651
|
+
Self {
|
|
652
|
+
provider,
|
|
653
|
+
fail_agent_id: fail_agent_id.map(str::to_string),
|
|
654
|
+
error: error.to_string(),
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
impl ProviderAdapter for CaptureCandidatesAdapter {
|
|
660
|
+
fn provider(&self) -> Provider {
|
|
661
|
+
self.provider
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
fn caps(&self) -> crate::provider::ProviderCaps {
|
|
665
|
+
crate::provider::ProviderCaps {
|
|
666
|
+
resume: true,
|
|
667
|
+
fork: false,
|
|
668
|
+
native_mcp_config: false,
|
|
669
|
+
writes_global_settings: false,
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
fn is_installed(&self) -> bool {
|
|
674
|
+
true
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
fn version(&self) -> Result<String, ProviderError> {
|
|
678
|
+
Ok("test".to_string())
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
fn auth_hint(
|
|
682
|
+
&self,
|
|
683
|
+
_auth_mode: crate::provider::AuthMode,
|
|
684
|
+
) -> crate::provider::AuthHintStatus {
|
|
685
|
+
crate::provider::AuthHintStatus::Unknown
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
fn build_command(
|
|
689
|
+
&self,
|
|
690
|
+
_auth_mode: crate::provider::AuthMode,
|
|
691
|
+
_mcp_config: Option<&crate::provider::McpConfig>,
|
|
692
|
+
_system_prompt: Option<&str>,
|
|
693
|
+
_model: Option<&str>,
|
|
694
|
+
) -> Result<Vec<String>, ProviderError> {
|
|
695
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
696
|
+
"test adapter".to_string(),
|
|
697
|
+
))
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
fn build_command_with_tools(
|
|
701
|
+
&self,
|
|
702
|
+
_auth_mode: crate::provider::AuthMode,
|
|
703
|
+
_mcp_config: Option<&crate::provider::McpConfig>,
|
|
704
|
+
_system_prompt: Option<&str>,
|
|
705
|
+
_model: Option<&str>,
|
|
706
|
+
_tools: &[&str],
|
|
707
|
+
) -> Result<Vec<String>, ProviderError> {
|
|
708
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
709
|
+
"test adapter".to_string(),
|
|
710
|
+
))
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
fn capture_session_id(
|
|
714
|
+
&self,
|
|
715
|
+
_agent_id: &str,
|
|
716
|
+
_spawn_cwd: &std::path::Path,
|
|
717
|
+
_timeout_s: u64,
|
|
718
|
+
) -> Result<Option<CapturedSession>, ProviderError> {
|
|
719
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
720
|
+
"test adapter".to_string(),
|
|
721
|
+
))
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
fn capture_session_candidates(
|
|
725
|
+
&self,
|
|
726
|
+
context: &CaptureSessionContext,
|
|
727
|
+
_timeout_s: u64,
|
|
728
|
+
) -> Result<Vec<CapturedSessionCandidate>, ProviderError> {
|
|
729
|
+
if self.fail_agent_id.as_deref() == Some(context.agent_id.as_str()) {
|
|
730
|
+
return Err(ProviderError::Io(self.error.clone()));
|
|
731
|
+
}
|
|
732
|
+
Ok(Vec::new())
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
fn recover_session_id(
|
|
736
|
+
&self,
|
|
737
|
+
_agent_id: &str,
|
|
738
|
+
_spawn_cwd: &std::path::Path,
|
|
739
|
+
) -> Result<Option<SessionId>, ProviderError> {
|
|
740
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
741
|
+
"test adapter".to_string(),
|
|
742
|
+
))
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
fn session_is_resumable(
|
|
746
|
+
&self,
|
|
747
|
+
_session_id: Option<&SessionId>,
|
|
748
|
+
_auth_mode: crate::provider::AuthMode,
|
|
749
|
+
) -> Result<bool, ProviderError> {
|
|
750
|
+
Ok(true)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
fn build_resume_command(
|
|
754
|
+
&self,
|
|
755
|
+
_session_id: Option<&SessionId>,
|
|
756
|
+
_auth_mode: crate::provider::AuthMode,
|
|
757
|
+
_mcp_config: Option<&crate::provider::McpConfig>,
|
|
758
|
+
) -> Result<Vec<String>, ProviderError> {
|
|
759
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
760
|
+
"test adapter".to_string(),
|
|
761
|
+
))
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
fn build_resume_command_with_context(
|
|
765
|
+
&self,
|
|
766
|
+
_session_id: Option<&SessionId>,
|
|
767
|
+
_auth_mode: crate::provider::AuthMode,
|
|
768
|
+
_mcp_config: Option<&crate::provider::McpConfig>,
|
|
769
|
+
_system_prompt: Option<&str>,
|
|
770
|
+
_model: Option<&str>,
|
|
771
|
+
_tools: &[&str],
|
|
772
|
+
) -> Result<Vec<String>, ProviderError> {
|
|
773
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
774
|
+
"test adapter".to_string(),
|
|
775
|
+
))
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
fn fork(
|
|
779
|
+
&self,
|
|
780
|
+
_session_id: Option<&SessionId>,
|
|
781
|
+
_auth_mode: crate::provider::AuthMode,
|
|
782
|
+
_mcp_config: Option<&crate::provider::McpConfig>,
|
|
783
|
+
) -> Result<Vec<String>, ProviderError> {
|
|
784
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
785
|
+
"test adapter".to_string(),
|
|
786
|
+
))
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
fn fork_with_context(
|
|
790
|
+
&self,
|
|
791
|
+
_session_id: Option<&SessionId>,
|
|
792
|
+
_auth_mode: crate::provider::AuthMode,
|
|
793
|
+
_mcp_config: Option<&crate::provider::McpConfig>,
|
|
794
|
+
_system_prompt: Option<&str>,
|
|
795
|
+
_model: Option<&str>,
|
|
796
|
+
_tools: &[&str],
|
|
797
|
+
) -> Result<Vec<String>, ProviderError> {
|
|
798
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
799
|
+
"test adapter".to_string(),
|
|
800
|
+
))
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
fn mcp_config(
|
|
804
|
+
&self,
|
|
805
|
+
_auth_mode: crate::provider::AuthMode,
|
|
806
|
+
) -> Result<crate::provider::McpConfig, ProviderError> {
|
|
807
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
808
|
+
"test adapter".to_string(),
|
|
809
|
+
))
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
fn install_mcp(&self, _config: &crate::provider::McpConfig) -> Result<(), ProviderError> {
|
|
813
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
814
|
+
"test adapter".to_string(),
|
|
815
|
+
))
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
fn status_patterns(&self) -> Result<crate::provider::StatusPatterns, ProviderError> {
|
|
819
|
+
Err(ProviderError::CapabilityUnsupported(
|
|
820
|
+
"test adapter".to_string(),
|
|
821
|
+
))
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
fn validate_model(&self, _model: &str) -> Result<bool, ProviderError> {
|
|
825
|
+
Ok(true)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
#[cfg(test)]
|
|
831
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
832
|
+
mod u1_tests {
|
|
833
|
+
use super::*;
|
|
834
|
+
|
|
835
|
+
#[test]
|
|
836
|
+
fn capture_pass_keeps_pending_agent_when_one_adapter_capture_fails() {
|
|
837
|
+
let mut state = serde_json::json!({
|
|
838
|
+
"agents": {
|
|
839
|
+
"bad": {
|
|
840
|
+
"provider": "codex",
|
|
841
|
+
"status": "running",
|
|
842
|
+
"spawn_cwd": "/tmp/u1-bad"
|
|
843
|
+
},
|
|
844
|
+
"good": {
|
|
845
|
+
"provider": "codex",
|
|
846
|
+
"status": "running",
|
|
847
|
+
"spawn_cwd": "/tmp/u1-good"
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
let mut adapter_for = |provider| {
|
|
852
|
+
Box::new(test_support::CaptureCandidatesAdapter::new(
|
|
853
|
+
provider,
|
|
854
|
+
Some("bad"),
|
|
855
|
+
"capture exploded",
|
|
856
|
+
)) as Box<dyn ProviderAdapter>
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
|
|
860
|
+
.expect("one agent capture failure must not abort the whole pass");
|
|
861
|
+
|
|
862
|
+
assert_eq!(report.pending, vec!["bad".to_string(), "good".to_string()]);
|
|
863
|
+
assert_eq!(report.assigned, Vec::<String>::new());
|
|
864
|
+
assert_eq!(
|
|
865
|
+
report.candidate_count_by_agent.get("good"),
|
|
866
|
+
Some(&0),
|
|
867
|
+
"the non-failing agent must still be probed"
|
|
868
|
+
);
|
|
869
|
+
assert_eq!(
|
|
870
|
+
report.capture_failures,
|
|
871
|
+
vec![SessionCaptureFailure {
|
|
872
|
+
agent_id: "bad".to_string(),
|
|
873
|
+
error: "provider io error: capture exploded".to_string(),
|
|
874
|
+
}]
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
@@ -349,6 +349,81 @@
|
|
|
349
349
|
assert_eq!(result.pane_id.as_str(), "%4");
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
#[test]
|
|
353
|
+
fn spawn_split_selects_even_horizontal_not_tiled() {
|
|
354
|
+
let (be, rec) = backend_with(
|
|
355
|
+
MockResp::Out(ok("")),
|
|
356
|
+
vec![MockResp::Out(ok("%5")), MockResp::Out(ok(""))],
|
|
357
|
+
);
|
|
358
|
+
let s = SessionName::new("teamsess");
|
|
359
|
+
let w = WindowName::new("team-w1");
|
|
360
|
+
let result = be
|
|
361
|
+
.spawn_split_with_env_unset(
|
|
362
|
+
&s,
|
|
363
|
+
&w,
|
|
364
|
+
&svec(&["provider-bin"]),
|
|
365
|
+
Path::new("/work/dir"),
|
|
366
|
+
&BTreeMap::new(),
|
|
367
|
+
&[],
|
|
368
|
+
)
|
|
369
|
+
.expect("spawn_split");
|
|
370
|
+
assert_eq!(result.pane_id.as_str(), "%5");
|
|
371
|
+
let calls = rec.lock().unwrap().clone();
|
|
372
|
+
assert_eq!(
|
|
373
|
+
calls[0][0..4],
|
|
374
|
+
svec(&["tmux", "split-window", "-t", "teamsess:team-w1"])
|
|
375
|
+
);
|
|
376
|
+
assert_eq!(
|
|
377
|
+
calls[1],
|
|
378
|
+
svec(&[
|
|
379
|
+
"tmux",
|
|
380
|
+
"select-layout",
|
|
381
|
+
"-t",
|
|
382
|
+
"teamsess:team-w1",
|
|
383
|
+
"even-horizontal",
|
|
384
|
+
])
|
|
385
|
+
);
|
|
386
|
+
assert!(
|
|
387
|
+
!calls.iter().flatten().any(|arg| arg == "tiled"),
|
|
388
|
+
"adaptive split must not leave tmux tiled layout in the command stream: {calls:?}"
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#[test]
|
|
393
|
+
fn configure_adaptive_pane_title_sets_border_and_pane_title() {
|
|
394
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
395
|
+
be.configure_adaptive_pane_title(
|
|
396
|
+
&SessionName::new("teamsess"),
|
|
397
|
+
&WindowName::new("team-w1"),
|
|
398
|
+
&PaneId::new("%7"),
|
|
399
|
+
"builder",
|
|
400
|
+
)
|
|
401
|
+
.expect("configure title");
|
|
402
|
+
let calls = rec.lock().unwrap().clone();
|
|
403
|
+
assert_eq!(
|
|
404
|
+
calls,
|
|
405
|
+
vec![
|
|
406
|
+
svec(&[
|
|
407
|
+
"tmux",
|
|
408
|
+
"set-window-option",
|
|
409
|
+
"-t",
|
|
410
|
+
"teamsess:team-w1",
|
|
411
|
+
"pane-border-status",
|
|
412
|
+
"bottom",
|
|
413
|
+
]),
|
|
414
|
+
svec(&[
|
|
415
|
+
"tmux",
|
|
416
|
+
"set-window-option",
|
|
417
|
+
"-t",
|
|
418
|
+
"teamsess:team-w1",
|
|
419
|
+
"pane-border-format",
|
|
420
|
+
" #{pane_title} ",
|
|
421
|
+
]),
|
|
422
|
+
svec(&["tmux", "select-pane", "-t", "%7", "-T", "builder"]),
|
|
423
|
+
]
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
352
427
|
// ── 3. set_session_env: argv = `tmux set-environment -t <s> <k> <v>`; success -> Applied ───────
|
|
353
428
|
#[test]
|
|
354
429
|
fn set_session_env_argv_and_applied_outcome() {
|
|
@@ -414,8 +489,10 @@
|
|
|
414
489
|
|
|
415
490
|
#[test]
|
|
416
491
|
fn inject_large_text_load_buffer_writes_stdin_and_token_report() {
|
|
417
|
-
let (be, rec, stdin_rec) = backend_with_stdin(MockResp::Out(ok("")), vec![]);
|
|
418
492
|
let text = format!("{}{}", "x".repeat(16 * 1024), " [team-agent-token:abc]");
|
|
493
|
+
// U1 #7: the pre-submit readback captures the pane; a pane that ECHOES the token
|
|
494
|
+
// back (default capture returns the text) → CaptureContainsToken (true positive).
|
|
495
|
+
let (be, rec, stdin_rec) = backend_with_stdin(MockResp::Out(ok(&text)), vec![]);
|
|
419
496
|
let report = be
|
|
420
497
|
.inject(&Target::Pane(PaneId::new("%7")), &InjectPayload::Text(text.clone()), Key::Down, true)
|
|
421
498
|
.expect("inject large text");
|
|
@@ -430,6 +507,25 @@
|
|
|
430
507
|
assert_eq!(stdin_rec.lock().unwrap()[0], text);
|
|
431
508
|
}
|
|
432
509
|
|
|
510
|
+
// U1 #7 (RED→GREEN) — pre-submit pane readback: a token payload whose token is NOT
|
|
511
|
+
// visible in the pane before submit (paste silently dropped) must report
|
|
512
|
+
// CaptureMissingToken, not the static false-positive CaptureContainsToken.
|
|
513
|
+
#[test]
|
|
514
|
+
fn inject_token_not_visible_in_pane_reports_capture_missing_token() {
|
|
515
|
+
let text = format!("{}{}", "x".repeat(16 * 1024), " [team-agent-token:zzz]");
|
|
516
|
+
// default capture returns empty → token not visible → readback says missing.
|
|
517
|
+
let (be, _rec, _stdin) = backend_with_stdin(MockResp::Out(ok("")), vec![]);
|
|
518
|
+
let report = be
|
|
519
|
+
.inject(&Target::Pane(PaneId::new("%9")), &InjectPayload::Text(text), Key::Down, true)
|
|
520
|
+
.expect("inject runs");
|
|
521
|
+
assert_eq!(
|
|
522
|
+
report.inject_verification,
|
|
523
|
+
InjectVerification::CaptureMissingToken,
|
|
524
|
+
"U1 #7: a token that never appeared in the pane must read back as \
|
|
525
|
+
CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
433
529
|
#[test]
|
|
434
530
|
fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
|
|
435
531
|
let (be, rec) = backend_with(
|
|
@@ -669,7 +669,7 @@ impl TmuxBackend {
|
|
|
669
669
|
"select-layout".to_string(),
|
|
670
670
|
"-t".to_string(),
|
|
671
671
|
target,
|
|
672
|
-
"
|
|
672
|
+
"even-horizontal".to_string(),
|
|
673
673
|
];
|
|
674
674
|
self.run_ok(&layout_argv)?;
|
|
675
675
|
Ok(SpawnResult {
|
|
@@ -816,6 +816,90 @@ fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerificatio
|
|
|
816
816
|
}
|
|
817
817
|
}
|
|
818
818
|
|
|
819
|
+
/// U1 #7: the exact delivery-token marker a token payload carries
|
|
820
|
+
/// (`[team-agent-token:<id>]`). Use the full marker, not only the prefix, so an old
|
|
821
|
+
/// scrollback token cannot verify a new message.
|
|
822
|
+
fn payload_token_marker(payload: &InjectPayload) -> Option<&str> {
|
|
823
|
+
match payload {
|
|
824
|
+
InjectPayload::Text(text) => {
|
|
825
|
+
let start = text.find("[team-agent-token:")?;
|
|
826
|
+
let marker = &text[start..];
|
|
827
|
+
let end = marker.find(']')?;
|
|
828
|
+
Some(&marker[..=end])
|
|
829
|
+
}
|
|
830
|
+
_ => None,
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
fn token_visible_in_capture(
|
|
835
|
+
backend: &TmuxBackend,
|
|
836
|
+
target: &Target,
|
|
837
|
+
payload: &InjectPayload,
|
|
838
|
+
) -> Result<Option<bool>, TransportError> {
|
|
839
|
+
match payload_token_marker(payload) {
|
|
840
|
+
None => Ok(None),
|
|
841
|
+
Some(marker) => {
|
|
842
|
+
let captured = backend.capture(target, CaptureRange::Tail(80))?;
|
|
843
|
+
Ok(Some(captured.text.contains(marker)))
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/// U1 #7: capture the pane just before submit and report whether the just-pasted
|
|
849
|
+
/// token marker is actually visible. `Ok(None)` for non-token payloads (nothing to
|
|
850
|
+
/// check). `Ok(Some(false))` means the paste silently dropped — a false-positive that
|
|
851
|
+
/// the static `inject_verification_for_payload` would have reported as success.
|
|
852
|
+
fn pre_submit_token_visible(
|
|
853
|
+
backend: &TmuxBackend,
|
|
854
|
+
target: &Target,
|
|
855
|
+
payload: &InjectPayload,
|
|
856
|
+
) -> Result<Option<bool>, TransportError> {
|
|
857
|
+
token_visible_in_capture(backend, target, payload)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const TOKEN_POST_SUBMIT_READBACK_POLLS: u32 = 5;
|
|
861
|
+
|
|
862
|
+
/// Some non-echo panes, including the integration harness' `stty -echo; cat`, only
|
|
863
|
+
/// render the injected line after the submit key. If pre-submit readback missed the
|
|
864
|
+
/// token, do a bounded post-submit check before reporting `CaptureMissingToken`.
|
|
865
|
+
fn post_submit_token_visible(
|
|
866
|
+
backend: &TmuxBackend,
|
|
867
|
+
target: &Target,
|
|
868
|
+
payload: &InjectPayload,
|
|
869
|
+
) -> Result<Option<bool>, TransportError> {
|
|
870
|
+
if payload_token_marker(payload).is_none() {
|
|
871
|
+
return Ok(None);
|
|
872
|
+
}
|
|
873
|
+
for attempt in 0..TOKEN_POST_SUBMIT_READBACK_POLLS {
|
|
874
|
+
if let Some(true) = token_visible_in_capture(backend, target, payload)? {
|
|
875
|
+
return Ok(Some(true));
|
|
876
|
+
}
|
|
877
|
+
if attempt + 1 < TOKEN_POST_SUBMIT_READBACK_POLLS {
|
|
878
|
+
std::thread::sleep(Duration::from_millis(25));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
Ok(Some(false))
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/// U1 #7: downgrade the static token verification to `CaptureMissingToken` when the
|
|
885
|
+
/// pre-submit readback did not see the token in the pane. A `None` readback (non-token
|
|
886
|
+
/// payload, or capture unavailable) falls back to the static verification.
|
|
887
|
+
fn inject_verification_after_readback(
|
|
888
|
+
payload: &InjectPayload,
|
|
889
|
+
token_visible_before_submit: Option<bool>,
|
|
890
|
+
) -> InjectVerification {
|
|
891
|
+
match (payload, token_visible_before_submit) {
|
|
892
|
+
(_, Some(visible)) if payload_token_marker(payload).is_some() => {
|
|
893
|
+
if visible {
|
|
894
|
+
InjectVerification::CaptureContainsToken
|
|
895
|
+
} else {
|
|
896
|
+
InjectVerification::CaptureMissingToken
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
_ => inject_verification_for_payload(payload),
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
819
903
|
fn submit_verification_for_key(key: Key) -> SubmitVerification {
|
|
820
904
|
match key {
|
|
821
905
|
Key::Enter => SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
@@ -960,6 +1044,8 @@ impl Transport for TmuxBackend {
|
|
|
960
1044
|
bracketed: bool,
|
|
961
1045
|
) -> Result<InjectReport, TransportError> {
|
|
962
1046
|
let pane = pane_from_target(target);
|
|
1047
|
+
// U1 #7: pane readback signal for the non-pasted-prompt text path.
|
|
1048
|
+
let mut token_visible_for_report: Option<bool> = None;
|
|
963
1049
|
match payload {
|
|
964
1050
|
InjectPayload::Empty => {
|
|
965
1051
|
let argv = tmux_empty_inject_argv(&pane, submit);
|
|
@@ -1010,12 +1096,28 @@ impl Transport for TmuxBackend {
|
|
|
1010
1096
|
attempts,
|
|
1011
1097
|
});
|
|
1012
1098
|
}
|
|
1099
|
+
// U1 #7 (efd189b redo, canonical-native): around the final submit, read
|
|
1100
|
+
// back the pane and confirm the just-pasted token is actually visible.
|
|
1101
|
+
// The static `inject_verification_for_payload` returns CaptureContainsToken
|
|
1102
|
+
// for any token payload WITHOUT checking the pane — a false positive when
|
|
1103
|
+
// the paste silently dropped. A no-echo pane may only render after Enter,
|
|
1104
|
+
// so a pre-submit miss gets one bounded post-submit readback before we
|
|
1105
|
+
// downgrade to CaptureMissingToken.
|
|
1106
|
+
token_visible_for_report =
|
|
1107
|
+
pre_submit_token_visible(self, target, payload).unwrap_or(None);
|
|
1013
1108
|
self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
|
|
1109
|
+
if matches!(token_visible_for_report, Some(false)) {
|
|
1110
|
+
token_visible_for_report =
|
|
1111
|
+
post_submit_token_visible(self, target, payload).unwrap_or(Some(false));
|
|
1112
|
+
}
|
|
1014
1113
|
}
|
|
1015
1114
|
}
|
|
1016
1115
|
Ok(InjectReport {
|
|
1017
1116
|
stage_reached: InjectStage::Submit,
|
|
1018
|
-
inject_verification:
|
|
1117
|
+
inject_verification: inject_verification_after_readback(
|
|
1118
|
+
payload,
|
|
1119
|
+
token_visible_for_report,
|
|
1120
|
+
),
|
|
1019
1121
|
submit_verification: submit_verification_for_key(submit),
|
|
1020
1122
|
turn_verification: match payload {
|
|
1021
1123
|
InjectPayload::Empty => TurnVerification::NotRequired,
|
|
@@ -1207,6 +1309,40 @@ impl Transport for TmuxBackend {
|
|
|
1207
1309
|
.collect())
|
|
1208
1310
|
}
|
|
1209
1311
|
|
|
1312
|
+
fn configure_adaptive_pane_title(
|
|
1313
|
+
&self,
|
|
1314
|
+
session: &SessionName,
|
|
1315
|
+
window: &WindowName,
|
|
1316
|
+
pane: &PaneId,
|
|
1317
|
+
title: &str,
|
|
1318
|
+
) -> Result<(), TransportError> {
|
|
1319
|
+
let target = format!("{}:{}", session.as_str(), window.as_str());
|
|
1320
|
+
self.run_ok(&[
|
|
1321
|
+
"tmux".to_string(),
|
|
1322
|
+
"set-window-option".to_string(),
|
|
1323
|
+
"-t".to_string(),
|
|
1324
|
+
target.clone(),
|
|
1325
|
+
"pane-border-status".to_string(),
|
|
1326
|
+
"bottom".to_string(),
|
|
1327
|
+
])?;
|
|
1328
|
+
self.run_ok(&[
|
|
1329
|
+
"tmux".to_string(),
|
|
1330
|
+
"set-window-option".to_string(),
|
|
1331
|
+
"-t".to_string(),
|
|
1332
|
+
target,
|
|
1333
|
+
"pane-border-format".to_string(),
|
|
1334
|
+
" #{pane_title} ".to_string(),
|
|
1335
|
+
])?;
|
|
1336
|
+
self.run_ok(&[
|
|
1337
|
+
"tmux".to_string(),
|
|
1338
|
+
"select-pane".to_string(),
|
|
1339
|
+
"-t".to_string(),
|
|
1340
|
+
pane.as_str().to_string(),
|
|
1341
|
+
"-T".to_string(),
|
|
1342
|
+
title.to_string(),
|
|
1343
|
+
])
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1210
1346
|
fn set_session_env(
|
|
1211
1347
|
&self,
|
|
1212
1348
|
session: &SessionName,
|