@team-agent/installer 0.5.106 → 0.5.108

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 (41) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -1
  4. package/crates/team-agent/src/cli/emit.rs +3 -3
  5. package/crates/team-agent/src/cli/mod.rs +87 -4
  6. package/crates/team-agent/src/cli/models.rs +252 -3
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/status_port/brief.rs +155 -37
  9. package/crates/team-agent/src/cli/tests/base.rs +112 -225
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +184 -0
  11. package/crates/team-agent/src/cli/tests/status_brief.rs +624 -0
  12. package/crates/team-agent/src/compiler/tests.rs +46 -59
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
  14. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
  15. package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
  16. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
  17. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +0 -9
  18. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
  19. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -66
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +0 -392
  21. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
  22. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
  23. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
  24. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
  25. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
  26. package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
  27. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
  29. package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
  30. package/crates/team-agent/src/model/name_similarity.rs +52 -67
  31. package/crates/team-agent/src/model/task_graph.rs +37 -124
  32. package/crates/team-agent/src/model/yaml/tests.rs +46 -165
  33. package/crates/team-agent/src/provider/model_catalog.rs +166 -3
  34. package/crates/team-agent/src/provider/testdata/cursor-list-models-native.stdout +250 -0
  35. package/crates/team-agent/src/provider/testdata/grok-models-authenticated.stdout +9 -0
  36. package/crates/team-agent/src/provider/testdata/grok-models-unauthenticated.stdout +7 -0
  37. package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
  38. package/crates/team-agent/src/provider/tests/classify.rs +106 -198
  39. package/crates/team-agent/src/tmux_backend/tests.rs +178 -501
  40. package/crates/team-agent/src/transport/tests/wire.rs +0 -55
  41. package/package.json +4 -4
@@ -117,6 +117,14 @@ struct BriefNode {
117
117
  tmux_command: Option<String>,
118
118
  }
119
119
 
120
+ #[derive(Clone, Debug, PartialEq, Eq)]
121
+ struct AttachContext {
122
+ endpoint: String,
123
+ session: String,
124
+ window: String,
125
+ pane: String,
126
+ }
127
+
120
128
  #[derive(Debug)]
121
129
  struct ProbeResult {
122
130
  nodes: Vec<ProbeNode>,
@@ -150,34 +158,45 @@ pub(crate) fn status_brief_scoped(
150
158
  state: &Value,
151
159
  agent: Option<&str>,
152
160
  ) -> Value {
153
- let registered = registered_nodes(state, agent);
154
- let probes = probe_registered_endpoints(&registered);
155
- let nodes = registered
156
- .iter()
157
- .map(|node| project_node(node, probes.get(node.endpoint.as_deref().unwrap_or(""))))
158
- .collect::<Vec<_>>();
161
+ let nodes = project_status_nodes(state, agent);
159
162
  json!({
160
- "nodes": nodes.into_iter().map(BriefNode::into_json).collect::<Vec<_>>()
163
+ "nodes": nodes
164
+ .into_iter()
165
+ .map(|(node, _)| node.into_json())
166
+ .collect::<Vec<_>>()
161
167
  })
162
168
  }
163
169
 
164
- /// Human output is the exact same nine-field projection as JSON, rendered in
165
- /// stable field order. Null values remain explicit rather than becoming a
166
- /// diagnostic or an inferred value.
170
+ /// Human output keeps the legacy single-node form and groups shared attach
171
+ /// context only when multiple nodes make that repetition useful.
167
172
  pub(crate) fn format_status_brief(
168
- workspace: &Path,
173
+ _workspace: &Path,
169
174
  state: &Value,
170
175
  agent: Option<&str>,
171
176
  ) -> String {
172
- let value = status_brief_scoped(workspace, state, agent);
173
- value
174
- .get("nodes")
175
- .and_then(Value::as_array)
176
- .into_iter()
177
- .flatten()
178
- .filter_map(format_brief_node)
179
- .collect::<Vec<_>>()
180
- .join("\n")
177
+ let nodes = project_status_nodes(state, agent);
178
+ if nodes.len() <= 1 {
179
+ return nodes
180
+ .into_iter()
181
+ .filter_map(|(node, _)| format_brief_node(&node.into_json()))
182
+ .collect::<Vec<_>>()
183
+ .join("\n");
184
+ }
185
+ format_multi_node_brief(nodes)
186
+ }
187
+
188
+ fn project_status_nodes(
189
+ state: &Value,
190
+ agent: Option<&str>,
191
+ ) -> Vec<(BriefNode, Option<AttachContext>)> {
192
+ let registered = registered_nodes(state, agent);
193
+ let probes = probe_registered_endpoints(&registered);
194
+ registered
195
+ .iter()
196
+ .map(|node| {
197
+ project_node_with_attach(node, probes.get(node.endpoint.as_deref().unwrap_or("")))
198
+ })
199
+ .collect()
181
200
  }
182
201
 
183
202
  pub(crate) fn registered_agent_exists(state: &Value, agent: &str) -> bool {
@@ -322,6 +341,86 @@ fn format_brief_node(value: &Value) -> Option<String> {
322
341
  ))
323
342
  }
