@team-agent/installer 0.5.117 → 0.5.119

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.
@@ -299,34 +299,6 @@ fn prepare_message_state(
299
299
  }
300
300
  }
301
301
  }
302
- if batch
303
- && recipient_is_busy(
304
- prepared.scoped.as_ref().unwrap_or(state),
305
- &message.recipient,
306
- )
307
- {
308
- event_log.write(
309
- "send.deferred_busy",
310
- serde_json::json!({
311
- "message_id": message_id,
312
- "sender": message.sender,
313
- "recipient": message.recipient,
314
- "reason": "recipient_busy",
315
- }),
316
- )?;
317
- return Ok(Err(DeliveryOutcome {
318
- ok: false,
319
- status: DeliveryStatus::Queued,
320
- message_status: MessageStatusShadow(message.status.clone()),
321
- message_id: Some(message_id.to_string()),
322
- verification: None,
323
- stage: None,
324
- reason: None,
325
- channel: None,
326
- ack_forced_off: false,
327
- turn_verification: None,
328
- }));
329
- }
330
302
  Ok(Ok(prepared))
331
303
  }
332
304
 
@@ -639,21 +611,30 @@ fn deliver_prepared_message(
639
611
  });
640
612
  }
641
613
  if token_already_visible(transport, state, &message.recipient, &target, message_id) {
614
+ // Visibility prevents a second paste; it does not prove consumption.
615
+ let consumed = existing_token_consumed(transport, state, &message.recipient, &target, message_id);
616
+ let status = if consumed { "delivered" } else { "submitted_unverified" };
617
+ let reason = if consumed { "message_token_consumed" } else { "message_token_visible_without_consumption" };
642
618
  event_log.write(
643
619
  "delivery.duplicate_token_refused",
644
620
  serde_json::json!({
645
621
  "message_id": message_id,
646
622
  "recipient": message.recipient,
647
- "reason": "message_token_already_visible",
623
+ "reason": reason,
624
+ "consumed": consumed,
648
625
  }),
649
626
  )?;
650
- store.mark(message_id, "delivered", None)?;
627
+ store.mark(message_id, status, (!consumed).then_some(reason))?;
628
+ if !consumed {
629
+ bind_enter_watchdog(store, transport, state, &target, message_id, &message,
630
+ canonical_owner_team_id.as_deref(), event_log)?;
631
+ }
651
632
  return Ok(DeliveryOutcome {
652
- ok: true,
653
- status: DeliveryStatus::AlreadyDelivered,
654
- message_status: MessageStatusShadow("delivered".to_string()),
633
+ ok: consumed,
634
+ status: if consumed { DeliveryStatus::AlreadyDelivered } else { DeliveryStatus::Failed },
635
+ message_status: MessageStatusShadow(status.to_string()),
655
636
  message_id: Some(message_id.to_string()),
656
- verification: Some("message_token_already_visible".to_string()),
637
+ verification: Some(reason.to_string()),
657
638
  stage: Some(DeliveryStage::Submit),
658
639
  reason: None,
659
640
  channel: None,
@@ -682,34 +663,33 @@ fn deliver_prepared_message(
682
663
  canonical_owner_team_id.as_deref(),
683
664
  resolved.metadata.as_ref(),
684
665
  );
685
- let recipient_cursor = recipient_is_cursor_agent(state, &message.recipient);
686
- let native_request = recipient_provider(state, &message.recipient)
687
- .filter(|provider| matches!(provider, Provider::Pi | Provider::CursorAgent))
688
- .filter(|_| message.recipient != "leader" && transport.supports_owned_native_input())
689
- .map(|provider| crate::transport::native_input::NativeInputRequest {
690
- message_id: message_id.to_string(),
691
- owner_team_id: canonical_owner_team_id.clone().unwrap_or_default(),
692
- recipient: message.recipient.clone(), provider,
693
- pane_id: resolved.metadata.as_ref().map(|meta| meta.target_pane_id.clone()).unwrap_or_default(),
694
- pane_pid: resolved.metadata.as_ref().and_then(|meta| meta.target_pane_pid).unwrap_or_default(),
695
- session: resolved.metadata.as_ref().map(|meta| meta.target_session.clone()).unwrap_or_default(),
696
- window: resolved.metadata.as_ref().map(|meta| meta.target_window.clone()).unwrap_or_default(),
697
- });
698
- let guarded_native = native_request.is_some();
699
- let (inject_attempt, native_evidence) = crate::transport::native_input::with_native_input(native_request, || {
700
- crate::tmux_backend::with_paste_to_submit_floor(
701
- paste_to_submit_floor_for_recipient(state, &message.recipient),
702
- || crate::tmux_backend::with_cursor_single_enter(recipient_cursor, || {
703
- transport.inject_with_submit_observer(&target, &payload, Key::Enter, true, Some(&submit_observer))
704
- }),
705
- )
706
- });
666
+ // v0.5.113 delivery: address/owner and pane serialization are authoritative;
667
+ // composer appearance is not admission. Native CLIs buffer/queue input.
668
+ let inject_attempt = crate::tmux_backend::with_paste_to_submit_floor(
669
+ paste_to_submit_floor_for_recipient(state, &message.recipient),
670
+ || {
671
+ crate::tmux_backend::with_cursor_single_enter(
672
+ recipient_requires_single_enter(state, &message.recipient),
673
+ || {
674
+ transport.inject_with_submit_observer(
675
+ &target,
676
+ &payload,
677
+ Key::Enter,
678
+ true,
679
+ Some(&submit_observer),
680
+ )
681
+ },
682
+ )
683
+ },
684
+ );
707
685
  let inject_report = match inject_attempt {
708
686
  Ok(report) => {
709
687
  // grok 忙时第一次回车只入显式队列。默认 send-now:只重按回车顶出去。
710
688
  // 无 `Enter:send now` 时零次额外按键,claude/codex 行为不变。
711
689
  // cursor:第二下 Enter 打断进行中回合,不走 flush。
712
- if !guarded_native && !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
690
+ if !crate::provider::submit_now::keep_provider_queue_requested()
691
+ && recipient_allows_explicit_queue_flush(state, &message.recipient)
692
+ {
713
693
  match crate::provider::submit_now::flush_explicit_queue(transport, &target) {
714
694
  Ok(flush) if flush.extra_enters > 0 => {
715
695
  let _ = event_log.write(
@@ -739,28 +719,6 @@ fn deliver_prepared_message(
739
719
  }
740
720
  Err(error) => {
741
721
  let reason = format!("inject_failed:{error}");
742
- if guarded_native {
743
- let pasted = native_evidence.as_ref().filter(|evidence|
744
- evidence.phase >= crate::transport::native_input::NativeInputPhase::MayHavePasted);
745
- let status = if let Some(evidence) = pasted {
746
- super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
747
- "submitted_unverified"
748
- } else {
749
- super::enter_rescue::release_no_effect(store, message_id, attempt)?;
750
- "target_resolved"
751
- };
752
- event_log.write("native_input.deferred", serde_json::json!({
753
- "message_id": message_id, "reason": reason, "status": status,
754
- "may_have_pasted": pasted.is_some(),
755
- }))?;
756
- return Ok(DeliveryOutcome {
757
- ok: false, status: DeliveryStatus::Queued,
758
- message_status: MessageStatusShadow(status.to_string()),
759
- message_id: Some(message_id.to_string()), verification: Some(reason),
760
- stage: Some(DeliveryStage::Inject), reason: None, channel: None,
761
- ack_forced_off: false, turn_verification: None,
762
- });
763
- }
764
722
  if message.recipient == "leader" {
765
723
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
766
724
  event_log.write(
@@ -870,9 +828,12 @@ fn deliver_prepared_message(
870
828
  // A successful tmux command is never provider-acceptance proof. Leader and
871
829
  // worker delivery both require post-submit evidence; leader additionally
872
830
  // waits for its authoritative provider transcript below.
873
- let verified = submit_verified;
831
+ let native_consumed = observed_token_consumed(transport, state, &message.recipient, &target, message_id);
832
+ let verified = submit_verified && native_consumed != Some(false);
874
833
  if !verified {
875
- let reason = if !readback_verified {
834
+ let reason = if native_consumed == Some(false) {
835
+ "submit_unverified:current_composer_or_consumption_unknown".to_string()
836
+ } else if !readback_verified {
876
837
  "pane_readback_unverified:capture_missing_token".to_string()
877
838
  } else {
878
839
  format!(
@@ -926,11 +887,9 @@ fn deliver_prepared_message(
926
887
  turn_verification: Some(turn_verification),
927
888
  });
928
889
  }
929
- if let Some(evidence) = native_evidence.as_ref() {
930
- super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
931
- } else {
932
- store.mark(message_id, "submitted_unverified", Some(&reason))?;
933
- }
890
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
891
+ bind_enter_watchdog(store, transport, state, &target, message_id, &message,
892
+ canonical_owner_team_id.as_deref(), event_log)?;
934
893
  return Ok(DeliveryOutcome {
935
894
  ok: false,
936
895
  status: DeliveryStatus::Failed,
@@ -997,6 +956,8 @@ fn deliver_prepared_message(
997
956
  }),
998
957
  )?;
999
958
  store.mark(message_id, "submitted_unverified", Some(&reason))?;
959
+ bind_enter_watchdog(store, transport, state, &target, message_id, &message,
960
+ canonical_owner_team_id.as_deref(), event_log)?;
1000
961
  return Ok(DeliveryOutcome {
1001
962
  ok: false,
1002
963
  status: DeliveryStatus::Failed,
@@ -2305,8 +2266,8 @@ fn only_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
2305
2266
  }
2306
2267
  }
2307
2268
 
2308
- /// `_deliver_pending_messages` (`delivery.py:484`):扫 pending 队列逐条投递;busy 收件人写
2309
- /// `send.deferred_busy` 跳过 (**不丢**,card §131)。返回投递的 message_id 列表。
2269
+ /// Deliver pending rows without activity/composer admission; native CLIs queue input.
2270
+ /// Returns the message IDs whose post-submit evidence verified delivery.
2310
2271
  pub fn deliver_pending_messages(
2311
2272
  workspace: &Path,
2312
2273
  state: &serde_json::Value,
@@ -2500,13 +2461,6 @@ fn recipient_provider(state: &serde_json::Value, recipient: &str) -> Option<Prov
2500
2461
  .and_then(parse_canonical_provider)
2501
2462
  }
2502
2463
 
2503
- fn recipient_is_cursor_agent(state: &serde_json::Value, recipient: &str) -> bool {
2504
- matches!(
2505
- recipient_provider(state, recipient),
2506
- Some(Provider::CursorAgent)
2507
- )
2508
- }
2509
-
2510
2464
  /// ---
2511
2465
  /// purpose: paste→Enter 地板按收件人 provider 选择
2512
2466
  /// contract: CursorAgent 与 Grok 为 1s;其余(含 claude)为 ZERO
@@ -2549,16 +2503,6 @@ pub(crate) fn recipient_allows_explicit_queue_flush(
2549
2503
  )
2550
2504
  }
2551
2505
 
2552
- pub(crate) fn recipient_is_busy(state: &serde_json::Value, recipient: &str) -> bool {
2553
- state
2554
- .get("agents")
2555
- .and_then(serde_json::Value::as_object)
2556
- .and_then(|agents| agents.get(recipient))
2557
- .and_then(|agent| agent.get("status"))
2558
- .and_then(serde_json::Value::as_str)
2559
- == Some("busy")
2560
- }
2561
-
2562
2506
  fn leader_rollout_path(state: &serde_json::Value) -> Option<std::path::PathBuf> {
2563
2507
  if let Some(receiver) = leader_receiver_value(state).filter(|receiver| {
2564
2508
  receiver.get("provider").and_then(serde_json::Value::as_str) == Some("pi")
@@ -2838,11 +2782,13 @@ pub(crate) fn record_turn_open_if_leader_to_worker(
2838
2782
  pub(super) fn with_rescue_submit_observer<R>(
2839
2783
  workspace: &Path,
2840
2784
  store: &MessageStore,
2841
- evidence: &crate::transport::native_input::NativeInputEvidence,
2785
+ context: &crate::transport::enter_rescue::EnterRescueContext,
2842
2786
  event_log: &EventLog,
2843
2787
  execute: impl FnOnce(&dyn SubmitObserver) -> Result<R, MessagingError>,
2844
2788
  ) -> Result<R, MessagingError> {
2845
- let request = &evidence.request;
2789
+ let request = context;
2790
+ let binding = context.target.as_ref().filter(|target| target.bound())
2791
+ .ok_or_else(|| MessagingError::Validation("enter_rescue: original binding missing".into()))?;
2846
2792
  let conn = crate::db::schema::open_db(store.db_path())?;
2847
2793
  let sender: String = conn.query_row(
2848
2794
  "select sender from messages where message_id=?1 and owner_team_id=?2 and recipient=?3 and status='submitted_unverified'",
@@ -2850,18 +2796,18 @@ pub(super) fn with_rescue_submit_observer<R>(
2850
2796
  |row| row.get(0),
2851
2797
  )?;
2852
2798
  let metadata = TargetMetadata {
2853
- tmux_endpoint: Some(evidence.binding.endpoint.clone()),
2854
- target_session: evidence.binding.session.clone(),
2855
- target_window: evidence.binding.window.clone(),
2856
- target_pane_id: evidence.binding.pane_id.clone(),
2857
- target_pane_pid: Some(evidence.binding.pane_pid),
2799
+ tmux_endpoint: Some(binding.endpoint.clone()),
2800
+ target_session: binding.session.clone(),
2801
+ target_window: binding.window.clone(),
2802
+ target_pane_id: binding.pane_id.clone(),
2803
+ target_pane_pid: Some(binding.pane_pid),
2858
2804
  resolved_from: "enter_rescue_owned_binding",
2859
2805
  };
2860
2806
  let mut observer = CurrentTurnSubmitObserver::new(
2861
2807
  workspace, &sender, &request.recipient, &request.message_id, event_log,
2862
2808
  Some(&request.owner_team_id), Some(&metadata),
2863
2809
  );
2864
- observer.rescue_evidence = Some(evidence);
2810
+ observer.rescue_context = Some(context);
2865
2811
  let result = execute(&observer)?;
2866
2812
  if let Some(error) = observer.take_error() { return Err(error); }
2867
2813
  Ok(result)
@@ -2875,7 +2821,7 @@ struct CurrentTurnSubmitObserver<'a> {
2875
2821
  event_log: &'a EventLog,
2876
2822
  owner_team_id: Option<&'a str>,
2877
2823
  metadata: Option<&'a TargetMetadata>,
2878
- rescue_evidence: Option<&'a crate::transport::native_input::NativeInputEvidence>,
2824
+ rescue_context: Option<&'a crate::transport::enter_rescue::EnterRescueContext>,
2879
2825
  fired: Cell<bool>,
2880
2826
  error: RefCell<Option<MessagingError>>,
2881
2827
  }
@@ -2898,7 +2844,7 @@ impl<'a> CurrentTurnSubmitObserver<'a> {
2898
2844
  event_log,
2899
2845
  owner_team_id,
2900
2846
  metadata,
2901
- rescue_evidence: None,
2847
+ rescue_context: None,
2902
2848
  fired: Cell::new(false),
2903
2849
  error: RefCell::new(None),
2904
2850
  }
@@ -2914,8 +2860,8 @@ impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
2914
2860
  if self.fired.replace(true) {
2915
2861
  return;
2916
2862
  }
2917
- let result = if let Some(evidence) = self.rescue_evidence {
2918
- record_rescue_turn_after_submit(self.workspace, self.sender, evidence, self.event_log, self.metadata)
2863
+ let result = if let Some(context) = self.rescue_context {
2864
+ record_rescue_turn_after_submit(self.workspace, self.sender, context, self.event_log, self.metadata)
2919
2865
  } else {
2920
2866
  record_current_turn_after_inject_if_leader_to_worker_scoped(
2921
2867
  self.workspace, self.sender, self.recipient, self.message_id,
@@ -2929,11 +2875,13 @@ impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
2929
2875
  fn record_rescue_turn_after_submit(
2930
2876
  workspace: &Path,
2931
2877
  sender: &str,
2932
- evidence: &crate::transport::native_input::NativeInputEvidence,
2878
+ context: &crate::transport::enter_rescue::EnterRescueContext,
2933
2879
  event_log: &EventLog,
2934
2880
  metadata: Option<&TargetMetadata>,
2935
2881
  ) -> Result<(), MessagingError> {
2936
- let request = &evidence.request;
2882
+ let request = context;
2883
+ let target = context.target.as_ref().filter(|target| target.bound())
2884
+ .ok_or_else(|| MessagingError::Validation("enter_rescue: original binding missing".into()))?;
2937
2885
  if !matches!(sender, "leader" | "Leader") { return Ok(()); }
2938
2886
  crate::state::repository::StateRepository::new(workspace).commit_messaging_checked(
2939
2887
  crate::state::repository::StateWriteIntent::MessagingDeliveryState {
@@ -2944,12 +2892,11 @@ fn record_rescue_turn_after_submit(
2944
2892
  let matches = agent.is_some_and(|agent| {
2945
2893
  agent.get("status").and_then(serde_json::Value::as_str) == Some("running")
2946
2894
  && agent.get("explicit_stop").and_then(serde_json::Value::as_bool) != Some(true)
2947
- && agent.get("pane_id").and_then(serde_json::Value::as_str) == Some(request.pane_id.as_str())
2948
- && agent.get("pane_pid").and_then(serde_json::Value::as_u64) == Some(u64::from(request.pane_pid))
2949
- && request.pane_pid == evidence.binding.pane_pid
2950
- && agent.get("window").and_then(serde_json::Value::as_str).unwrap_or(&request.recipient) == request.window
2951
- && agent.get("provider").cloned().and_then(|value| serde_json::from_value::<Provider>(value).ok()) == Some(request.provider)
2952
- && selected.get("session_name").and_then(serde_json::Value::as_str) == Some(request.session.as_str())
2895
+ && agent.get("pane_id").and_then(serde_json::Value::as_str) == Some(target.pane_id.as_str())
2896
+ && agent.get("pane_pid").and_then(serde_json::Value::as_u64) == Some(u64::from(target.pane_pid))
2897
+ && agent.get("window").and_then(serde_json::Value::as_str).unwrap_or(&request.recipient) == target.window
2898
+ && agent.get("provider").cloned().and_then(|value| serde_json::from_value::<Provider>(value).ok()) == Some(target.provider)
2899
+ && selected.get("session_name").and_then(serde_json::Value::as_str) == Some(target.session.as_str())
2953
2900
  });
2954
2901
  if !matches {
2955
2902
  return Err(crate::state::StateError::SaveConflict("rescue receiver changed before turn commit".into()));
@@ -3486,17 +3433,87 @@ fn text_contains_message_token(text: &str, message_id: &str) -> bool {
3486
3433
  !message_id.is_empty() && text.contains(&message_token_marker(message_id))
3487
3434
  }
3488
3435
 
3489
- /// ---
3490
- /// purpose: 同 message_id 已在 pane 或转录出现则视为已注入
3491
- /// contract:
3492
- /// provides:
3493
- /// - name: token_already_visible
3494
- /// what: capture Tail(80) 或 recipient rollout_path 尾含 `[team-agent-token:{id}]` 则为 true
3495
- /// boundary:
3496
- /// - capture 失败不当已见(倒向允许注入,避免丢信)
3497
- /// - 不请求 Full(wave-2:滚出屏的 trust 残渣不当成已见/可行动)
3498
- /// - 不把 JSON 字段里的 message_id 当 pane token
3499
- /// ---
3436
+ fn marker_occurrences(text: &str, message_id: &str) -> usize {
3437
+ text.lines().map(str::trim).collect::<String>()
3438
+ .matches(&message_token_marker(message_id)).count()
3439
+ }
3440
+
3441
+ /// Provider-specific parsing is behind an observation port, never a delivery branch.
3442
+ /// None retains existing non-native transport receipts; unknown native frames veto success.
3443
+ fn observed_token_consumed(
3444
+ transport: &dyn Transport,
3445
+ state: &serde_json::Value,
3446
+ recipient: &str,
3447
+ target: &Target,
3448
+ message_id: &str,
3449
+ ) -> Option<bool> {
3450
+ let provider = recipient_provider(state, recipient)?;
3451
+ match transport.observe_native_input(target, provider) {
3452
+ Ok(Some(frame)) => Some(frame.composer.is_empty()
3453
+ && marker_occurrences(&frame.transcript, message_id) == 1),
3454
+ Ok(None) => None,
3455
+ Err(_) => Some(false),
3456
+ }
3457
+ }
3458
+
3459
+ fn existing_token_consumed(
3460
+ transport: &dyn Transport,
3461
+ state: &serde_json::Value,
3462
+ recipient: &str,
3463
+ target: &Target,
3464
+ message_id: &str,
3465
+ ) -> bool {
3466
+ observed_token_consumed(transport, state, recipient, target, message_id)
3467
+ .unwrap_or_else(|| recipient_transcript_contains(state, recipient, &message_token_marker(message_id)))
3468
+ }
3469
+
3470
+ #[allow(clippy::too_many_arguments)]
3471
+ fn bind_enter_watchdog(
3472
+ store: &MessageStore,
3473
+ transport: &dyn Transport,
3474
+ state: &serde_json::Value,
3475
+ target: &Target,
3476
+ message_id: &str,
3477
+ message: &PendingMessage,
3478
+ owner_team_id: Option<&str>,
3479
+ event_log: &EventLog,
3480
+ ) -> Result<(), MessagingError> {
3481
+ use crate::transport::enter_rescue::{EnterRescueContext, EnterRescueTarget};
3482
+ use sha2::{Digest, Sha256};
3483
+ let rendered = render_message(&message.sender, message.task_id.as_deref(), &message.content, message_id);
3484
+ let agent = state.get("agents").and_then(|agents| agents.get(&message.recipient));
3485
+ let selected = (|| {
3486
+ let agent = agent?;
3487
+ Some(EnterRescueTarget::selected(
3488
+ recipient_provider(state, &message.recipient)?,
3489
+ agent.get("pane_id")?.as_str()?.to_string(),
3490
+ u32::try_from(agent.get("pane_pid")?.as_u64()?).ok()?,
3491
+ state.get("session_name")?.as_str()?.to_string(),
3492
+ agent.get("window").and_then(serde_json::Value::as_str)
3493
+ .unwrap_or(&message.recipient).to_string(),
3494
+ ))
3495
+ })();
3496
+ let context = EnterRescueContext {
3497
+ message_id: message_id.into(),
3498
+ owner_team_id: owner_team_id.unwrap_or_default().into(),
3499
+ recipient: message.recipient.clone(),
3500
+ payload_sha256: format!("{:x}", Sha256::digest(rendered.as_bytes())),
3501
+ target: selected,
3502
+ proof: None,
3503
+ };
3504
+ match transport.snapshot_enter_rescue(target, &context, &rendered) {
3505
+ Ok(snapshot) => { super::enter_rescue::bind_pending_context(store, &snapshot)?; }
3506
+ Err(error) => {
3507
+ event_log.write("enter_rescue.snapshot_unavailable", serde_json::json!({
3508
+ "message_id": message_id, "reason": error.to_string(),
3509
+ }))?;
3510
+ }
3511
+ }
3512
+ Ok(())
3513
+ }
3514
+
3515
+ /// Visibility prevents a duplicate paste; only a separate consumption receipt permits success.
3516
+ /// Read failures do not manufacture visibility. Capture stays bounded to Tail(80).
3500
3517
  pub(crate) fn token_already_visible(
3501
3518
  transport: &dyn Transport,
3502
3519
  state: &serde_json::Value,
@@ -3623,6 +3640,10 @@ fn rollout_tail_contains_result(
3623
3640
  Ok(haystack.contains(needle))
3624
3641
  }
3625
3642
 
3643
+ #[cfg(test)]
3644
+ #[path = "delivery_consumption_tests.rs"]
3645
+ mod consumption_tests;
3646
+
3626
3647
  #[cfg(test)]
3627
3648
  mod paste_floor_tests {
3628
3649
  use super::*;
@@ -0,0 +1,223 @@
1
+ #![allow(clippy::unwrap_used)]
2
+
3
+ use super::*;
4
+ use crate::transport::{native_input::InputFrame, test_support::OfflineTransport};
5
+ use std::sync::atomic::{AtomicU64, Ordering};
6
+
7
+ struct Fixture {
8
+ workspace: std::path::PathBuf,
9
+ store: MessageStore,
10
+ state: serde_json::Value,
11
+ }
12
+ impl Fixture {
13
+ fn new(provider: &str) -> Self {
14
+ static NEXT: AtomicU64 = AtomicU64::new(0);
15
+ let workspace = std::env::temp_dir().join(format!(
16
+ "ta-consumption-{}-{}",
17
+ std::process::id(),
18
+ NEXT.fetch_add(1, Ordering::Relaxed)
19
+ ));
20
+ std::fs::create_dir_all(&workspace).unwrap();
21
+ let store = MessageStore::open(&workspace).unwrap();
22
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
23
+ conn.execute("insert into messages(message_id,owner_team_id,sender,recipient,content,status,created_at,updated_at,delivery_attempts) values('msg_one','team','operator','worker','body','accepted','old','old',0)", []).unwrap();
24
+ let state = serde_json::json!({"team_key":"team","session_name":"session","agents":{"worker":{
25
+ "status":"running","provider":provider,"pane_id":"%1","pane_pid":2,"window":"worker"
26
+ }}});
27
+ Self {
28
+ workspace,
29
+ store,
30
+ state,
31
+ }
32
+ }
33
+ fn transport(&self) -> OfflineTransport {
34
+ OfflineTransport::new()
35
+ .with_session_present(true)
36
+ .with_targets(vec![PaneInfo {
37
+ pane_id: PaneId::new("%1"),
38
+ session: SessionName::new("session"),
39
+ window_index: Some(1),
40
+ window_name: Some(WindowName::new("worker")),
41
+ pane_index: Some(0),
42
+ tty: None,
43
+ current_command: None,
44
+ current_path: Some(self.workspace.clone()),
45
+ active: true,
46
+ pane_pid: Some(2),
47
+ leader_env: Default::default(),
48
+ }])
49
+ .with_default_liveness(crate::transport::PaneLiveness::Live)
50
+ }
51
+ fn deliver(&self, transport: &OfflineTransport) -> DeliveryOutcome {
52
+ let message = message_for_delivery(&self.store, "msg_one")
53
+ .unwrap()
54
+ .unwrap();
55
+ deliver_prepared_message(
56
+ &self.workspace,
57
+ &self.store,
58
+ transport,
59
+ "msg_one",
60
+ &EventLog::new(&self.workspace),
61
+ &self.state,
62
+ message,
63
+ PreparedMessageState {
64
+ scoped: None,
65
+ canonical_owner_team_id: Some("team".into()),
66
+ },
67
+ )
68
+ .unwrap()
69
+ }
70
+ fn receipt(&self) -> (String, i64) {
71
+ let conn = crate::db::schema::open_db(self.store.db_path()).unwrap();
72
+ (
73
+ conn.query_row(
74
+ "select status from messages where message_id='msg_one'",
75
+ [],
76
+ |r| r.get(0),
77
+ )
78
+ .unwrap(),
79
+ conn.query_row(
80
+ "select count(*) from scheduled_events where kind='enter_rescue'",
81
+ [],
82
+ |r| r.get(0),
83
+ )
84
+ .unwrap(),
85
+ )
86
+ }
87
+ }
88
+ impl Drop for Fixture {
89
+ fn drop(&mut self) {
90
+ let _ = std::fs::remove_dir_all(&self.workspace);
91
+ }
92
+ }
93
+ fn frame(composer: &str, transcript: &str) -> InputFrame {
94
+ InputFrame {
95
+ composer: composer.into(),
96
+ transcript: transcript.into(),
97
+ identity: "known-current-ui".into(),
98
+ footer: "owned cwd".into(),
99
+ busy: false,
100
+ capsule: None,
101
+ }
102
+ }
103
+
104
+ #[test]
105
+ fn current_token_in_composer_is_not_duplicate_delivered_or_repasted() {
106
+ let f = Fixture::new("cursor_agent");
107
+ let marker = message_token_marker("msg_one");
108
+ let t = f
109
+ .transport()
110
+ .with_capture_for_pane("%1", &marker)
111
+ .with_native_input_observation(frame(&marker, &marker));
112
+ let out = f.deliver(&t);
113
+ assert!(!out.ok);
114
+ assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
115
+ assert!(t.inject_payloads().is_empty());
116
+ }
117
+
118
+ #[test]
119
+ fn successful_transport_report_does_not_override_unconsumed_composer() {
120
+ for composer in [
121
+ "[team-agent-token:msg_one]",
122
+ "[Pasted text #1 +25 lines]",
123
+ "foreign draft",
124
+ ] {
125
+ let f = Fixture::new("cursor_agent");
126
+ let t = f
127
+ .transport()
128
+ .with_native_input_observation(frame(composer, ""));
129
+ let out = f.deliver(&t);
130
+ assert!(!out.ok, "composer={composer}");
131
+ assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
132
+ assert_eq!(t.inject_payloads().len(), 1);
133
+ }
134
+ }
135
+
136
+ #[test]
137
+ fn unknown_current_native_surface_is_unverified_not_success() {
138
+ let f = Fixture::new("pi");
139
+ let t = f
140
+ .transport()
141
+ .with_native_input_error("native_surface_unknown");
142
+ assert!(!f.deliver(&t).ok);
143
+ assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
144
+ }
145
+
146
+ #[test]
147
+ fn pane_visibility_without_an_observation_protocol_is_not_duplicate_acceptance() {
148
+ let f = Fixture::new("fake");
149
+ let t = f
150
+ .transport()
151
+ .with_capture_for_pane("%1", message_token_marker("msg_one"));
152
+ assert!(!f.deliver(&t).ok);
153
+ assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
154
+ assert!(t.inject_payloads().is_empty());
155
+ }
156
+
157
+ #[test]
158
+ fn current_empty_composer_and_unique_transcript_token_prove_consumption() {
159
+ let f = Fixture::new("cursor_agent");
160
+ let marker = message_token_marker("msg_one");
161
+ let t = f
162
+ .transport()
163
+ .with_capture_for_pane("%1", &marker)
164
+ .with_native_input_observation(frame("", &marker));
165
+ assert!(f.deliver(&t).ok);
166
+ assert_eq!(f.receipt(), ("delivered".into(), 0));
167
+ assert!(t.inject_payloads().is_empty());
168
+ }
169
+
170
+ #[test]
171
+ fn busy_answers_or_duplicate_history_tokens_do_not_prove_consumption() {
172
+ let f = Fixture::new("cursor_agent");
173
+ let target = Target::Pane(PaneId::new("%1"));
174
+ for transcript in [
175
+ "answer is 5535",
176
+ "[team-agent-token:msg_one]\n[team-agent-token:msg_one]",
177
+ ] {
178
+ let mut view = frame("", transcript);
179
+ view.busy = true;
180
+ let t = f.transport().with_native_input_observation(view);
181
+ assert_eq!(
182
+ observed_token_consumed(&t, &f.state, "worker", &target, "msg_one"),
183
+ Some(false)
184
+ );
185
+ }
186
+ }
187
+
188
+ #[test]
189
+ fn unsupported_observation_keeps_existing_positive_transport_receipt() {
190
+ let f = Fixture::new("fake");
191
+ assert!(f.deliver(&f.transport()).ok);
192
+ assert_eq!(f.receipt(), ("delivered".into(), 0));
193
+ }
194
+
195
+ #[test]
196
+ fn mark_unverified_schedules_once_and_insert_failure_rolls_back_status() {
197
+ let f = Fixture::new("fake");
198
+ f.store
199
+ .mark("msg_one", "submitted_unverified", Some("opaque no proof"))
200
+ .unwrap();
201
+ f.store
202
+ .mark("msg_one", "submitted_unverified", Some("again"))
203
+ .unwrap();
204
+ assert_eq!(f.receipt(), ("submitted_unverified".into(), 1));
205
+ let conn = crate::db::schema::open_db(f.store.db_path()).unwrap();
206
+ let payload: String = conn
207
+ .query_row("select payload_json from scheduled_events", [], |r| {
208
+ r.get(0)
209
+ })
210
+ .unwrap();
211
+ let job: serde_json::Value = serde_json::from_str(&payload).unwrap();
212
+ assert!(job["context"]["target"].is_null());
213
+ assert!(job["context"]["proof"].is_null());
214
+
215
+ let g = Fixture::new("fake");
216
+ let conn = crate::db::schema::open_db(g.store.db_path()).unwrap();
217
+ conn.execute_batch("create trigger refuse_rescue before insert on scheduled_events begin select raise(abort,'fixture failure'); end;").unwrap();
218
+ assert!(g
219
+ .store
220
+ .mark("msg_one", "submitted_unverified", None)
221
+ .is_err());
222
+ assert_eq!(g.receipt(), ("accepted".into(), 0));
223
+ }