@team-agent/installer 0.5.53 → 0.5.54

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 (40) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +94 -3
  4. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  5. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  6. package/crates/team-agent/src/cli/send.rs +1 -0
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  12. package/crates/team-agent/src/cli/types.rs +1 -0
  13. package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
  14. package/crates/team-agent/src/db/message_store.rs +31 -2
  15. package/crates/team-agent/src/db/migration.rs +7 -6
  16. package/crates/team-agent/src/db/schema.rs +18 -5
  17. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  18. package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
  19. package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
  20. package/crates/team-agent/src/mcp_server/tools.rs +71 -0
  21. package/crates/team-agent/src/mcp_server/types.rs +4 -0
  22. package/crates/team-agent/src/mcp_server/wire.rs +42 -4
  23. package/crates/team-agent/src/messaging/delivery.rs +2 -0
  24. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  25. package/crates/team-agent/src/messaging/leader_receiver.rs +32 -0
  26. package/crates/team-agent/src/messaging/mod.rs +2 -1
  27. package/crates/team-agent/src/messaging/persist.rs +68 -2
  28. package/crates/team-agent/src/messaging/presentation.rs +307 -0
  29. package/crates/team-agent/src/messaging/results.rs +130 -3
  30. package/crates/team-agent/src/messaging/selftest.rs +1 -0
  31. package/crates/team-agent/src/messaging/send.rs +54 -2
  32. package/crates/team-agent/src/messaging/tests/runtime.rs +51 -1
  33. package/crates/team-agent/src/messaging/types.rs +2 -0
  34. package/crates/team-agent/src/messaging/watchers.rs +1 -0
  35. package/crates/team-agent/src/provider/session/capture.rs +74 -0
  36. package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
  37. package/crates/team-agent/src/provider/session_scan/common.rs +14 -86
  38. package/package.json +4 -4
  39. package/schemas/result-envelope.schema.json +10 -0
  40. package/skills/team-agent/SKILL.md +3 -0