324
343
 
344
+ fn format_multi_node_brief(nodes: Vec<(BriefNode, Option<AttachContext>)>) -> String {
345
+ let mut groups = Vec::<(String, String)>::new();
346
+ for (_, context) in &nodes {
347
+ let Some(context) = context else { continue };
348
+ if !groups.iter().any(|(endpoint, session)| {
349
+ endpoint == &context.endpoint && session == &context.session
350
+ }) {
351
+ groups.push((context.endpoint.clone(), context.session.clone()));
352
+ }
353
+ }
354
+
355
+ let mut lines = groups
356
+ .iter()
357
+ .enumerate()
358
+ .map(|(index, (endpoint, session))| {
359
+ format_attach_template(index + 1, endpoint, session)
360
+ })
361
+ .collect::<Vec<_>>();
362
+ if !groups.is_empty() {
363
+ lines.push(
364
+ "TMUX templates: replace <target> with the window.pane part of the matching ATTACH value."
365
+ .to_string(),
366
+ );
367
+ }
368
+ lines.push(
369
+ "NAME\tPROVIDER\tMODEL\tEFFORT\tRUNTIME_STATUS\tACTIVITY\tHEALTH\tSESSION_NAME\tATTACH"
370
+ .to_string(),
371
+ );
372
+ for (node, context) in nodes {
373
+ let attach = context
374
+ .as_ref()
375
+ .and_then(|context| {
376
+ groups
377
+ .iter()
378
+ .position(|(endpoint, session)| {
379
+ endpoint == &context.endpoint && session == &context.session
380
+ })
381
+ .map(|index| format!("{}:{}.{}", index + 1, context.window, context.pane))
382
+ })
383
+ .unwrap_or_else(|| "null".to_string());
384
+ lines.push(
385
+ [
386
+ escape_human_cell(&node.name),
387
+ escape_human_cell(&node.provider),
388
+ escape_human_cell(node.model.as_deref().unwrap_or("null")),
389
+ escape_human_cell(node.effort.as_deref().unwrap_or("null")),
390
+ escape_human_cell(&node.runtime_status),
391
+ escape_human_cell(&node.activity),
392
+ escape_human_cell(&node.health),
393
+ escape_human_cell(node.session_name.as_deref().unwrap_or("null")),
394
+ escape_human_cell(&attach),
395
+ ]
396
+ .join("\t"),
397
+ );
398
+ }
399
+ lines.join("\n")
400
+ }
401
+
402
+ fn format_attach_template(group: usize, endpoint: &str, session: &str) -> String {
403
+ let flag = if Path::new(endpoint).is_absolute() {
404
+ "-S"
405
+ } else {
406
+ "-L"
407
+ };
408
+ let target = format!("{session}:<target>");
409
+ format!(
410
+ "TMUX[{group}] tmux {flag} {} attach -t {}",
411
+ shell_quote(endpoint),
412
+ shell_quote(&target)
413
+ )
414
+ }
415
+
416
+ fn escape_human_cell(value: &str) -> String {
417
+ value
418
+ .replace('\\', "\\\\")
419
+ .replace('\t', "\\t")
420
+ .replace('\n', "\\n")
421
+ .replace('\r', "\\r")
422
+ }
423
+
325
424
  fn display_optional(value: &Value) -> String {
326
425
  value
327
426
  .as_str()
@@ -1051,14 +1150,22 @@ fn non_empty(value: &Value, key: &str) -> Option<String> {
1051
1150
  .map(str::to_string)
1052
1151
  }
1053
1152
 
1153
+ #[cfg(test)]
1054
1154
  fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> BriefNode {
1155
+ project_node_with_attach(node, probe).0
1156
+ }
1157
+
1158
+ fn project_node_with_attach(
1159
+ node: &RegisteredNode,
1160
+ probe: Option<&Option<ProbeResult>>,
1161
+ ) -> (BriefNode, Option<AttachContext>) {
1055
1162
  let stopped = matches!(
1056
1163
  node.lifecycle.as_deref(),
1057
1164
  Some("stopped" | "done" | "failed" | "error" | "terminated")
1058
1165
  );
1059
1166
  let matched = match_result(node, probe);
1060
1167
  if stopped {
1061
- return match matched {
1168
+ let projected = match matched {
1062
1169
  MatchResult::Missing => BriefNode {
1063
1170
  name: node.name.clone(),
1064
1171
  provider: node.provider.clone(),
@@ -1072,14 +1179,15 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
1072
1179
  },
1073
1180
  MatchResult::Unique(_) | MatchResult::Ambiguous => BriefNode::unknown(node),
1074
1181
  };
1182
+ return (projected, None);
1075
1183
  }
1076
1184
  let MatchResult::Unique(observed) = matched else {
1077
- return BriefNode::unknown(node);
1185
+ return (BriefNode::unknown(node), None);
1078
1186
  };
1079
1187
  if !node.provider.eq_ignore_ascii_case("unknown")
1080
1188
  && !node.provider.eq_ignore_ascii_case(&observed.provider)
1081
1189
  {
1082
- return BriefNode::unknown(node);
1190
+ return (BriefNode::unknown(node), None);
1083
1191
  }
1084
1192
  let pi_channel = observed.provider.eq_ignore_ascii_case("pi")
1085
1193
  && observed.evidence_method.as_deref() == Some("pi_activity_channel");
@@ -1097,21 +1205,31 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
1097
1205
  } else {
1098
1206
  valid_health(&observed.health)
1099
1207
  };
1100
- BriefNode {
1101
- name: node.name.clone(),
1102
- provider: if node.provider.eq_ignore_ascii_case("unknown") {
1103
- observed.provider.clone()
1104
- } else {
1105
- node.provider.clone()
1208
+ let tmux_command = tmux_command(node, observed);
1209
+ let attach = tmux_command.as_ref().map(|_| AttachContext {
1210
+ endpoint: observed.socket.clone(),
1211
+ session: observed.session.clone(),
1212
+ window: observed.window.clone(),
1213
+ pane: observed.pane.clone(),
1214
+ });
1215
+ (
1216
+ BriefNode {
1217
+ name: node.name.clone(),
1218
+ provider: if node.provider.eq_ignore_ascii_case("unknown") {
1219
+ observed.provider.clone()
1220
+ } else {
1221
+ node.provider.clone()
1222
+ },
1223
+ model: node.model.clone(),
1224
+ effort: node.effort.clone(),
1225
+ runtime_status: "running".to_string(),
1226
+ activity: activity.to_string(),
1227
+ health: health.to_string(),
1228
+ session_name: observed.session_name.clone(),
1229
+ tmux_command,
1106
1230
  },
1107
- model: node.model.clone(),
1108
- effort: node.effort.clone(),
1109
- runtime_status: "running".to_string(),
1110
- activity: activity.to_string(),
1111
- health: health.to_string(),
1112
- session_name: observed.session_name.clone(),
1113
- tmux_command: tmux_command(node, observed),
1114
- }
1231
+ attach,
1232
+ )
1115
1233
  }
1116
1234
 
1117
1235
  fn match_result<'a>(
@@ -13,33 +13,19 @@ use serde_json::json;
13
13
  // =========================================================================
14
14
 
15
15
  #[test]
16
- fn provider_args_strips_leading_dashdash() {
17
- // golden: _provider_args(["--","-x"]) == ["-x"]
18
- assert_eq!(
19
- provider_args(&["--".into(), "-x".into()]),
20
- vec!["-x".to_string()]
21
- );
22
- }
23
-
24
- #[test]
25
- fn provider_args_keeps_when_no_leading_dashdash() {
26
- // golden: _provider_args(["-x","-y"]) == ["-x","-y"]
27
- assert_eq!(
28
- provider_args(&["-x".into(), "-y".into()]),
29
- vec!["-x".to_string(), "-y".to_string()]
30
- );
31
- }
32
-
33
- #[test]
34
- fn provider_args_empty_is_empty() {
35
- // golden: _provider_args([]) == []
36
- assert_eq!(provider_args(&[]), Vec::<String>::new());
37
- }
38
-
39
- #[test]
40
- fn provider_args_lone_dashdash_yields_empty() {
41
- // golden: _provider_args(["--"]) == [] (values[1:] of single-elem list)
42
- assert_eq!(provider_args(&["--".into()]), Vec::<String>::new());
16
+ fn provider_args_cases() {
17
+ // (old case-id, input, Python golden); values[1:] of a lone "--" is empty.
18
+ const CASES: &[(&str, &[&str], &[&str])] = &[
19
+ ("provider_args_strips_leading_dashdash", &["--", "-x"], &["-x"]),
20
+ ("provider_args_keeps_when_no_leading_dashdash", &["-x", "-y"], &["-x", "-y"]),
21
+ ("provider_args_empty_is_empty", &[], &[]),
22
+ ("provider_args_lone_dashdash_yields_empty", &["--"], &[]),
23
+ ];
24
+ for (id, input, expected) in CASES {
25
+ let argv: Vec<String> = input.iter().map(|arg| (*arg).to_string()).collect();
26
+ let expected: Vec<String> = expected.iter().map(|arg| (*arg).to_string()).collect();
27
+ assert_eq!(provider_args(&argv), expected, "{id}");
28
+ }
43
29
  }
44
30
 
45
31
  // =========================================================================
@@ -47,118 +33,72 @@ fn provider_args_lone_dashdash_yields_empty() {
47
33
  // =========================================================================
48
34
 
49
35
  #[test]
50
- fn leader_launcher_args_empty_all_default() {
51
- // golden: {'provider_args': [], 'attach_existing': False, 'confirm_attach': False, 'attach_session': None}
52
- let got = leader_launcher_args(&[]).expect("empty should parse");
53
- assert_eq!(got, LeaderLauncherArgs::default());
54
- assert!(got.provider_args.is_empty());
55
- assert!(!got.attach_existing);
56
- assert!(!got.confirm_attach);
57
- assert_eq!(got.attach_session, None);
58
- assert!(!got.external_leader);
59
- }
60
-
61
- #[test]
62
- fn leader_launcher_args_attach_and_confirm() {
63
- // golden: ["--attach","--confirm"] -> attach_existing=True, confirm_attach=True
64
- let got = leader_launcher_args(&["--attach".into(), "--confirm".into()]).unwrap();
65
- assert!(got.attach_existing);
66
- assert!(got.confirm_attach);
67
- assert!(got.provider_args.is_empty());
68
- assert_eq!(got.attach_session, None);
69
- }
70
-
71
- #[test]
72
- fn leader_launcher_args_attach_existing_alias() {
73
- // golden: ["--attach-existing"] -> attach_existing=True (alias of --attach)
74
- let got = leader_launcher_args(&["--attach-existing".into()]).unwrap();
75
- assert!(got.attach_existing);
76
- assert!(!got.confirm_attach);
77
- }
78
-
79
- #[test]
80
- fn leader_launcher_args_external_leader_opt_out() {
81
- let got = leader_launcher_args(&[
82
- "--external-leader".into(),
83
- "--".into(),
84
- "--model".into(),
85
- "opus".into(),
86
- ])
87
- .unwrap();
88
- assert!(got.external_leader);
89
- assert!(!got.attach_existing);
90
- assert_eq!(
91
- got.provider_args,
92
- vec!["--model".to_string(), "opus".to_string()]
93
- );
94
- }
95
-
96
- #[test]
97
- fn leader_launcher_args_external_leader_after_dashdash_errors() {
98
- let err = leader_launcher_args(&["--".into(), "--external-leader".into()])
99
- .expect_err("Team Agent flags after -- must not be silently passed to provider");
100
- assert!(
101
- err.to_string()
102
- .contains("Team Agent launcher flag --external-leader must appear before --"),
103
- "unexpected error: {err}"
104
- );
105
- }
106
-
107
- #[test]
108
- fn leader_launcher_args_attach_session_spaced() {
109
- // golden: ["--attach-session","mysess"] -> attach_session="mysess"
110
- let got = leader_launcher_args(&["--attach-session".into(), "mysess".into()]).unwrap();
111
- assert_eq!(got.attach_session, Some("mysess".to_string()));
112
- assert!(!got.attach_existing);
113
- }
114
-
115
- #[test]
116
- fn leader_launcher_args_attach_session_equals() {
117
- // golden: ["--attach-session=mysess"] -> attach_session="mysess"
118
- let got = leader_launcher_args(&["--attach-session=mysess".into()]).unwrap();
119
- assert_eq!(got.attach_session, Some("mysess".to_string()));
120
- }
121
-
122
- #[test]
123
- fn leader_launcher_args_dashdash_passthrough_strips_separator() {
124
- // ["--attach","--","-x","--provider-confirm"] ->
125
- // provider_args=["-x","--provider-confirm"], attach_existing=True, confirm_attach=False
126
- // Known Team Agent launcher flags after `--` are rejected by a separate guard.
127
- let got = leader_launcher_args(&[
128
- "--attach".into(),
129
- "--".into(),
130
- "-x".into(),
131
- "--provider-confirm".into(),
132
- ])
133
- .unwrap();
134
- assert!(got.attach_existing);
135
- assert!(!got.confirm_attach);
136
- assert_eq!(
137
- got.provider_args,
138
- vec!["-x".to_string(), "--provider-confirm".to_string()]
139
- );
140
- }
141
-
142
- #[test]
143
- fn leader_launcher_args_unknown_tokens_collect_as_provider_args() {
144
- // golden: ["foo","--attach","bar"] -> provider_args=["foo","bar"], attach_existing=True
145
- let got = leader_launcher_args(&["foo".into(), "--attach".into(), "bar".into()]).unwrap();
146
- assert_eq!(
147
- got.provider_args,
148
- vec!["foo".to_string(), "bar".to_string()]
149
- );
150
- assert!(got.attach_existing);
151
- }
152
-
153
- #[test]
154
- fn leader_launcher_args_attach_session_missing_value_errors() {
155
- // golden: ["--attach-session"] raises RuntimeError("--attach-session requires a tmux session name")
156
- let err = leader_launcher_args(&["--attach-session".into()]).unwrap_err();
157
- let msg = err.to_string();
158
- assert!(
159
- msg.contains("--attach-session requires a tmux session name"),
160
- "expected exact missing-value message, got: {msg}"
161
- );
36
+ fn leader_launcher_args_success_cases() {
37
+ // (old case-id, argv, complete expected structure), including default false flags.
38
+ let cases: &[(&str, &[&str], LeaderLauncherArgs)] = &[
39
+ ("leader_launcher_args_empty_all_default", &[], LeaderLauncherArgs::default()),
40
+ ("leader_launcher_args_attach_and_confirm", &["--attach", "--confirm"], LeaderLauncherArgs {
41
+ attach_existing: true,
42
+ confirm_attach: true,
43
+ ..Default::default()
44
+ }),
45
+ // --attach-existing is an alias of --attach.
46
+ ("leader_launcher_args_attach_existing_alias", &["--attach-existing"], LeaderLauncherArgs {
47
+ attach_existing: true,
48
+ ..Default::default()
49
+ }),
50
+ ("leader_launcher_args_external_leader_opt_out", &["--external-leader", "--", "--model", "opus"], LeaderLauncherArgs {
51
+ provider_args: vec!["--model".to_string(), "opus".to_string()],
52
+ external_leader: true,
53
+ ..Default::default()
54
+ }),
55
+ ("leader_launcher_args_attach_session_spaced", &["--attach-session", "mysess"], LeaderLauncherArgs {
56
+ attach_session: Some("mysess".to_string()),
57
+ ..Default::default()
58
+ }),
59
+ ("leader_launcher_args_attach_session_equals", &["--attach-session=mysess"], LeaderLauncherArgs {
60
+ attach_session: Some("mysess".to_string()),
61
+ ..Default::default()
62
+ }),
63
+ // Known Team Agent launcher flags after `--` are rejected by the error table.
64
+ ("leader_launcher_args_dashdash_passthrough_strips_separator", &["--attach", "--", "-x", "--provider-confirm"], LeaderLauncherArgs {
65
+ provider_args: vec!["-x".to_string(), "--provider-confirm".to_string()],
66
+ attach_existing: true,
67
+ ..Default::default()
68
+ }),
69
+ ("leader_launcher_args_unknown_tokens_collect_as_provider_args", &["foo", "--attach", "bar"], LeaderLauncherArgs {
70
+ provider_args: vec!["foo".to_string(), "bar".to_string()],
71
+ attach_existing: true,
72
+ ..Default::default()
73
+ }),
74
+ ];
75
+ for (id, input, expected) in cases {
76
+ let argv: Vec<String> = input.iter().map(|arg| (*arg).to_string()).collect();
77
+ let got = leader_launcher_args(&argv).unwrap_or_else(|err| panic!("{id}: {err}"));
78
+ assert_eq!(got, *expected, "{id}");
79
+ }
80
+ }
81
+
82
+ #[test]
83
+ fn leader_launcher_args_error_cases() {
84
+ // Preserve the original error-substring contract, rather than requiring exact equality.
85
+ const CASES: &[(&str, &[&str], &str)] = &[
86
+ (
87
+ "leader_launcher_args_external_leader_after_dashdash_errors",
88
+ &["--", "--external-leader"],
89
+ "Team Agent launcher flag --external-leader must appear before --",
90
+ ),
91
+ (
92
+ "leader_launcher_args_attach_session_missing_value_errors",
93
+ &["--attach-session"],
94
+ "--attach-session requires a tmux session name",
95
+ ),
96
+ ];
97
+ for (id, input, expected) in CASES {
98
+ let argv: Vec<String> = input.iter().map(|arg| (*arg).to_string()).collect();
99
+ let err = leader_launcher_args(&argv).expect_err(id);
100
+ assert!(err.to_string().contains(*expected), "{id}: unexpected error: {err}");
101
+ }
162
102
  }
163
103
 
164
104
  // =========================================================================
@@ -166,37 +106,22 @@ fn leader_launcher_args_attach_session_missing_value_errors() {
166
106
  // =========================================================================
167
107
 
168
108
  #[test]
169
- fn send_target_fanout_strips_and_filters_empty() {
170
- // golden: _send_target(targets="a, b ,,c") == ["a","b","c"]
171
- let got = send_target(Some("a, b ,,c"), None);
172
- assert_eq!(
173
- got,
174
- MessageTarget::Fanout(vec!["a".to_string(), "b".to_string(), "c".to_string()])
175
- );
176
- }
177
-
178
- #[test]
179
- fn send_target_single_target() {
180
- // golden: _send_target(target="agent_x") == "agent_x"
181
- assert_eq!(
182
- send_target(None, Some("agent_x")),
183
- MessageTarget::Single("agent_x".to_string())
184
- );
185
- }
186
-
187
- #[test]
188
- fn send_target_broadcast_star() {
189
- // skeleton contract: bare "*" target -> Broadcast (send.py interprets "*" as全队广播)
190
- assert_eq!(send_target(None, Some("*")), MessageTarget::Broadcast);
191
- }
192
-
193
- #[test]
194
- fn send_target_empty_targets_falls_through_to_target() {
195
- // golden: targets="" is falsy in Python -> returns args.target ("fallback")
196
- assert_eq!(
197
- send_target(Some(""), Some("fallback")),
198
- MessageTarget::Single("fallback".to_string())
199
- );
109
+ fn send_target_cases() {
110
+ // (old case-id, targets, target, complete expected enum).
111
+ let cases: &[(&str, Option<&str>, Option<&str>, MessageTarget)] = &[
112
+ (
113
+ "send_target_fanout_strips_and_filters_empty", Some("a, b ,,c"), None,
114
+ MessageTarget::Fanout(vec!["a".to_string(), "b".to_string(), "c".to_string()]),
115
+ ),
116
+ ("send_target_single_target", None, Some("agent_x"), MessageTarget::Single("agent_x".to_string())),
117
+ // A bare "*" means broadcast to the whole team.
118
+ ("send_target_broadcast_star", None, Some("*"), MessageTarget::Broadcast),
119
+ // targets="" is falsy in Python, so it falls through to args.target.
120
+ ("send_target_empty_targets_falls_through_to_target", Some(""), Some("fallback"), MessageTarget::Single("fallback".to_string())),
121
+ ];
122
+ for (id, targets, target, expected) in cases {
123
+ assert_eq!(send_target(*targets, *target), *expected, "{id}");
124
+ }
200
125
  }
201
126
 
202
127
  // =========================================================================
@@ -205,61 +130,23 @@ fn send_target_empty_targets_falls_through_to_target() {
205
130
  // =========================================================================
206
131
 
207
132
  #[test]
208
- fn classify_failed_takes_priority() {
209
- // raw in {failed,error} OR hstatus in {failed,error} -> Failed
210
- assert_eq!(classify_agent_bucket("failed", ""), SummaryBucket::Failed);
211
- assert_eq!(classify_agent_bucket("error", ""), SummaryBucket::Failed);
212
- assert_eq!(
213
- classify_agent_bucket("running", "error"),
214
- SummaryBucket::Failed
215
- );
216
- }
217
-
218
- #[test]
219
- fn classify_stopped() {
220
- // raw in {stopped,done} OR hstatus==done -> Stopped
221
- assert_eq!(classify_agent_bucket("stopped", ""), SummaryBucket::Stopped);
222
- assert_eq!(classify_agent_bucket("done", ""), SummaryBucket::Stopped);
223
- assert_eq!(
224
- classify_agent_bucket("running", "done"),
225
- SummaryBucket::Stopped
226
- );
227
- }
228
-
229
- #[test]
230
- fn classify_busy() {
231
- // raw==busy OR hstatus in {running,working} -> Busy
232
- assert_eq!(classify_agent_bucket("busy", ""), SummaryBucket::Busy);
233
- assert_eq!(classify_agent_bucket("", "running"), SummaryBucket::Busy);
234
- assert_eq!(classify_agent_bucket("", "working"), SummaryBucket::Busy);
235
- }
236
-
237
- #[test]
238
- fn classify_hstatus_idle_beats_raw_running() {
239
- // golden: raw=running, h=idle -> idle (hstatus==idle branch precedes raw==running branch)
240
- assert_eq!(
241
- classify_agent_bucket("running", "idle"),
242
- SummaryBucket::Idle
243
- );
244
- }
245
-
246
- #[test]
247
- fn classify_pure_running() {
248
- // raw==running, no overriding hstatus -> Running
249
- assert_eq!(classify_agent_bucket("running", ""), SummaryBucket::Running);
250
- }
251
-
252
- #[test]
253
- fn classify_blocked_and_unmatched_are_unknown_never_idle() {
254
- // bug-071/077/085: blocked/stuck/missing AND any unmatched value -> Unknown, NOT idle.
255
- assert_eq!(classify_agent_bucket("blocked", ""), SummaryBucket::Unknown);
256
- assert_eq!(classify_agent_bucket("stuck", ""), SummaryBucket::Unknown);
257
- assert_eq!(classify_agent_bucket("", "missing"), SummaryBucket::Unknown);
258
- assert_eq!(
259
- classify_agent_bucket("weird_value", ""),
260
- SummaryBucket::Unknown
261
- );
262
- assert_eq!(classify_agent_bucket("", ""), SummaryBucket::Unknown);
133
+ fn classify_agent_bucket_cases() {
134
+ // (old case-id, all original (raw, health) inputs, expected bucket).
135
+ const CASES: &[(&str, &[(&str, &str)], SummaryBucket)] = &[
136
+ ("classify_failed_takes_priority", &[("failed", ""), ("error", ""), ("running", "error")], SummaryBucket::Failed),
137
+ ("classify_stopped", &[("stopped", ""), ("done", ""), ("running", "done")], SummaryBucket::Stopped),
138
+ ("classify_busy", &[("busy", ""), ("", "running"), ("", "working")], SummaryBucket::Busy),
139
+ // hstatus==idle precedes the raw==running branch.
140
+ ("classify_hstatus_idle_beats_raw_running", &[("running", "idle")], SummaryBucket::Idle),
141
+ ("classify_pure_running", &[("running", "")], SummaryBucket::Running),
142
+ // bug-071/077/085: unmatched and blocked/stuck/missing are Unknown, NEVER idle.
143
+ ("classify_blocked_and_unmatched_are_unknown_never_idle", &[("blocked", ""), ("stuck", ""), ("", "missing"), ("weird_value", ""), ("", "")], SummaryBucket::Unknown),
144
+ ];
145
+ for (id, inputs, expected) in CASES {
146
+ for &(raw, health) in inputs.iter() {
147
+ assert_eq!(classify_agent_bucket(raw, health), *expected, "{id}: raw={raw:?}, health={health:?}");
148
+ }
149
+ }
263
150
  }
264
151
 
265
152
  #[test]