@team-agent/installer 0.3.10 → 0.3.12

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.
@@ -1,237 +1,335 @@
1
- #[test]
2
- fn requires_ack_for_target_leader_vs_worker() {
3
- assert!(!requires_ack_for_target(&MessageTarget::Single("leader".to_string())));
4
- assert!(!requires_ack_for_target(&MessageTarget::Single("Leader".to_string())));
5
- assert!(requires_ack_for_target(&MessageTarget::Single("alice".to_string())));
6
- // list: all-leader → false; any non-leader → true
7
- assert!(!requires_ack_for_target(&MessageTarget::Fanout(vec![
8
- "leader".to_string(), "Leader".to_string()
9
- ])));
10
- assert!(requires_ack_for_target(&MessageTarget::Fanout(vec![
11
- "leader".to_string(), "alice".to_string()
12
- ])));
13
- }
1
+ #[test]
2
+ fn requires_ack_for_target_leader_vs_worker() {
3
+ assert!(!requires_ack_for_target(&MessageTarget::Single(
4
+ "leader".to_string()
5
+ )));
6
+ assert!(!requires_ack_for_target(&MessageTarget::Single(
7
+ "Leader".to_string()
8
+ )));
9
+ assert!(requires_ack_for_target(&MessageTarget::Single(
10
+ "alice".to_string()
11
+ )));
12
+ // list: all-leader → false; any non-leader → true
13
+ assert!(!requires_ack_for_target(&MessageTarget::Fanout(vec![
14
+ "leader".to_string(),
15
+ "Leader".to_string()
16
+ ])));
17
+ assert!(requires_ack_for_target(&MessageTarget::Fanout(vec![
18
+ "leader".to_string(),
19
+ "alice".to_string()
20
+ ])));
21
+ }
14
22
 
15
- // ════════════════════════════════════════════════════════════════════════
16
- // is_worker_recipient — single str not in {"","*","leader","Leader"} (tools.py:22)
17
- // ════════════════════════════════════════════════════════════════════════
18
- #[test]
19
- fn is_worker_recipient_classification() {
20
- assert!(is_worker_recipient(&MessageTarget::Single("alice".to_string())));
21
- assert!(!is_worker_recipient(&MessageTarget::Single("".to_string())));
22
- assert!(!is_worker_recipient(&MessageTarget::Single("leader".to_string())));
23
- assert!(!is_worker_recipient(&MessageTarget::Single("Leader".to_string())));
24
- // Broadcast "*" is NOT a worker recipient
25
- assert!(!is_worker_recipient(&MessageTarget::Broadcast));
26
- // Fanout list is NOT a worker recipient (not a single str)
27
- assert!(!is_worker_recipient(&MessageTarget::Fanout(vec!["alice".to_string()])));
28
- }
23
+ // ════════════════════════════════════════════════════════════════════════
24
+ // is_worker_recipient — single str not in {"","*","leader","Leader"} (tools.py:22)
25
+ // ════════════════════════════════════════════════════════════════════════
26
+ #[test]
27
+ fn is_worker_recipient_classification() {
28
+ assert!(is_worker_recipient(&MessageTarget::Single(
29
+ "alice".to_string()
30
+ )));
31
+ assert!(!is_worker_recipient(&MessageTarget::Single("".to_string())));
32
+ assert!(!is_worker_recipient(&MessageTarget::Single(
33
+ "leader".to_string()
34
+ )));
35
+ assert!(!is_worker_recipient(&MessageTarget::Single(
36
+ "Leader".to_string()
37
+ )));
38
+ // Broadcast "*" is NOT a worker recipient
39
+ assert!(!is_worker_recipient(&MessageTarget::Broadcast));
40
+ // Fanout list is NOT a worker recipient (not a single str)
41
+ assert!(!is_worker_recipient(&MessageTarget::Fanout(vec![
42
+ "alice".to_string()
43
+ ])));
44
+ }
29
45
 
