@team-agent/installer 0.3.25 → 0.3.27
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 +1 -0
- package/crates/team-agent/src/cli/mod.rs +193 -7
- package/crates/team-agent/src/cli/send.rs +106 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
- package/crates/team-agent/src/leader/lease.rs +57 -0
- package/crates/team-agent/src/leader/start.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +9 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +55 -0
- package/crates/team-agent/src/messaging/delivery.rs +83 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +61 -24
- package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +227 -25
- package/crates/team-agent/src/tmux_backend.rs +409 -105
- package/crates/team-agent/src/transport/test_support.rs +1 -0
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport/tests/wire.rs +2 -1
- package/crates/team-agent/src/transport.rs +77 -0
- package/npm/install.mjs +222 -14
- package/package.json +4 -4
|
@@ -348,6 +348,7 @@ pub fn deliver_pending_message(
|
|
|
348
348
|
attempt,
|
|
349
349
|
"send_inject_exhausted",
|
|
350
350
|
&reason,
|
|
351
|
+
None,
|
|
351
352
|
)?;
|
|
352
353
|
return Ok(DeliveryOutcome {
|
|
353
354
|
ok: false,
|
|
@@ -387,6 +388,13 @@ pub fn deliver_pending_message(
|
|
|
387
388
|
submit_verification_wire(inject_report.submit_verification)
|
|
388
389
|
)
|
|
389
390
|
};
|
|
391
|
+
// E50 PR-1 (0.3.24 P0): render forensic submit_diagnostics into the
|
|
392
|
+
// event so operators see per-attempt pane state without grepping. The
|
|
393
|
+
// legacy keys (`message_id` / `recipient` / `reason` / `attempts`)
|
|
394
|
+
// are preserved byte-for-byte for grep compatibility; new keys are
|
|
395
|
+
// ADDITIONAL.
|
|
396
|
+
let submit_attempts_detail =
|
|
397
|
+
render_submit_diagnostics(&inject_report);
|
|
390
398
|
event_log.write(
|
|
391
399
|
"send.unverified",
|
|
392
400
|
serde_json::json!({
|
|
@@ -394,6 +402,7 @@ pub fn deliver_pending_message(
|
|
|
394
402
|
"recipient": message.recipient,
|
|
395
403
|
"reason": reason,
|
|
396
404
|
"attempts": inject_report.attempts,
|
|
405
|
+
"submit_attempts_detail": submit_attempts_detail,
|
|
397
406
|
}),
|
|
398
407
|
)?;
|
|
399
408
|
if inject_report.attempts >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
|
|
@@ -407,6 +416,7 @@ pub fn deliver_pending_message(
|
|
|
407
416
|
inject_report.attempts,
|
|
408
417
|
"send_unverified_exhausted",
|
|
409
418
|
&reason,
|
|
419
|
+
Some(&inject_report),
|
|
410
420
|
)?;
|
|
411
421
|
return Ok(DeliveryOutcome {
|
|
412
422
|
ok: false,
|
|
@@ -463,7 +473,8 @@ pub fn deliver_pending_message(
|
|
|
463
473
|
Ok(outcome)
|
|
464
474
|
}
|
|
465
475
|
|
|
466
|
-
|
|
476
|
+
/// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
|
|
477
|
+
pub(crate) fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
467
478
|
match report.submit_verification {
|
|
468
479
|
SubmitVerification::SendKeysFailed => false,
|
|
469
480
|
SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
|
|
@@ -492,7 +503,8 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
|
492
503
|
/// `CaptureMissingToken` → readback failed → not delivered (degraded/unverified). All
|
|
493
504
|
/// other inject_verification variants (incl. token-less payloads, empty sends, new
|
|
494
505
|
/// pasted-content prompt) are not readback-negative and pass this gate.
|
|
495
|
-
|
|
506
|
+
/// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
|
|
507
|
+
pub(crate) fn pane_readback_verified(report: &InjectReport) -> bool {
|
|
496
508
|
!matches!(
|
|
497
509
|
report.inject_verification,
|
|
498
510
|
InjectVerification::CaptureMissingToken
|
|
@@ -504,7 +516,8 @@ fn pane_readback_verified(report: &InjectReport) -> bool {
|
|
|
504
516
|
/// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
|
|
505
517
|
/// when it sees this block + extracts the token — the bare content gives WORKING but never a report
|
|
506
518
|
/// (rt-host-a loop #4). token == message_id (exactly-once correlation).
|
|
507
|
-
|
|
519
|
+
/// F1 (0.3.26): promoted to `pub` for direct pane send in cli/send.rs.
|
|
520
|
+
pub fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id: &str) -> String {
|
|
508
521
|
let mut header = format!("Team Agent message from {sender}");
|
|
509
522
|
if let Some(task_id) = task_id.filter(|t| !t.is_empty()) {
|
|
510
523
|
header.push_str(&format!(" for {task_id}"));
|
|
@@ -557,7 +570,23 @@ fn resolve_inject_target(
|
|
|
557
570
|
.and_then(serde_json::Value::as_str)
|
|
558
571
|
.filter(|s| !s.is_empty())
|
|
559
572
|
.map(PaneId::new);
|
|
573
|
+
// E51 (0.3.26 P0, delivery loop guard): if the worker's cached pane_id is
|
|
574
|
+
// the SAME as leader_receiver.pane_id (the leader's handle), injecting into
|
|
575
|
+
// it would deliver the worker's message back to the leader pane — a routing
|
|
576
|
+
// loop that the macmini "hand-handle mapping 灾難" truth source exposed. Fail
|
|
577
|
+
// loud with a SessionWindow target that will surface as a structured error
|
|
578
|
+
// (the SessionWindow "leader" target trips the existing leader_not_attached
|
|
579
|
+
// guard on any subsequent delivery attempt for this message). The root fix
|
|
580
|
+
// is in lease.rs (E51 guard #1) which prevents the conflation; this is the
|
|
581
|
+
// defence-in-depth in the delivery layer.
|
|
560
582
|
if let Some(pane) = cached_pane.as_ref() {
|
|
583
|
+
let leader_pane = leader_receiver_pane_id(state);
|
|
584
|
+
if leader_pane.is_some_and(|lp| lp == pane.as_str()) {
|
|
585
|
+
return Target::SessionWindow {
|
|
586
|
+
session: SessionName::new(session),
|
|
587
|
+
window: WindowName::new(format!("{recipient}_pane_conflicts_with_leader")),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
561
590
|
if live_targets
|
|
562
591
|
.iter()
|
|
563
592
|
.any(|target| target.pane_id.as_str() == pane.as_str())
|
|
@@ -755,6 +784,34 @@ fn leader_receiver_field_in_state<'a>(
|
|
|
755
784
|
.filter(|value| !value.is_empty())
|
|
756
785
|
}
|
|
757
786
|
|
|
787
|
+
/// E50 PR-1 (0.3.24 P0): render `InjectReport.submit_diagnostics` as a JSON
|
|
788
|
+
/// array suitable for inclusion in `send.unverified` / `send.failed` events.
|
|
789
|
+
/// `null` if no diagnostics were attached (e.g. the inject went through a
|
|
790
|
+
/// path that bypassed the paste-prompt + Enter instrumentation, or this is
|
|
791
|
+
/// the pre-PR-2 inject-failed path with no `InjectReport`).
|
|
792
|
+
fn render_submit_diagnostics(report: &InjectReport) -> serde_json::Value {
|
|
793
|
+
let Some(diag) = report.submit_diagnostics.as_ref() else {
|
|
794
|
+
return serde_json::Value::Null;
|
|
795
|
+
};
|
|
796
|
+
serde_json::Value::Array(
|
|
797
|
+
diag.attempts_detail
|
|
798
|
+
.iter()
|
|
799
|
+
.map(|obs| {
|
|
800
|
+
serde_json::json!({
|
|
801
|
+
"attempt_index": obs.attempt_index,
|
|
802
|
+
"matched": obs.matched,
|
|
803
|
+
"matched_literal": obs.matched_literal,
|
|
804
|
+
"where_in_tail": obs.where_in_tail,
|
|
805
|
+
"pane_tail_excerpt": obs.pane_tail_excerpt,
|
|
806
|
+
"pane_tail_lines": obs.pane_tail_lines,
|
|
807
|
+
"elapsed_ms": obs.elapsed_ms,
|
|
808
|
+
})
|
|
809
|
+
})
|
|
810
|
+
.collect(),
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
#[allow(clippy::too_many_arguments)]
|
|
758
815
|
fn emit_send_failed_exhausted(
|
|
759
816
|
workspace: &Path,
|
|
760
817
|
state: &serde_json::Value,
|
|
@@ -764,7 +821,26 @@ fn emit_send_failed_exhausted(
|
|
|
764
821
|
attempts: u32,
|
|
765
822
|
failure_reason: &str,
|
|
766
823
|
verification: &str,
|
|
824
|
+
inject_report: Option<&InjectReport>,
|
|
767
825
|
) -> Result<(), MessagingError> {
|
|
826
|
+
// E50 PR-1 (0.3.24 P0): forensic fields on send.failed. Legacy keys
|
|
827
|
+
// (`message_id` / `recipient` / `attempts` / `max_attempts` / `reason` /
|
|
828
|
+
// `verification`) preserved byte-for-byte for grep compatibility.
|
|
829
|
+
let submit_attempts_detail = inject_report
|
|
830
|
+
.map(render_submit_diagnostics)
|
|
831
|
+
.unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
|
|
832
|
+
let total_elapsed_ms = inject_report
|
|
833
|
+
.and_then(|r| r.submit_diagnostics.as_ref())
|
|
834
|
+
.map(|d| d.total_elapsed_ms)
|
|
835
|
+
.unwrap_or(0);
|
|
836
|
+
let last_matched_literal = inject_report
|
|
837
|
+
.and_then(|r| r.submit_diagnostics.as_ref())
|
|
838
|
+
.and_then(|d| d.attempts_detail.last())
|
|
839
|
+
.and_then(|a| a.matched_literal.clone());
|
|
840
|
+
let last_pane_tail_excerpt = inject_report
|
|
841
|
+
.and_then(|r| r.submit_diagnostics.as_ref())
|
|
842
|
+
.and_then(|d| d.attempts_detail.last())
|
|
843
|
+
.map(|a| a.pane_tail_excerpt.clone());
|
|
768
844
|
event_log.write(
|
|
769
845
|
"send.failed",
|
|
770
846
|
serde_json::json!({
|
|
@@ -774,6 +850,10 @@ fn emit_send_failed_exhausted(
|
|
|
774
850
|
"max_attempts": SEND_RETRY_MAX_ATTEMPTS,
|
|
775
851
|
"reason": failure_reason,
|
|
776
852
|
"verification": verification,
|
|
853
|
+
"submit_attempts_detail": submit_attempts_detail,
|
|
854
|
+
"total_elapsed_ms": total_elapsed_ms,
|
|
855
|
+
"last_matched_literal": last_matched_literal,
|
|
856
|
+
"last_pane_tail_excerpt": last_pane_tail_excerpt,
|
|
777
857
|
}),
|
|
778
858
|
)?;
|
|
779
859
|
let content = format!(
|
|
@@ -11,7 +11,7 @@ use crate::model::ids::{OwnerEpoch, TaskId};
|
|
|
11
11
|
use crate::transport::{InjectPayload, Key, PaneId, Target, Transport};
|
|
12
12
|
|
|
13
13
|
use super::helpers::MessageStatusShadow;
|
|
14
|
-
use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
|
|
14
|
+
use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError};
|
|
15
15
|
|
|
16
16
|
/// `_send_to_leader_receiver` (`leader.py:69`) — **N31/N32 funnel primitive**:所有 leader-bound
|
|
17
17
|
/// caller(send_message(to=leader) / report_result / request_human / idle reminder /
|
|
@@ -393,29 +393,66 @@ pub fn deliver_to_leader_fallback_pane(
|
|
|
393
393
|
|
|
394
394
|
match inject_result {
|
|
395
395
|
Ok(report) => {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
396
|
+
// 0.3.27 P0: leader_receiver verification gate. The pre-fix path
|
|
397
|
+
// marked every inject Ok as delivered regardless of whether the
|
|
398
|
+
// submit was actually verified. Apply the same two-gate check that
|
|
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).
|
|
404
|
+
let submit_ok = super::delivery::inject_submit_verified(&report);
|
|
405
|
+
let readback_ok = super::delivery::pane_readback_verified(&report);
|
|
406
|
+
if submit_ok && readback_ok {
|
|
407
|
+
store.mark(message_id, "delivered", None)?;
|
|
408
|
+
event_log.write(
|
|
409
|
+
"leader_receiver.fallback_pane_submitted",
|
|
410
|
+
serde_json::json!({
|
|
411
|
+
"message_id": message_id,
|
|
412
|
+
"result_id": result_id,
|
|
413
|
+
"owner_team_id": owner_team_id,
|
|
414
|
+
"pane_id": pane_id,
|
|
415
|
+
"primary_error": primary_error,
|
|
416
|
+
"delivered_via": "fallback_pane",
|
|
417
|
+
"verification": format!("{:?}", report.submit_verification),
|
|
418
|
+
}),
|
|
419
|
+
)?;
|
|
420
|
+
Ok(DeliveryOutcome {
|
|
421
|
+
ok: true,
|
|
422
|
+
status: DeliveryStatus::Delivered,
|
|
423
|
+
message_status: MessageStatusShadow("delivered".to_string()),
|
|
424
|
+
message_id: Some(message_id.to_string()),
|
|
425
|
+
verification: Some("delivered_via=fallback_pane".to_string()),
|
|
426
|
+
stage: None,
|
|
427
|
+
reason: None,
|
|
428
|
+
channel: Some("fallback_pane".to_string()),
|
|
429
|
+
})
|
|
430
|
+
} else {
|
|
431
|
+
let reason = format!(
|
|
432
|
+
"fallback_pane_unverified:submit={submit_ok},readback={readback_ok},sv={:?}",
|
|
433
|
+
report.submit_verification
|
|
434
|
+
);
|
|
435
|
+
store.mark(message_id, "submitted_unverified", Some(&reason))?;
|
|
436
|
+
event_log.write(
|
|
437
|
+
"leader_receiver.fallback_pane_unverified",
|
|
438
|
+
serde_json::json!({
|
|
439
|
+
"message_id": message_id,
|
|
440
|
+
"pane_id": pane_id,
|
|
441
|
+
"reason": reason,
|
|
442
|
+
"primary_error": primary_error,
|
|
443
|
+
}),
|
|
444
|
+
)?;
|
|
445
|
+
Ok(DeliveryOutcome {
|
|
446
|
+
ok: false,
|
|
447
|
+
status: DeliveryStatus::Failed,
|
|
448
|
+
message_status: MessageStatusShadow("submitted_unverified".to_string()),
|
|
449
|
+
message_id: Some(message_id.to_string()),
|
|
450
|
+
verification: Some(reason),
|
|
451
|
+
stage: Some(DeliveryStage::Submit),
|
|
452
|
+
reason: None,
|
|
453
|
+
channel: Some("fallback_pane".to_string()),
|
|
454
|
+
})
|
|
455
|
+
}
|
|
419
456
|
}
|
|
420
457
|
Err(error) => {
|
|
421
458
|
event_log.write(
|
|
@@ -1308,6 +1308,7 @@ impl Transport for UnverifiedInjectTransport {
|
|
|
1308
1308
|
crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
|
|
1309
1309
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
1310
1310
|
attempts: u32::from(SEND_RETRY_MAX_ATTEMPTS),
|
|
1311
|
+
submit_diagnostics: None,
|
|
1311
1312
|
})
|
|
1312
1313
|
}
|
|
1313
1314
|
fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
|
|
@@ -1467,6 +1468,7 @@ impl Transport for ReadbackMissingTransport {
|
|
|
1467
1468
|
},
|
|
1468
1469
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
1469
1470
|
attempts: 1,
|
|
1471
|
+
submit_diagnostics: None,
|
|
1470
1472
|
})
|
|
1471
1473
|
}
|
|
1472
1474
|
fn send_keys(&self, _: &Target, _: &[Key]) -> Result<(), TransportError> {
|
|
@@ -173,6 +173,7 @@ impl Transport for DeliverOkTransport {
|
|
|
173
173
|
submit_verification: crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
174
174
|
turn_verification: crate::transport::TurnVerification::NotYetObserved,
|
|
175
175
|
attempts: 1,
|
|
176
|
+
submit_diagnostics: None,
|
|
176
177
|
})
|
|
177
178
|
}
|
|
178
179
|
fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
|
|
@@ -369,9 +369,10 @@
|
|
|
369
369
|
.expect("spawn_split");
|
|
370
370
|
assert_eq!(result.pane_id.as_str(), "%5");
|
|
371
371
|
let calls = rec.lock().unwrap().clone();
|
|
372
|
+
// E53 (0.3.26): split-window now carries `-d` (no focus steal).
|
|
372
373
|
assert_eq!(
|
|
373
|
-
calls[0][0..
|
|
374
|
-
svec(&["tmux", "split-window", "-t", "teamsess:team-w1"])
|
|
374
|
+
calls[0][0..5],
|
|
375
|
+
svec(&["tmux", "split-window", "-d", "-t", "teamsess:team-w1"])
|
|
375
376
|
);
|
|
376
377
|
assert_eq!(
|
|
377
378
|
calls[1],
|
|
@@ -594,11 +595,15 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
594
595
|
/// report `SubmitConsumptionUnverified`, NOT
|
|
595
596
|
/// `EnterSentWithoutPlaceholderCheck`. Otherwise delivery emits delivered
|
|
596
597
|
/// + message.delivered (假阳) even though the worker never consumed it.
|
|
598
|
+
/// 0.3.27 amendment: when token IS visible in the pane (Phase 1 confirmed)
|
|
599
|
+
/// but NOT consumed from bottom 5 after all retries, the grace fallback
|
|
600
|
+
/// treats "paste landed but composer didn't clear" as
|
|
601
|
+
/// EnterSentWithoutPlaceholderCheck. This handles bare-shell panes (MCP sim)
|
|
602
|
+
/// and busy-agent TUIs (E55) that keep the token visible after submit.
|
|
603
|
+
/// SubmitConsumptionUnverified is reserved for the case where the token was
|
|
604
|
+
/// NEVER visible (paste truly failed / dropped).
|
|
597
605
|
#[test]
|
|
598
|
-
fn
|
|
599
|
-
// Mock always returns the token in the captured tail — simulates the
|
|
600
|
-
// macmini fresh claude TUI where the paste lands but the input never
|
|
601
|
-
// clears (Enter swallowed by bracketed-paste).
|
|
606
|
+
fn e46_inject_text_with_token_visible_but_unconsumed_uses_grace_fallback() {
|
|
602
607
|
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
|
|
603
608
|
let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
|
|
604
609
|
let report = be
|
|
@@ -609,19 +614,50 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
609
614
|
true,
|
|
610
615
|
)
|
|
611
616
|
.expect("inject runs");
|
|
617
|
+
// 0.3.27 grace fallback: token visible + not consumed from bottom →
|
|
618
|
+
// EnterSentWithoutPlaceholderCheck (paste landed, consumption unclear).
|
|
612
619
|
assert_eq!(
|
|
613
620
|
report.submit_verification,
|
|
614
|
-
SubmitVerification::
|
|
615
|
-
"
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
621
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
622
|
+
"0.3.27: when token is visible but not consumed from bottom 5 \
|
|
623
|
+
after all retries, grace fallback degrades to \
|
|
624
|
+
EnterSentWithoutPlaceholderCheck (paste landed, bare-shell/busy \
|
|
625
|
+
pane parity). Got {:?}",
|
|
619
626
|
report.submit_verification
|
|
620
627
|
);
|
|
621
|
-
// turn_verification stays NotYetObserved (Gap42: metadata only).
|
|
622
628
|
assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
|
|
623
629
|
}
|
|
624
630
|
|
|
631
|
+
/// 0.3.27: empty pane captures → consumption poll sees "no token in
|
|
632
|
+
/// bottom 5" → consumed=true → EnterSentWithoutPlaceholderCheck. This is
|
|
633
|
+
/// the generous default for panes where the capture can't distinguish
|
|
634
|
+
/// "token consumed" from "token never landed" (empty mock, MCP sim).
|
|
635
|
+
/// SubmitConsumptionUnverified only fires when the grace fallback
|
|
636
|
+
/// explicitly rejects (token_visible_for_report=false AND consumed=false
|
|
637
|
+
/// at the same time — a state that requires the token to be in the pane
|
|
638
|
+
/// during consumption poll but absent during Phase 1, which is a
|
|
639
|
+
/// contradictory mock state that doesn't arise in production).
|
|
640
|
+
#[test]
|
|
641
|
+
fn e46_inject_text_with_empty_pane_defaults_to_consumed() {
|
|
642
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_empty]";
|
|
643
|
+
let (be, _rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
644
|
+
let report = be
|
|
645
|
+
.inject(
|
|
646
|
+
&Target::Pane(PaneId::new("%7")),
|
|
647
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
648
|
+
Key::Enter,
|
|
649
|
+
true,
|
|
650
|
+
)
|
|
651
|
+
.expect("inject runs");
|
|
652
|
+
assert_eq!(
|
|
653
|
+
report.submit_verification,
|
|
654
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
655
|
+
"0.3.27: empty pane → consumption poll sees no token in bottom → \
|
|
656
|
+
consumed=true → EnterSentWithoutPlaceholderCheck. Got {:?}",
|
|
657
|
+
report.submit_verification
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
625
661
|
/// **E46 RED-2 (正向消费确认 → delivered)**: token-bearing Text + Enter +
|
|
626
662
|
/// bracketed paste. Token VISIBLE on first capture (pre/post-submit token
|
|
627
663
|
/// readback) BUT then GONE from the post-submit input-consumption probe
|
|
@@ -762,8 +798,13 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
762
798
|
/// mode on stuck composer). Non-Enter submits (e.g. Key::Down for codex
|
|
763
799
|
/// menu) must NOT receive the Escape pre-step.
|
|
764
800
|
#[test]
|
|
765
|
-
|
|
801
|
+
/// 0.3.27 amendment: Escape is now RETRY-ONLY (attempt > 0). The first
|
|
802
|
+
/// attempt sends Enter directly (Python parity — Python never sends Escape).
|
|
803
|
+
/// Escape fires only if the first Enter failed to consume the token and a
|
|
804
|
+
/// retry is needed. This test now asserts the first attempt has NO Escape.
|
|
805
|
+
fn e46_inject_text_first_attempt_no_escape_retry_only() {
|
|
766
806
|
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_esc]";
|
|
807
|
+
// Mock returns empty — token not in tail → consumed=true on first attempt.
|
|
767
808
|
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
768
809
|
let _report = be
|
|
769
810
|
.inject(
|
|
@@ -774,21 +815,22 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
774
815
|
)
|
|
775
816
|
.expect("inject runs");
|
|
776
817
|
let calls = rec.lock().unwrap().clone();
|
|
777
|
-
let
|
|
778
|
-
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
779
|
-
&& argv.contains(&"Escape".to_string())
|
|
780
|
-
});
|
|
781
|
-
let enter_idx = calls.iter().position(|argv| {
|
|
818
|
+
let enter_count = calls.iter().filter(|argv| {
|
|
782
819
|
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
783
820
|
&& argv.contains(&"Enter".to_string())
|
|
784
|
-
});
|
|
785
|
-
let
|
|
786
|
-
|
|
821
|
+
}).count();
|
|
822
|
+
let escape_count = calls.iter().filter(|argv| {
|
|
823
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
824
|
+
&& argv.contains(&"Escape".to_string())
|
|
825
|
+
}).count();
|
|
787
826
|
assert!(
|
|
788
|
-
|
|
789
|
-
"
|
|
790
|
-
|
|
791
|
-
|
|
827
|
+
enter_count >= 1,
|
|
828
|
+
"0.3.27: first attempt must send Enter; calls={calls:?}"
|
|
829
|
+
);
|
|
830
|
+
assert_eq!(
|
|
831
|
+
escape_count, 0,
|
|
832
|
+
"0.3.27: first attempt (consumed=true) must NOT send Escape \
|
|
833
|
+
(Escape is retry-only); calls={calls:?}"
|
|
792
834
|
);
|
|
793
835
|
}
|
|
794
836
|
|
|
@@ -823,6 +865,166 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
823
865
|
);
|
|
824
866
|
}
|
|
825
867
|
|
|
868
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
869
|
+
// E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断) — InjectReport.submit_diagnostics
|
|
870
|
+
// is populated for paste-prompt loops, and `pasted_prompt_match` / scrubbing
|
|
871
|
+
// helpers are pure functions safe to test deterministically.
|
|
872
|
+
//
|
|
873
|
+
// Architect verdict (Workflow forensic): the pre-fix
|
|
874
|
+
// `capture_has_pasted_content_prompt` matched ANY `pasted content` /
|
|
875
|
+
// `pasted text` token in the full Tail(80), including scrolled-off
|
|
876
|
+
// submitted blocks. PR-1 just adds the diagnostics; PR-2 will USE
|
|
877
|
+
// `where_in_tail` to fix the criterion. These tests pin the data shape.
|
|
878
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
879
|
+
|
|
880
|
+
/// **E50 RED-1 (判据误判, pure function)**: `pasted_prompt_match` must
|
|
881
|
+
/// distinguish bottom-region (composer) from scrollback (line 6+ from
|
|
882
|
+
/// bottom). Pre-fix `capture_has_pasted_content_prompt` returned `true`
|
|
883
|
+
/// for BOTH; the new tuple-returning helper surfaces the offset so PR-2
|
|
884
|
+
/// can fix the criterion.
|
|
885
|
+
#[test]
|
|
886
|
+
fn e50_pasted_prompt_match_reports_where_in_tail_for_scrollback_vs_composer() {
|
|
887
|
+
use super::pasted_prompt_match;
|
|
888
|
+
|
|
889
|
+
// Composer (bottom) match.
|
|
890
|
+
let composer_only = "some output\n\
|
|
891
|
+
[Pasted content 1.2k]\n\
|
|
892
|
+
❯ ";
|
|
893
|
+
// The match is "pasted content" in the line at index 1 from bottom
|
|
894
|
+
// among non-empty lines (`[Pasted content...]`, `❯ ` is at idx 0).
|
|
895
|
+
let m = pasted_prompt_match(composer_only).expect("match");
|
|
896
|
+
assert_eq!(m.0, "pasted content");
|
|
897
|
+
assert_eq!(
|
|
898
|
+
m.1, 1,
|
|
899
|
+
"E50: where_in_tail must reflect bottom offset (idx 1 = above the \
|
|
900
|
+
`❯` composer line); got {m:?}"
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
// Scrollback (top) match — 8 lines from bottom.
|
|
904
|
+
let scrollback_only = "[Pasted content 5.0k]\n\
|
|
905
|
+
assistant reply line 1\n\
|
|
906
|
+
assistant reply line 2\n\
|
|
907
|
+
assistant reply line 3\n\
|
|
908
|
+
assistant reply line 4\n\
|
|
909
|
+
assistant reply line 5\n\
|
|
910
|
+
assistant reply line 6\n\
|
|
911
|
+
assistant reply line 7\n\
|
|
912
|
+
❯ ";
|
|
913
|
+
let m2 = pasted_prompt_match(scrollback_only).expect("match");
|
|
914
|
+
assert_eq!(m2.0, "pasted content");
|
|
915
|
+
assert!(
|
|
916
|
+
m2.1 >= 6,
|
|
917
|
+
"E50: scrollback `Pasted content` must report where_in_tail >= 6 \
|
|
918
|
+
(clearly above the bottom composer region); got {m2:?}. PR-2 \
|
|
919
|
+
will use this offset to distinguish live composer vs scrollback \
|
|
920
|
+
residue."
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// No match.
|
|
924
|
+
assert!(pasted_prompt_match("nothing here\n❯ ").is_none());
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/// **E50 PR-2 Fix-B (amends PR-1 RED-2)**: with Fix-B, token-bearing
|
|
928
|
+
/// payloads SKIP the paste-prompt weak loop and fall through to the E46
|
|
929
|
+
/// token consumption gate. The `submit_diagnostics` still records the
|
|
930
|
+
/// appear-gate timing (was `saw_pasted_prompt` matched?), but
|
|
931
|
+
/// `attempts_detail` is populated by the E46 consumption gate path, not
|
|
932
|
+
/// the deleted weak loop. This test pins the NEW shape: `appear_gate_matched`
|
|
933
|
+
/// may be false (mock default returns the pasted-content text, but the
|
|
934
|
+
/// appear-gate polls 5 times with 25ms sleep and the mock answers the SAME
|
|
935
|
+
/// text every time → appear-gate DOES match). However, the `has_token` guard
|
|
936
|
+
/// skips the weak loop body → the token path's diagnostics fill in instead.
|
|
937
|
+
#[test]
|
|
938
|
+
fn e50_inject_paste_prompt_path_populates_submit_diagnostics_per_attempt() {
|
|
939
|
+
let token_text =
|
|
940
|
+
"Team Agent message from leader:\n\nhello\n\n[team-agent-token:msg_pr1]";
|
|
941
|
+
// Mock keeps returning the pasted-content placeholder. With Fix-B,
|
|
942
|
+
// the token path takes over: post_submit_input_consumed checks
|
|
943
|
+
// bottom 5 lines for the token marker. The mock returns the pasted
|
|
944
|
+
// placeholder text (without the token) → token NOT in tail → consumed
|
|
945
|
+
// = Some(true) → EnterSentWithoutPlaceholderCheck.
|
|
946
|
+
// We test: (a) diagnostics present (b) submit verification reflects
|
|
947
|
+
// the E46 token path, not the deleted weak loop.
|
|
948
|
+
let pasted = "[Pasted content 1.2k]";
|
|
949
|
+
let (be, _rec) = backend_with(MockResp::Out(ok(pasted)), vec![]);
|
|
950
|
+
let report = be
|
|
951
|
+
.inject(
|
|
952
|
+
&Target::Pane(PaneId::new("%7")),
|
|
953
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
954
|
+
Key::Enter,
|
|
955
|
+
true,
|
|
956
|
+
)
|
|
957
|
+
.expect("inject");
|
|
958
|
+
// Fix-B: token payload → E46 token path. The submit verification is
|
|
959
|
+
// either EnterSentWithoutPlaceholderCheck (consumed) or
|
|
960
|
+
// SubmitConsumptionUnverified (not consumed). With the mock returning
|
|
961
|
+
// pasted-content text (no token in tail), consumed = true.
|
|
962
|
+
assert_eq!(
|
|
963
|
+
report.submit_verification,
|
|
964
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
965
|
+
"E50 PR-2 Fix-B: token payload that went through the E46 gate \
|
|
966
|
+
and was consumed (token not in bottom 5 lines) must report \
|
|
967
|
+
EnterSentWithoutPlaceholderCheck; got {:?}",
|
|
968
|
+
report.submit_verification
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/// **E50 PR-1 secret scrubbing**: `scrub_pane_excerpt` must strip ANSI
|
|
973
|
+
/// + redact common secret shapes so emitted events.jsonl doesn't leak.
|
|
974
|
+
#[test]
|
|
975
|
+
fn e50_scrub_pane_excerpt_strips_ansi_and_redacts_secret_shapes() {
|
|
976
|
+
use super::scrub_pane_excerpt;
|
|
977
|
+
let raw = "\x1b[31merror\x1b[0m: sk-abcdef123ghi\n\
|
|
978
|
+
token Bearer abc.def.ghi\n\
|
|
979
|
+
key AKIAIOSFODNN7EXAMPLE\n\
|
|
980
|
+
ghp_1234567890abcdef\n\
|
|
981
|
+
hex deadbeefdeadbeefdeadbeefdeadbeefcafebabe\n\
|
|
982
|
+
plain text below";
|
|
983
|
+
let (out, lines) = scrub_pane_excerpt(raw, 20);
|
|
984
|
+
assert!(lines >= 5, "E50: at least 5 non-empty lines; got {lines}");
|
|
985
|
+
assert!(!out.contains("\x1b"), "E50: ANSI escape stripped; got {out:?}");
|
|
986
|
+
assert!(
|
|
987
|
+
!out.contains("sk-abcdef123ghi"),
|
|
988
|
+
"E50: sk- secret must be redacted; got {out:?}"
|
|
989
|
+
);
|
|
990
|
+
assert!(
|
|
991
|
+
!out.contains("ghp_1234567890abcdef"),
|
|
992
|
+
"E50: ghp_ secret must be redacted; got {out:?}"
|
|
993
|
+
);
|
|
994
|
+
assert!(
|
|
995
|
+
!out.contains("AKIAIOSFODNN7EXAMPLE"),
|
|
996
|
+
"E50: AKIA secret must be redacted; got {out:?}"
|
|
997
|
+
);
|
|
998
|
+
assert!(
|
|
999
|
+
!out.contains("abc.def.ghi"),
|
|
1000
|
+
"E50: Bearer secret token must be redacted; got {out:?}"
|
|
1001
|
+
);
|
|
1002
|
+
assert!(
|
|
1003
|
+
!out.contains("deadbeefdeadbeefdeadbeefdeadbeefcafebabe"),
|
|
1004
|
+
"E50: 32+ hex run must be redacted; got {out:?}"
|
|
1005
|
+
);
|
|
1006
|
+
assert!(
|
|
1007
|
+
out.contains("REDACTED"),
|
|
1008
|
+
"E50: redactions visible; got {out:?}"
|
|
1009
|
+
);
|
|
1010
|
+
assert!(
|
|
1011
|
+
out.contains("plain text below"),
|
|
1012
|
+
"E50: non-secret content preserved; got {out:?}"
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/// **E50 PR-1 byte-identical legacy matcher**: `capture_has_pasted_content_prompt`
|
|
1017
|
+
/// wrapper must still return the same bool the 3 legacy callers depend on
|
|
1018
|
+
/// (the bool-returning fn shape is the contract for byte-locked behaviour).
|
|
1019
|
+
#[test]
|
|
1020
|
+
fn e50_capture_has_pasted_content_prompt_byte_identical_wrapper() {
|
|
1021
|
+
use super::capture_has_pasted_content_prompt;
|
|
1022
|
+
assert!(capture_has_pasted_content_prompt("[Pasted content 1k]"));
|
|
1023
|
+
assert!(capture_has_pasted_content_prompt("[Pasted text foo]"));
|
|
1024
|
+
assert!(!capture_has_pasted_content_prompt("just composer text"));
|
|
1025
|
+
assert!(!capture_has_pasted_content_prompt(""));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
826
1028
|
#[test]
|
|
827
1029
|
fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
|
|
828
1030
|
let (be, rec) = backend_with(
|