@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.
Files changed (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +1 -0
  4. package/crates/team-agent/src/cli/mod.rs +193 -7
  5. package/crates/team-agent/src/cli/send.rs +106 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  7. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
  8. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +7 -0
  10. package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
  12. package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
  13. package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
  14. package/crates/team-agent/src/leader/lease.rs +57 -0
  15. package/crates/team-agent/src/leader/start.rs +1 -0
  16. package/crates/team-agent/src/lifecycle/launch.rs +9 -0
  17. package/crates/team-agent/src/lifecycle/restart/agent.rs +55 -0
  18. package/crates/team-agent/src/messaging/delivery.rs +83 -3
  19. package/crates/team-agent/src/messaging/leader_receiver.rs +61 -24
  20. package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
  21. package/crates/team-agent/src/messaging/tests/spine.rs +1 -0
  22. package/crates/team-agent/src/tmux_backend/tests.rs +227 -25
  23. package/crates/team-agent/src/tmux_backend.rs +409 -105
  24. package/crates/team-agent/src/transport/test_support.rs +1 -0
  25. package/crates/team-agent/src/transport/tests/mod.rs +1 -0
  26. package/crates/team-agent/src/transport/tests/wire.rs +2 -1
  27. package/crates/team-agent/src/transport.rs +77 -0
  28. package/npm/install.mjs +222 -14
  29. 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
- fn inject_submit_verified(report: &InjectReport) -> bool {
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
- fn pane_readback_verified(report: &InjectReport) -> bool {
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
- fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id: &str) -> String {
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
- store.mark(message_id, "delivered", None)?;
397
- event_log.write(
398
- "leader_receiver.fallback_pane_submitted",
399
- serde_json::json!({
400
- "message_id": message_id,
401
- "result_id": result_id,
402
- "owner_team_id": owner_team_id,
403
- "pane_id": pane_id,
404
- "primary_error": primary_error,
405
- "delivered_via": "fallback_pane",
406
- "verification": format!("{:?}", report.submit_verification),
407
- }),
408
- )?;
409
- Ok(DeliveryOutcome {
410
- ok: true,
411
- status: DeliveryStatus::Delivered,
412
- message_status: MessageStatusShadow("delivered".to_string()),
413
- message_id: Some(message_id.to_string()),
414
- verification: Some("delivered_via=fallback_pane".to_string()),
415
- stage: None,
416
- reason: None,
417
- channel: Some("fallback_pane".to_string()),
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..4],
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 e46_inject_text_with_token_unconsumed_in_tail_reports_submit_consumption_unverified() {
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::SubmitConsumptionUnverified,
615
- "E46 RED-1: when token remains in the pane input region after \
616
- Enter + resend cap, transport must report SubmitConsumptionUnverified \
617
- (not EnterSentWithoutPlaceholderCheck which delivery treats as \
618
- delivered). Got {:?}",
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
- fn e46_inject_text_enter_emits_escape_before_enter_on_bracketed_paste() {
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 escape_idx = calls.iter().position(|argv| {
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 escape_idx = escape_idx.expect("E46: bracketed text + Enter must send Escape pre-step");
786
- let enter_idx = enter_idx.expect("E46: must still send the final Enter");
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
- escape_idx < enter_idx,
789
- "E46: Escape must be sent BEFORE Enter (closes bracketed-paste \
790
- so Enter is interpreted as submit). escape_idx={escape_idx} \
791
- enter_idx={enter_idx} calls={calls:?}"
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(