30
- // ════════════════════════════════════════════════════════════════════════
31
- // merge_tasks_by_id — prefer wins, prefer-first insertion order (tools.py:30)
32
- // Golden: prefer t1(done),t2 + fallback t1(pending),t3,{no id},"notdict"
33
- // → [t1(done), t2, t3] (t1 from prefer wins; non-dict / no-id dropped)
34
- // ════════════════════════════════════════════════════════════════════════
35
- #[test]
36
- fn merge_tasks_by_id_prefer_wins_no_done_regression() {
37
- let prefer = vec![
38
- json!({"id": "t1", "status": "done"}),
39
- json!({"id": "t2", "status": "pending"}),
40
- ];
41
- let fallback = vec![
42
- json!({"id": "t1", "status": "pending"}), // must NOT regress t1
43
- json!({"id": "t3", "status": "ready"}),
44
- json!({"no": "id"}), // dropped (no id)
45
- json!("notdict"), // dropped (not object)
46
- ];
47
- let merged = merge_tasks_by_id(&prefer, &fallback);
48
- assert_eq!(merged.len(), 3);
49
- assert_eq!(merged[0]["id"], json!("t1"));
50
- assert_eq!(merged[0]["status"], json!("done")); // prefer wins → no regression
51
- assert_eq!(merged[1]["id"], json!("t2"));
52
- assert_eq!(merged[2]["id"], json!("t3"));
53
- }
46
+ // ════════════════════════════════════════════════════════════════════════
47
+ // merge_tasks_by_id — prefer wins, prefer-first insertion order (tools.py:30)
48
+ // Golden: prefer t1(done),t2 + fallback t1(pending),t3,{no id},"notdict"
49
+ // → [t1(done), t2, t3] (t1 from prefer wins; non-dict / no-id dropped)
50
+ // ════════════════════════════════════════════════════════════════════════
51
+ #[test]
52
+ fn merge_tasks_by_id_prefer_wins_no_done_regression() {
53
+ let prefer = vec![
54
+ json!({"id": "t1", "status": "done"}),
55
+ json!({"id": "t2", "status": "pending"}),
56
+ ];
57
+ let fallback = vec![
58
+ json!({"id": "t1", "status": "pending"}), // must NOT regress t1
59
+ json!({"id": "t3", "status": "ready"}),
60
+ json!({"no": "id"}), // dropped (no id)
61
+ json!("notdict"), // dropped (not object)
62
+ ];
63
+ let merged = merge_tasks_by_id(&prefer, &fallback);
64
+ assert_eq!(merged.len(), 3);
65
+ assert_eq!(merged[0]["id"], json!("t1"));
66
+ assert_eq!(merged[0]["status"], json!("done")); // prefer wins → no regression
67
+ assert_eq!(merged[1]["id"], json!("t2"));
68
+ assert_eq!(merged[2]["id"], json!("t3"));
69
+ }
54
70
 