@@ -0,0 +1,307 @@
1
+ //! Typed presentation metadata shared by message and result ingress.
2
+
3
+ use serde::{Deserialize, Serialize};
4
+ use serde_json::Value;
5
+
6
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7
+ #[serde(rename_all = "snake_case")]
8
+ pub enum PresentationSink {
9
+ Leader,
10
+ Casefile,
11
+ Silent,
12
+ }
13
+
14
+ impl PresentationSink {
15
+ pub const fn as_str(self) -> &'static str {
16
+ match self {
17
+ Self::Leader => "leader",
18
+ Self::Casefile => "casefile",
19
+ Self::Silent => "silent",
20
+ }
21
+ }
22
+
23
+ fn parse(value: &str) -> Option<Self> {
24
+ match value {
25
+ "leader" => Some(Self::Leader),
26
+ "casefile" => Some(Self::Casefile),
27
+ "silent" => Some(Self::Silent),
28
+ _ => None,
29
+ }
30
+ }
31
+ }
32
+
33
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34
+ #[serde(rename_all = "snake_case")]
35
+ pub enum PresentationClass {
36
+ Message,
37
+ Progress,
38
+ StageResult,
39
+ StagePass,
40
+ Bounce,
41
+ Blocking,
42
+ FinalReview,
43
+ Timeout,
44
+ }
45
+
46
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
47
+ pub enum PresentationSource {
48
+ Send,
49
+ ReportResult,
50
+ }
51
+
52
+ impl PresentationClass {
53
+ pub const fn as_str(self) -> &'static str {
54
+ match self {
55
+ Self::Message => "message",
56
+ Self::Progress => "progress",
57
+ Self::StageResult => "stage_result",
58
+ Self::StagePass => "stage_pass",
59
+ Self::Bounce => "bounce",
60
+ Self::Blocking => "blocking",
61
+ Self::FinalReview => "final_review",
62
+ Self::Timeout => "timeout",
63
+ }
64
+ }
65
+
66
+ fn parse(value: &str) -> Option<Self> {
67
+ match value {
68
+ "message" => Some(Self::Message),
69
+ "progress" => Some(Self::Progress),
70
+ "stage_result" => Some(Self::StageResult),
71
+ "stage_pass" => Some(Self::StagePass),
72
+ "bounce" => Some(Self::Bounce),
73
+ "blocking" => Some(Self::Blocking),
74
+ "final_review" => Some(Self::FinalReview),
75
+ "timeout" => Some(Self::Timeout),
76
+ _ => None,
77
+ }
78
+ }
79
+ }
80
+
81
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82
+ pub struct PresentationRequest {
83
+ pub sink: PresentationSink,
84
+ pub class: PresentationClass,
85
+ #[serde(skip_serializing_if = "Option::is_none")]
86
+ pub case_id: Option<String>,
87
+ }
88
+
89
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90
+ pub struct PresentationDecision {
91
+ pub sink: PresentationSink,
92
+ pub class: PresentationClass,
93
+ #[serde(skip_serializing_if = "Option::is_none")]
94
+ pub case_id: Option<String>,
95
+ pub requested_sink: PresentationSink,
96
+ pub effective_sink: PresentationSink,
97
+ pub policy_reason: String,
98
+ pub policy_version: String,
99
+ }
100
+
101
+ impl Default for PresentationRequest {
102
+ fn default() -> Self {
103
+ Self {
104
+ sink: PresentationSink::Leader,
105
+ class: PresentationClass::Message,
106
+ case_id: None,
107
+ }
108
+ }
109
+ }
110
+
111
+ impl Default for PresentationDecision {
112
+ fn default() -> Self {
113
+ decide_presentation(
114
+ &PresentationRequest::default(),
115
+ PresentationSource::ReportResult,
116
+ )
117
+ }
118
+ }
119
+
120
+ pub fn decide_presentation(
121
+ request: &PresentationRequest,
122
+ source: PresentationSource,
123
+ ) -> PresentationDecision {
124
+ let critical = matches!(
125
+ request.class,
126
+ PresentationClass::StagePass
127
+ | PresentationClass::Bounce
128
+ | PresentationClass::Blocking
129
+ | PresentationClass::FinalReview
130
+ | PresentationClass::Timeout
131
+ );
132
+ let (effective_sink, policy_reason) = if critical {
133
+ (
134
+ PresentationSink::Leader,
135
+ format!("critical_class:{}", request.class.as_str()),
136
+ )
137
+ } else if source == PresentationSource::ReportResult
138
+ && request.class != PresentationClass::StageResult
139
+ {
140
+ (
141
+ PresentationSink::Leader,
142
+ format!("user_delivery_class:{}", request.class.as_str()),
143
+ )
144
+ } else {
145
+ (
146
+ request.sink,
147
+ format!("requested_sink:{}", request.sink.as_str()),
148
+ )
149
+ };
150
+ PresentationDecision {
151
+ sink: request.sink,
152
+ class: request.class,
153
+ case_id: request.case_id.clone(),
154
+ requested_sink: request.sink,
155
+ effective_sink,
156
+ policy_reason,
157
+ policy_version: "team-presentation-v1".to_string(),
158
+ }
159
+ }
160
+
161
+ pub fn normalize_report_presentation(
162
+ value: Option<&Value>,
163
+ ) -> (PresentationRequest, Option<String>) {
164
+ let (request, error) = normalize_presentation(value);
165
+ if error.is_some() {
166
+ return (request, error);
167
+ }
168
+ let missing_case_id = request.class == PresentationClass::StageResult
169
+ && request.sink != PresentationSink::Leader
170
+ && request
171
+ .case_id
172
+ .as_deref()
173
+ .is_none_or(|case_id| case_id.trim().is_empty());
174
+ if missing_case_id {
175
+ return (request, Some("missing_case_id".to_string()));
176
+ }
177
+ (request, None)
178
+ }
179
+
180
+ pub fn normalize_presentation(value: Option<&Value>) -> (PresentationRequest, Option<String>) {
181
+ let Some(value) = value else {
182
+ return (PresentationRequest::default(), None);
183
+ };
184
+ let Some(object) = value.as_object() else {
185
+ return (
186
+ PresentationRequest::default(),
187
+ Some("malformed_presentation".to_string()),
188
+ );
189
+ };
190
+ let Some(sink) = object.get("sink").and_then(Value::as_str) else {
191
+ return (
192
+ PresentationRequest::default(),
193
+ Some("missing_sink".to_string()),
194
+ );
195
+ };
196
+ let Some(sink) = PresentationSink::parse(sink) else {
197
+ return (
198
+ PresentationRequest::default(),
199
+ Some(format!("unknown_sink:{sink}")),
200
+ );
201
+ };
202
+ let Some(class) = object.get("class").and_then(Value::as_str) else {
203
+ return (
204
+ PresentationRequest::default(),
205
+ Some("missing_class".to_string()),
206
+ );
207
+ };
208
+ let Some(class) = PresentationClass::parse(class) else {
209
+ return (
210
+ PresentationRequest::default(),
211
+ Some(format!("unknown_class:{class}")),
212
+ );
213
+ };
214
+ let case_id = object
215
+ .get("case_id")
216
+ .and_then(Value::as_str)
217
+ .map(ToOwned::to_owned);
218
+ (
219
+ PresentationRequest {
220
+ sink,
221
+ class,
222
+ case_id,
223
+ },
224
+ None,
225
+ )
226
+ }
227
+
228
+ #[cfg(test)]
229
+ mod tests {
230
+ use super::*;
231
+ use serde_json::json;
232
+
233
+ #[test]
234
+ fn missing_metadata_keeps_legacy_leader_default() {
235
+ assert_eq!(
236
+ normalize_presentation(None),
237
+ (PresentationRequest::default(), None)
238
+ );
239
+ }
240
+
241
+ #[test]
242
+ fn malformed_or_unknown_metadata_is_observable() {
243
+ assert_eq!(
244
+ normalize_presentation(Some(&json!({"sink": "casefile"}))).1,
245
+ Some("missing_class".to_string())
246
+ );
247
+ assert_eq!(
248
+ normalize_presentation(Some(&json!({"sink": "bogus", "class": "message"}))).1,
249
+ Some("unknown_sink:bogus".to_string())
250
+ );
251
+ }
252
+
253
+ #[test]
254
+ fn critical_classes_force_leader_while_prose_is_ignored() {
255
+ let request = PresentationRequest {
256
+ sink: PresentationSink::Casefile,
257
+ class: PresentationClass::Blocking,
258
+ case_id: None,
259
+ };
260
+ let decision = decide_presentation(&request, PresentationSource::ReportResult);
261
+ assert_eq!(decision.effective_sink, PresentationSink::Leader);
262
+ assert_eq!(decision.policy_reason, "critical_class:blocking");
263
+
264
+ let benign = PresentationRequest {
265
+ sink: PresentationSink::Casefile,
266
+ class: PresentationClass::Message,
267
+ case_id: None,
268
+ };
269
+ assert_eq!(
270
+ decide_presentation(&benign, PresentationSource::Send).effective_sink,
271
+ PresentationSink::Casefile
272
+ );
273
+ assert_eq!(
274
+ decide_presentation(&benign, PresentationSource::ReportResult).effective_sink,
275
+ PresentationSink::Leader
276
+ );
277
+ }
278
+
279
+ #[test]
280
+ fn report_stage_result_requires_case_id_only_for_non_leader_sink() {
281
+ assert_eq!(
282
+ normalize_report_presentation(Some(
283
+ &json!({"sink": "casefile", "class": "stage_result"})
284
+ ))
285
+ .1,
286
+ Some("missing_case_id".to_string())
287
+ );
288
+ assert_eq!(
289
+ normalize_report_presentation(Some(&json!({
290
+ "sink": "casefile",
291
+ "class": "stage_result",
292
+ "case_id": "case-1"
293
+ })))
294
+ .1,
295
+ None
296
+ );
297
+ assert_eq!(
298
+ normalize_presentation(Some(&json!({
299
+ "sink": "casefile",
300
+ "class": "stage_result"
301
+ })))
302
+ .1,
303
+ None,
304
+ "send normalization remains unchanged"
305
+ );
306
+ }
307
+ }
@@ -628,6 +628,17 @@ fn report_result_for_owner_team_inner(
628
628
  fallback_primary_error: Option<&str>,
629
629
  ) -> Result<serde_json::Value, MessagingError> {
630
630
  validate_result_envelope(envelope)?;
631
+ let (presentation_request, presentation_error) =
632
+ super::presentation::normalize_report_presentation(envelope.get("presentation"));
633
+ if let Some(error) = presentation_error {
634
+ return Err(MessagingError::Validation(format!(
635
+ "invalid presentation: {error}"
636
+ )));
637
+ }
638
+ let presentation = super::presentation::decide_presentation(
639
+ &presentation_request,
640
+ super::presentation::PresentationSource::ReportResult,
641
+ );
631
642
  let store = MessageStore::open(workspace)?;
632
643
  let result_id = envelope
633
644
  .get("result_id")
@@ -645,6 +656,12 @@ fn report_result_for_owner_team_inner(
645
656
  );
646
657
  }
