@team-agent/installer 0.5.41 → 0.5.43

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 (152) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +129 -54
  4. package/crates/team-agent/src/cli/diagnose.rs +1 -2
  5. package/crates/team-agent/src/cli/emit.rs +1 -7
  6. package/crates/team-agent/src/cli/helpers.rs +3 -1
  7. package/crates/team-agent/src/cli/leader.rs +2 -1
  8. package/crates/team-agent/src/cli/mod.rs +27 -7
  9. package/crates/team-agent/src/cli/named_address.rs +14 -5
  10. package/crates/team-agent/src/cli/profile.rs +19 -7
  11. package/crates/team-agent/src/cli/send.rs +9 -3
  12. package/crates/team-agent/src/cli/status.rs +8 -30
  13. package/crates/team-agent/src/cli/status_port.rs +1341 -1272
  14. package/crates/team-agent/src/cli/tests/base.rs +738 -660
  15. package/crates/team-agent/src/cli/tests/compile.rs +45 -18
  16. package/crates/team-agent/src/cli/tests/divergence.rs +462 -444
  17. package/crates/team-agent/src/cli/tests/lane_c.rs +365 -282
  18. package/crates/team-agent/src/cli/tests/leader_watch.rs +356 -329
  19. package/crates/team-agent/src/cli/tests/main_preserved.rs +672 -564
  20. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +284 -224
  21. package/crates/team-agent/src/cli/tests/mod.rs +17 -7
  22. package/crates/team-agent/src/cli/tests/named_address.rs +8 -2
  23. package/crates/team-agent/src/cli/tests/peer_allow.rs +10 -2
  24. package/crates/team-agent/src/cli/tests/run_delegation.rs +314 -273
  25. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +26 -15
  26. package/crates/team-agent/src/cli/tests/status_send.rs +707 -629
  27. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +20 -4
  28. package/crates/team-agent/src/cli/tests/verb_profile.rs +46 -17
  29. package/crates/team-agent/src/cli/tests/verb_validate.rs +15 -3
  30. package/crates/team-agent/src/codex_app_server.rs +2 -5
  31. package/crates/team-agent/src/compiler/tests.rs +139 -33
  32. package/crates/team-agent/src/compiler.rs +55 -22
  33. package/crates/team-agent/src/conpty/backend.rs +23 -33
  34. package/crates/team-agent/src/coordinator/backoff.rs +2 -7
  35. package/crates/team-agent/src/coordinator/conpty_shim.rs +55 -67
  36. package/crates/team-agent/src/coordinator/health.rs +46 -31
  37. package/crates/team-agent/src/coordinator/mod.rs +3 -3
  38. package/crates/team-agent/src/coordinator/orphan.rs +22 -10
  39. package/crates/team-agent/src/coordinator/steps/abnormal.rs +47 -52
  40. package/crates/team-agent/src/coordinator/tests/abnormal.rs +55 -19
  41. package/crates/team-agent/src/coordinator/tests/basics.rs +179 -41
  42. package/crates/team-agent/src/coordinator/tests/daemon.rs +53 -13
  43. package/crates/team-agent/src/coordinator/tests/health_sync.rs +78 -19
  44. package/crates/team-agent/src/coordinator/tests/main_preserved.rs +61 -11
  45. package/crates/team-agent/src/coordinator/tests/mod.rs +33 -39
  46. package/crates/team-agent/src/coordinator/tests/spine.rs +52 -12
  47. package/crates/team-agent/src/coordinator/tests/takeover.rs +73 -15
  48. package/crates/team-agent/src/coordinator/tests/tick_core.rs +50 -15
  49. package/crates/team-agent/src/coordinator/tests/watch.rs +74 -20
  50. package/crates/team-agent/src/coordinator/tick.rs +19 -1
  51. package/crates/team-agent/src/db/message_store.rs +138 -30
  52. package/crates/team-agent/src/db/migration.rs +249 -61
  53. package/crates/team-agent/src/db/schema.rs +303 -82
  54. package/crates/team-agent/src/diagnose/comms.rs +9 -2
  55. package/crates/team-agent/src/diagnose/mod.rs +1 -3
  56. package/crates/team-agent/src/diagnose/orphans.rs +79 -61
  57. package/crates/team-agent/src/event_log.rs +70 -16
  58. package/crates/team-agent/src/layout/manager.rs +15 -4
  59. package/crates/team-agent/src/layout/mod.rs +4 -4
  60. package/crates/team-agent/src/layout/overlay.rs +10 -3
  61. package/crates/team-agent/src/layout/placement.rs +5 -1
  62. package/crates/team-agent/src/layout/recovery.rs +4 -2
  63. package/crates/team-agent/src/layout/runtime_sessions.rs +7 -7
  64. package/crates/team-agent/src/layout/sessions.rs +17 -9
  65. package/crates/team-agent/src/layout/tmux_endpoint.rs +1 -1
  66. package/crates/team-agent/src/layout/worker_env.rs +52 -19
  67. package/crates/team-agent/src/leader/helpers.rs +7 -1
  68. package/crates/team-agent/src/leader/lease.rs +195 -82
  69. package/crates/team-agent/src/leader/owner_bind.rs +55 -22
  70. package/crates/team-agent/src/leader/rediscover/tests.rs +88 -24
  71. package/crates/team-agent/src/leader/rediscover.rs +74 -25
  72. package/crates/team-agent/src/leader/start.rs +75 -54
  73. package/crates/team-agent/src/leader/takeover.rs +46 -11
  74. package/crates/team-agent/src/leader/tests/basics.rs +320 -167
  75. package/crates/team-agent/src/leader/tests/byte_findings.rs +361 -219
  76. package/crates/team-agent/src/leader/tests/identity.rs +428 -356
  77. package/crates/team-agent/src/leader/tests/idle.rs +285 -254
  78. package/crates/team-agent/src/leader/tests/lease_api.rs +338 -274
  79. package/crates/team-agent/src/leader/tests/lease_claim.rs +643 -593
  80. package/crates/team-agent/src/leader/tests/mod.rs +115 -99
  81. package/crates/team-agent/src/leader/tests/rediscover.rs +74 -22
  82. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +237 -211
  83. package/crates/team-agent/src/lib.rs +4 -4
  84. package/crates/team-agent/src/lifecycle/display.rs +7 -3
  85. package/crates/team-agent/src/lifecycle/launch.rs +6 -1
  86. package/crates/team-agent/src/lifecycle/mod.rs +9 -1
  87. package/crates/team-agent/src/lifecycle/profile_launch.rs +77 -34
  88. package/crates/team-agent/src/lifecycle/profile_smoke.rs +3 -1
  89. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -6
  90. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +1 -4
  91. package/crates/team-agent/src/lifecycle/restart/preflight.rs +6 -5
  92. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -22
  93. package/crates/team-agent/src/lifecycle/restart/remove.rs +45 -35
  94. package/crates/team-agent/src/lifecycle/restart/team_state.rs +63 -17
  95. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +251 -84
  96. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +198 -48
  97. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +2 -1
  98. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +152 -32
  99. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -5
  100. package/crates/team-agent/src/lifecycle/tests.rs +2 -2
  101. package/crates/team-agent/src/main.rs +4 -4
  102. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +47 -20
  103. package/crates/team-agent/src/mcp_server/mod.rs +11 -2
  104. package/crates/team-agent/src/mcp_server/types.rs +14 -3
  105. package/crates/team-agent/src/mcp_server/wire.rs +230 -68
  106. package/crates/team-agent/src/messaging/delivery.rs +20 -18
  107. package/crates/team-agent/src/messaging/helpers.rs +25 -4
  108. package/crates/team-agent/src/messaging/leader_receiver.rs +4 -5
  109. package/crates/team-agent/src/messaging/mod.rs +2 -3
  110. package/crates/team-agent/src/messaging/selftest.rs +46 -26
  111. package/crates/team-agent/src/messaging/tests/main_preserved.rs +47 -12
  112. package/crates/team-agent/src/messaging/tests/runtime.rs +57 -22
  113. package/crates/team-agent/src/messaging/tests/spine.rs +154 -40
  114. package/crates/team-agent/src/messaging/tests/wave2.rs +31 -32
  115. package/crates/team-agent/src/messaging/trust.rs +25 -2
  116. package/crates/team-agent/src/messaging/watchers.rs +37 -9
  117. package/crates/team-agent/src/model/enums.rs +65 -16
  118. package/crates/team-agent/src/model/ids.rs +12 -3
  119. package/crates/team-agent/src/model/paths.rs +28 -7
  120. package/crates/team-agent/src/model/permissions.rs +176 -33
  121. package/crates/team-agent/src/model/routing.rs +66 -20
  122. package/crates/team-agent/src/model/spec.rs +365 -69
  123. package/crates/team-agent/src/model/task_graph.rs +36 -9
  124. package/crates/team-agent/src/model/yaml/tests.rs +24 -6
  125. package/crates/team-agent/src/model/yaml.rs +7 -6
  126. package/crates/team-agent/src/packaging/install.rs +23 -9
  127. package/crates/team-agent/src/packaging/migrate.rs +5 -7
  128. package/crates/team-agent/src/packaging/mod.rs +9 -1
  129. package/crates/team-agent/src/packaging/repair.rs +13 -6
  130. package/crates/team-agent/src/packaging/tests.rs +63 -16
  131. package/crates/team-agent/src/packaging/types.rs +22 -7
  132. package/crates/team-agent/src/platform/argv.rs +4 -1
  133. package/crates/team-agent/src/platform/file_lock.rs +22 -8
  134. package/crates/team-agent/src/platform/process.rs +21 -22
  135. package/crates/team-agent/src/provider/adapters/claude.rs +1 -3
  136. package/crates/team-agent/src/provider/approvals/parsing.rs +134 -26
  137. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +13 -3
  138. package/crates/team-agent/src/provider/classify.rs +127 -42
  139. package/crates/team-agent/src/provider/faults.rs +17 -5
  140. package/crates/team-agent/src/provider/helpers.rs +6 -5
  141. package/crates/team-agent/src/provider/startup_prompt.rs +75 -27
  142. package/crates/team-agent/src/state/repository.rs +27 -14
  143. package/crates/team-agent/src/tmux_backend/tests.rs +1637 -1398
  144. package/crates/team-agent/src/tmux_backend.rs +80 -44
  145. package/crates/team-agent/src/topology.rs +27 -20
  146. package/crates/team-agent/src/transport/test_support.rs +20 -23
  147. package/crates/team-agent/src/transport/tests/behavior.rs +292 -293
  148. package/crates/team-agent/src/transport/tests/mod.rs +178 -187
  149. package/crates/team-agent/src/transport/tests/wire.rs +561 -525
  150. package/crates/team-agent/src/transport.rs +12 -17
  151. package/crates/team-agent/src/transport_factory.rs +29 -14
  152. package/package.json +4 -4