55
- // ════════════════════════════════════════════════════════════════════════
56
- // SendOutcome::to_value — worker-accepted async envelope (tools.py:177-182)
57
- // byte-stable: {status:"accepted",delivery_pending:true,
58
- // poll_via:"team-agent inbox <id>",message_id:<id>}
59
- // ════════════════════════════════════════════════════════════════════════
60
- #[test]
61
- fn send_outcome_worker_accepted_envelope_byte_stable() {
62
- let outcome = SendOutcome::WorkerAccepted {
63
- message_id: "42".to_string(),
64
- poll_via: "team-agent inbox 42".to_string(),
65
- };
66
- let v = outcome.to_value();
67
- assert_eq!(keys(&v), vec!["status", "delivery_pending", "poll_via", "message_id"]);
68
- assert_eq!(s(&v),
69
- r#"{"status":"accepted","delivery_pending":true,"poll_via":"team-agent inbox 42","message_id":"42"}"#);
70
- }
71
+ // ════════════════════════════════════════════════════════════════════════
72
+ // SendOutcome::to_value — worker-accepted async envelope (tools.py:177-182)
73
+ // byte-stable: {status:"accepted",delivery_pending:true,
74
+ // poll_via:"team-agent inbox <id>",message_id:<id>}
75
+ // ════════════════════════════════════════════════════════════════════════
76
+ #[test]
77
+ fn send_outcome_worker_accepted_envelope_byte_stable() {
78
+ let outcome = SendOutcome::WorkerAccepted {
79
+ message_id: "42".to_string(),
80
+ poll_via: "team-agent inbox 42".to_string(),
81
+ };
82
+ let v = outcome.to_value();
83
+ assert_eq!(
84
+ keys(&v),
85
+ vec!["status", "delivery_pending", "poll_via", "message_id"]
86
+ );
87
+ assert_eq!(
88
+ s(&v),
89
+ r#"{"status":"accepted","delivery_pending":true,"poll_via":"team-agent inbox 42","message_id":"42"}"#
90
+ );
91
+ }
92
+
93
+ #[test]
94
+ fn send_outcome_direct_renders_compact_body() {
95
+ // leader / * / broadcast path → compacted delegate body, not the accepted envelope.
96
+ let ok = ToolOk {
97
+ fields: {
98
+ let mut m = serde_json::Map::new();
99
+ m.insert("ok".to_string(), json!(true));
100
+ m.insert("status".to_string(), json!("queued"));
101
+ m
102
+ },
103
+ };
104
+ let v = SendOutcome::Direct(ok).to_value();
105
+ assert_eq!(v.get("status"), Some(&json!("queued")));
106
+ assert!(
107
+ v.get("delivery_pending").is_none(),
108
+ "Direct is NOT the accepted envelope"
109
+ );
110
+ }
71
111
 
72
- #[test]
73
- fn send_outcome_direct_renders_compact_body() {
74
- // leader / * / broadcast path → compacted delegate body, not the accepted envelope.
75
- let ok = ToolOk {
76
- fields: {
77
- let mut m = serde_json::Map::new();
78
- m.insert("ok".to_string(), json!(true));
79
- m.insert("status".to_string(), json!("queued"));
80
- m
112
+ // ════════════════════════════════════════════════════════════════════════
113
+ // CONTROL-PLANE: send_message worker recipient → WorkerAccepted (tools.py:135-183)
114
+ // ════════════════════════════════════════════════════════════════════════
115
+ #[test]
116
+ fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
117
+ // A worker recipient w/ a delivered message_id → async accepted carrying the
118
+ // byte-stable poll hint. Identity anchored on injected env (no candidate scan).
119
+ // golden: a leader WITH owner_team_id on an unseeded ws would hit the C23 cross-team
120
+ // refusal first (worker-1 not in visible peers) -> PeerNotInScope. owner_team_id=None
121
+ // (legacy single-team) bypasses that, isolating the worker-recipient accepted path.
122
+ // The cross-team refusal has its own tests (refuse_cross_team_peer_* / send_message_cross_team_*).
123
+ // A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
124
+ // fabricated ids are gone), so the workspace seeds a running worker-1 the
125
+ // delivery layer can actually queue for.
126
+ let ws = unique_ws("send-worker");
127
+ crate::state::persist::save_runtime_state(
128
+ &ws,
129
+ &serde_json::json!({
130
+ "session_name": "team-x",
131
+ "agents": {
132
+ "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
81
133
  },
82
- };
83
- let v = SendOutcome::Direct(ok).to_value();
84
- assert_eq!(v.get("status"), Some(&json!("queued")));
85
- assert!(v.get("delivery_pending").is_none(), "Direct is NOT the accepted envelope");
134
+ }),
135
+ )
136
+ .unwrap();
137
+ let tools = TeamOrchestratorTools::with_identity(&ws, Some(AgentId::new("leader")), None);
138
+ let outcome = tools.send_message(
139
+ &MessageTarget::Single("worker-1".to_string()),
140
+ "do the thing",
141
+ None,
142
+ None,
143
+ None,
144
+ None,
145
+ );
146
+ match outcome {
147
+ Ok(SendOutcome::WorkerAccepted {
148
+ message_id,
149
+ poll_via,
150
+ }) => {
151
+ assert!(!message_id.is_empty());
152
+ assert_eq!(poll_via, format!("team-agent inbox {message_id}"));
153
+ }
154
+ other => panic!("worker recipient must be WorkerAccepted, got {other:?}"),
86
155
  }
156
+ }
87
157
 
88
- // ════════════════════════════════════════════════════════════════════════
89
- // CONTROL-PLANE: send_message worker recipient → WorkerAccepted (tools.py:135-183)
90
- // ════════════════════════════════════════════════════════════════════════
91
- #[test]
92
- fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
93
- // A worker recipient w/ a delivered message_id → async accepted carrying the
94
- // byte-stable poll hint. Identity anchored on injected env (no candidate scan).
95
- // golden: a leader WITH owner_team_id on an unseeded ws would hit the C23 cross-team
96
- // refusal first (worker-1 not in visible peers) -> PeerNotInScope. owner_team_id=None
97
- // (legacy single-team) bypasses that, isolating the worker-recipient accepted path.
98
- // The cross-team refusal has its own tests (refuse_cross_team_peer_* / send_message_cross_team_*).
99
- // A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
100
- // fabricated ids are gone), so the workspace seeds a running worker-1 the
101
- // delivery layer can actually queue for.
102
- let ws = unique_ws("send-worker");
103
- crate::state::persist::save_runtime_state(
104
- &ws,
105
- &serde_json::json!({
106
- "session_name": "team-x",
107
- "agents": {
108
- "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
109
- },
110
- }),
111
- )
112
- .unwrap();
113
- let tools = TeamOrchestratorTools::with_identity(
114
- &ws,
115
- Some(AgentId::new("leader")),
116
- None,
117
- );
118
- let outcome = tools.send_message(
158
+ #[test]
159
+ fn send_message_worker_recipient_surfaces_dead_coordinator_warning() {
160
+ let ws = unique_ws("send-worker-dead-coord");
161
+ crate::state::persist::save_runtime_state(
162
+ &ws,
163
+ &serde_json::json!({
164
+ "session_name": "team-x",
165
+ "agents": {
166
+ "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
167
+ },
168
+ }),
169
+ )
170
+ .unwrap();
171
+ let coordinator_ws = crate::coordinator::WorkspacePath::new(ws.clone());
172
+ std::fs::create_dir_all(crate::model::paths::runtime_dir(&ws)).unwrap();
173
+ let _ = crate::message_store::MessageStore::open(&ws).unwrap();
174
+ let stale_pid = crate::coordinator::Pid::new(99_999_999);
175
+ crate::coordinator::write_coordinator_metadata(
176
+ &coordinator_ws,
177
+ stale_pid,
178
+ crate::coordinator::MetadataSource::Boot,
179
+ )
180
+ .unwrap();
181
+ std::fs::write(
182
+ crate::coordinator::coordinator_pid_path(&coordinator_ws),
183
+ stale_pid.to_string(),
184
+ )
185
+ .unwrap();
186
+ let tools = TeamOrchestratorTools::with_identity(&ws, Some(AgentId::new("leader")), None);
187
+
188
+ let outcome = tools
189
+ .send_message(
119
190
  &MessageTarget::Single("worker-1".to_string()),
120
191
  "do the thing",
121
- None, None, None, None,
122
- );
123
- match outcome {
124
- Ok(SendOutcome::WorkerAccepted { message_id, poll_via }) => {
125
- assert!(!message_id.is_empty());
126
- assert_eq!(poll_via, format!("team-agent inbox {message_id}"));
127
- }
128
- other => panic!("worker recipient must be WorkerAccepted, got {other:?}"),
129
- }
130
- }
192
+ None,
193
+ None,
194
+ None,
195
+ None,
196
+ )
197
+ .expect("send returns degraded warning, not an MCP error");
198
+ let v = outcome.to_value();
199
+ assert_eq!(v.get("status"), Some(&json!("degraded")));
200
+ assert_eq!(v.get("reason"), Some(&json!("coordinator_unavailable")));
201
+ assert!(
202
+ v.get("warning")
203
+ .and_then(Value::as_str)
204
+ .is_some_and(|warning| warning.contains("message was not queued")),
205
+ "warning must explain the accepted-row avoidance; value={v}"
206
+ );
207
+ assert!(
208
+ v.get("delivery_pending").is_none(),
209
+ "dead coordinator must not return the old accepted async envelope"
210
+ );
211
+ }
131
212
 
132
- #[test]
133
- fn send_message_leader_recipient_is_direct_not_accepted() {
134
- let tools = TeamOrchestratorTools::with_identity(
135
- &unique_ws("send-leader"),
136
- Some(AgentId::new("worker-1")),
137
- Some(TeamKey::new("teamA")),
138
- );
139
- let outcome = tools.send_message(
213
+ #[test]
214
+ fn send_message_leader_recipient_is_direct_not_accepted() {
215
+ let tools = TeamOrchestratorTools::with_identity(
216
+ &unique_ws("send-leader"),
217
+ Some(AgentId::new("worker-1")),
218
+ Some(TeamKey::new("teamA")),
219
+ );
220
+ let outcome = tools
221
+ .send_message(
140
222
  &MessageTarget::Single("leader".to_string()),
141
223
  "status update",
142
- None, None, None, None,
143
- ).expect("leader send ok");
144
- assert!(matches!(outcome, SendOutcome::Direct(_)),
145
- "leader recipient → Direct (synchronous), not WorkerAccepted");
146
- }
147
-
148
- // ════════════════════════════════════════════════════════════════════════
149
- // CROSS-TEAM PRE-REFUSAL (C23) — refuse_cross_team_peer (tools.py:185-213)
150
- // ════════════════════════════════════════════════════════════════════════
151
- #[test]
152
- fn refuse_cross_team_peer_blocks_unknown_peer_without_workspace_scope() {
153
- // owner_team set, target a peer NOT in scope, scope != workspace → PeerNotInScope.
154
- let tools = TeamOrchestratorTools::with_identity(
155
- Path::new("/tmp/ws"),
156
- Some(AgentId::new("worker-1")),
157
- Some(TeamKey::new("teamA")),
158
- );
159
- let refusal = tools.refuse_cross_team_peer(
160
- &MessageTarget::Single("other-team-bob".to_string()),
161
224
  None,
162
- );
163
- let te = refusal.expect("cross-team peer must be refused");
164
- assert_eq!(te.reason, ToolErrorReason::PeerNotInScope);
165
- // hint preserved in extra (tools.py:208-213 status:"refused" + hint)
166
- let env = te.to_envelope();
167
- assert_eq!(env.get("status"), Some(&json!("refused")));
168
- assert_eq!(env.get("reason"), Some(&json!("peer_not_in_scope")));
169
- assert_eq!(
225
+ None,
226
+ None,
227
+ None,
228
+ )
229
+ .expect("leader send ok");
230
+ assert!(
231
+ matches!(outcome, SendOutcome::Direct(_)),
232
+ "leader recipient → Direct (synchronous), not WorkerAccepted"
233
+ );
234
+ }
235
+
236
+ // ════════════════════════════════════════════════════════════════════════
237
+ // CROSS-TEAM PRE-REFUSAL (C23) — refuse_cross_team_peer (tools.py:185-213)
238
+ // ════════════════════════════════════════════════════════════════════════
239
+ #[test]
240
+ fn refuse_cross_team_peer_blocks_unknown_peer_without_workspace_scope() {
241
+ // owner_team set, target a peer NOT in scope, scope != workspace → PeerNotInScope.
242
+ let tools = TeamOrchestratorTools::with_identity(
243
+ Path::new("/tmp/ws"),
244
+ Some(AgentId::new("worker-1")),
245
+ Some(TeamKey::new("teamA")),
246
+ );
247
+ let refusal =
248
+ tools.refuse_cross_team_peer(&MessageTarget::Single("other-team-bob".to_string()), None);
249
+ let te = refusal.expect("cross-team peer must be refused");
250
+ assert_eq!(te.reason, ToolErrorReason::PeerNotInScope);
251
+ // hint preserved in extra (tools.py:208-213 status:"refused" + hint)
252
+ let env = te.to_envelope();
253
+ assert_eq!(env.get("status"), Some(&json!("refused")));
254
+ assert_eq!(env.get("reason"), Some(&json!("peer_not_in_scope")));
255
+ assert_eq!(
170
256
  env.get("hint"),
171
257
  Some(&json!("the requested peer is not part of your team; worker-origin MCP cannot widen team scope."))
172
258
  );
173
- }
259
+ }
174
260
 
175
- #[test]
176
- fn refuse_cross_team_peer_rejects_workspace_scope_override_for_worker() {
177
- let tools = TeamOrchestratorTools::with_identity(
178
- Path::new("/tmp/ws"),
179
- Some(AgentId::new("worker-1")),
180
- Some(TeamKey::new("teamA")),
181
- );
182
- // scope="workspace" is not worker consent to cross team boundaries.
183
- let te = tools.refuse_cross_team_peer(
261
+ #[test]
262
+ fn refuse_cross_team_peer_rejects_workspace_scope_override_for_worker() {
263
+ let tools = TeamOrchestratorTools::with_identity(
264
+ Path::new("/tmp/ws"),
265
+ Some(AgentId::new("worker-1")),
266
+ Some(TeamKey::new("teamA")),
267
+ );
268
+ // scope="workspace" is not worker consent to cross team boundaries.
269
+ let te = tools
270
+ .refuse_cross_team_peer(
184
271
  &MessageTarget::Single("other-team-bob".to_string()),
185
272
  Some(Scope::Workspace),
186
- ).expect("workspace scope override must still be refused for worker-origin MCP");
187
- assert_eq!(te.reason, ToolErrorReason::McpScopeRefused);
188
- }
273
+ )
274
+ .expect("workspace scope override must still be refused for worker-origin MCP");
275
+ assert_eq!(te.reason, ToolErrorReason::McpScopeRefused);
276
+ }
189
277
 
190
- #[test]
191
- fn refuse_cross_team_peer_allows_leader_broadcast_and_self() {
192
- let tools = TeamOrchestratorTools::with_identity(
193
- Path::new("/tmp/ws"),
194
- Some(AgentId::new("worker-1")),
195
- Some(TeamKey::new("teamA")),
196
- );
197
- // leader / "*" / "" / self are never refused (tools.py:190,195)
198
- assert!(tools.refuse_cross_team_peer(&MessageTarget::Single("leader".to_string()), None).is_none());
199
- assert!(tools.refuse_cross_team_peer(&MessageTarget::Broadcast, None).is_none());
200
- assert!(tools.refuse_cross_team_peer(&MessageTarget::Single("worker-1".to_string()), None).is_none());
201
- }
278
+ #[test]
279
+ fn refuse_cross_team_peer_allows_leader_broadcast_and_self() {
280
+ let tools = TeamOrchestratorTools::with_identity(
281
+ Path::new("/tmp/ws"),
282
+ Some(AgentId::new("worker-1")),
283
+ Some(TeamKey::new("teamA")),
284
+ );
285
+ // leader / "*" / "" / self are never refused (tools.py:190,195)
286
+ assert!(tools
287
+ .refuse_cross_team_peer(&MessageTarget::Single("leader".to_string()), None)
288
+ .is_none());
289
+ assert!(tools
290
+ .refuse_cross_team_peer(&MessageTarget::Broadcast, None)
291
+ .is_none());
292
+ assert!(tools
293
+ .refuse_cross_team_peer(&MessageTarget::Single("worker-1".to_string()), None)
294
+ .is_none());
295
+ }
202
296
 
203
- #[test]
204
- fn refuse_cross_team_peer_no_owner_team_is_legacy_passthrough() {
205
- // No owner_team_id (legacy single-team) → never refuse (tools.py:192).
206
- let tools = TeamOrchestratorTools::with_identity(
207
- Path::new("/tmp/ws"),
208
- Some(AgentId::new("worker-1")),
209
- None,
210
- );
211
- assert!(tools.refuse_cross_team_peer(
212
- &MessageTarget::Single("anybody".to_string()),
213
- None,
214
- ).is_none());
215
- }
297
+ #[test]
298
+ fn refuse_cross_team_peer_no_owner_team_is_legacy_passthrough() {
299
+ // No owner_team_id (legacy single-team) → never refuse (tools.py:192).
300
+ let tools = TeamOrchestratorTools::with_identity(
301
+ Path::new("/tmp/ws"),
302
+ Some(AgentId::new("worker-1")),
303
+ None,
304
+ );
305
+ assert!(tools
306
+ .refuse_cross_team_peer(&MessageTarget::Single("anybody".to_string()), None,)
307
+ .is_none());
308
+ }
216
309
 
217
- #[test]
218
- fn send_message_cross_team_peer_surfaces_peer_not_in_scope_error() {
219
- // End-to-end: send_message to an out-of-scope peer → Err(ToolError{PeerNotInScope})
220
- // BEFORE any runtime delivery (server-side guard, no peer-name leak).
221
- let tools = TeamOrchestratorTools::with_identity(
222
- Path::new("/tmp/ws"),
223
- Some(AgentId::new("worker-1")),
224
- Some(TeamKey::new("teamA")),
225
- );
226
- let err = tools.send_message(
310
+ #[test]
311
+ fn send_message_cross_team_peer_surfaces_peer_not_in_scope_error() {
312
+ // End-to-end: send_message to an out-of-scope peer → Err(ToolError{PeerNotInScope})
313
+ // BEFORE any runtime delivery (server-side guard, no peer-name leak).
314
+ let tools = TeamOrchestratorTools::with_identity(
315
+ Path::new("/tmp/ws"),
316
+ Some(AgentId::new("worker-1")),
317
+ Some(TeamKey::new("teamA")),
318
+ );
319
+ let err = tools
320
+ .send_message(
227
321
  &MessageTarget::Single("other-team-bob".to_string()),
228
322
  "leak attempt",
229
- None, None, None, None,
230
- ).expect_err("out-of-scope peer must be refused");
231
- assert_eq!(err.reason, ToolErrorReason::PeerNotInScope);
232
- }
323
+ None,
324
+ None,
325
+ None,
326
+ None,
327
+ )
328
+ .expect_err("out-of-scope peer must be refused");
329
+ assert_eq!(err.reason, ToolErrorReason::PeerNotInScope);
330
+ }
233
331
 
234
- // ════════════════════════════════════════════════════════════════════════
235
- // WORKER-ID INFERENCE FALLBACK (bug-085, C17) — report_result identity.
236
- // explicit > env > "unknown"; task → "manual". NEVER treat worker as leader.
237
- // ════════════════════════════════════════════════════════════════════════
332
+ // ════════════════════════════════════════════════════════════════════════
333
+ // WORKER-ID INFERENCE FALLBACK (bug-085, C17) — report_result identity.
334
+ // explicit > env > "unknown"; task → "manual". NEVER treat worker as leader.
335
+ // ════════════════════════════════════════════════════════════════════════
@@ -29,6 +29,7 @@ pub(crate) fn status_wire(status: DeliveryStatus) -> &'static str {
29
29
  DeliveryStatus::Queued => "queued",
30
30
  DeliveryStatus::Blocked => "blocked",
31
31
  DeliveryStatus::Refused => "refused",
32
+ DeliveryStatus::Degraded => "degraded",
32
33
  DeliveryStatus::RetryScheduled => "retry_scheduled",
33
34
  DeliveryStatus::TrustAutoAnswerExhausted => "trust_auto_answer_exhausted",
34
35
  DeliveryStatus::AlreadyDelivered => "already_delivered",
@@ -40,7 +41,10 @@ pub(crate) fn status_wire(status: DeliveryStatus) -> &'static str {
40
41
  }
41
42
  }