647
658
  }
659
+ if let Some(obj) = stored.as_object_mut() {
660
+ obj.insert(
661
+ "presentation".to_string(),
662
+ serde_json::to_value(&presentation)?,
663
+ );
664
+ }
648
665
  let conn = crate::db::schema::open_db(store.db_path())?;
649
666
  let state_for_owner =
650
667
  crate::state::persist::load_runtime_state(workspace).unwrap_or(serde_json::json!({}));
@@ -710,6 +727,71 @@ fn report_result_for_owner_team_inner(
710
727
  out.insert("notification_event_id".to_string(), serde_json::Value::Null);
711
728
  return Ok(serde_json::Value::Object(out));
712
729
  }
730
+ let event_log = EventLog::new(workspace);
731
+ if presentation.effective_sink != super::presentation::PresentationSink::Leader {
732
+ event_log.write(
733
+ "presentation.stored_without_live_inject",
734
+ serde_json::json!({
735
+ "result_id": result_id,
736
+ "owner_team_id": owner_team,
737
+ "requested_sink": presentation.requested_sink,
738
+ "effective_sink": presentation.effective_sink,
739
+ "class": presentation.class,
740
+ "policy_reason": presentation.policy_reason,
741
+ }),
742
+ )?;
743
+ event_log.write(
744
+ "mcp.report_result",
745
+ serde_json::json!({
746
+ "leader_notified": false,
747
+ "notification_channel": presentation.effective_sink,
748
+ "notification_message_id": serde_json::Value::Null,
749
+ "notification_status": "stored_not_presented",
750
+ "owner_team_id": owner_team,
751
+ "result_id": result_id,
752
+ }),
753
+ )?;
754
+ let mut out = serde_json::Map::new();
755
+ out.insert("ok".to_string(), serde_json::Value::Bool(true));
756
+ out.insert(
757
+ "result_id".to_string(),
758
+ serde_json::Value::String(result_id),
759
+ );
760
+ out.insert(
761
+ "task_id".to_string(),
762
+ serde_json::Value::String(task_id.to_string()),
763
+ );
764
+ copy_report_attribution_fields(envelope, &mut out);
765
+ out.insert(
766
+ "agent_id".to_string(),
767
+ serde_json::Value::String(agent_id.to_string()),
768
+ );
769
+ out.insert("acknowledged_messages".to_string(), serde_json::json!([]));
770
+ out.insert(
771
+ "leader_notified".to_string(),
772
+ serde_json::Value::Bool(false),
773
+ );
774
+ out.insert(
775
+ "notification_message_id".to_string(),
776
+ serde_json::Value::Null,
777
+ );
778
+ out.insert(
779
+ "notification_status".to_string(),
780
+ serde_json::Value::String("stored_not_presented".to_string()),
781
+ );
782
+ out.insert(
783
+ "notification_channel".to_string(),
784
+ serde_json::Value::String(presentation.effective_sink.as_str().to_string()),
785
+ );
786
+ out.insert("notification_event_id".to_string(), serde_json::Value::Null);
787
+ if let Some(warnings) = report_result_array(envelope, "warnings") {
788
+ out.insert(
789
+ "warnings".to_string(),
790
+ serde_json::Value::Array(warnings.clone()),
791
+ );
792
+ }
793
+ return Ok(serde_json::Value::Object(out));
794
+ }
713
795
  // #230 N31/N32 funnel: report_result must go through the shared leader-delivery