@@ -1,463 +1,534 @@
1
1
  use super::*;
2
2
 
3
- // RED 契约 lane:argv→run 端到端、五行 summary 字节锁(Gap 18a)、
4
- // classify_agent_bucket(unknown≠idle)、cli-error 信封字节锁、leader_launcher_args 解析、
5
- // consume_leader_inbox_summary 游标+预算截断、send_target fanout 解析。
6
- // Python golden 来源:cli/{parser,commands,helpers}.py @ v0.2.11(439bef8)。
7
- // 所有期望值用 `PYTHONPATH=.../src python3` 实跑 Python 实现抓取的字节级 golden。
8
- use super::*;
9
- use serde_json::json;
10
-
11
- // =========================================================================
12
- // provider_args(helpers.py:190-193):values[0]=='--' ? values[1..] : values
13
- // =========================================================================
14
-
15
- #[test]
16
- fn provider_args_strips_leading_dashdash() {
17
- // golden: _provider_args(["--","-x"]) == ["-x"]
18
- assert_eq!(provider_args(&["--".into(), "-x".into()]), vec!["-x".to_string()]);
19
- }
20
-
21
- #[test]
22
- fn provider_args_keeps_when_no_leading_dashdash() {
23
- // golden: _provider_args(["-x","-y"]) == ["-x","-y"]
24
- assert_eq!(
25
- provider_args(&["-x".into(), "-y".into()]),
26
- vec!["-x".to_string(), "-y".to_string()]
27
- );
28
- }
29
-
30
- #[test]
31
- fn provider_args_empty_is_empty() {
32
- // golden: _provider_args([]) == []
33
- assert_eq!(provider_args(&[]), Vec::<String>::new());
34
- }
35
-
36
- #[test]
37
- fn provider_args_lone_dashdash_yields_empty() {
38
- // golden: _provider_args(["--"]) == [] (values[1:] of single-elem list)
39
- assert_eq!(provider_args(&["--".into()]), Vec::<String>::new());
40
- }
41
-
42
- // =========================================================================
43
- // leader_launcher_args(helpers.py:196-226):attach 旗标解析 + 缺值 Err
44
- // =========================================================================
45
-
46
- #[test]
47
- fn leader_launcher_args_empty_all_default() {
48
- // golden: {'provider_args': [], 'attach_existing': False, 'confirm_attach': False, 'attach_session': None}
49
- let got = leader_launcher_args(&[]).expect("empty should parse");
50
- assert_eq!(got, LeaderLauncherArgs::default());
51
- assert!(got.provider_args.is_empty());
52
- assert!(!got.attach_existing);
53
- assert!(!got.confirm_attach);
54
- assert_eq!(got.attach_session, None);
55
- assert!(!got.external_leader);
56
- }
57
-
58
- #[test]
59
- fn leader_launcher_args_attach_and_confirm() {
60
- // golden: ["--attach","--confirm"] -> attach_existing=True, confirm_attach=True
61
- let got = leader_launcher_args(&["--attach".into(), "--confirm".into()]).unwrap();
62
- assert!(got.attach_existing);
63
- assert!(got.confirm_attach);
64
- assert!(got.provider_args.is_empty());
65
- assert_eq!(got.attach_session, None);
66
- }
67
-
68
- #[test]
69
- fn leader_launcher_args_attach_existing_alias() {
70
- // golden: ["--attach-existing"] -> attach_existing=True (alias of --attach)
71
- let got = leader_launcher_args(&["--attach-existing".into()]).unwrap();
72
- assert!(got.attach_existing);
73
- assert!(!got.confirm_attach);
74
- }
75
-
76
- #[test]
77
- fn leader_launcher_args_external_leader_opt_out() {
78
- let got = leader_launcher_args(&[
79
- "--external-leader".into(),
80
- "--".into(),
81
- "--model".into(),
82
- "opus".into(),
83
- ])
84
- .unwrap();
85
- assert!(got.external_leader);
86
- assert!(!got.attach_existing);
87
- assert_eq!(
88
- got.provider_args,
89
- vec!["--model".to_string(), "opus".to_string()]
90
- );
91
- }
92
-
93
- #[test]
94
- fn leader_launcher_args_external_leader_after_dashdash_errors() {
95
- let err = leader_launcher_args(&["--".into(), "--external-leader".into()])
96
- .expect_err("Team Agent flags after -- must not be silently passed to provider");
97
- assert!(
98
- err.to_string()
99
- .contains("Team Agent launcher flag --external-leader must appear before --"),
100
- "unexpected error: {err}"
101
- );
102
- }
103
-
104
- #[test]
105
- fn leader_launcher_args_attach_session_spaced() {
106
- // golden: ["--attach-session","mysess"] -> attach_session="mysess"
107
- let got = leader_launcher_args(&["--attach-session".into(), "mysess".into()]).unwrap();
108
- assert_eq!(got.attach_session, Some("mysess".to_string()));
109
- assert!(!got.attach_existing);
110
- }
111
-
112
- #[test]
113
- fn leader_launcher_args_attach_session_equals() {
114
- // golden: ["--attach-session=mysess"] -> attach_session="mysess"
115
- let got = leader_launcher_args(&["--attach-session=mysess".into()]).unwrap();
116
- assert_eq!(got.attach_session, Some("mysess".to_string()));
117
- }
118
-
119
- #[test]
120
- fn leader_launcher_args_dashdash_passthrough_strips_separator() {
121
- // ["--attach","--","-x","--provider-confirm"] ->
122
- // provider_args=["-x","--provider-confirm"], attach_existing=True, confirm_attach=False
123
- // Known Team Agent launcher flags after `--` are rejected by a separate guard.
124
- let got = leader_launcher_args(&[
125
- "--attach".into(),
126
- "--".into(),
127
- "-x".into(),
128
- "--provider-confirm".into(),
129
- ])
130
- .unwrap();
131
- assert!(got.attach_existing);
132
- assert!(!got.confirm_attach);
133
- assert_eq!(
134
- got.provider_args,
135
- vec!["-x".to_string(), "--provider-confirm".to_string()]
136
- );
137
- }
138
-
139
- #[test]
140
- fn leader_launcher_args_unknown_tokens_collect_as_provider_args() {
141
- // golden: ["foo","--attach","bar"] -> provider_args=["foo","bar"], attach_existing=True
142
- let got = leader_launcher_args(&["foo".into(), "--attach".into(), "bar".into()]).unwrap();
143
- assert_eq!(got.provider_args, vec!["foo".to_string(), "bar".to_string()]);
144
- assert!(got.attach_existing);
145
- }
146
-
147
- #[test]
148
- fn leader_launcher_args_attach_session_missing_value_errors() {
149
- // golden: ["--attach-session"] raises RuntimeError("--attach-session requires a tmux session name")
150
- let err = leader_launcher_args(&["--attach-session".into()]).unwrap_err();
151
- let msg = err.to_string();
152
- assert!(
153
- msg.contains("--attach-session requires a tmux session name"),
154
- "expected exact missing-value message, got: {msg}"
155
- );
156
- }
157
-
158
- // =========================================================================
159
- // send_target(commands.py:181-184):--to split / target / None
160
- // =========================================================================
161
-
162
- #[test]
163
- fn send_target_fanout_strips_and_filters_empty() {
164
- // golden: _send_target(targets="a, b ,,c") == ["a","b","c"]
165
- let got = send_target(Some("a, b ,,c"), None);
166
- assert_eq!(
167
- got,
168
- MessageTarget::Fanout(vec!["a".to_string(), "b".to_string(), "c".to_string()])
169
- );
170
- }
171
-
172
- #[test]
173
- fn send_target_single_target() {
174
- // golden: _send_target(target="agent_x") == "agent_x"
175
- assert_eq!(send_target(None, Some("agent_x")), MessageTarget::Single("agent_x".to_string()));
176
- }
177
-
178
- #[test]
179
- fn send_target_broadcast_star() {
180
- // skeleton contract: bare "*" target -> Broadcast (send.py interprets "*" as全队广播)
181
- assert_eq!(send_target(None, Some("*")), MessageTarget::Broadcast);
182
- }
183
-
184
- #[test]
185
- fn send_target_empty_targets_falls_through_to_target() {
186
- // golden: targets="" is falsy in Python -> returns args.target ("fallback")
187
- assert_eq!(send_target(Some(""), Some("fallback")), MessageTarget::Single("fallback".to_string()));
188
- }
189
-
190
- // =========================================================================
191
- // classify_agent_bucket / agent_summary_counts(commands.py:309-330)
192
- // bug-071/077/085 铁律:unknown ≠ idle,无匹配态显式落 Unknown
193
- // =========================================================================
194
-
195
- #[test]
196
- fn classify_failed_takes_priority() {
197
- // raw in {failed,error} OR hstatus in {failed,error} -> Failed
198
- assert_eq!(classify_agent_bucket("failed", ""), SummaryBucket::Failed);
199
- assert_eq!(classify_agent_bucket("error", ""), SummaryBucket::Failed);
200
- assert_eq!(classify_agent_bucket("running", "error"), SummaryBucket::Failed);
201
- }
202
-
203
- #[test]
204
- fn classify_stopped() {
205
- // raw in {stopped,done} OR hstatus==done -> Stopped
206
- assert_eq!(classify_agent_bucket("stopped", ""), SummaryBucket::Stopped);
207
- assert_eq!(classify_agent_bucket("done", ""), SummaryBucket::Stopped);
208
- assert_eq!(classify_agent_bucket("running", "done"), SummaryBucket::Stopped);
209
- }
210
-
211
- #[test]
212
- fn classify_busy() {
213
- // raw==busy OR hstatus in {running,working} -> Busy
214
- assert_eq!(classify_agent_bucket("busy", ""), SummaryBucket::Busy);
215
- assert_eq!(classify_agent_bucket("", "running"), SummaryBucket::Busy);
216
- assert_eq!(classify_agent_bucket("", "working"), SummaryBucket::Busy);
217
- }
218
-
219
- #[test]
220
- fn classify_hstatus_idle_beats_raw_running() {
221
- // golden: raw=running, h=idle -> idle (hstatus==idle branch precedes raw==running branch)
222
- assert_eq!(classify_agent_bucket("running", "idle"), SummaryBucket::Idle);
223
- }
224
-
225
- #[test]
226
- fn classify_pure_running() {
227
- // raw==running, no overriding hstatus -> Running
228
- assert_eq!(classify_agent_bucket("running", ""), SummaryBucket::Running);
229
- }
230
-
231
- #[test]
232
- fn classify_blocked_and_unmatched_are_unknown_never_idle() {
233
- // bug-071/077/085: blocked/stuck/missing AND any unmatched value -> Unknown, NOT idle.
234
- assert_eq!(classify_agent_bucket("blocked", ""), SummaryBucket::Unknown);
235
- assert_eq!(classify_agent_bucket("stuck", ""), SummaryBucket::Unknown);
236
- assert_eq!(classify_agent_bucket("", "missing"), SummaryBucket::Unknown);
237
- assert_eq!(classify_agent_bucket("weird_value", ""), SummaryBucket::Unknown);
238
- assert_eq!(classify_agent_bucket("", ""), SummaryBucket::Unknown);
239
- }
240
-
241
- #[test]
242
- fn agent_summary_counts_mixed_golden() {
243
- // golden (empty health): a1 running->Running; a2 busy->Busy; a3 failed->Failed;
244
- // a4 stopped->Stopped; a5 blocked->Unknown; a6 ""->Unknown; a7 weird->Unknown.
245
- // => running=1 busy=1 idle=0 stopped=1 failed=1 unknown=3
246
- let agents = json!({
247
- "a1": {"status": "running"},
248
- "a2": {"status": "busy"},
249
- "a3": {"status": "failed"},
250
- "a4": {"status": "stopped"},
251
- "a5": {"status": "blocked"},
252
- "a6": {"status": ""},
253
- "a7": {"status": "weird_value"},
254
- });
255
- let got = agent_summary_counts(&agents, &json!({}));
256
- assert_eq!(
257
- got,
258
- SummaryCounts { running: 1, busy: 1, idle: 0, stopped: 1, failed: 1, unknown: 3 }
259
- );
260
- assert_eq!(got.total(), 7);
261
- }
262
-
263
- #[test]
264
- fn agent_summary_counts_none_agent_is_unknown() {
265
- // golden: {"x": None} -> unknown=1
266
- let got = agent_summary_counts(&json!({"x": Value::Null}), &json!({}));
267
- assert_eq!(got, SummaryCounts { unknown: 1, ..Default::default() });
268
- }
269
-
270
- #[test]
271
- fn agent_summary_counts_uppercase_status_lowercased() {
272
- // golden: {"x":{"status":"RUNNING"}} -> running=1 (str(...).lower())
273
- let got = agent_summary_counts(&json!({"x": {"status": "RUNNING"}}), &json!({}));
274
- assert_eq!(got, SummaryCounts { running: 1, ..Default::default() });
275
- }
276
-
277
- // =========================================================================
278
- // interaction_counts(commands.py:292-306):interacted 非空且≠"never"
279
- // =========================================================================
280
-
281
- #[test]
282
- fn interaction_counts_mixed_golden() {
283
- // golden: a:"5m ago"->interacted; b:"never"->never; c:""->never; d:{}->never; e:None->never
284
- // result (1, 4)
285
- let agents = json!({
286
- "a": {"interacted": "5m ago"},
287
- "b": {"interacted": "never"},
288
- "c": {"interacted": ""},
289
- "d": {},
290
- "e": Value::Null,
291
- });
292
- let got = interaction_counts(&agents);
293
- assert_eq!(got, InteractionCounts { interacted: 1, never: 4 });
294
- }
295
-
296
- // =========================================================================
297
- // format_status_summary(commands.py:263-289):五行 triage 字节锁(Gap 18a)
298
- // =========================================================================
299
-
300
- #[test]
301
- fn format_status_summary_full_byte_lock() {
302
- // golden:
303
- // coordinator: running schema_ok=True tmux=True
304
- // receiver: %3 cmd=codex topology=external
305
- // agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0
306
- // queued: 2 mailbox messages awaiting delivery
307
- // latest result: a1 -> did the thing @ -
308
- let data = json!({
309
- "coordinator": {"status": "running", "schema_ok": true},
310
- "leader_receiver": {"pane_id": "%3", "pane_current_command": "codex"},
311
- "agents": {"a1": {"status": "running"}, "a2": {"status": "busy"}},
312
- "agent_health": {},
313
- "tmux_session_present": true,
314
- "queued_messages": [1, 2],
315
- "latest_results": [{"agent_id": "a1", "summary": "did the thing", "created_at": Value::Null}],
316
- });
317
- let got = format_status_summary(&data);
318
- let expected = "coordinator: running schema_ok=true tmux=true\n\
3
+ // RED 契约 lane:argv→run 端到端、五行 summary 字节锁(Gap 18a)、
4
+ // classify_agent_bucket(unknown≠idle)、cli-error 信封字节锁、leader_launcher_args 解析、
5
+ // consume_leader_inbox_summary 游标+预算截断、send_target fanout 解析。
6
+ // Python golden 来源:cli/{parser,commands,helpers}.py @ v0.2.11(439bef8)。
7
+ // 所有期望值用 `PYTHONPATH=.../src python3` 实跑 Python 实现抓取的字节级 golden。
8
+ use super::*;
9
+ use serde_json::json;
10
+
11
+ // =========================================================================
12
+ // provider_args(helpers.py:190-193):values[0]=='--' ? values[1..] : values
13
+ // =========================================================================
14
+
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());
43
+ }
44
+
45
+ // =========================================================================
46
+ // leader_launcher_args(helpers.py:196-226):attach 旗标解析 + 缺值 Err
47
+ // =========================================================================
48
+
49
+ #[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
+ );
162
+ }
163
+
164
+ // =========================================================================
165
+ // send_target(commands.py:181-184):--to split / target / None
166
+ // =========================================================================
167
+
168
+ #[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
+ );
200
+ }
201
+
202
+ // =========================================================================
203
+ // classify_agent_bucket / agent_summary_counts(commands.py:309-330)
204
+ // bug-071/077/085 铁律:unknown ≠ idle,无匹配态显式落 Unknown
205
+ // =========================================================================
206
+
207
+ #[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);
263
+ }
264
+
265
+ #[test]
266
+ fn agent_summary_counts_mixed_golden() {
267
+ // golden (empty health): a1 running->Running; a2 busy->Busy; a3 failed->Failed;
268
+ // a4 stopped->Stopped; a5 blocked->Unknown; a6 ""->Unknown; a7 weird->Unknown.
269
+ // => running=1 busy=1 idle=0 stopped=1 failed=1 unknown=3
270
+ let agents = json!({
271
+ "a1": {"status": "running"},
272
+ "a2": {"status": "busy"},
273
+ "a3": {"status": "failed"},
274
+ "a4": {"status": "stopped"},
275
+ "a5": {"status": "blocked"},
276
+ "a6": {"status": ""},
277
+ "a7": {"status": "weird_value"},
278
+ });
279
+ let got = agent_summary_counts(&agents, &json!({}));
280
+ assert_eq!(
281
+ got,
282
+ SummaryCounts {
283
+ running: 1,
284
+ busy: 1,
285
+ idle: 0,
286
+ stopped: 1,
287
+ failed: 1,
288
+ unknown: 3
289
+ }
290
+ );
291
+ assert_eq!(got.total(), 7);
292
+ }
293
+
294
+ #[test]
295
+ fn agent_summary_counts_none_agent_is_unknown() {
296
+ // golden: {"x": None} -> unknown=1
297
+ let got = agent_summary_counts(&json!({"x": Value::Null}), &json!({}));
298
+ assert_eq!(
299
+ got,
300
+ SummaryCounts {
301
+ unknown: 1,
302
+ ..Default::default()
303
+ }
304
+ );
305
+ }
306
+
307
+ #[test]
308
+ fn agent_summary_counts_uppercase_status_lowercased() {
309
+ // golden: {"x":{"status":"RUNNING"}} -> running=1 (str(...).lower())
310
+ let got = agent_summary_counts(&json!({"x": {"status": "RUNNING"}}), &json!({}));
311
+ assert_eq!(
312
+ got,
313
+ SummaryCounts {
314
+ running: 1,
315
+ ..Default::default()
316
+ }
317
+ );
318
+ }
319
+
320
+ // =========================================================================
321
+ // interaction_counts(commands.py:292-306):interacted 非空且≠"never"
322
+ // =========================================================================
323
+
324
+ #[test]
325
+ fn interaction_counts_mixed_golden() {
326
+ // golden: a:"5m ago"->interacted; b:"never"->never; c:""->never; d:{}->never; e:None->never
327
+ // result (1, 4)
328
+ let agents = json!({
329
+ "a": {"interacted": "5m ago"},
330
+ "b": {"interacted": "never"},
331
+ "c": {"interacted": ""},
332
+ "d": {},
333
+ "e": Value::Null,
334
+ });
335
+ let got = interaction_counts(&agents);
336
+ assert_eq!(
337
+ got,
338
+ InteractionCounts {
339
+ interacted: 1,
340
+ never: 4
341
+ }
342
+ );
343
+ }
344
+
345
+ // =========================================================================
346
+ // format_status_summary(commands.py:263-289):五行 triage 字节锁(Gap 18a)
347
+ // =========================================================================
348
+
349
+ #[test]
350
+ fn format_status_summary_full_byte_lock() {
351
+ // golden:
352
+ // coordinator: running schema_ok=True tmux=True
353
+ // receiver: %3 cmd=codex topology=external
354
+ // agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0
355
+ // queued: 2 mailbox messages awaiting delivery
356
+ // latest result: a1 -> did the thing @ -
357
+ let data = json!({
358
+ "coordinator": {"status": "running", "schema_ok": true},
359
+ "leader_receiver": {"pane_id": "%3", "pane_current_command": "codex"},
360
+ "agents": {"a1": {"status": "running"}, "a2": {"status": "busy"}},
361
+ "agent_health": {},
362
+ "tmux_session_present": true,
363
+ "queued_messages": [1, 2],
364
+ "latest_results": [{"agent_id": "a1", "summary": "did the thing", "created_at": Value::Null}],
365
+ });
366
+ let got = format_status_summary(&data);
367
+ let expected = "coordinator: running schema_ok=true tmux=true\n\
319
368
  receiver: %3 cmd=codex topology=external\n\
320
369
  agents: 2 — running=1 busy=1 idle=0 stopped=0 failed=0 unknown=0\n\
321
370
  queued: 2 mailbox messages awaiting delivery\n\
322
371
  latest result: a1 -> did the thing @ -";
323
- assert_eq!(got, expected);
324
- }
325
-
326
- #[test]
327
- fn format_status_summary_empty_byte_lock() {
328
- // golden empty data: stopped/false/false, dashes, 0 counts, none latest.
329
- let got = format_status_summary(&json!({}));
330
- let expected = "coordinator: stopped schema_ok=false tmux=false\n\
372
+ assert_eq!(got, expected);
373
+ }
374
+
375
+ #[test]
376
+ fn format_status_summary_empty_byte_lock() {
377
+ // golden empty data: stopped/false/false, dashes, 0 counts, none latest.
378
+ let got = format_status_summary(&json!({}));
379
+ let expected = "coordinator: stopped schema_ok=false tmux=false\n\
331
380
  receiver: - cmd=- topology=external\n\
332
381
  agents: 0 — running=0 busy=0 idle=0 stopped=0 failed=0 unknown=0\n\
333
382
  queued: 0 mailbox messages awaiting delivery\n\
334
383
  latest result: none";
335
- assert_eq!(got, expected);
336
- }
337
-
338
- #[test]
339
- fn format_status_summary_interacted_marker_appended() {
340
- // golden: when interacted>0, agents line gets " (1 interacted, 1 never)" suffix.
341
- let data = json!({
342
- "coordinator": {},
343
- "agents": {"a1": {"status": "running", "interacted": "3m"}, "a2": {"status": "idle"}},
344
- "agent_health": {"a2": {"status": "idle"}},
345
- });
346
- let got = format_status_summary(&data);
347
- let line2 = got.lines().nth(2).unwrap();
348
- assert_eq!(
349
- line2,
350
- "agents: 2 — running=1 busy=0 idle=1 stopped=0 failed=0 unknown=0 (1 interacted, 1 never)"
351
- );
352
- }
353
-
354
- #[test]
355
- fn format_status_summary_no_interacted_marker_when_zero() {
356
- // Gap 18a contract: interacted==0 -> line[2] stays byte-identical with NO marker suffix.
357
- let data = json!({
358
- "agents": {"a1": {"status": "running"}},
359
- "agent_health": {},
360
- });
361
- let line2 = format_status_summary(&data).lines().nth(2).unwrap().to_string();
362
- assert_eq!(line2, "agents: 1 — running=1 busy=0 idle=0 stopped=0 failed=0 unknown=0");
363
- assert!(!line2.contains("interacted"), "no marker when interacted==0");
364
- }
365
-
366
- #[test]
367
- fn format_status_csv_preserves_agent_order_and_collapses_errors() {
368
- let data = json!({
369
- "agents": {
370
- "zeta": {"status": "idle", "pane_id": "%1"},
371
- "alpha": {"status": "running", "pane_id": "%2"},
372
- "err_failed": {"status": "failed", "pane_id": "%3"},
373
- "err_missing_pane": {"status": "running"},
374
- "err_unknown": {"status": "mystery", "pane_id": "%4"},
375
- "err_stopped": {"status": "stopped", "pane_id": "%5"}
376
- },
377
- "agent_health": {
378
- "alpha": {"status": "working"}
379
- }
380
- });
381
- assert_eq!(
384
+ assert_eq!(got, expected);
385
+ }
386
+
387
+ #[test]
388
+ fn format_status_summary_interacted_marker_appended() {
389
+ // golden: when interacted>0, agents line gets " (1 interacted, 1 never)" suffix.
390
+ let data = json!({
391
+ "coordinator": {},
392
+ "agents": {"a1": {"status": "running", "interacted": "3m"}, "a2": {"status": "idle"}},
393
+ "agent_health": {"a2": {"status": "idle"}},
394
+ });
395
+ let got = format_status_summary(&data);
396
+ let line2 = got.lines().nth(2).unwrap();
397
+ assert_eq!(
398
+ line2,
399
+ "agents: 2 — running=1 busy=0 idle=1 stopped=0 failed=0 unknown=0 (1 interacted, 1 never)"
400
+ );
401
+ }
402
+
403
+ #[test]
404
+ fn format_status_summary_no_interacted_marker_when_zero() {
405
+ // Gap 18a contract: interacted==0 -> line[2] stays byte-identical with NO marker suffix.
406
+ let data = json!({
407
+ "agents": {"a1": {"status": "running"}},
408
+ "agent_health": {},
409
+ });
410
+ let line2 = format_status_summary(&data)
411
+ .lines()
412
+ .nth(2)
413
+ .unwrap()
414
+ .to_string();
415
+ assert_eq!(
416
+ line2,
417
+ "agents: 1 — running=1 busy=0 idle=0 stopped=0 failed=0 unknown=0"
418
+ );
419
+ assert!(
420
+ !line2.contains("interacted"),
421
+ "no marker when interacted==0"
422
+ );
423
+ }
424
+
425
+ #[test]
426
+ fn format_status_csv_preserves_agent_order_and_collapses_errors() {
427
+ let data = json!({
428
+ "agents": {
429
+ "zeta": {"status": "idle", "pane_id": "%1"},
430
+ "alpha": {"status": "running", "pane_id": "%2"},
431
+ "err_failed": {"status": "failed", "pane_id": "%3"},
432
+ "err_missing_pane": {"status": "running"},
433
+ "err_unknown": {"status": "mystery", "pane_id": "%4"},
434
+ "err_stopped": {"status": "stopped", "pane_id": "%5"}
435
+ },
436
+ "agent_health": {
437
+ "alpha": {"status": "working"}
438
+ }
439
+ });
440
+ assert_eq!(
382
441
  format_status_csv(&data),
383
442
  "zeta,空闲\nalpha,工作\nerr_failed,错误\nerr_missing_pane,错误\nerr_unknown,错误\nerr_stopped,错误"
384
443
  );
385
- }
386
-
387
- #[test]
388
- fn format_status_csv_zero_workers_is_empty() {
389
- assert_eq!(format_status_csv(&json!({"agents": {}, "agent_health": {}})), "");
390
- }
391
-
392
- // =========================================================================
393
- // emit(helpers.py:12-23):--json sort_keys+indent=2 | dict 逐键 | 非 dict
394
- // =========================================================================
395
-
396
- #[test]
397
- fn emit_json_sorted_indented() {
398
- // golden json.dumps(indent=2, sort_keys=True): keys sorted a,b,nested; nested list expanded.
399
- let out = emit(&CmdOutput::Json(json!({"b": 2, "a": 1, "nested": {"x": [1, 2]}})), true)
400
- .expect("json emit returns Some");
401
- let expected = "{\n \"a\": 1,\n \"b\": 2,\n \"nested\": {\n \"x\": [\n 1,\n 2\n ]\n }\n}";
402
- assert_eq!(out, expected);
403
- }
404
-
405
- #[test]
406
- fn emit_dict_human_per_key() {
407
- // golden human dict: scalar -> "key: value"; dict/list -> compact json value.
408
- // KEY INSERTION ORDER preserved (NOT sorted) in non-json path.
409
- let out = emit(
410
- &CmdOutput::Json(json!({"key1": "val1", "nested": {"a": 1}, "lst": [1, 2]})),
411
- false,
412
- )
413
- .expect("dict human emit returns Some");
414
- let expected = "key1: val1\nnested: {\"a\": 1}\nlst: [1, 2]";
415
- assert_eq!(out, expected);
416
- }
417
-
418
- #[test]
419
- fn emit_human_non_dict_passthrough() {
420
- // golden: non-dict (Human string) printed raw.
421
- let out = emit(&CmdOutput::Human("just a string".into()), false)
422
- .expect("human emit returns Some");
423
- assert_eq!(out, "just a string");
424
- }
425
-
426
- #[test]
427
- fn emit_none_output_produces_nothing() {
428
- // passthrough/watch: CmdOutput::None never reaches emit -> None (no stdout line).
429
- assert_eq!(emit(&CmdOutput::None, false), None);
430
- assert_eq!(emit(&CmdOutput::None, true), None);
431
- }
432
-
433
- // =========================================================================
434
- // CliError::to_payload(helpers.py:137-187):稳定信封 + tmux 冲突富化
435
- // =========================================================================
436
-
437
- #[test]
438
- fn cli_error_payload_plain_runtime() {
439
- // golden plain: ok=false, error=str(exc), action=generic, log=path, NO reason/session/next.
440
- let err = CliError::Runtime("some other error".into());
441
- let payload = err.to_payload(Path::new("/tmp/y.log"), "status");
442
- assert!(!payload.ok);
443
- assert_eq!(payload.error, "some other error");
444
- assert_eq!(payload.action, "run `team-agent doctor` or inspect the log path shown here");
445
- assert_eq!(payload.log, "/tmp/y.log");
446
- assert_eq!(payload.reason, None);
447
- assert_eq!(payload.session_name, None);
448
- assert_eq!(payload.next_actions, None);
449
- }
450
-
451
- #[test]
452
- fn cli_error_payload_tmux_conflict_quick_start_enrichment() {
453
- // golden quick-start enrichment (exact bytes):
454
- let err = CliError::Runtime("tmux session already exists: my-team. Startup aborted".into());
455
- let payload = err.to_payload(Path::new("/tmp/cli-error-123.log"), "quick-start");
456
- assert_eq!(payload.reason.as_deref(), Some("tmux_session_name_conflict"));
457
- assert_eq!(payload.session_name.as_deref(), Some("my-team"));
458
- // E8 (N38): quick-start 撞已有 runtime 引导到 restart(resume);
459
- // context reset is only through restart --allow-fresh with explicit consent.
460
- assert_eq!(
444
+ }
445
+
446
+ #[test]
447
+ fn format_status_csv_zero_workers_is_empty() {
448
+ assert_eq!(
449
+ format_status_csv(&json!({"agents": {}, "agent_health": {}})),
450
+ ""
451
+ );
452
+ }
453
+
454
+ // =========================================================================
455
+ // emit(helpers.py:12-23):--json sort_keys+indent=2 | dict 逐键 | 非 dict
456
+ // =========================================================================
457
+
458
+ #[test]
459
+ fn emit_json_sorted_indented() {
460
+ // golden json.dumps(indent=2, sort_keys=True): keys sorted a,b,nested; nested list expanded.
461
+ let out = emit(
462
+ &CmdOutput::Json(json!({"b": 2, "a": 1, "nested": {"x": [1, 2]}})),
463
+ true,
464
+ )
465
+ .expect("json emit returns Some");
466
+ let expected = "{\n \"a\": 1,\n \"b\": 2,\n \"nested\": {\n \"x\": [\n 1,\n 2\n ]\n }\n}";
467
+ assert_eq!(out, expected);
468
+ }
469
+
470
+ #[test]
471
+ fn emit_dict_human_per_key() {
472
+ // golden human dict: scalar -> "key: value"; dict/list -> compact json value.
473
+ // KEY INSERTION ORDER preserved (NOT sorted) in non-json path.
474
+ let out = emit(
475
+ &CmdOutput::Json(json!({"key1": "val1", "nested": {"a": 1}, "lst": [1, 2]})),
476
+ false,
477
+ )
478
+ .expect("dict human emit returns Some");
479
+ let expected = "key1: val1\nnested: {\"a\": 1}\nlst: [1, 2]";
480
+ assert_eq!(out, expected);
481
+ }
482
+
483
+ #[test]
484
+ fn emit_human_non_dict_passthrough() {
485
+ // golden: non-dict (Human string) printed raw.
486
+ let out =
487
+ emit(&CmdOutput::Human("just a string".into()), false).expect("human emit returns Some");
488
+ assert_eq!(out, "just a string");
489
+ }
490
+
491
+ #[test]
492
+ fn emit_none_output_produces_nothing() {
493
+ // passthrough/watch: CmdOutput::None never reaches emit -> None (no stdout line).
494
+ assert_eq!(emit(&CmdOutput::None, false), None);
495
+ assert_eq!(emit(&CmdOutput::None, true), None);
496
+ }
497
+
498
+ // =========================================================================
499
+ // CliError::to_payload(helpers.py:137-187):稳定信封 + tmux 冲突富化
500
+ // =========================================================================
501
+
502
+ #[test]
503
+ fn cli_error_payload_plain_runtime() {
504
+ // golden plain: ok=false, error=str(exc), action=generic, log=path, NO reason/session/next.
505
+ let err = CliError::Runtime("some other error".into());
506
+ let payload = err.to_payload(Path::new("/tmp/y.log"), "status");
507
+ assert!(!payload.ok);
508
+ assert_eq!(payload.error, "some other error");
509
+ assert_eq!(
510
+ payload.action,
511
+ "run `team-agent doctor` or inspect the log path shown here"
512
+ );
513
+ assert_eq!(payload.log, "/tmp/y.log");
514
+ assert_eq!(payload.reason, None);
515
+ assert_eq!(payload.session_name, None);
516
+ assert_eq!(payload.next_actions, None);
517
+ }
518
+
519
+ #[test]
520
+ fn cli_error_payload_tmux_conflict_quick_start_enrichment() {
521
+ // golden quick-start enrichment (exact bytes):
522
+ let err = CliError::Runtime("tmux session already exists: my-team. Startup aborted".into());
523
+ let payload = err.to_payload(Path::new("/tmp/cli-error-123.log"), "quick-start");
524
+ assert_eq!(
525
+ payload.reason.as_deref(),
526
+ Some("tmux_session_name_conflict")
527
+ );
528
+ assert_eq!(payload.session_name.as_deref(), Some("my-team"));
529
+ // E8 (N38): quick-start 撞已有 runtime 引导到 restart(resume);
530
+ // context reset is only through restart --allow-fresh with explicit consent.
531
+ assert_eq!(
461
532
  payload.action,
462
533
  "tmux session `my-team` already exists. It may be your own existing team. \
463
534
  To resume it use `team-agent restart`. \
@@ -465,230 +536,237 @@ If recovery is impossible, use `team-agent restart --allow-fresh` only after exp
465
536
  Only if you want a separate team, change `name:` in TEAM.md and run quick-start again. \
466
537
  Never terminate existing tmux sessions from quick-start."
467
538
  );
468
- assert_eq!(
539
+ assert_eq!(
469
540
  payload.next_actions,
470
541
  Some(vec![
471
542
  "If this is your existing team, resume it with `team-agent restart`.".to_string(),
472
543
  "If you want a separate team, change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
473
544
  ])
474
545
  );
475
- }
476
-
477
- #[test]
478
- fn cli_error_payload_tmux_conflict_non_quick_start_enrichment() {
479
- // golden non-quick-start (command="restart") enrichment uses generic startup wording.
480
- let err = CliError::Runtime("tmux session already exists: my-team. Startup aborted".into());
481
- let payload = err.to_payload(Path::new("/tmp/x.log"), "restart");
482
- assert_eq!(payload.session_name.as_deref(), Some("my-team"));
483
- assert_eq!(
484
- payload.action,
485
- "tmux session `my-team` already exists. It may be an active team. \
546
+ }
547
+
548
+ #[test]
549
+ fn cli_error_payload_tmux_conflict_non_quick_start_enrichment() {
550
+ // golden non-quick-start (command="restart") enrichment uses generic startup wording.
551
+ let err = CliError::Runtime("tmux session already exists: my-team. Startup aborted".into());
552
+ let payload = err.to_payload(Path::new("/tmp/x.log"), "restart");
553
+ assert_eq!(payload.session_name.as_deref(), Some("my-team"));
554
+ assert_eq!(
555
+ payload.action,
556
+ "tmux session `my-team` already exists. It may be an active team. \
486
557
  Do not terminate existing tmux sessions from startup; \
487
558
  use a different team name or runtime.session_name and start again."
488
- );
489
- assert_eq!(
490
- payload.next_actions,
491
- Some(vec!["Use a different team name or runtime.session_name before starting again.".to_string()])
492
- );
493
- }
494
-
495
- #[test]
496
- fn cli_error_payload_json_shape_serializes_optional_fields_skipped() {
497
- // skip_serializing_if for reason/session_name/next_actions: plain payload omits them.
498
- let err = CliError::Runtime("boom".into());
499
- let payload = err.to_payload(Path::new("/tmp/z.log"), "status");
500
- let v = serde_json::to_value(&payload).unwrap();
501
- let obj = v.as_object().unwrap();
502
- assert!(!obj.contains_key("reason"), "reason omitted on plain error");
503
- assert!(!obj.contains_key("session_name"));
504
- assert!(!obj.contains_key("next_actions"));
505
- assert_eq!(obj.get("ok"), Some(&json!(false)));
506
- }
507
-
508
- #[test]
509
- fn consume_inbox_missing_file_returns_none() {
510
- // helpers.py:30-31: inbox_path absent -> None (no crash).
511
- let ws = tmp_workspace();
512
- assert_eq!(consume_leader_inbox_summary(&ws, 500), None);
513
- let _ = std::fs::remove_dir_all(&ws);
514
- }
515
-
516
- #[test]
517
- fn consume_inbox_single_entry_summary_and_cursor_advance() {
518
- // golden _leader_inbox_summary single entry:
519
- // "Leader inbox: 1 new fallback entry\n- Hello world message\nHint: team-agent inbox leader"
520
- let ws = tmp_workspace();
521
- let inbox = ws.join(".team").join("runtime").join("leader-inbox.log");
522
- std::fs::write(&inbox, "[x fallback]\nHello world message").unwrap();
523
- let summary = consume_leader_inbox_summary(&ws, 500).expect("new entry -> Some");
524
- assert_eq!(
525
- summary,
526
- "Leader inbox: 1 new fallback entry\n- Hello world message\nHint: team-agent inbox leader"
527
- );
528
- // cursor advanced: a second call with no new bytes -> None (offset==size).
529
- assert_eq!(consume_leader_inbox_summary(&ws, 500), None);
530
- let _ = std::fs::remove_dir_all(&ws);
531
- }
532
-
533
- #[test]
534
- fn consume_inbox_two_entries_plural() {
535
- // golden two-entry summary uses plural "entries".
536
- let ws = tmp_workspace();
537
- let inbox = ws.join(".team").join("runtime").join("leader-inbox.log");
538
- std::fs::write(&inbox, "[a fallback]\nFirst msg\n[b fallback]\nSecond msg").unwrap();
539
- let summary = consume_leader_inbox_summary(&ws, 500).expect("Some");
540
- assert_eq!(
559
+ );
560
+ assert_eq!(
561
+ payload.next_actions,
562
+ Some(vec![
563
+ "Use a different team name or runtime.session_name before starting again.".to_string()
564
+ ])
565
+ );
566
+ }
567
+
568
+ #[test]
569
+ fn cli_error_payload_json_shape_serializes_optional_fields_skipped() {
570
+ // skip_serializing_if for reason/session_name/next_actions: plain payload omits them.
571
+ let err = CliError::Runtime("boom".into());
572
+ let payload = err.to_payload(Path::new("/tmp/z.log"), "status");
573
+ let v = serde_json::to_value(&payload).unwrap();
574
+ let obj = v.as_object().unwrap();
575
+ assert!(!obj.contains_key("reason"), "reason omitted on plain error");
576
+ assert!(!obj.contains_key("session_name"));
577
+ assert!(!obj.contains_key("next_actions"));
578
+ assert_eq!(obj.get("ok"), Some(&json!(false)));
579
+ }
580
+
581
+ #[test]
582
+ fn consume_inbox_missing_file_returns_none() {
583
+ // helpers.py:30-31: inbox_path absent -> None (no crash).
584
+ let ws = tmp_workspace();
585
+ assert_eq!(consume_leader_inbox_summary(&ws, 500), None);
586
+ let _ = std::fs::remove_dir_all(&ws);
587
+ }
588
+
589
+ #[test]
590
+ fn consume_inbox_single_entry_summary_and_cursor_advance() {
591
+ // golden _leader_inbox_summary single entry:
592
+ // "Leader inbox: 1 new fallback entry\n- Hello world message\nHint: team-agent inbox leader"
593
+ let ws = tmp_workspace();
594
+ let inbox = ws.join(".team").join("runtime").join("leader-inbox.log");
595
+ std::fs::write(&inbox, "[x fallback]\nHello world message").unwrap();
596
+ let summary = consume_leader_inbox_summary(&ws, 500).expect("new entry -> Some");
597
+ assert_eq!(
598
+ summary,
599
+ "Leader inbox: 1 new fallback entry\n- Hello world message\nHint: team-agent inbox leader"
600
+ );
601
+ // cursor advanced: a second call with no new bytes -> None (offset==size).
602
+ assert_eq!(consume_leader_inbox_summary(&ws, 500), None);
603
+ let _ = std::fs::remove_dir_all(&ws);
604
+ }
605
+
606
+ #[test]
607
+ fn consume_inbox_two_entries_plural() {
608
+ // golden two-entry summary uses plural "entries".
609
+ let ws = tmp_workspace();
610
+ let inbox = ws.join(".team").join("runtime").join("leader-inbox.log");
611
+ std::fs::write(&inbox, "[a fallback]\nFirst msg\n[b fallback]\nSecond msg").unwrap();
612
+ let summary = consume_leader_inbox_summary(&ws, 500).expect("Some");
613
+ assert_eq!(
541
614
  summary,
542
615
  "Leader inbox: 2 new fallback entries\n- First msg\n- Second msg\nHint: team-agent inbox leader"
543
616
  );
544
- let _ = std::fs::remove_dir_all(&ws);
545
- }
546
-
547
- #[test]
548
- fn consume_inbox_budget_truncation_footer() {
549
- // golden budget=200: header + 2 lines then truncation footer (exact bytes from Python).
550
- let ws = tmp_workspace();
551
- let inbox = ws.join(".team").join("runtime").join("leader-inbox.log");
552
- let many: String = (0..20)
553
- .map(|i| format!("[e{i} fallback]\nMessage number {i} with some text padding here"))
554
- .collect::<Vec<_>>()
555
- .join("\n");
556
- std::fs::write(&inbox, &many).unwrap();
557
- let summary = consume_leader_inbox_summary(&ws, 200).expect("Some");
558
- let expected = "Leader inbox: 20 new fallback entries\n\
617
+ let _ = std::fs::remove_dir_all(&ws);
618
+ }
619
+
620
+ #[test]
621
+ fn consume_inbox_budget_truncation_footer() {
622
+ // golden budget=200: header + 2 lines then truncation footer (exact bytes from Python).
623
+ let ws = tmp_workspace();
624
+ let inbox = ws.join(".team").join("runtime").join("leader-inbox.log");
625
+ let many: String = (0..20)
626
+ .map(|i| format!("[e{i} fallback]\nMessage number {i} with some text padding here"))
627
+ .collect::<Vec<_>>()
628
+ .join("\n");
629
+ std::fs::write(&inbox, &many).unwrap();
630
+ let summary = consume_leader_inbox_summary(&ws, 200).expect("Some");
631
+ let expected = "Leader inbox: 20 new fallback entries\n\
559
632
  - Message number 0 with some text padding here\n\
560
633
  - Message number 1 with some text padd ...\n\
561
634
  Truncated: more fallback entries available; run team-agent inbox leader";
562
- assert_eq!(summary, expected);
563
- let _ = std::fs::remove_dir_all(&ws);
564
- }
565
-
566
- // =========================================================================
567
- // CmdResult::from_json(parser.py:507-508):ok is False -> ExitCode::Error
568
- // =========================================================================
569
-
570
- #[test]
571
- fn cmd_result_from_json_ok_true_exits_ok() {
572
- let r = CmdResult::from_json(json!({"ok": true, "x": 1}), true);
573
- assert_eq!(r.exit, ExitCode::Ok);
574
- assert!(r.as_json);
575
- assert_eq!(r.output, CmdOutput::Json(json!({"ok": true, "x": 1})));
576
- }
577
-
578
- #[test]
579
- fn cmd_result_from_json_ok_false_exits_error() {
580
- // parser.py:507: result.get("ok") is False -> SystemExit(1)
581
- let r = CmdResult::from_json(json!({"ok": false, "error": "x"}), false);
582
- assert_eq!(r.exit, ExitCode::Error);
583
- assert!(!r.as_json);
584
- }
585
-
586
- #[test]
587
- fn cmd_result_from_json_missing_ok_exits_ok() {
588
- // result with NO "ok" key: `result.get("ok") is False` is False -> NOT an error (exit Ok).
589
- // None-vs-missing: absence of ok != ok:false.
590
- let r = CmdResult::from_json(json!({"summary": "fine"}), false);
591
- assert_eq!(r.exit, ExitCode::Ok);
592
- }
593
-
594
- #[test]
595
- fn exit_code_numeric() {
596
- assert_eq!(ExitCode::Ok.code(), 0);
597
- assert_eq!(ExitCode::Error.code(), 1);
598
- }
599
-
600
- // =========================================================================
601
- // cmd_doctor 分派(commands.py:218-260):--fix 缺 gate -> Usage err
602
- // =========================================================================
603
-
604
- #[test]
605
- fn cmd_doctor_fix_without_gate_is_usage_error() {
606
- // commands.py:220-221: --fix and not gate -> TeamAgentError("--fix requires --gate")
607
- let args = DoctorArgs {
608
- spec: None,
609
- workspace: PathBuf::from("."),
610
- gate: None,
611
- comms: false,
612
- team: None,
613
- fix: true,
614
- fix_schema: false,
615
- cleanup_orphans: false,
616
- confirm: false,
617
- json: false,
618
- };
619
- let err = cmd_doctor(&args).unwrap_err();
620
- let msg = err.to_string();
621
- assert!(
622
- msg.contains("--fix requires --gate"),
623
- "expected '--fix requires --gate', got: {msg}"
624
- );
625
- }
626
-
627
- // =========================================================================
628
- // cmd_status 三态互斥(commands.py:90-100)
629
- // =========================================================================
630
-
631
- #[test]
632
- fn cmd_status_summary_with_json_is_mutually_exclusive() {
633
- // commands.py:92-93: --summary and --json -> TeamAgentError(mutually exclusive)
634
- let args = StatusArgs {
635
- agent: None,
636
- workspace: PathBuf::from("."),
637
- detail: false,
638
- summary: true,
639
- json: true,
640
- team: None,
641
- };
642
- let err = cmd_status(&args).unwrap_err();
643
- assert!(
644
- err.to_string().contains("--summary and --json are mutually exclusive"),
645
- "got: {err}"
646
- );
647
- }
648
-
649
- #[test]
650
- fn cmd_status_summary_with_agent_rejected() {
651
- // commands.py:94-95: --summary + agent -> TeamAgentError(does not accept an agent argument)
652
- let args = StatusArgs {
653
- agent: Some("a1".into()),
654
- workspace: PathBuf::from("."),
655
- detail: false,
656
- summary: true,
657
- json: false,
658
- team: None,
659
- };
660
- let err = cmd_status(&args).unwrap_err();
661
- assert!(
662
- err.to_string().contains("status --summary does not accept an agent argument"),
663
- "got: {err}"
664
- );
665
- }
666
-
667
- // =========================================================================
668
- // cmd_leader_passthrough(parser.py:515-522):-h/--help 早返回 CmdResult::none
669
- // =========================================================================
670
-
671
- #[test]
672
- fn cmd_leader_passthrough_help_returns_none() {
673
- // parser.py:516: provider_args in (["-h"],["--help"]) -> print usage, return (no emit).
674
- let r = cmd_leader_passthrough("codex", &["-h".into()], Path::new(".")).unwrap();
675
- assert_eq!(r.output, CmdOutput::None);
676
- assert_eq!(r.exit, ExitCode::Ok);
677
- let r2 = cmd_leader_passthrough("claude", &["--help".into()], Path::new(".")).unwrap();
678
- assert_eq!(r2.output, CmdOutput::None);
679
- let r3 = cmd_leader_passthrough("copilot", &["--help".into()], Path::new(".")).unwrap();
680
- assert_eq!(r3.output, CmdOutput::None);
681
- }
682
-
683
- #[test]
684
- fn cmd_leader_passthrough_maps_copilot_provider() {
685
- assert_eq!(leader_passthrough_provider("codex"), crate::model::enums::Provider::Codex);
686
- assert_eq!(
687
- leader_passthrough_provider("claude"),
688
- crate::model::enums::Provider::ClaudeCode
689
- );
690
- assert_eq!(
691
- leader_passthrough_provider("copilot"),
692
- crate::model::enums::Provider::Copilot
693
- );
694
- }
635
+ assert_eq!(summary, expected);
636
+ let _ = std::fs::remove_dir_all(&ws);
637
+ }
638
+
639
+ // =========================================================================
640
+ // CmdResult::from_json(parser.py:507-508):ok is False -> ExitCode::Error
641
+ // =========================================================================
642
+
643
+ #[test]
644
+ fn cmd_result_from_json_ok_true_exits_ok() {
645
+ let r = CmdResult::from_json(json!({"ok": true, "x": 1}), true);
646
+ assert_eq!(r.exit, ExitCode::Ok);
647
+ assert!(r.as_json);
648
+ assert_eq!(r.output, CmdOutput::Json(json!({"ok": true, "x": 1})));
649
+ }
650
+
651
+ #[test]
652
+ fn cmd_result_from_json_ok_false_exits_error() {
653
+ // parser.py:507: result.get("ok") is False -> SystemExit(1)
654
+ let r = CmdResult::from_json(json!({"ok": false, "error": "x"}), false);
655
+ assert_eq!(r.exit, ExitCode::Error);
656
+ assert!(!r.as_json);
657
+ }
658
+
659
+ #[test]
660
+ fn cmd_result_from_json_missing_ok_exits_ok() {
661
+ // result with NO "ok" key: `result.get("ok") is False` is False -> NOT an error (exit Ok).
662
+ // None-vs-missing: absence of ok != ok:false.
663
+ let r = CmdResult::from_json(json!({"summary": "fine"}), false);
664
+ assert_eq!(r.exit, ExitCode::Ok);
665
+ }
666
+
667
+ #[test]
668
+ fn exit_code_numeric() {
669
+ assert_eq!(ExitCode::Ok.code(), 0);
670
+ assert_eq!(ExitCode::Error.code(), 1);
671
+ }
672
+
673
+ // =========================================================================
674
+ // cmd_doctor 分派(commands.py:218-260):--fix 缺 gate -> Usage err
675
+ // =========================================================================
676
+
677
+ #[test]
678
+ fn cmd_doctor_fix_without_gate_is_usage_error() {
679
+ // commands.py:220-221: --fix and not gate -> TeamAgentError("--fix requires --gate")
680
+ let args = DoctorArgs {
681
+ spec: None,
682
+ workspace: PathBuf::from("."),
683
+ gate: None,
684
+ comms: false,
685
+ team: None,
686
+ fix: true,
687
+ fix_schema: false,
688
+ cleanup_orphans: false,
689
+ confirm: false,
690
+ json: false,
691
+ };
692
+ let err = cmd_doctor(&args).unwrap_err();
693
+ let msg = err.to_string();
694
+ assert!(
695
+ msg.contains("--fix requires --gate"),
696
+ "expected '--fix requires --gate', got: {msg}"
697
+ );
698
+ }
699
+
700
+ // =========================================================================
701
+ // cmd_status 三态互斥(commands.py:90-100)
702
+ // =========================================================================
703
+
704
+ #[test]
705
+ fn cmd_status_summary_with_json_is_mutually_exclusive() {
706
+ // commands.py:92-93: --summary and --json -> TeamAgentError(mutually exclusive)
707
+ let args = StatusArgs {
708
+ agent: None,
709
+ workspace: PathBuf::from("."),
710
+ detail: false,
711
+ summary: true,
712
+ json: true,
713
+ team: None,
714
+ };
715
+ let err = cmd_status(&args).unwrap_err();
716
+ assert!(
717
+ err.to_string()
718
+ .contains("--summary and --json are mutually exclusive"),
719
+ "got: {err}"
720
+ );
721
+ }
722
+
723
+ #[test]
724
+ fn cmd_status_summary_with_agent_rejected() {
725
+ // commands.py:94-95: --summary + agent -> TeamAgentError(does not accept an agent argument)
726
+ let args = StatusArgs {
727
+ agent: Some("a1".into()),
728
+ workspace: PathBuf::from("."),
729
+ detail: false,
730
+ summary: true,
731
+ json: false,
732
+ team: None,
733
+ };
734
+ let err = cmd_status(&args).unwrap_err();
735
+ assert!(
736
+ err.to_string()
737
+ .contains("status --summary does not accept an agent argument"),
738
+ "got: {err}"
739
+ );
740
+ }
741
+
742
+ // =========================================================================
743
+ // cmd_leader_passthrough(parser.py:515-522):-h/--help 早返回 CmdResult::none
744
+ // =========================================================================
745
+
746
+ #[test]
747
+ fn cmd_leader_passthrough_help_returns_none() {
748
+ // parser.py:516: provider_args in (["-h"],["--help"]) -> print usage, return (no emit).
749
+ let r = cmd_leader_passthrough("codex", &["-h".into()], Path::new(".")).unwrap();
750
+ assert_eq!(r.output, CmdOutput::None);
751
+ assert_eq!(r.exit, ExitCode::Ok);
752
+ let r2 = cmd_leader_passthrough("claude", &["--help".into()], Path::new(".")).unwrap();
753
+ assert_eq!(r2.output, CmdOutput::None);
754
+ let r3 = cmd_leader_passthrough("copilot", &["--help".into()], Path::new(".")).unwrap();
755
+ assert_eq!(r3.output, CmdOutput::None);
756
+ }
757
+
758
+ #[test]
759
+ fn cmd_leader_passthrough_maps_copilot_provider() {
760
+ assert_eq!(
761
+ leader_passthrough_provider("codex"),
762
+ crate::model::enums::Provider::Codex
763
+ );
764
+ assert_eq!(
765
+ leader_passthrough_provider("claude"),
766
+ crate::model::enums::Provider::ClaudeCode
767
+ );
768
+ assert_eq!(
769
+ leader_passthrough_provider("copilot"),
770
+ crate::model::enums::Provider::Copilot
771
+ );
772
+ }