42
43
 
43
- pub(crate) fn message_exists(store: &MessageStore, message_id: &str) -> Result<bool, MessagingError> {
44
+ pub(crate) fn message_exists(
45
+ store: &MessageStore,
46
+ message_id: &str,
47
+ ) -> Result<bool, MessagingError> {
44
48
  let conn = crate::db::schema::open_db(store.db_path())?;
45
49
  let found: Option<String> = conn
46
50
  .query_row(
@@ -65,7 +69,10 @@ pub(crate) fn next_run_id() -> String {
65
69
  id.chars().filter(|c| *c != '_').take(12).collect()
66
70
  }
67
71
 
68
- pub(crate) fn required_str<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str, MessagingError> {
72
+ pub(crate) fn required_str<'a>(
73
+ value: &'a serde_json::Value,
74
+ key: &str,
75
+ ) -> Result<&'a str, MessagingError> {
69
76
  value
70
77
  .get(key)
71
78
  .and_then(|v| v.as_str())
@@ -85,7 +92,9 @@ pub(crate) fn validate_result_envelope(envelope: &serde_json::Value) -> Result<(
85
92
  }
86
93
  for key in ["changes", "tests", "risks", "artifacts", "next_actions"] {
87
94
  if !envelope.get(key).is_some_and(serde_json::Value::is_array) {
88
- return Err(MessagingError::Validation(format!("missing required array field: {key}")));
95
+ return Err(MessagingError::Validation(format!(
96
+ "missing required array field: {key}"
97
+ )));
89
98
  }
90
99
  }
91
100
  Ok(())
@@ -122,10 +131,12 @@ pub(crate) fn non_provider_command(command: &str) -> Option<&str> {
122
131
  pub(crate) fn latest_prompt_signal(scrollback: &str) -> Option<AgentActivity> {
123
132
  let lower = scrollback.to_ascii_lowercase();
124
133
  let idle_pos = latest_idle_prompt_pos(scrollback);
125
- let working_pos = ["working", "thinking", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
126
- .iter()
127
- .filter_map(|needle| lower.rfind(needle))
128
- .max();
134
+ let working_pos = [
135
+ "working", "thinking", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
136
+ ]
137
+ .iter()
138
+ .filter_map(|needle| lower.rfind(needle))
139
+ .max();
129
140
  match (idle_pos, working_pos) {
130
141
  (Some(i), Some(w)) if i > w => Some(idle_activity()),
131
142
  (Some(_), None) => Some(idle_activity()),
@@ -168,10 +179,19 @@ pub fn fail_leader_delivery(
168
179
  error: Option<&str>,
169
180
  ) -> Result<DeliveryOutcome, MessagingError> {
170
181
  let store = MessageStore::open(workspace)?;
171
- let sender = payload.get("sender").and_then(serde_json::Value::as_str).unwrap_or("system");
172
- let content = payload.get("content").and_then(serde_json::Value::as_str).unwrap_or("");
182
+ let sender = payload
183
+ .get("sender")
184
+ .and_then(serde_json::Value::as_str)
185
+ .unwrap_or("system");
186
+ let content = payload
187
+ .get("content")
188
+ .and_then(serde_json::Value::as_str)
189
+ .unwrap_or("");
173
190
  let task_id = payload.get("task_id").and_then(serde_json::Value::as_str);
174
- let message_id = match payload.get("message_id").and_then(serde_json::Value::as_str) {
191
+ let message_id = match payload
192
+ .get("message_id")
193
+ .and_then(serde_json::Value::as_str)
194
+ {
175
195
  Some(existing) => existing.to_string(),
176
196
  None => store.create_message(task_id, sender, "leader", content, None, false, None)?,
177
197
  };