714
796
  // primitive synchronously, NOT via a parallel queued scheduled_events row. The
715
797
  // legacy path was MUST-8 / I-3 violating (the deferred notification status was returned
@@ -717,8 +799,7 @@ fn report_result_for_owner_team_inner(
717
799
  let content =
718
800
  format_report_result_notification(&result_id, task_id, agent_id, status, envelope);
719
801
  let state = report_owner_state(&state_for_owner, &owner_team);
720
- let event_log = EventLog::new(workspace);
721
- let mut outcome = match super::leader_receiver::send_to_leader_receiver(
802
+ let mut outcome = match super::leader_receiver::send_to_leader_receiver_with_presentation(
722
803
  workspace,
723
804
  &state,
724
805
  "leader",
@@ -727,6 +808,8 @@ fn report_result_for_owner_team_inner(
727
808
  agent_id,
728
809
  false,
729
810
  Some(&result_id),
811
+ None,
812
+ &presentation,
730
813
  &event_log,
731
814
  ) {
732
815
  Ok(outcome) => outcome,
@@ -1245,7 +1328,8 @@ pub fn collect_results_and_notify_watchers(
1245
1328
 
1246
1329
  #[cfg(test)]
1247
1330
  mod tests {
1248
- use super::format_report_result_notification;
1331
+ use super::{format_report_result_notification, report_result};
1332
+ use crate::message_store::MessageStore;
1249
1333
 
1250
1334
  #[test]
1251
1335
  fn report_result_notification_includes_full_envelope_sections() {
@@ -1292,4 +1376,47 @@ mod tests {
1292
1376
  assert!(notification.contains("Next actions: ship after review"));
1293
1377
  assert!(notification.contains("Result id: res_1"));
1294
1378
  }
1379
+
1380
+ #[test]
1381
+ fn casefile_result_is_stored_without_a_leader_notification_row() {
1382
+ let workspace = std::env::temp_dir().join(format!(
1383
+ "ta-casefile-result-{}-{}",
1384
+ std::process::id(),
1385
+ super::next_result_id()
1386
+ ));
1387
+ std::fs::create_dir_all(&workspace).unwrap();
1388
+ let out = report_result(
1389
+ &workspace,
1390
+ &serde_json::json!({
1391
+ "schema_version": "result_envelope_v1",
1392
+ "task_id": "task-1",
1393
+ "agent_id": "worker",
1394
+ "status": "success",
1395
+ "summary": "stage evidence",
1396
+ "changes": [],
1397
+ "tests": [],
1398
+ "risks": [],
1399
+ "artifacts": [],
1400
+ "next_actions": [],
1401
+ "presentation": {
1402
+ "sink": "casefile",
1403
+ "class": "stage_result",
1404
+ "case_id": "case-1"
1405
+ }
1406
+ }),
1407
+ )
1408
+ .unwrap();
1409
+ assert_eq!(out["notification_status"], "stored_not_presented");
1410
+ assert_eq!(out["leader_notified"], false);
1411
+ let store = MessageStore::open(&workspace).unwrap();
1412
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
1413
+ let result_count: i64 = conn
1414
+ .query_row("select count(*) from results", [], |row| row.get(0))
1415
+ .unwrap();
1416
+ let message_count: i64 = conn
1417
+ .query_row("select count(*) from messages", [], |row| row.get(0))
1418
+ .unwrap();
1419
+ assert_eq!(result_count, 1);
1420
+ assert_eq!(message_count, 0);
1421
+ }
1295
1422
  }
@@ -142,6 +142,7 @@ fn run_contract_suite(
142
142
  false,
143
143
  None,
144
144
  super::InitialDisposition::Accepted,
145
+ None,
145
146
  )
146
147
  .ok()?
147
148
  {
@@ -9,7 +9,10 @@ use crate::model::ids::{AgentId, TaskId, TeamKey};
9
9
  use crate::transport::{PaneId, Transport};
10
10
 
11
11
  use super::helpers::{status_wire, MessageStatusShadow};
12
- use super::leader_receiver::{send_to_leader_receiver, send_to_leader_receiver_with_message_id};
12
+ use super::leader_receiver::{send_to_leader_receiver, send_to_leader_receiver_with_presentation};
13
+ use super::presentation::{
14
+ decide_presentation, PresentationRequest, PresentationSink, PresentationSource,
15
+ };
13
16
  use super::{
14
17
  persist_resolved_send, DeliveryBlocker, DeliveryOutcome, DeliveryRefusal, DeliveryStatus,
15
18
  InitialDisposition, LogicalRecipient, MessagingError, PersistResolution, ResolvedSendIntent,
@@ -72,6 +75,7 @@ pub struct SendOptions {
72
75
  /// the store insert uses this id verbatim; a repeat with the same id is rejected
73
76
  /// as [`DeliveryRefusal::Duplicate`] instead of creating a second row.
74
77
  pub message_id: Option<String>,
78
+ pub presentation: PresentationRequest,
75
79
  }
76
80
 
77
81
  impl Default for SendOptions {
@@ -91,6 +95,7 @@ impl Default for SendOptions {
91
95
  block_until_delivered: true,
92
96
  team: None,
93
97
  message_id: None,
98
+ presentation: PresentationRequest::default(),
94
99
  }
95
100
  }
96
101
  }
@@ -118,7 +123,53 @@ pub fn send_message(
118
123
  backfill_leader_binding_for_delivery_view(&mut state, &raw_state);
119
124
  let recipient = match target {
120
125
  MessageTarget::Single(target) if target == "leader" => {
121
- let outcome = send_to_leader_receiver_with_message_id(
126
+ let presentation = decide_presentation(&opts.presentation, PresentationSource::Send);
127
+ if presentation.effective_sink != PresentationSink::Leader {
128
+ let mut intent = ResolvedSendIntent::accepted(
129
+ opts.origin,
130
+ workspace,
131
+ opts.team.clone(),
132
+ LogicalRecipient::Leader,
133
+ opts.sender.clone(),
134
+ opts.task_id.clone(),
135
+ content,
136
+ None,
137
+ false,
138
+ opts.message_id.clone(),
139
+ );
140
+ intent.initial_disposition = InitialDisposition::StoredOnly;
141
+ intent.presentation = presentation.clone();
142
+ let persisted = match persist_resolved_send(&intent)? {
143
+ PersistResolution::Persisted(persisted) => persisted,
144
+ PersistResolution::Duplicate(message_id) => {
145
+ return Ok(refused_outcome_with_id(
146
+ DeliveryRefusal::Duplicate,
147
+ Some(message_id),
148
+ ));
149
+ }
150
+ };
151
+ event_log.write(
152
+ "presentation.stored_without_live_inject",
153
+ serde_json::json!({
154
+ "message_id": persisted.message_id,
155
+ "requested_sink": presentation.requested_sink,
156
+ "effective_sink": presentation.effective_sink,
157
+ "class": presentation.class,
158
+ "policy_reason": presentation.policy_reason,
159
+ }),
160
+ )?;
161
+ return Ok(DeliveryOutcome {
162
+ ok: true,
163
+ status: DeliveryStatus::StoredOnly,
164
+ message_status: MessageStatusShadow("stored_only".to_string()),
165
+ message_id: Some(persisted.message_id),
166
+ verification: Some("durable_without_live_inject".to_string()),
167
+ stage: None,
168
+ reason: None,
169
+ channel: Some(presentation.effective_sink.as_str().to_string()),
170
+ });
171
+ }
172
+ let outcome = send_to_leader_receiver_with_presentation(
122
173
  workspace,
123
174
  &state,
124
175
  "leader",
@@ -128,6 +179,7 @@ pub fn send_message(
128
179
  opts.requires_ack,
129
180
  None,
130
181
  opts.message_id.as_deref(),
182
+ &presentation,
131
183
  &event_log,
132
184
  )?;
133
185
  if matches!(outcome.status, DeliveryStatus::Queued) && owner_pane_is_dead(&state) {
@@ -446,7 +446,10 @@ fn message_not_silently_stuck_accepted_when_coordinator_dead() {
446
446
  assert!(out.ok, "durable persistence is the send success boundary");
447
447
  assert_eq!(out.status, DeliveryStatus::Blocked);
448
448
  assert_eq!(out.message_status.0, "queued_coordinator_unavailable");
449
- assert!(out.message_id.as_deref().is_some_and(|id| id.starts_with("msg_")));
449
+ assert!(out
450
+ .message_id
451
+ .as_deref()
452
+ .is_some_and(|id| id.starts_with("msg_")));
450
453
  assert_eq!(out.reason, Some(DeliveryRefusal::CoordinatorUnavailable));
451
454
  assert!(
452
455
  out.verification
@@ -2290,6 +2293,53 @@ fn u1_multi_team_send_does_not_backfill_top_level_leader_binding() {
2290
2293
  assert_eq!(owner_team_id.as_deref(), Some("team-b"));
2291
2294
  }
2292
2295
 
2296
+ #[test]
2297
+ fn casefile_leader_send_is_durable_without_entering_leader_funnel() {
2298
+ let ws = tmp_ws("casefile-send");
2299
+ crate::state::persist::save_runtime_state(
2300
+ &ws,
2301
+ &serde_json::json!({
2302
+ "session_name": "team-a",
2303
+ "active_team_key": "team-a",
2304
+ "agents": {}
2305
+ }),
2306
+ )
2307
+ .unwrap();
2308
+ let opts = SendOptions {
2309
+ team: Some(TeamKey::new("team-a")),
2310
+ requires_ack: false,
2311
+ presentation: crate::messaging::presentation::PresentationRequest {
2312
+ sink: crate::messaging::presentation::PresentationSink::Casefile,
2313
+ class: crate::messaging::presentation::PresentationClass::Progress,
2314
+ case_id: Some("case-1".to_string()),
2315
+ },
2316
+ ..SendOptions::default()
2317
+ };
2318
+ let out = send_message(
2319
+ &ws,
2320
+ &MessageTarget::Single("leader".to_string()),
2321
+ "internal progress",
2322
+ &opts,
2323
+ )
2324
+ .unwrap();
2325
+ assert_eq!(out.status, DeliveryStatus::StoredOnly);
2326
+ assert_eq!(out.message_status.0, "stored_only");
2327
+ let store = store_for(&ws);
2328
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
2329
+ let (status, presentation): (String, String) = conn
2330
+ .query_row(
2331
+ "select status, presentation from messages where message_id = ?1",
2332
+ [out.message_id.as_deref().unwrap()],
2333
+ |row| Ok((row.get(0)?, row.get(1)?)),
2334
+ )
2335
+ .unwrap();
2336
+ assert_eq!(status, "stored_only");
2337
+ assert_eq!(
2338
+ serde_json::from_str::<serde_json::Value>(&presentation).unwrap()["effective_sink"],
2339
+ "casefile"
2340
+ );
2341
+ }
2342
+
2293
2343
  // ════════════════════════════════════════════════════════════════════════
2294
2344
  // GROUP V — retry_result_deliveries: re-route notify_failed watchers with
2295
2345
  // dedupe_reason rebind_retry. result_delivery.py:19-35.
@@ -20,6 +20,8 @@ use super::helpers::MessageStatusShadow;
20
20
  #[serde(rename_all = "snake_case")]
21
21
  pub enum DeliveryStatus {
22
22
  Delivered,
23
+ /// Durable presentation obligation intentionally did not enter physical injection.
24
+ StoredOnly,
23
25
  Failed,
24
26
  /// busy → 延后不丢 (card §131:不 mark failed,留队列)。
25
27
  Queued,
@@ -195,6 +195,7 @@ fn deliver_primary_watcher(
195
195
  false,
196
196
  None,
197
197
  super::InitialDisposition::Accepted,
198
+ None,
198
199
  )?
199
200
  else {
200
201
  unreachable!("watcher notifications do not accept caller-supplied ids")