@team-agent/installer 0.3.27 → 0.3.29
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/mod.rs +14 -0
- package/crates/team-agent/src/layout/manager.rs +241 -0
- package/crates/team-agent/src/layout/mod.rs +29 -0
- package/crates/team-agent/src/layout/overlay.rs +122 -0
- package/crates/team-agent/src/layout/placement.rs +47 -0
- package/crates/team-agent/src/layout/recovery.rs +101 -0
- package/crates/team-agent/src/layout/sessions.rs +277 -0
- package/crates/team-agent/src/layout/worker_env.rs +267 -0
- package/crates/team-agent/src/layout/worker_window_helpers.rs +35 -0
- package/crates/team-agent/src/leader/start.rs +32 -14
- package/crates/team-agent/src/leader/tests/identity.rs +30 -15
- package/crates/team-agent/src/lib.rs +3 -0
- package/crates/team-agent/src/lifecycle/launch.rs +58 -65
- package/crates/team-agent/src/lifecycle/restart/agent.rs +18 -10
- package/crates/team-agent/src/lifecycle/restart/common.rs +16 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +49 -75
- package/crates/team-agent/src/messaging/delivery.rs +17 -6
- package/crates/team-agent/src/messaging/results.rs +143 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +94 -15
- package/crates/team-agent/src/tmux_backend.rs +164 -62
- package/crates/team-agent/src/transport/test_support.rs +3 -1
- package/crates/team-agent/src/transport/tests/mod.rs +4 -2
- package/crates/team-agent/src/transport.rs +15 -0
- package/package.json +4 -4
|
@@ -959,6 +959,18 @@ fn format_report_result_notification(
|
|
|
959
959
|
if let Some(tests) = format_report_result_tests(envelope) {
|
|
960
960
|
lines.push(tests);
|
|
961
961
|
}
|
|
962
|
+
if let Some(changes) = format_report_result_changes(envelope) {
|
|
963
|
+
lines.push(changes);
|
|
964
|
+
}
|
|
965
|
+
if let Some(risks) = format_report_result_risks(envelope) {
|
|
966
|
+
lines.push(risks);
|
|
967
|
+
}
|
|
968
|
+
if let Some(artifacts) = format_report_result_artifacts(envelope) {
|
|
969
|
+
lines.push(artifacts);
|
|
970
|
+
}
|
|
971
|
+
if let Some(next_actions) = format_report_result_next_actions(envelope) {
|
|
972
|
+
lines.push(next_actions);
|
|
973
|
+
}
|
|
962
974
|
lines.push(format!("Result id: {result_id}"));
|
|
963
975
|
lines.push(
|
|
964
976
|
"Team Agent stored this result. The coordinator/collect path will update team_state.md; no manual polling loop is needed."
|
|
@@ -986,6 +998,97 @@ fn format_report_result_tests(envelope: &serde_json::Value) -> Option<String> {
|
|
|
986
998
|
}
|
|
987
999
|
}
|
|
988
1000
|
|
|
1001
|
+
fn report_result_array<'a>(
|
|
1002
|
+
envelope: &'a serde_json::Value,
|
|
1003
|
+
key: &str,
|
|
1004
|
+
) -> Option<&'a Vec<serde_json::Value>> {
|
|
1005
|
+
let values = envelope.get(key).and_then(serde_json::Value::as_array)?;
|
|
1006
|
+
if values.is_empty() {
|
|
1007
|
+
None
|
|
1008
|
+
} else {
|
|
1009
|
+
Some(values)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
fn report_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
|
1014
|
+
value
|
|
1015
|
+
.get(key)
|
|
1016
|
+
.and_then(serde_json::Value::as_str)
|
|
1017
|
+
.filter(|text| !text.is_empty())
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
fn report_field_any<'a>(value: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
|
|
1021
|
+
keys.iter().find_map(|key| report_field(value, key))
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
fn format_report_result_changes(envelope: &serde_json::Value) -> Option<String> {
|
|
1025
|
+
let parts = report_result_array(envelope, "changes")?
|
|
1026
|
+
.iter()
|
|
1027
|
+
.filter_map(|change| {
|
|
1028
|
+
let path = report_field_any(change, &["path", "file", "filepath", "filename"])?;
|
|
1029
|
+
let kind = report_field_any(change, &["kind", "type", "action"]).unwrap_or("changed");
|
|
1030
|
+
let description =
|
|
1031
|
+
report_field_any(change, &["description", "summary", "detail", "details", "message"])
|
|
1032
|
+
.unwrap_or(path);
|
|
1033
|
+
Some(format!("{kind} {path}: {description}"))
|
|
1034
|
+
})
|
|
1035
|
+
.collect::<Vec<_>>();
|
|
1036
|
+
if parts.is_empty() {
|
|
1037
|
+
None
|
|
1038
|
+
} else {
|
|
1039
|
+
Some(format!("Changes: {}", parts.join(", ")))
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
fn format_report_result_risks(envelope: &serde_json::Value) -> Option<String> {
|
|
1044
|
+
let parts = report_result_array(envelope, "risks")?
|
|
1045
|
+
.iter()
|
|
1046
|
+
.filter_map(|risk| {
|
|
1047
|
+
let severity = report_field_any(risk, &["severity", "level"]).unwrap_or("low");
|
|
1048
|
+
let description =
|
|
1049
|
+
report_field_any(risk, &["description", "summary", "detail", "message"])?;
|
|
1050
|
+
Some(format!("{severity}: {description}"))
|
|
1051
|
+
})
|
|
1052
|
+
.collect::<Vec<_>>();
|
|
1053
|
+
if parts.is_empty() {
|
|
1054
|
+
None
|
|
1055
|
+
} else {
|
|
1056
|
+
Some(format!("Risks: {}", parts.join(", ")))
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
fn format_report_result_artifacts(envelope: &serde_json::Value) -> Option<String> {
|
|
1061
|
+
let parts = report_result_array(envelope, "artifacts")?
|
|
1062
|
+
.iter()
|
|
1063
|
+
.filter_map(|artifact| {
|
|
1064
|
+
let path = report_field_any(artifact, &["path", "file", "filepath", "filename"])?;
|
|
1065
|
+
let description =
|
|
1066
|
+
report_field_any(artifact, &["description", "summary", "detail"]).unwrap_or(path);
|
|
1067
|
+
Some(format!("{path}: {description}"))
|
|
1068
|
+
})
|
|
1069
|
+
.collect::<Vec<_>>();
|
|
1070
|
+
if parts.is_empty() {
|
|
1071
|
+
None
|
|
1072
|
+
} else {
|
|
1073
|
+
Some(format!("Artifacts: {}", parts.join(", ")))
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
fn format_report_result_next_actions(envelope: &serde_json::Value) -> Option<String> {
|
|
1078
|
+
let parts = report_result_array(envelope, "next_actions")?
|
|
1079
|
+
.iter()
|
|
1080
|
+
.filter_map(|action| {
|
|
1081
|
+
report_field_any(action, &["description", "summary", "action", "todo", "message"])
|
|
1082
|
+
.map(|text| text.to_string())
|
|
1083
|
+
})
|
|
1084
|
+
.collect::<Vec<_>>();
|
|
1085
|
+
if parts.is_empty() {
|
|
1086
|
+
None
|
|
1087
|
+
} else {
|
|
1088
|
+
Some(format!("Next actions: {}", parts.join(", ")))
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
989
1092
|
/// `_collect_results_and_notify_watchers` (`results.py:430`):coordinator tick 调用 —— collect +
|
|
990
1093
|
/// notify_result_watchers 编排。daemon-path → Result。
|
|
991
1094
|
pub fn collect_results_and_notify_watchers(
|
|
@@ -999,3 +1102,43 @@ pub fn collect_results_and_notify_watchers(
|
|
|
999
1102
|
"notified": notified
|
|
1000
1103
|
}))
|
|
1001
1104
|
}
|
|
1105
|
+
|
|
1106
|
+
#[cfg(test)]
|
|
1107
|
+
mod tests {
|
|
1108
|
+
use super::format_report_result_notification;
|
|
1109
|
+
|
|
1110
|
+
#[test]
|
|
1111
|
+
fn report_result_notification_includes_full_envelope_sections() {
|
|
1112
|
+
let envelope = serde_json::json!({
|
|
1113
|
+
"schema_version": "result_envelope_v1",
|
|
1114
|
+
"task_id": "task-1",
|
|
1115
|
+
"agent_id": "worker",
|
|
1116
|
+
"status": "success",
|
|
1117
|
+
"summary": "done",
|
|
1118
|
+
"changes": [
|
|
1119
|
+
{"path": "src/a.rs", "kind": "modified", "description": "patched delivery"}
|
|
1120
|
+
],
|
|
1121
|
+
"tests": [
|
|
1122
|
+
{"command": "cargo test", "status": "passed"}
|
|
1123
|
+
],
|
|
1124
|
+
"risks": [
|
|
1125
|
+
{"severity": "low", "description": "none known"}
|
|
1126
|
+
],
|
|
1127
|
+
"artifacts": [
|
|
1128
|
+
{"path": ".team/artifacts/evidence.md", "description": "evidence"}
|
|
1129
|
+
],
|
|
1130
|
+
"next_actions": [
|
|
1131
|
+
{"description": "ship after review"}
|
|
1132
|
+
]
|
|
1133
|
+
});
|
|
1134
|
+
let notification =
|
|
1135
|
+
format_report_result_notification("res_1", "task-1", "worker", "success", &envelope);
|
|
1136
|
+
assert!(notification.contains("Task task-1 reported success from worker: done"));
|
|
1137
|
+
assert!(notification.contains("Tests: cargo test=passed"));
|
|
1138
|
+
assert!(notification.contains("Changes: modified src/a.rs: patched delivery"));
|
|
1139
|
+
assert!(notification.contains("Risks: low: none known"));
|
|
1140
|
+
assert!(notification.contains("Artifacts: .team/artifacts/evidence.md: evidence"));
|
|
1141
|
+
assert!(notification.contains("Next actions: ship after review"));
|
|
1142
|
+
assert!(notification.contains("Result id: res_1"));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
@@ -573,6 +573,40 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
573
573
|
);
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
#[test]
|
|
577
|
+
fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
|
|
578
|
+
let text = "hello leader [team-agent-token:skip]";
|
|
579
|
+
let (be, rec) = backend_with(MockResp::Out(ok(text)), vec![]);
|
|
580
|
+
let report = be
|
|
581
|
+
.inject(
|
|
582
|
+
&Target::Pane(PaneId::new("%7")),
|
|
583
|
+
&InjectPayload::TextSkipConsumptionPoll(text.to_string()),
|
|
584
|
+
Key::Enter,
|
|
585
|
+
true,
|
|
586
|
+
)
|
|
587
|
+
.expect("inject");
|
|
588
|
+
let calls = rec.lock().unwrap().clone();
|
|
589
|
+
|
|
590
|
+
assert_eq!(
|
|
591
|
+
report.submit_verification,
|
|
592
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
593
|
+
);
|
|
594
|
+
assert_eq!(report.attempts, 1);
|
|
595
|
+
assert!(
|
|
596
|
+
calls.iter().any(|argv| {
|
|
597
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
598
|
+
&& argv.contains(&"Enter".to_string())
|
|
599
|
+
}),
|
|
600
|
+
"skip-consumption payload must still submit once; calls={calls:?}"
|
|
601
|
+
);
|
|
602
|
+
assert!(
|
|
603
|
+
!calls.iter().any(|argv| {
|
|
604
|
+
argv == &tmux_capture_argv(&PaneId::new("%7"), CaptureRange::Tail(40))
|
|
605
|
+
}),
|
|
606
|
+
"leader-bound skip payload must not run Phase 2 consumption polls; calls={calls:?}"
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
576
610
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
577
611
|
// E46 0.3.24 task#327 P0 — submit verification false-positive / false-negative.
|
|
578
612
|
//
|
|
@@ -595,15 +629,15 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
595
629
|
/// report `SubmitConsumptionUnverified`, NOT
|
|
596
630
|
/// `EnterSentWithoutPlaceholderCheck`. Otherwise delivery emits delivered
|
|
597
631
|
/// + message.delivered (假阳) even though the worker never consumed it.
|
|
598
|
-
/// 0.3.
|
|
599
|
-
///
|
|
600
|
-
///
|
|
601
|
-
/// EnterSentWithoutPlaceholderCheck
|
|
602
|
-
///
|
|
603
|
-
///
|
|
604
|
-
///
|
|
632
|
+
/// 0.3.28-final E55 truth source: grace fallback DELETED.
|
|
633
|
+
/// `consumed=Some(false)` (token still in bottom 5 after all retries)
|
|
634
|
+
/// MUST report SubmitConsumptionUnverified unconditionally. Pre-final
|
|
635
|
+
/// returned EnterSentWithoutPlaceholderCheck when Phase-1 saw the token
|
|
636
|
+
/// at all (token_visible_for_report=Some(true)) — but Phase-1 visibility
|
|
637
|
+
/// only proves the paste LANDED, not that the provider CONSUMED it.
|
|
638
|
+
/// That masked E55 busy-agent false positives as delivered=true.
|
|
605
639
|
#[test]
|
|
606
|
-
fn
|
|
640
|
+
fn e46_inject_text_with_token_visible_but_unconsumed_reports_unverified() {
|
|
607
641
|
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
|
|
608
642
|
let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
|
|
609
643
|
let report = be
|
|
@@ -614,20 +648,52 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
614
648
|
true,
|
|
615
649
|
)
|
|
616
650
|
.expect("inject runs");
|
|
617
|
-
// 0.3.
|
|
618
|
-
//
|
|
651
|
+
// 0.3.28-final: no grace fallback. Token visible + not consumed →
|
|
652
|
+
// SubmitConsumptionUnverified (delivery treats as not delivered).
|
|
619
653
|
assert_eq!(
|
|
620
654
|
report.submit_verification,
|
|
621
|
-
SubmitVerification::
|
|
622
|
-
"0.3.
|
|
623
|
-
|
|
624
|
-
EnterSentWithoutPlaceholderCheck
|
|
625
|
-
|
|
655
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
656
|
+
"0.3.28-final: when token still in bottom 5 after all retries, \
|
|
657
|
+
MUST be SubmitConsumptionUnverified — grace fallback masking \
|
|
658
|
+
this as EnterSentWithoutPlaceholderCheck caused E55 false \
|
|
659
|
+
positives (paste landed but busy agent never consumed). \
|
|
660
|
+
Got {:?}",
|
|
626
661
|
report.submit_verification
|
|
627
662
|
);
|
|
628
663
|
assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
|
|
629
664
|
}
|
|
630
665
|
|
|
666
|
+
#[test]
|
|
667
|
+
fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
|
|
668
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_busy]";
|
|
669
|
+
let busy_tail = format!("{token_text}\n● Working (1s · esc to interrupt)\n");
|
|
670
|
+
let (be, _rec) = backend_with(MockResp::Out(ok(&busy_tail)), vec![]);
|
|
671
|
+
let report = be
|
|
672
|
+
.inject(
|
|
673
|
+
&Target::Pane(PaneId::new("%7")),
|
|
674
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
675
|
+
Key::Enter,
|
|
676
|
+
true,
|
|
677
|
+
)
|
|
678
|
+
.expect("inject runs");
|
|
679
|
+
assert_eq!(
|
|
680
|
+
report.submit_verification,
|
|
681
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
682
|
+
"busy provider state means the turn is being processed even if \
|
|
683
|
+
the token has not yet scrolled out of the bottom capture"
|
|
684
|
+
);
|
|
685
|
+
let diagnostics = report.submit_diagnostics.expect("diagnostics");
|
|
686
|
+
assert!(
|
|
687
|
+
diagnostics
|
|
688
|
+
.attempts_detail
|
|
689
|
+
.last()
|
|
690
|
+
.map(|obs| obs.pane_tail_excerpt.to_ascii_lowercase().contains("working"))
|
|
691
|
+
.unwrap_or(false),
|
|
692
|
+
"busy-state capture should be recorded in attempts_detail: {:?}",
|
|
693
|
+
diagnostics.attempts_detail
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
631
697
|
/// 0.3.27: empty pane captures → consumption poll sees "no token in
|
|
632
698
|
/// bottom 5" → consumed=true → EnterSentWithoutPlaceholderCheck. This is
|
|
633
699
|
/// the generous default for panes where the capture can't distinguish
|
|
@@ -967,6 +1033,19 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
967
1033
|
EnterSentWithoutPlaceholderCheck; got {:?}",
|
|
968
1034
|
report.submit_verification
|
|
969
1035
|
);
|
|
1036
|
+
let diagnostics = report.submit_diagnostics.expect("diagnostics");
|
|
1037
|
+
assert!(
|
|
1038
|
+
!diagnostics.attempts_detail.is_empty(),
|
|
1039
|
+
"E50: E46 consumption gate must preserve per-capture diagnostics"
|
|
1040
|
+
);
|
|
1041
|
+
assert_eq!(diagnostics.attempts_detail[0].attempt_index, 1);
|
|
1042
|
+
assert!(
|
|
1043
|
+
diagnostics.attempts_detail[0]
|
|
1044
|
+
.pane_tail_excerpt
|
|
1045
|
+
.contains("Pasted content"),
|
|
1046
|
+
"E50: recorded pane tail should contain the post-submit capture; got {:?}",
|
|
1047
|
+
diagnostics.attempts_detail[0]
|
|
1048
|
+
);
|
|
970
1049
|
}
|
|
971
1050
|
|
|
972
1051
|
/// **E50 PR-1 secret scrubbing**: `scrub_pane_excerpt` must strip ANSI
|
|
@@ -29,7 +29,8 @@ use crate::transport::{
|
|
|
29
29
|
tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv, AttachOutcome, BackendKind,
|
|
30
30
|
CaptureRange, CapturedText, InjectPayload, InjectReport, InjectStage, InjectVerification, Key,
|
|
31
31
|
PaneField, PaneId, PaneInfo, PaneMode, SessionName, SetEnvOutcome, SpawnResult,
|
|
32
|
-
SubmitVerification, Target, Transport, TransportError,
|
|
32
|
+
SubmitAttemptObservation, SubmitVerification, Target, Transport, TransportError,
|
|
33
|
+
TurnVerification, WindowName,
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
/// Result of running an external command — the typed output of the OS edge.
|
|
@@ -815,10 +816,12 @@ fn buffer_name_for_text(text: &str) -> String {
|
|
|
815
816
|
fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerification {
|
|
816
817
|
match payload {
|
|
817
818
|
InjectPayload::Empty => InjectVerification::EmptyTextSendKeys,
|
|
818
|
-
InjectPayload::Text(text)
|
|
819
|
+
InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text)
|
|
820
|
+
if text.contains("[team-agent-token:") =>
|
|
821
|
+
{
|
|
819
822
|
InjectVerification::CaptureContainsToken
|
|
820
823
|
}
|
|
821
|
-
InjectPayload::Text(_) => InjectVerification::NoToken,
|
|
824
|
+
InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => InjectVerification::NoToken,
|
|
822
825
|
}
|
|
823
826
|
}
|
|
824
827
|
|
|
@@ -826,15 +829,11 @@ fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerificatio
|
|
|
826
829
|
/// (`[team-agent-token:<id>]`). Use the full marker, not only the prefix, so an old
|
|
827
830
|
/// scrollback token cannot verify a new message.
|
|
828
831
|
fn payload_token_marker(payload: &InjectPayload) -> Option<&str> {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
Some(&marker[..=end])
|
|
835
|
-
}
|
|
836
|
-
_ => None,
|
|
837
|
-
}
|
|
832
|
+
let text = payload.text()?;
|
|
833
|
+
let start = text.find("[team-agent-token:")?;
|
|
834
|
+
let marker = &text[start..];
|
|
835
|
+
let end = marker.find(']')?;
|
|
836
|
+
Some(&marker[..=end])
|
|
838
837
|
}
|
|
839
838
|
|
|
840
839
|
fn token_visible_in_capture(
|
|
@@ -940,6 +939,74 @@ fn token_in_bottom_n(text: &str, marker: &str, n: usize) -> bool {
|
|
|
940
939
|
.any(|line| line.contains(marker))
|
|
941
940
|
}
|
|
942
941
|
|
|
942
|
+
fn marker_position_from_bottom(text: &str, marker: &str) -> Option<u32> {
|
|
943
|
+
let mut from_bottom = 0u32;
|
|
944
|
+
for line in text.lines().rev().filter(|line| !line.trim().is_empty()) {
|
|
945
|
+
if line.contains(marker) {
|
|
946
|
+
return Some(from_bottom);
|
|
947
|
+
}
|
|
948
|
+
from_bottom = from_bottom.saturating_add(1);
|
|
949
|
+
}
|
|
950
|
+
None
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
fn provider_busy_signal_in_tail(text: &str) -> bool {
|
|
954
|
+
text.lines()
|
|
955
|
+
.rev()
|
|
956
|
+
.filter(|line| !line.trim().is_empty())
|
|
957
|
+
.take(15)
|
|
958
|
+
.any(|line| {
|
|
959
|
+
let lower = line.to_ascii_lowercase();
|
|
960
|
+
lower.contains("working")
|
|
961
|
+
|| lower.contains("thinking")
|
|
962
|
+
|| lower.contains("esc to interrupt")
|
|
963
|
+
|| line.contains('●')
|
|
964
|
+
|| line.contains('⏳')
|
|
965
|
+
|| line.contains('⠋')
|
|
966
|
+
|| line.contains('⠙')
|
|
967
|
+
|| line.contains('⠹')
|
|
968
|
+
|| line.contains('⠸')
|
|
969
|
+
|| line.contains('⠼')
|
|
970
|
+
|| line.contains('⠴')
|
|
971
|
+
|| line.contains('⠦')
|
|
972
|
+
|| line.contains('⠧')
|
|
973
|
+
|| line.contains('⠇')
|
|
974
|
+
|| line.contains('⠏')
|
|
975
|
+
|| line.contains('✶')
|
|
976
|
+
|| line.contains('✢')
|
|
977
|
+
})
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
fn submit_attempt_observation(
|
|
981
|
+
attempt_index: u32,
|
|
982
|
+
captured: &CapturedText,
|
|
983
|
+
marker: Option<&str>,
|
|
984
|
+
elapsed_ms: u64,
|
|
985
|
+
) -> SubmitAttemptObservation {
|
|
986
|
+
let marker_position = marker.and_then(|m| marker_position_from_bottom(&captured.text, m));
|
|
987
|
+
let (matched, matched_literal, where_in_tail) = if let Some(marker) = marker {
|
|
988
|
+
(
|
|
989
|
+
token_in_bottom_n(&captured.text, marker, 15),
|
|
990
|
+
marker_position.map(|_| marker.to_string()),
|
|
991
|
+
marker_position,
|
|
992
|
+
)
|
|
993
|
+
} else if let Some((literal, where_in_tail)) = pasted_prompt_match(&captured.text) {
|
|
994
|
+
(true, Some(literal.to_string()), Some(where_in_tail))
|
|
995
|
+
} else {
|
|
996
|
+
(false, None, None)
|
|
997
|
+
};
|
|
998
|
+
let (pane_tail_excerpt, pane_tail_lines) = scrub_pane_excerpt(&captured.text, 20);
|
|
999
|
+
SubmitAttemptObservation {
|
|
1000
|
+
attempt_index,
|
|
1001
|
+
matched,
|
|
1002
|
+
matched_literal,
|
|
1003
|
+
where_in_tail,
|
|
1004
|
+
pane_tail_excerpt,
|
|
1005
|
+
pane_tail_lines,
|
|
1006
|
+
elapsed_ms,
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
943
1010
|
/// 0.3.27: check if a pasted-content prompt literal (`pasted content` / `pasted text`)
|
|
944
1011
|
/// appears in the bottom N non-empty lines. Narrower than the full-Tail(80) check
|
|
945
1012
|
/// that caused scrollback ghost matches (E50 defect B).
|
|
@@ -1184,15 +1251,15 @@ fn post_submit_input_consumed(
|
|
|
1184
1251
|
let Some(marker) = payload_token_marker(payload) else {
|
|
1185
1252
|
return Ok(None);
|
|
1186
1253
|
};
|
|
1187
|
-
let captured = backend.capture(target, CaptureRange::Tail(
|
|
1254
|
+
let captured = backend.capture(target, CaptureRange::Tail(40))?;
|
|
1188
1255
|
// The token may legitimately appear in scrollback (a successful submit
|
|
1189
1256
|
// pushes it into history). We only treat the BOTTOM-of-pane region (last
|
|
1190
1257
|
// few lines, where the input area lives) as the consumption signal. Tail
|
|
1191
1258
|
// 30 lines is small enough that the input area still dominates if the
|
|
1192
1259
|
// submit didn't go through, while a successful submit has pushed the
|
|
1193
|
-
// token marker out of the bottom
|
|
1260
|
+
// token marker out of the bottom 15 lines by the time the response
|
|
1194
1261
|
// composer redraws.
|
|
1195
|
-
let tail_lines: Vec<&str> = captured.text.lines().rev().take(
|
|
1262
|
+
let tail_lines: Vec<&str> = captured.text.lines().rev().take(15).collect();
|
|
1196
1263
|
let token_in_tail = tail_lines.iter().any(|line| line.contains(marker));
|
|
1197
1264
|
Ok(Some(!token_in_tail))
|
|
1198
1265
|
}
|
|
@@ -1333,7 +1400,7 @@ impl Transport for TmuxBackend {
|
|
|
1333
1400
|
let argv = tmux_empty_inject_argv(&pane, submit);
|
|
1334
1401
|
self.run_ok(&argv)?;
|
|
1335
1402
|
}
|
|
1336
|
-
InjectPayload::Text(text) => {
|
|
1403
|
+
InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text) => {
|
|
1337
1404
|
let buffer = buffer_name_for_text(text);
|
|
1338
1405
|
for argv in tmux_inject_text_argv(&pane, &buffer, text, bracketed) {
|
|
1339
1406
|
let stage = inject_stage_for_argv(&argv);
|
|
@@ -1389,7 +1456,7 @@ impl Transport for TmuxBackend {
|
|
|
1389
1456
|
|
|
1390
1457
|
// Phase 2: submit_and_verify — unified Escape+Enter+poll loop.
|
|
1391
1458
|
let use_escape = bracketed
|
|
1392
|
-
&&
|
|
1459
|
+
&& payload.text().is_some()
|
|
1393
1460
|
&& matches!(submit, Key::Enter);
|
|
1394
1461
|
let escape_argv = if use_escape {
|
|
1395
1462
|
Some(tmux_send_keys_argv(&pane, &[Key::Escape]))
|
|
@@ -1424,16 +1491,33 @@ impl Transport for TmuxBackend {
|
|
|
1424
1491
|
let max_submit_attempts: u32 = 3;
|
|
1425
1492
|
let mut consumption_attempts: u32 = 0;
|
|
1426
1493
|
let mut consumed: Option<bool> = None;
|
|
1494
|
+
let mut attempts_detail: Vec<SubmitAttemptObservation> = Vec::new();
|
|
1427
1495
|
|
|
1428
|
-
|
|
1496
|
+
let poll_consumption = !payload.skip_consumption_poll();
|
|
1497
|
+
if !poll_consumption {
|
|
1498
|
+
if self.run_inject_stage(&submit_argv, InjectStage::Submit).is_ok() {
|
|
1499
|
+
consumption_attempts = 1;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
let submit_attempt_limit = if poll_consumption { max_submit_attempts } else { 0 };
|
|
1503
|
+
|
|
1504
|
+
for attempt in 0..submit_attempt_limit {
|
|
1505
|
+
let attempt_index = attempt + 1;
|
|
1506
|
+
let attempt_start = std::time::Instant::now();
|
|
1429
1507
|
// Before resending (attempt > 0), re-check if the token
|
|
1430
1508
|
// already disappeared — guards against double-submit (C3).
|
|
1431
1509
|
// Capture failures are non-fatal (tmux may not be running
|
|
1432
1510
|
// in MCP sim / test env).
|
|
1433
1511
|
if attempt > 0 {
|
|
1434
1512
|
if let Some(m) = marker {
|
|
1435
|
-
if let Ok(cap) = self.capture(target, CaptureRange::Tail(
|
|
1436
|
-
|
|
1513
|
+
if let Ok(cap) = self.capture(target, CaptureRange::Tail(40)) {
|
|
1514
|
+
attempts_detail.push(submit_attempt_observation(
|
|
1515
|
+
attempt_index,
|
|
1516
|
+
&cap,
|
|
1517
|
+
marker,
|
|
1518
|
+
attempt_start.elapsed().as_millis() as u64,
|
|
1519
|
+
));
|
|
1520
|
+
if !token_in_bottom_n(&cap.text, m, 15) {
|
|
1437
1521
|
consumed = Some(true);
|
|
1438
1522
|
break;
|
|
1439
1523
|
}
|
|
@@ -1441,25 +1525,15 @@ impl Transport for TmuxBackend {
|
|
|
1441
1525
|
}
|
|
1442
1526
|
}
|
|
1443
1527
|
|
|
1444
|
-
//
|
|
1445
|
-
// Escape
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
for _ in 0..10 {
|
|
1454
|
-
match self.capture(target, CaptureRange::Tail(30)) {
|
|
1455
|
-
Ok(cap) if !pasted_prompt_in_bottom(&cap.text, 3) => break,
|
|
1456
|
-
Err(_) => break,
|
|
1457
|
-
_ => {}
|
|
1458
|
-
}
|
|
1459
|
-
std::thread::sleep(Duration::from_millis(50));
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1528
|
+
// 0.3.28-final (E55 false-positive truth source):
|
|
1529
|
+
// Escape retry is DELETED. The researcher established
|
|
1530
|
+
// Escape on Claude TUI with [Pasted content] visible
|
|
1531
|
+
// may CLEAR the composer content rather than exit paste
|
|
1532
|
+
// mode — sending Escape+Enter on retry would submit an
|
|
1533
|
+
// empty message and hide the genuine consumption failure
|
|
1534
|
+
// under a fake-success path. Python parity: ONLY ever
|
|
1535
|
+
// send Enter, never Escape.
|
|
1536
|
+
let _ = escape_argv;
|
|
1463
1537
|
|
|
1464
1538
|
// Enter — send-keys failure is degraded (tmux may not have
|
|
1465
1539
|
// the pane in sim/test env). Break to consumed=None path
|
|
@@ -1478,16 +1552,22 @@ impl Transport for TmuxBackend {
|
|
|
1478
1552
|
.unwrap_or(Some(false));
|
|
1479
1553
|
}
|
|
1480
1554
|
|
|
1481
|
-
// Poll: token disappeared from bottom
|
|
1555
|
+
// Poll: token disappeared from bottom 15 lines = consumed.
|
|
1482
1556
|
// Capture failures → consumed=None (non-blocking).
|
|
1483
1557
|
if let Some(m) = marker {
|
|
1484
1558
|
let mut found_consumed = false;
|
|
1485
1559
|
let mut capture_failed = false;
|
|
1486
|
-
for _ in 0..
|
|
1487
|
-
std::thread::sleep(Duration::from_millis(
|
|
1488
|
-
match self.capture(target, CaptureRange::Tail(
|
|
1560
|
+
for _ in 0..12 {
|
|
1561
|
+
std::thread::sleep(Duration::from_millis(100));
|
|
1562
|
+
match self.capture(target, CaptureRange::Tail(40)) {
|
|
1489
1563
|
Ok(cap) => {
|
|
1490
|
-
|
|
1564
|
+
attempts_detail.push(submit_attempt_observation(
|
|
1565
|
+
attempt_index,
|
|
1566
|
+
&cap,
|
|
1567
|
+
marker,
|
|
1568
|
+
attempt_start.elapsed().as_millis() as u64,
|
|
1569
|
+
));
|
|
1570
|
+
if !token_in_bottom_n(&cap.text, m, 15) {
|
|
1491
1571
|
found_consumed = true;
|
|
1492
1572
|
break;
|
|
1493
1573
|
}
|
|
@@ -1512,23 +1592,45 @@ impl Transport for TmuxBackend {
|
|
|
1512
1592
|
|
|
1513
1593
|
let submit_verification = match consumed {
|
|
1514
1594
|
Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
1515
|
-
Some(false)
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1595
|
+
// 0.3.28-final (E55 truth source): consumed=Some(false)
|
|
1596
|
+
// means we ran ALL retries and the token never left
|
|
1597
|
+
// bottom 15 lines. That is a genuine consumption failure
|
|
1598
|
+
// unless a provider busy marker proves the TUI is already
|
|
1599
|
+
// processing the turn and simply has not scrolled yet;
|
|
1600
|
+
// it MUST NOT be masked. Pre-final used a grace fallback
|
|
1601
|
+
// that returned EnterSentWithoutPlaceholderCheck whenever
|
|
1602
|
+
// token_visible_for_report=Some(true) — but Phase-1 token
|
|
1603
|
+
// visibility only proves the paste landed, NOT that the
|
|
1604
|
+
// provider consumed it. The fallback caused
|
|
1605
|
+
// delivered=true under E55 (busy-agent paste-landed-not-
|
|
1606
|
+
// consumed false positive). Do not restore that grace
|
|
1607
|
+
// fallback; only a live busy-state signal upgrades this to
|
|
1608
|
+
// EnterSentWithoutPlaceholderCheck.
|
|
1609
|
+
Some(false) => match self.capture(target, CaptureRange::Tail(15)) {
|
|
1610
|
+
Ok(cap) => {
|
|
1611
|
+
attempts_detail.push(submit_attempt_observation(
|
|
1612
|
+
consumption_attempts.max(1),
|
|
1613
|
+
&cap,
|
|
1614
|
+
marker,
|
|
1615
|
+
inject_start.elapsed().as_millis() as u64,
|
|
1616
|
+
));
|
|
1617
|
+
let busy = provider_busy_signal_in_tail(&cap.text);
|
|
1618
|
+
eprintln!(
|
|
1619
|
+
"team-agent submit consumption fallback: consumed=false busy_state={busy}"
|
|
1620
|
+
);
|
|
1621
|
+
if busy {
|
|
1622
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
1623
|
+
} else {
|
|
1624
|
+
SubmitVerification::SubmitConsumptionUnverified
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
Err(err) => {
|
|
1628
|
+
eprintln!(
|
|
1629
|
+
"team-agent submit consumption fallback: consumed=false busy_capture_error={err}"
|
|
1630
|
+
);
|
|
1529
1631
|
SubmitVerification::SubmitConsumptionUnverified
|
|
1530
1632
|
}
|
|
1531
|
-
}
|
|
1633
|
+
},
|
|
1532
1634
|
None => submit_verification_for_key(submit),
|
|
1533
1635
|
};
|
|
1534
1636
|
let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
|
|
@@ -1541,14 +1643,14 @@ impl Transport for TmuxBackend {
|
|
|
1541
1643
|
submit_verification,
|
|
1542
1644
|
turn_verification: match payload {
|
|
1543
1645
|
InjectPayload::Empty => TurnVerification::NotRequired,
|
|
1544
|
-
InjectPayload::Text(_) => TurnVerification::NotYetObserved,
|
|
1646
|
+
InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => TurnVerification::NotYetObserved,
|
|
1545
1647
|
},
|
|
1546
1648
|
attempts: consumption_attempts,
|
|
1547
1649
|
submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
|
|
1548
1650
|
appear_gate_elapsed_ms: 0,
|
|
1549
1651
|
appear_gate_matched: false,
|
|
1550
1652
|
total_elapsed_ms,
|
|
1551
|
-
attempts_detail
|
|
1653
|
+
attempts_detail,
|
|
1552
1654
|
}),
|
|
1553
1655
|
});
|
|
1554
1656
|
}
|
|
@@ -1562,7 +1664,7 @@ impl Transport for TmuxBackend {
|
|
|
1562
1664
|
submit_verification: submit_verification_for_key(submit),
|
|
1563
1665
|
turn_verification: match payload {
|
|
1564
1666
|
InjectPayload::Empty => TurnVerification::NotRequired,
|
|
1565
|
-
InjectPayload::Text(_) => TurnVerification::NotYetObserved,
|
|
1667
|
+
InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => TurnVerification::NotYetObserved,
|
|
1566
1668
|
},
|
|
1567
1669
|
attempts: 1,
|
|
1568
1670
|
// E50 PR-1: Empty payload / non-Text fallthrough path — no submit
|