@team-agent/installer 0.3.28 → 0.3.30
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/leader/start.rs +3 -1
- package/crates/team-agent/src/lifecycle/launch.rs +12 -1
- package/crates/team-agent/src/lifecycle/profile_launch.rs +1 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +23 -0
- package/crates/team-agent/src/messaging/delivery.rs +20 -13
- package/crates/team-agent/src/messaging/leader_receiver.rs +4 -9
- package/crates/team-agent/src/messaging/results.rs +143 -0
- package/crates/team-agent/src/messaging/tests/e23.rs +5 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +50 -22
- package/crates/team-agent/src/tmux_backend/tests.rs +90 -22
- package/crates/team-agent/src/tmux_backend.rs +161 -40
- 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
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -493,7 +493,9 @@ fn persist_managed_leader_binding(
|
|
|
493
493
|
.unwrap_or(0)
|
|
494
494
|
.saturating_add(1);
|
|
495
495
|
let now = chrono::Utc::now().to_rfc3339();
|
|
496
|
-
let socket = crate::tmux_backend::
|
|
496
|
+
let socket = crate::tmux_backend::socket_path_for_workspace(workspace)
|
|
497
|
+
.map(|path| path.to_string_lossy().to_string())
|
|
498
|
+
.unwrap_or_else(|| crate::tmux_backend::socket_name_for_workspace(workspace));
|
|
497
499
|
let provider = serde_json::to_value(plan.provider)?;
|
|
498
500
|
let session = spawned.session.as_str().to_string();
|
|
499
501
|
let window = spawned.window.as_str().to_string();
|
|
@@ -2985,8 +2985,19 @@ pub(crate) fn annotate_runtime_tmux_endpoint(state: &mut serde_json::Value, tran
|
|
|
2985
2985
|
let Some(endpoint) = transport.tmux_endpoint() else {
|
|
2986
2986
|
return;
|
|
2987
2987
|
};
|
|
2988
|
+
let endpoint_for_state = if Path::new(&endpoint).is_absolute() || endpoint == "default" {
|
|
2989
|
+
endpoint.clone()
|
|
2990
|
+
} else if endpoint == crate::tmux_backend::socket_name_for_workspace(workspace) {
|
|
2991
|
+
crate::tmux_backend::socket_path_for_workspace(workspace)
|
|
2992
|
+
.map(|path| path.to_string_lossy().to_string())
|
|
2993
|
+
.unwrap_or_else(|| endpoint.clone())
|
|
2994
|
+
} else {
|
|
2995
|
+
crate::tmux_backend::socket_path_for_name(&endpoint)
|
|
2996
|
+
.map(|path| path.to_string_lossy().to_string())
|
|
2997
|
+
.unwrap_or_else(|| endpoint.clone())
|
|
2998
|
+
};
|
|
2988
2999
|
if let Some(obj) = state.as_object_mut() {
|
|
2989
|
-
obj.insert("tmux_endpoint".to_string(), serde_json::json!(
|
|
3000
|
+
obj.insert("tmux_endpoint".to_string(), serde_json::json!(endpoint_for_state));
|
|
2990
3001
|
obj.insert(
|
|
2991
3002
|
"tmux_socket".to_string(),
|
|
2992
3003
|
obj.get("tmux_endpoint")
|
|
@@ -157,9 +157,7 @@ fn prepare_profile_launch(
|
|
|
157
157
|
let mut claude_projects_root = None;
|
|
158
158
|
let mut managed_mcp_config = false;
|
|
159
159
|
|
|
160
|
-
if matches!(agent.provider, Provider::Claude | Provider::ClaudeCode)
|
|
161
|
-
&& agent.auth_mode == AuthMode::CompatibleApi
|
|
162
|
-
{
|
|
160
|
+
if matches!(agent.provider, Provider::Claude | Provider::ClaudeCode) {
|
|
163
161
|
let dir = compatible_claude_config_dir(workspace, &agent.id)?;
|
|
164
162
|
if let Some(config) = mcp_config {
|
|
165
163
|
ensure_compatible_claude_mcp_config(&dir, workspace, config)?;
|
|
@@ -1192,6 +1192,29 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
|
|
|
1192
1192
|
assert_eq!(state["teams"]["teamdir"]["is_external_leader"], json!(false));
|
|
1193
1193
|
}
|
|
1194
1194
|
|
|
1195
|
+
#[test]
|
|
1196
|
+
fn annotate_runtime_tmux_endpoint_persists_workspace_socket_as_full_path() {
|
|
1197
|
+
let workspace = temp_ws();
|
|
1198
|
+
let short = crate::tmux_backend::socket_name_for_workspace(&workspace);
|
|
1199
|
+
let expected = crate::tmux_backend::socket_path_for_workspace(&workspace)
|
|
1200
|
+
.expect("workspace socket should have a physical path");
|
|
1201
|
+
let transport = OfflineTransport::new().with_tmux_endpoint(short);
|
|
1202
|
+
let mut state = json!({});
|
|
1203
|
+
|
|
1204
|
+
annotate_runtime_tmux_endpoint(&mut state, &transport, &workspace);
|
|
1205
|
+
|
|
1206
|
+
assert_eq!(
|
|
1207
|
+
state["tmux_endpoint"],
|
|
1208
|
+
json!(expected.to_string_lossy().to_string()),
|
|
1209
|
+
"runtime endpoint state must persist the full tmux socket path, not the short -L name"
|
|
1210
|
+
);
|
|
1211
|
+
assert_eq!(
|
|
1212
|
+
state["tmux_socket"],
|
|
1213
|
+
state["tmux_endpoint"],
|
|
1214
|
+
"tmux_socket mirrors the canonical persisted endpoint"
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1195
1218
|
#[test]
|
|
1196
1219
|
fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command() {
|
|
1197
1220
|
let team = quick_start_team_dir(QS_VALID_ROLE);
|
|
@@ -296,8 +296,14 @@ pub fn deliver_pending_message(
|
|
|
296
296
|
&message.content,
|
|
297
297
|
message_id,
|
|
298
298
|
);
|
|
299
|
+
let is_leader_recipient = message.recipient == "leader";
|
|
300
|
+
let payload = if is_leader_recipient {
|
|
301
|
+
InjectPayload::TextSkipConsumptionPoll(rendered)
|
|
302
|
+
} else {
|
|
303
|
+
InjectPayload::Text(rendered)
|
|
304
|
+
};
|
|
299
305
|
let inject_report =
|
|
300
|
-
match transport.inject(&target, &
|
|
306
|
+
match transport.inject(&target, &payload, Key::Enter, true) {
|
|
301
307
|
Ok(report) => report,
|
|
302
308
|
Err(error) => {
|
|
303
309
|
let reason = format!("inject_failed:{error}");
|
|
@@ -373,13 +379,18 @@ pub fn deliver_pending_message(
|
|
|
373
379
|
channel: None,
|
|
374
380
|
});
|
|
375
381
|
}
|
|
376
|
-
|
|
382
|
+
};
|
|
377
383
|
let submit_verified = inject_submit_verified(&inject_report);
|
|
378
384
|
let readback_verified = pane_readback_verified(&inject_report);
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
385
|
+
// Leader pane: inject success is delivery proof. Worker pane: post-submit
|
|
386
|
+
// evidence is the delivery proof; stale Phase-1 readback must not veto it
|
|
387
|
+
// and cannot independently prove the current Enter submitted.
|
|
388
|
+
let verified = if is_leader_recipient {
|
|
389
|
+
true
|
|
390
|
+
} else {
|
|
391
|
+
submit_verified
|
|
392
|
+
};
|
|
393
|
+
if !verified {
|
|
383
394
|
let reason = if !readback_verified {
|
|
384
395
|
"pane_readback_unverified:capture_missing_token".to_string()
|
|
385
396
|
} else {
|
|
@@ -496,13 +507,9 @@ pub(crate) fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
|
496
507
|
}
|
|
497
508
|
}
|
|
498
509
|
|
|
499
|
-
/// U1 #7 step-2: pane-readback gate.
|
|
500
|
-
///
|
|
501
|
-
///
|
|
502
|
-
/// verified when BOTH the submit succeeded AND the token was read back as visible.
|
|
503
|
-
/// `CaptureMissingToken` → readback failed → not delivered (degraded/unverified). All
|
|
504
|
-
/// other inject_verification variants (incl. token-less payloads, empty sends, new
|
|
505
|
-
/// pasted-content prompt) are not readback-negative and pass this gate.
|
|
510
|
+
/// U1 #7 step-2: pane-readback gate. `CaptureMissingToken` is negative readback
|
|
511
|
+
/// evidence, but 0.3.30 submit evidence can supersede it: consumption or
|
|
512
|
+
/// post-submit token observation proves the message reached the pane.
|
|
506
513
|
/// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
|
|
507
514
|
pub(crate) fn pane_readback_verified(report: &InjectReport) -> bool {
|
|
508
515
|
!matches!(
|
|
@@ -393,17 +393,12 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
393
393
|
|
|
394
394
|
match inject_result {
|
|
395
395
|
Ok(report) => {
|
|
396
|
-
// 0.3.
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// deliver_pending_message uses (delivery.rs:376-381):
|
|
400
|
-
// (a) inject_submit_verified — Enter was accepted by the TUI
|
|
401
|
-
// (b) pane_readback_verified — token was visible in the pane
|
|
402
|
-
// Both must pass to mark delivered; otherwise degrade to
|
|
403
|
-
// submitted_unverified (loud, not silent).
|
|
396
|
+
// 0.3.30: submit verification is enough for fallback-pane
|
|
397
|
+
// delivery. Readback is retained for diagnostics when submit
|
|
398
|
+
// itself is unverified.
|
|
404
399
|
let submit_ok = super::delivery::inject_submit_verified(&report);
|
|
405
400
|
let readback_ok = super::delivery::pane_readback_verified(&report);
|
|
406
|
-
if submit_ok
|
|
401
|
+
if submit_ok {
|
|
407
402
|
store.mark(message_id, "delivered", None)?;
|
|
408
403
|
event_log.write(
|
|
409
404
|
"leader_receiver.fallback_pane_submitted",
|
|
@@ -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
|
+
}
|
|
@@ -6,6 +6,11 @@ fn e23_fallback_pane_has_single_shared_noisy_surface() {
|
|
|
6
6
|
assert!(leader_receiver.contains("leader_receiver.fallback_pane_submitted"));
|
|
7
7
|
assert!(leader_receiver.contains("leader_receiver.fallback_pane_failed"));
|
|
8
8
|
assert!(leader_receiver.contains("delivered_via=fallback_pane"));
|
|
9
|
+
assert!(
|
|
10
|
+
leader_receiver.contains("if submit_ok {")
|
|
11
|
+
&& !leader_receiver.contains("submit_ok && readback_ok"),
|
|
12
|
+
"fallback pane delivery must accept submit_ok alone; stale readback must not veto it"
|
|
13
|
+
);
|
|
9
14
|
assert!(
|
|
10
15
|
leader_receiver.find("if primary_ok").unwrap()
|
|
11
16
|
< leader_receiver
|
|
@@ -1269,7 +1269,9 @@ fn fire_due_scheduled_events_fires_each_scheduled_kind() {
|
|
|
1269
1269
|
);
|
|
1270
1270
|
}
|
|
1271
1271
|
|
|
1272
|
-
struct UnverifiedInjectTransport
|
|
1272
|
+
struct UnverifiedInjectTransport {
|
|
1273
|
+
readback_visible: bool,
|
|
1274
|
+
}
|
|
1273
1275
|
impl Transport for UnverifiedInjectTransport {
|
|
1274
1276
|
fn kind(&self) -> BackendKind {
|
|
1275
1277
|
BackendKind::Tmux
|
|
@@ -1303,7 +1305,11 @@ impl Transport for UnverifiedInjectTransport {
|
|
|
1303
1305
|
) -> Result<InjectReport, TransportError> {
|
|
1304
1306
|
Ok(InjectReport {
|
|
1305
1307
|
stage_reached: crate::transport::InjectStage::Submit,
|
|
1306
|
-
inject_verification:
|
|
1308
|
+
inject_verification: if self.readback_visible {
|
|
1309
|
+
crate::transport::InjectVerification::CaptureContainsToken
|
|
1310
|
+
} else {
|
|
1311
|
+
crate::transport::InjectVerification::CaptureMissingToken
|
|
1312
|
+
},
|
|
1307
1313
|
submit_verification:
|
|
1308
1314
|
crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
|
|
1309
1315
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
@@ -1441,12 +1447,8 @@ impl Transport for FailingInjectTransport {
|
|
|
1441
1447
|
}
|
|
1442
1448
|
}
|
|
1443
1449
|
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
// the step-1 tmux_backend readback) must NOT be counted delivered, even when the SUBMIT
|
|
1447
|
-
// itself verified. Today the delivery gate only consulted submit_verification → a silent
|
|
1448
|
-
// paste-drop reported delivered (false green). This transport returns a verified submit
|
|
1449
|
-
// (KeySentAfterVisibleToken) but a missing-token readback.
|
|
1450
|
+
// 0.3.30: submit evidence supersedes stale readback. This transport returns a
|
|
1451
|
+
// verified submit but a missing-token readback.
|
|
1450
1452
|
struct ReadbackMissingTransport;
|
|
1451
1453
|
impl Transport for ReadbackMissingTransport {
|
|
1452
1454
|
fn kind(&self) -> BackendKind {
|
|
@@ -1507,7 +1509,7 @@ impl Transport for ReadbackMissingTransport {
|
|
|
1507
1509
|
}
|
|
1508
1510
|
|
|
1509
1511
|
#[test]
|
|
1510
|
-
fn
|
|
1512
|
+
fn deliver_pending_submit_verified_overrides_missing_readback() {
|
|
1511
1513
|
let ws = tmp_ws("readbackmiss");
|
|
1512
1514
|
let store = store_for(&ws);
|
|
1513
1515
|
let log = EventLog::new(&ws);
|
|
@@ -1532,23 +1534,47 @@ fn deliver_pending_readback_missing_token_is_not_delivered() {
|
|
|
1532
1534
|
.unwrap();
|
|
1533
1535
|
|
|
1534
1536
|
assert!(
|
|
1535
|
-
|
|
1536
|
-
"
|
|
1537
|
-
must NOT be delivered — readback gate catches the silent paste drop"
|
|
1537
|
+
out.ok,
|
|
1538
|
+
"0.3.30: submit-verified delivery must not be vetoed by stale CaptureMissingToken readback"
|
|
1538
1539
|
);
|
|
1539
|
-
|
|
1540
|
+
assert_eq!(
|
|
1540
1541
|
out.message_status.0, "delivered",
|
|
1541
|
-
"
|
|
1542
|
+
"0.3.30: submit evidence is stronger than readback"
|
|
1542
1543
|
);
|
|
1543
|
-
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
#[test]
|
|
1547
|
+
fn deliver_pending_submit_unverified_is_not_saved_by_readback() {
|
|
1548
|
+
let ws = tmp_ws("readbacksaves");
|
|
1549
|
+
let store = store_for(&ws);
|
|
1550
|
+
let log = EventLog::new(&ws);
|
|
1551
|
+
let state = serde_json::json!({
|
|
1552
|
+
"session_name": "team-readbacksaves",
|
|
1553
|
+
"leader_receiver": {"pane_id": "%leader"},
|
|
1554
|
+
"agents": {"w1": {"provider": "fake", "pane_id": "%1"}}
|
|
1555
|
+
});
|
|
1556
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
1557
|
+
let message_id = store
|
|
1558
|
+
.create_message(None, "leader", "w1", "ping", None, false, None)
|
|
1559
|
+
.unwrap();
|
|
1560
|
+
|
|
1561
|
+
let out = deliver_pending_message(
|
|
1562
|
+
&ws,
|
|
1563
|
+
&store,
|
|
1564
|
+
&UnverifiedInjectTransport {
|
|
1565
|
+
readback_visible: true,
|
|
1566
|
+
},
|
|
1567
|
+
&message_id,
|
|
1568
|
+
&log,
|
|
1569
|
+
&state,
|
|
1570
|
+
)
|
|
1571
|
+
.unwrap();
|
|
1572
|
+
|
|
1544
1573
|
assert!(
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
.and_then(serde_json::Value::as_str)
|
|
1548
|
-
.map(|r| r.contains("pane_readback_unverified"))
|
|
1549
|
-
.unwrap_or(false)),
|
|
1550
|
-
"U1 #7: the failure reason must name pane_readback_unverified (loud, not silent); got {events:?}"
|
|
1574
|
+
!out.ok,
|
|
1575
|
+
"0.3.30: positive readback alone must not mark delivered when submit is unverified"
|
|
1551
1576
|
);
|
|
1577
|
+
assert_ne!(out.message_status.0, "delivered");
|
|
1552
1578
|
}
|
|
1553
1579
|
|
|
1554
1580
|
#[test]
|
|
@@ -1569,7 +1595,9 @@ fn deliver_pending_exhausted_unverified_send_emits_failed_event() {
|
|
|
1569
1595
|
let out = deliver_pending_message(
|
|
1570
1596
|
&ws,
|
|
1571
1597
|
&store,
|
|
1572
|
-
&UnverifiedInjectTransport
|
|
1598
|
+
&UnverifiedInjectTransport {
|
|
1599
|
+
readback_visible: false,
|
|
1600
|
+
},
|
|
1573
1601
|
&message_id,
|
|
1574
1602
|
&log,
|
|
1575
1603
|
&state,
|
|
@@ -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
|
//
|
|
@@ -589,21 +623,11 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
589
623
|
// no longer in the bottom 5 lines of the pane = composer cleared).
|
|
590
624
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
591
625
|
|
|
592
|
-
///
|
|
593
|
-
///
|
|
594
|
-
///
|
|
595
|
-
/// report `SubmitConsumptionUnverified`, NOT
|
|
596
|
-
/// `EnterSentWithoutPlaceholderCheck`. Otherwise delivery emits delivered
|
|
597
|
-
/// + message.delivered (假阳) even though the worker never consumed it.
|
|
598
|
-
/// 0.3.28-final E55 truth source: grace fallback DELETED.
|
|
599
|
-
/// `consumed=Some(false)` (token still in bottom 5 after all retries)
|
|
600
|
-
/// MUST report SubmitConsumptionUnverified unconditionally. Pre-final
|
|
601
|
-
/// returned EnterSentWithoutPlaceholderCheck when Phase-1 saw the token
|
|
602
|
-
/// at all (token_visible_for_report=Some(true)) — but Phase-1 visibility
|
|
603
|
-
/// only proves the paste LANDED, not that the provider CONSUMED it.
|
|
604
|
-
/// That masked E55 busy-agent false positives as delivered=true.
|
|
626
|
+
/// 0.3.30 false-negative fix: token seen during post-submit consumption
|
|
627
|
+
/// polling proves the paste landed after Enter was sent. If it never
|
|
628
|
+
/// scrolls away, that is slow provider output, not transport failure.
|
|
605
629
|
#[test]
|
|
606
|
-
fn
|
|
630
|
+
fn e46_post_submit_matched_token_without_scroll_is_verified() {
|
|
607
631
|
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
|
|
608
632
|
let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
|
|
609
633
|
let report = be
|
|
@@ -614,19 +638,50 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
614
638
|
true,
|
|
615
639
|
)
|
|
616
640
|
.expect("inject runs");
|
|
617
|
-
// 0.3.28-final: no grace fallback. Token visible + not consumed →
|
|
618
|
-
// SubmitConsumptionUnverified (delivery treats as not delivered).
|
|
619
641
|
assert_eq!(
|
|
620
642
|
report.submit_verification,
|
|
621
|
-
SubmitVerification::
|
|
622
|
-
"0.3.
|
|
623
|
-
|
|
624
|
-
this as EnterSentWithoutPlaceholderCheck caused E55 false \
|
|
625
|
-
positives (paste landed but busy agent never consumed). \
|
|
626
|
-
Got {:?}",
|
|
643
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
644
|
+
"0.3.30: post-submit matched=true is delivery proof even when \
|
|
645
|
+
the token stays in the bottom capture window. Got {:?}",
|
|
627
646
|
report.submit_verification
|
|
628
647
|
);
|
|
629
648
|
assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
|
|
649
|
+
let diagnostics = report.submit_diagnostics.expect("diagnostics");
|
|
650
|
+
assert!(
|
|
651
|
+
diagnostics.attempts_detail.iter().any(|obs| obs.matched),
|
|
652
|
+
"the positive verdict must be backed by a post-submit matched observation"
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
#[test]
|
|
657
|
+
fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
|
|
658
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_busy]";
|
|
659
|
+
let busy_tail = format!("{token_text}\n● Working (1s · esc to interrupt)\n");
|
|
660
|
+
let (be, _rec) = backend_with(MockResp::Out(ok(&busy_tail)), vec![]);
|
|
661
|
+
let report = be
|
|
662
|
+
.inject(
|
|
663
|
+
&Target::Pane(PaneId::new("%7")),
|
|
664
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
665
|
+
Key::Enter,
|
|
666
|
+
true,
|
|
667
|
+
)
|
|
668
|
+
.expect("inject runs");
|
|
669
|
+
assert_eq!(
|
|
670
|
+
report.submit_verification,
|
|
671
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
672
|
+
"busy provider state means the turn is being processed even if \
|
|
673
|
+
the token has not yet scrolled out of the bottom capture"
|
|
674
|
+
);
|
|
675
|
+
let diagnostics = report.submit_diagnostics.expect("diagnostics");
|
|
676
|
+
assert!(
|
|
677
|
+
diagnostics
|
|
678
|
+
.attempts_detail
|
|
679
|
+
.last()
|
|
680
|
+
.map(|obs| obs.pane_tail_excerpt.to_ascii_lowercase().contains("working"))
|
|
681
|
+
.unwrap_or(false),
|
|
682
|
+
"busy-state capture should be recorded in attempts_detail: {:?}",
|
|
683
|
+
diagnostics.attempts_detail
|
|
684
|
+
);
|
|
630
685
|
}
|
|
631
686
|
|
|
632
687
|
/// 0.3.27: empty pane captures → consumption poll sees "no token in
|
|
@@ -968,6 +1023,19 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
968
1023
|
EnterSentWithoutPlaceholderCheck; got {:?}",
|
|
969
1024
|
report.submit_verification
|
|
970
1025
|
);
|
|
1026
|
+
let diagnostics = report.submit_diagnostics.expect("diagnostics");
|
|
1027
|
+
assert!(
|
|
1028
|
+
!diagnostics.attempts_detail.is_empty(),
|
|
1029
|
+
"E50: E46 consumption gate must preserve per-capture diagnostics"
|
|
1030
|
+
);
|
|
1031
|
+
assert_eq!(diagnostics.attempts_detail[0].attempt_index, 1);
|
|
1032
|
+
assert!(
|
|
1033
|
+
diagnostics.attempts_detail[0]
|
|
1034
|
+
.pane_tail_excerpt
|
|
1035
|
+
.contains("Pasted content"),
|
|
1036
|
+
"E50: recorded pane tail should contain the post-submit capture; got {:?}",
|
|
1037
|
+
diagnostics.attempts_detail[0]
|
|
1038
|
+
);
|
|
971
1039
|
}
|
|
972
1040
|
|
|
973
1041
|
/// **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,38 @@ 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();
|
|
1495
|
+
let mut any_attempt_matched = false;
|
|
1427
1496
|
|
|
1428
|
-
|
|
1497
|
+
let poll_consumption = !payload.skip_consumption_poll();
|
|
1498
|
+
if !poll_consumption {
|
|
1499
|
+
if self.run_inject_stage(&submit_argv, InjectStage::Submit).is_ok() {
|
|
1500
|
+
consumption_attempts = 1;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
let submit_attempt_limit = if poll_consumption { max_submit_attempts } else { 0 };
|
|
1504
|
+
|
|
1505
|
+
for attempt in 0..submit_attempt_limit {
|
|
1506
|
+
let attempt_index = attempt + 1;
|
|
1507
|
+
let attempt_start = std::time::Instant::now();
|
|
1429
1508
|
// Before resending (attempt > 0), re-check if the token
|
|
1430
1509
|
// already disappeared — guards against double-submit (C3).
|
|
1431
1510
|
// Capture failures are non-fatal (tmux may not be running
|
|
1432
1511
|
// in MCP sim / test env).
|
|
1433
1512
|
if attempt > 0 {
|
|
1434
1513
|
if let Some(m) = marker {
|
|
1435
|
-
if let Ok(cap) = self.capture(target, CaptureRange::Tail(
|
|
1436
|
-
|
|
1514
|
+
if let Ok(cap) = self.capture(target, CaptureRange::Tail(40)) {
|
|
1515
|
+
let obs = submit_attempt_observation(
|
|
1516
|
+
attempt_index,
|
|
1517
|
+
&cap,
|
|
1518
|
+
marker,
|
|
1519
|
+
attempt_start.elapsed().as_millis() as u64,
|
|
1520
|
+
);
|
|
1521
|
+
if obs.matched {
|
|
1522
|
+
any_attempt_matched = true;
|
|
1523
|
+
}
|
|
1524
|
+
attempts_detail.push(obs);
|
|
1525
|
+
if !token_in_bottom_n(&cap.text, m, 15) {
|
|
1437
1526
|
consumed = Some(true);
|
|
1438
1527
|
break;
|
|
1439
1528
|
}
|
|
@@ -1468,16 +1557,26 @@ impl Transport for TmuxBackend {
|
|
|
1468
1557
|
.unwrap_or(Some(false));
|
|
1469
1558
|
}
|
|
1470
1559
|
|
|
1471
|
-
// Poll: token disappeared from bottom
|
|
1560
|
+
// Poll: token disappeared from bottom 15 lines = consumed.
|
|
1472
1561
|
// Capture failures → consumed=None (non-blocking).
|
|
1473
1562
|
if let Some(m) = marker {
|
|
1474
1563
|
let mut found_consumed = false;
|
|
1475
1564
|
let mut capture_failed = false;
|
|
1476
|
-
for _ in 0..
|
|
1477
|
-
std::thread::sleep(Duration::from_millis(
|
|
1478
|
-
match self.capture(target, CaptureRange::Tail(
|
|
1565
|
+
for _ in 0..12 {
|
|
1566
|
+
std::thread::sleep(Duration::from_millis(100));
|
|
1567
|
+
match self.capture(target, CaptureRange::Tail(40)) {
|
|
1479
1568
|
Ok(cap) => {
|
|
1480
|
-
|
|
1569
|
+
let obs = submit_attempt_observation(
|
|
1570
|
+
attempt_index,
|
|
1571
|
+
&cap,
|
|
1572
|
+
marker,
|
|
1573
|
+
attempt_start.elapsed().as_millis() as u64,
|
|
1574
|
+
);
|
|
1575
|
+
if obs.matched {
|
|
1576
|
+
any_attempt_matched = true;
|
|
1577
|
+
}
|
|
1578
|
+
attempts_detail.push(obs);
|
|
1579
|
+
if !token_in_bottom_n(&cap.text, m, 15) {
|
|
1481
1580
|
found_consumed = true;
|
|
1482
1581
|
break;
|
|
1483
1582
|
}
|
|
@@ -1502,18 +1601,40 @@ impl Transport for TmuxBackend {
|
|
|
1502
1601
|
|
|
1503
1602
|
let submit_verification = match consumed {
|
|
1504
1603
|
Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1604
|
+
Some(false) => {
|
|
1605
|
+
if any_attempt_matched {
|
|
1606
|
+
eprintln!(
|
|
1607
|
+
"team-agent submit consumption: consumed=false any_attempt_matched=true -> verified"
|
|
1608
|
+
);
|
|
1609
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
1610
|
+
} else {
|
|
1611
|
+
match self.capture(target, CaptureRange::Tail(15)) {
|
|
1612
|
+
Ok(cap) => {
|
|
1613
|
+
attempts_detail.push(submit_attempt_observation(
|
|
1614
|
+
consumption_attempts.max(1),
|
|
1615
|
+
&cap,
|
|
1616
|
+
marker,
|
|
1617
|
+
inject_start.elapsed().as_millis() as u64,
|
|
1618
|
+
));
|
|
1619
|
+
let busy = provider_busy_signal_in_tail(&cap.text);
|
|
1620
|
+
eprintln!(
|
|
1621
|
+
"team-agent submit consumption fallback: consumed=false any_attempt_matched=false busy_state={busy}"
|
|
1622
|
+
);
|
|
1623
|
+
if busy {
|
|
1624
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck
|
|
1625
|
+
} else {
|
|
1626
|
+
SubmitVerification::SubmitConsumptionUnverified
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
Err(err) => {
|
|
1630
|
+
eprintln!(
|
|
1631
|
+
"team-agent submit consumption fallback: consumed=false busy_capture_error={err}"
|
|
1632
|
+
);
|
|
1633
|
+
SubmitVerification::SubmitConsumptionUnverified
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1517
1638
|
None => submit_verification_for_key(submit),
|
|
1518
1639
|
};
|
|
1519
1640
|
let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
|
|
@@ -1526,14 +1647,14 @@ impl Transport for TmuxBackend {
|
|
|
1526
1647
|
submit_verification,
|
|
1527
1648
|
turn_verification: match payload {
|
|
1528
1649
|
InjectPayload::Empty => TurnVerification::NotRequired,
|
|
1529
|
-
InjectPayload::Text(_) => TurnVerification::NotYetObserved,
|
|
1650
|
+
InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => TurnVerification::NotYetObserved,
|
|
1530
1651
|
},
|
|
1531
1652
|
attempts: consumption_attempts,
|
|
1532
1653
|
submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
|
|
1533
1654
|
appear_gate_elapsed_ms: 0,
|
|
1534
1655
|
appear_gate_matched: false,
|
|
1535
1656
|
total_elapsed_ms,
|
|
1536
|
-
attempts_detail
|
|
1657
|
+
attempts_detail,
|
|
1537
1658
|
}),
|
|
1538
1659
|
});
|
|
1539
1660
|
}
|
|
@@ -1547,7 +1668,7 @@ impl Transport for TmuxBackend {
|
|
|
1547
1668
|
submit_verification: submit_verification_for_key(submit),
|
|
1548
1669
|
turn_verification: match payload {
|
|
1549
1670
|
InjectPayload::Empty => TurnVerification::NotRequired,
|
|
1550
|
-
InjectPayload::Text(_) => TurnVerification::NotYetObserved,
|
|
1671
|
+
InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => TurnVerification::NotYetObserved,
|
|
1551
1672
|
},
|
|
1552
1673
|
attempts: 1,
|
|
1553
1674
|
// E50 PR-1: Empty payload / non-Text fallthrough path — no submit
|
|
@@ -337,7 +337,9 @@ impl Transport for OfflineTransport {
|
|
|
337
337
|
state.inject_targets.push(target.clone());
|
|
338
338
|
state.inject_payloads.push(match payload {
|
|
339
339
|
InjectPayload::Empty => String::new(),
|
|
340
|
-
InjectPayload::Text(text)
|
|
340
|
+
InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text) => {
|
|
341
|
+
text.clone()
|
|
342
|
+
}
|
|
341
343
|
});
|
|
342
344
|
});
|
|
343
345
|
Ok(Self::inject_report())
|
|
@@ -55,11 +55,13 @@
|
|
|
55
55
|
InjectVerification::EmptyTextSendKeys,
|
|
56
56
|
TurnVerification::NotRequired,
|
|
57
57
|
),
|
|
58
|
-
InjectPayload::Text(text)
|
|
58
|
+
InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text)
|
|
59
|
+
if text.contains("[team-agent-token:") =>
|
|
60
|
+
(
|
|
59
61
|
InjectVerification::CaptureContainsToken,
|
|
60
62
|
TurnVerification::NotYetObserved,
|
|
61
63
|
),
|
|
62
|
-
InjectPayload::Text(_) => (
|
|
64
|
+
InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => (
|
|
63
65
|
InjectVerification::NoToken,
|
|
64
66
|
TurnVerification::NotYetObserved,
|
|
65
67
|
),
|
|
@@ -141,6 +141,21 @@ pub enum InjectPayload {
|
|
|
141
141
|
/// → 纯 send submit-key。
|
|
142
142
|
Empty,
|
|
143
143
|
Text(String),
|
|
144
|
+
/// Text payload for human-facing panes that do not consume provider turns.
|
|
145
|
+
TextSkipConsumptionPoll(String),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
impl InjectPayload {
|
|
149
|
+
pub fn text(&self) -> Option<&str> {
|
|
150
|
+
match self {
|
|
151
|
+
Self::Text(text) | Self::TextSkipConsumptionPoll(text) => Some(text),
|
|
152
|
+
Self::Empty => None,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pub fn skip_consumption_poll(&self) -> bool {
|
|
157
|
+
matches!(self, Self::TextSkipConsumptionPoll(_))
|
|
158
|
+
}
|
|
144
159
|
}
|
|
145
160
|
|
|
146
161
|
/// 抽象 Key 枚举(§gap-5):各后端翻译,不透传 tmux 字面量。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.30",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.3.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.3.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.3.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.3.30",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.3.30",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.3.30"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|