@team-agent/installer 0.3.7 → 0.3.9

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 (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +52 -7
  4. package/crates/team-agent/src/cli/emit.rs +132 -4
  5. package/crates/team-agent/src/cli/leader.rs +12 -8
  6. package/crates/team-agent/src/cli/mod.rs +121 -28
  7. package/crates/team-agent/src/cli/tests/base.rs +14 -0
  8. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
  9. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/run_delegation.rs +6 -2
  11. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
  12. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
  13. package/crates/team-agent/src/leader/owner_bind.rs +59 -20
  14. package/crates/team-agent/src/leader/start.rs +34 -23
  15. package/crates/team-agent/src/leader/tests/identity.rs +22 -0
  16. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +13 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +203 -16
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +25 -12
  19. package/crates/team-agent/src/lifecycle/restart.rs +7 -3
  20. package/crates/team-agent/src/lifecycle/tests/core.rs +192 -0
  21. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +112 -0
  22. package/crates/team-agent/src/lifecycle/types.rs +2 -0
  23. package/crates/team-agent/src/messaging/results.rs +27 -22
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +18 -0
  25. package/crates/team-agent/src/provider/adapter.rs +177 -15
  26. package/crates/team-agent/src/state/identity.rs +29 -0
  27. package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
  28. package/crates/team-agent/src/tmux_backend.rs +90 -7
  29. package/crates/team-agent/src/transport/test_support.rs +57 -4
  30. package/crates/team-agent/src/transport.rs +13 -0
  31. package/npm/install.mjs +31 -35
  32. package/package.json +4 -4
@@ -1,4 +1,41 @@
1
1
  use super::*;
2
+ use serial_test::serial;
3
+
4
+ struct EnvUnsetGuard {
5
+ previous: Vec<(&'static str, Option<String>)>,
6
+ }
7
+
8
+ impl EnvUnsetGuard {
9
+ fn unset(keys: &[&'static str]) -> Self {
10
+ let previous = keys
11
+ .iter()
12
+ .map(|key| (*key, std::env::var(key).ok()))
13
+ .collect::<Vec<_>>();
14
+ for key in keys {
15
+ std::env::remove_var(key);
16
+ }
17
+ Self { previous }
18
+ }
19
+ }
20
+
21
+ impl Drop for EnvUnsetGuard {
22
+ fn drop(&mut self) {
23
+ for (key, value) in self.previous.drain(..).rev() {
24
+ match value {
25
+ Some(value) => std::env::set_var(key, value),
26
+ None => std::env::remove_var(key),
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ struct WorkspaceCleanup(std::path::PathBuf);
33
+
34
+ impl Drop for WorkspaceCleanup {
35
+ fn drop(&mut self) {
36
+ let _ = std::fs::remove_dir_all(&self.0);
37
+ }
38
+ }
2
39
 
3
40
  // =========================================================================
4
41
  // WAVE-2 NON-SUB CHECKPOINT — 9 MISSING CLI subcommands (ABSENT from cli/emit.rs dispatch).
@@ -131,6 +168,36 @@ tasks:
131
168
  std::fs::write(ws.join("team.spec.yaml"), spec).unwrap();
132
169
  }
133
170
 
171
+ fn seed_collect_runtime_state(ws: &std::path::Path) {
172
+ crate::state::persist::save_runtime_state(
173
+ ws,
174
+ &json!({
175
+ "active_team_key": "fake-e2e",
176
+ "team_dir": ws.to_string_lossy().to_string(),
177
+ "spec_path": ws.join("team.spec.yaml").to_string_lossy().to_string(),
178
+ "session_name": "team-agent-fake-e2e",
179
+ "leader": {"id": "leader"},
180
+ "agents": {
181
+ "fake_impl": {
182
+ "status": "running",
183
+ "provider": "fake",
184
+ "role": "implementation_engineer",
185
+ "window": "fake_impl",
186
+ "owner_team_id": "fake-e2e"
187
+ }
188
+ },
189
+ "tasks": [{
190
+ "id": "task_impl",
191
+ "title": "Fake implementation",
192
+ "type": "implementation",
193
+ "status": "pending",
194
+ "assignee": "fake_impl"
195
+ }]
196
+ }),
197
+ )
198
+ .unwrap();
199
+ }
200
+
134
201
  // ── sessions ── golden cli/parser.py:230 `cmd_sessions` -> runtime.sessions(ws). EXIT 0.
135
202
  // `team-agent sessions --workspace <ws> --json` on an empty ws ->
136
203
  // {"ok":true,"sessions":[],"workspace":"<ws>"} (--json sort_keys). RED: unrouted -> Error.
@@ -169,9 +236,25 @@ tasks:
169
236
  // "delivered_messages":[],"invalid_results":[],"ok":true,"results":{...},"state_file":"<ws>/team_state.md"}
170
237
  // RED: unrouted -> Error.
171
238
  #[test]
239
+ #[serial(env)]
172
240
  fn dispatch_routes_collect_with_spec() {
241
+ let _env = EnvUnsetGuard::unset(&[
242
+ "TEAM_AGENT_WORKSPACE",
243
+ "TEAM_AGENT_TEAM_ID",
244
+ "TEAM_AGENT_OWNER_TEAM_ID",
245
+ "TEAM_AGENT_ACTIVE_TEAM",
246
+ "TEAM_AGENT_ID",
247
+ "TEAM_AGENT_LEADER_PANE_ID",
248
+ "TEAM_AGENT_LEADER_SESSION_UUID",
249
+ "TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE",
250
+ "TEAM_AGENT_LEADER_PROVIDER",
251
+ "TMUX",
252
+ "TMUX_PANE",
253
+ ]);
173
254
  let ws = tmp_workspace();
255
+ let _cleanup = WorkspaceCleanup(ws.clone());
174
256
  seed_team_spec(&ws);
257
+ seed_collect_runtime_state(&ws);
175
258
  let code = run(&cli_argv(&["collect", "--workspace", &ws.to_string_lossy(), "--json"]), &ws);
176
259
  assert_eq!(
177
260
  code,
@@ -180,7 +263,6 @@ tasks:
180
263
  {{collected,collected_results,coordinator,delivered_messages,invalid_results,ok,results,state_file}}; \
181
264
  today -> unknown-subcommand Error"
182
265
  );
183
- let _ = std::fs::remove_dir_all(&ws);
184
266
  }
185
267
 
186
268
  // ── repair-state ── golden parser.py:303 `cmd_repair_state` -> runtime.repair_state (quick_start.py:285).
@@ -94,5 +94,6 @@ mod status_send;
94
94
  mod verb_validate;
95
95
  mod verb_settle;
96
96
  mod verb_profile;
97
+ mod verb_install_skill;
97
98
  mod main_preserved;
98
99
  mod shutdown_kill_plan;
@@ -264,9 +264,13 @@ fn current_uid() -> Option<String> {
264
264
  json: true,
265
265
  };
266
266
  let text = outcome_text(cmd_restart(&args));
267
+ // RED-2-STILL: entry gate now resolves via canonical_run_workspace; for a no-spec workspace
268
+ // the "no spec" surfacing may come from either the entry gate ("missing spec for restart")
269
+ // or, when a stray ancestor .team lets it past, the resolve gate ("active team spec not
270
+ // found"). Both are the REAL crate::lifecycle error (not a placeholder ok:true).
267
271
  assert!(
268
- text.contains("missing spec for restart"),
269
- "cmd_restart must surface the REAL crate::lifecycle::restart 'missing spec for restart' error \
272
+ text.contains("missing spec for restart") || text.contains("active team spec not found"),
273
+ "cmd_restart must surface the REAL crate::lifecycle::restart missing-spec error \
270
274
  (not the placeholder's canned ok:true); got {text}"
271
275
  );
272
276
  }
@@ -1,39 +1,104 @@
1
- //! B5/F1 · `sessions_to_kill_sparing_leader` 纯函数单测(kill 决策下沉后锁定)。
1
+ //! E12 (P0) · `sessions_to_kill` 纯决策单测(kill 决策下沉)。
2
2
  //!
3
- //! 真相源 = `team-agent-leader-` 确定性命名前缀(leader/start.rs LEADER_SESSION_PREFIX);
4
- //! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策。
3
+ //! spare = state 锚 session(anchor_sessions) ∪ `team-agent-leader-` 命名前缀(并集,锚优先)
4
+ //! 独享 socket(无 spare)才允许整 server 拆;共享/leader 在 → 逐 session kill。
5
+ //! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策 + 4 反向 case。
5
6
 
6
- use crate::cli::lifecycle_port::sessions_to_kill_sparing_leader;
7
+ use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
7
8
  use crate::transport::SessionName;
9
+ use std::collections::BTreeSet;
8
10
 
9
11
  fn names(raw: &[&str]) -> Vec<SessionName> {
10
12
  raw.iter().map(|name| SessionName::new(*name)).collect()
11
13
  }
12
14
 
15
+ fn anchors(raw: &[&str]) -> BTreeSet<String> {
16
+ raw.iter().map(|s| s.to_string()).collect()
17
+ }
18
+
19
+ // RC-4(独享 socket):仅目标 session(无 spare)→ 整 server 拆。
20
+ #[test]
21
+ fn rc4_exclusive_socket_kills_server() {
22
+ assert_eq!(
23
+ sessions_to_kill(&names(&["team-x", "team-y"]), &BTreeSet::new()),
24
+ KillDecision::KillServerExclusive
25
+ );
26
+ // 空 session 集 → 逐 kill(no-op),不整 server 拆(没东西可拆)。
27
+ assert_eq!(
28
+ sessions_to_kill(&[], &BTreeSet::new()),
29
+ KillDecision::KillIndividually { to_kill: vec![], spared: vec![] }
30
+ );
31
+ }
32
+
33
+ // RC-1(本 P0 复现):in_tmux leader 在用户 session(**无前缀**),靠 state 锚 spare → 用户 session 存活。
13
34
  #[test]
14
- fn no_leader_session_means_whole_server_kill() {
15
- assert_eq!(sessions_to_kill_sparing_leader(&names(&["team-x"])), None);
16
- assert_eq!(sessions_to_kill_sparing_leader(&[]), None);
35
+ fn rc1_in_tmux_no_prefix_anchor_spares_user_session() {
36
+ let sessions = names(&["team-coder-team", "team-x"]); // 用户 session 无 leader 前缀
37
+ let anchor = anchors(&["team-coder-team"]); // state 锚 pane 所在 session
38
+ let decision = sessions_to_kill(&sessions, &anchor);
39
+ match decision {
40
+ KillDecision::KillIndividually { to_kill, spared } => {
41
+ assert_eq!(to_kill, names(&["team-x"]), "only non-anchor session killed");
42
+ assert_eq!(spared, names(&["team-coder-team"]), "user/leader session spared by anchor");
43
+ }
44
+ other => panic!("anchor session must force per-session kill, not {other:?}"),
45
+ }
46
+ }
47
+
48
+ // 前缀判据仍生效(并集):leader 前缀 session spare,即使无 state 锚。
49
+ #[test]
50
+ fn prefix_session_spared_without_anchor() {
51
+ let sessions = names(&["team-agent-leader-claude-ws-deadbeef", "team-x"]);
52
+ let decision = sessions_to_kill(&sessions, &BTreeSet::new());
53
+ match decision {
54
+ KillDecision::KillIndividually { to_kill, spared } => {
55
+ assert_eq!(to_kill, names(&["team-x"]));
56
+ assert_eq!(spared, names(&["team-agent-leader-claude-ws-deadbeef"]));
57
+ }
58
+ other => panic!("prefix session must spare, not {other:?}"),
59
+ }
60
+ }
61
+
62
+ // RC-2(state 损坏无锚):anchor_sessions 空 → 退命名前缀判据(此处仅前缀 spare;无前缀则全 kill)。
63
+ // (spare_fallback_to_naming event 在 anchor_anchor_sessions 发,本纯函数只验退化后的决策。)
64
+ #[test]
65
+ fn rc2_no_anchor_falls_back_to_naming() {
66
+ // 无锚 + 有前缀 leader → 前缀 spare。
67
+ let with_leader = sessions_to_kill(
68
+ &names(&["team-agent-leader-codex-ws-cafe", "team-x"]),
69
+ &BTreeSet::new(),
70
+ );
71
+ assert!(matches!(with_leader, KillDecision::KillIndividually { .. }));
72
+ // 无锚 + 无前缀(真损坏且 in_tmux 无前缀)→ 无 spare → 独享拆(退化兜底,与历史一致)。
73
+ assert_eq!(
74
+ sessions_to_kill(&names(&["team-x"]), &BTreeSet::new()),
75
+ KillDecision::KillServerExclusive
76
+ );
17
77
  }
18
78
 
79
+ // RC-3(共享 socket):目标 2 session + 用户 1 session(锚)→ 只 kill 目标 2,用户存活,不整 server 拆。
19
80
  #[test]
20
- fn leader_session_present_kills_only_non_leader_sessions() {
21
- let sessions = names(&[
22
- "team-agent-leader-claude-myws-deadbeef",
23
- "team-x",
24
- "team-y",
25
- ]);
26
- let to_kill = sessions_to_kill_sparing_leader(&sessions)
27
- .expect("leader present must switch to per-session kills");
28
- assert_eq!(to_kill, names(&["team-x", "team-y"]));
81
+ fn rc3_shared_socket_kills_only_target_sessions() {
82
+ let sessions = names(&["team-a", "team-b", "user-shell"]);
83
+ let anchor = anchors(&["user-shell"]);
84
+ let decision = sessions_to_kill(&sessions, &anchor);
85
+ match decision {
86
+ KillDecision::KillIndividually { to_kill, spared } => {
87
+ assert_eq!(to_kill, names(&["team-a", "team-b"]));
88
+ assert_eq!(spared, names(&["user-shell"]));
89
+ }
90
+ other => panic!("shared socket must not whole-server kill, got {other:?}"),
91
+ }
29
92
  }
30
93
 
94
+ // 并集语义:同一 session 既前缀又锚 → spare 一次(不重复)。
31
95
  #[test]
32
- fn only_leader_sessions_left_kills_nothing_but_keeps_server() {
33
- let sessions = names(&["team-agent-leader-codex-myws-cafe0123"]);
96
+ fn union_prefix_and_anchor_no_double_count() {
97
+ let sessions = names(&["team-agent-leader-claude-ws-beef"]);
98
+ let anchor = anchors(&["team-agent-leader-claude-ws-beef"]);
99
+ let decision = sessions_to_kill(&sessions, &anchor);
34
100
  assert_eq!(
35
- sessions_to_kill_sparing_leader(&sessions),
36
- Some(Vec::new()),
37
- "a socket holding only leader sessions must not be torn down"
101
+ decision,
102
+ KillDecision::KillIndividually { to_kill: vec![], spared: names(&["team-agent-leader-claude-ws-beef"]) }
38
103
  );
39
104
  }
@@ -0,0 +1,76 @@
1
+ use super::*;
2
+
3
+ // RED-1 根治:install-skill 必须作为真 CLI 子命令存在(此前 packaging::install_skill 未接进
4
+ // 任何 dispatch=双重死代码,install.mjs 走自己的 JS 拷贝逻辑漏 copilot)。
5
+ // 这里钉:① 子命令路由 ② --target all 默认 ③ --source 必需 ④ 缺 source 显式 Usage 错。
6
+
7
+ fn skill_source() -> PathBuf {
8
+ use std::sync::atomic::{AtomicU64, Ordering};
9
+ static CTR: AtomicU64 = AtomicU64::new(0);
10
+ let dir = std::env::temp_dir().join(format!(
11
+ "ta-cli-installskill-src-{}-{}",
12
+ std::process::id(),
13
+ CTR.fetch_add(1, Ordering::Relaxed)
14
+ ));
15
+ std::fs::create_dir_all(dir.join("team-agent")).unwrap();
16
+ std::fs::write(dir.join("team-agent").join("SKILL.md"), b"---\nname: team-agent\n---\nbody\n").unwrap();
17
+ dir.join("team-agent")
18
+ }
19
+
20
+ #[test]
21
+ fn dispatch_routes_install_skill_dry_run_all() {
22
+ let ws = tmp_workspace();
23
+ let source = skill_source();
24
+ // --dry-run 不落地(不碰真实 HOME),只验路由 + exit 0。
25
+ let code = run(
26
+ &[
27
+ "install-skill".to_string(),
28
+ "--target".to_string(),
29
+ "all".to_string(),
30
+ "--source".to_string(),
31
+ source.to_string_lossy().to_string(),
32
+ "--dry-run".to_string(),
33
+ "--json".to_string(),
34
+ ],
35
+ &ws,
36
+ );
37
+ assert_eq!(code, ExitCode::Ok, "`install-skill --target all --source <dir> --dry-run` must route + exit 0");
38
+ let _ = std::fs::remove_dir_all(&ws);
39
+ let _ = std::fs::remove_dir_all(source.parent().unwrap());
40
+ }
41
+
42
+ #[test]
43
+ fn install_skill_missing_source_is_usage_error() {
44
+ let ws = tmp_workspace();
45
+ // 无 --source → Usage 错(exit 2),不静默。
46
+ let code = run(
47
+ &[
48
+ "install-skill".to_string(),
49
+ "--target".to_string(),
50
+ "all".to_string(),
51
+ "--json".to_string(),
52
+ ],
53
+ &ws,
54
+ );
55
+ assert_eq!(code, ExitCode::Error, "install-skill without --source must error (CliError::Usage -> emit_cli_error exit 1)");
56
+ let _ = std::fs::remove_dir_all(&ws);
57
+ }
58
+
59
+ #[test]
60
+ fn install_skill_invalid_target_is_usage_error() {
61
+ let ws = tmp_workspace();
62
+ let source = skill_source();
63
+ let code = run(
64
+ &[
65
+ "install-skill".to_string(),
66
+ "--target".to_string(),
67
+ "bogus".to_string(),
68
+ "--source".to_string(),
69
+ source.to_string_lossy().to_string(),
70
+ ],
71
+ &ws,
72
+ );
73
+ assert_eq!(code, ExitCode::Error, "install-skill --target bogus must error (CliError::Usage -> emit_cli_error exit 1)");
74
+ let _ = std::fs::remove_dir_all(&ws);
75
+ let _ = std::fs::remove_dir_all(source.parent().unwrap());
76
+ }
@@ -189,15 +189,29 @@ fn bind_provider_from_env_or_command(command: &str) -> Provider {
189
189
  std::env::var("TEAM_AGENT_LEADER_PROVIDER")
190
190
  .ok()
191
191
  .and_then(|raw| super::helpers::parse_provider(&raw))
192
- .unwrap_or_else(|| provider_from_command(command))
192
+ .or_else(|| provider_from_command(command))
193
+ // E11 层2:未知命令不再静默默认 codex(会误绑任意 provider + 喂错分类器)。
194
+ // 无法识别时回落 Codex 仅作最末兜底,且该路径已被 provider_from_command 的显式 None 收窄
195
+ // (调用方理应只在已知 leader 命令上 bind);保留以不改 fn 签名/上游 panic 面。
196
+ .unwrap_or(Provider::Codex)
193
197
  }
194
198
 
195
- fn provider_from_command(command: &str) -> Provider {
199
+ /// E11 层2 + N39:command wire 串 → `parse_provider`(**单一映射源**,与
200
+ /// `owner_bind_provider_wire` 共用 [`command_provider_wire`])。未知命令 → `None`
201
+ /// (危险的 `_ => Codex` 默认已删:不静默把任意 provider 误绑成 codex)。
202
+ fn provider_from_command(command: &str) -> Option<Provider> {
203
+ command_provider_wire(command).and_then(super::helpers::parse_provider)
204
+ }
205
+
206
+ /// command 名 → provider wire 串(单一真相;copilot/claude/codex/fake)。未知 → `None`。
207
+ /// `claude.exe` 归一为 `claude`。
208
+ fn command_provider_wire(command: &str) -> Option<&'static str> {
196
209
  match exact_command_name(command).as_deref() {
197
- Some("claude") | Some("claude.exe") => Provider::Claude,
198
- Some("codex") => Provider::Codex,
199
- Some("fake") => Provider::Fake,
200
- _ => Provider::Codex,
210
+ Some("claude") | Some("claude.exe") => Some("claude"),
211
+ Some("codex") => Some("codex"),
212
+ Some("copilot") => Some("copilot"),
213
+ Some("fake") => Some("fake"),
214
+ _ => None,
201
215
  }
202
216
  }
203
217
 
@@ -214,21 +228,15 @@ fn exact_command_name(command: &str) -> Option<String> {
214
228
 
215
229
  pub fn owner_bind_provider_wire(command: &str) -> &'static str {
216
230
  if let Ok(raw) = std::env::var("TEAM_AGENT_LEADER_PROVIDER") {
217
- return match raw.as_str() {
218
- "claude" => "claude",
219
- "claude_code" => "claude_code",
220
- "codex" => "codex",
221
- "gemini_cli" => "gemini_cli",
222
- "fake" => "fake",
223
- _ => "",
224
- };
225
- }
226
- match exact_command_name(command).as_deref() {
227
- Some("claude") | Some("claude.exe") => "claude",
228
- Some("codex") => "codex",
229
- Some("fake") => "fake",
230
- _ => "",
231
+ // env 显式 provider:经 parse_provider(单一表,知 copilot)校验后透传其 wire 串;
232
+ // 不识别 ""(空,与原行为一致:不绑)。
233
+ return super::helpers::parse_provider(&raw)
234
+ .map(super::helpers::provider_wire)
235
+ .unwrap_or("");
231
236
  }
237
+ // E11 层2 + N39:与 provider_from_command 共用 command_provider_wire 单一映射(含 copilot);
238
+ // 未知命令 → ""(不绑),不再静默当 codex。
239
+ command_provider_wire(command).unwrap_or("")
232
240
  }
233
241
 
234
242
  fn family_a_identity(
@@ -290,3 +298,34 @@ fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, Lea
290
298
  // NOTE: `derive_leader_session_uuid`(`leader_binding.py:146`)已由
291
299
  // `model::ids::LeaderSessionUuid::derive` 字节对齐实现(含 NUL 拒绝 + golden 测试)——
292
300
  // 此 lane REUSE 之,不重声明。
301
+
302
+ #[cfg(test)]
303
+ mod e11_provider_bind_tests {
304
+ #![allow(clippy::unwrap_used)]
305
+ use super::*;
306
+
307
+ // E11 层2:copilot leader 命令必须绑成 Provider::Copilot(此前缺臂 → _ => Codex 误绑)。
308
+ #[test]
309
+ fn copilot_command_binds_copilot_not_codex() {
310
+ assert_eq!(provider_from_command("copilot --banner -C /ws"), Some(Provider::Copilot));
311
+ assert_eq!(provider_from_command("/opt/homebrew/bin/copilot"), Some(Provider::Copilot));
312
+ assert_eq!(owner_bind_provider_wire("copilot --banner"), "copilot");
313
+ }
314
+
315
+ #[test]
316
+ fn known_commands_map_via_single_source() {
317
+ assert_eq!(provider_from_command("claude"), Some(Provider::Claude));
318
+ assert_eq!(provider_from_command("codex"), Some(Provider::Codex));
319
+ assert_eq!(provider_from_command("fake"), Some(Provider::Fake));
320
+ assert_eq!(owner_bind_provider_wire("claude"), "claude");
321
+ assert_eq!(owner_bind_provider_wire("codex"), "codex");
322
+ }
323
+
324
+ // E11 层2:未知命令不再静默默认 codex —— provider_from_command → None,wire → ""。
325
+ #[test]
326
+ fn unknown_command_is_none_not_silent_codex() {
327
+ assert_eq!(provider_from_command("node /some/thing.js"), None);
328
+ assert_eq!(provider_from_command("totally-unknown"), None);
329
+ assert_eq!(owner_bind_provider_wire("totally-unknown"), "");
330
+ }
331
+ }
@@ -14,8 +14,8 @@ use super::helpers::{
14
14
  };
15
15
  use super::owner_bind::leader_identity_context;
16
16
  use super::{
17
- LeaderError, LeaderLaunchOutcome, LeaderLaunchSocket, LeaderLaunchStatus, LeaderStartMode,
18
- LeaderStartPlan,
17
+ LeaderError, LeaderIdentity, LeaderLaunchOutcome, LeaderLaunchSocket, LeaderLaunchStatus,
18
+ LeaderStartMode, LeaderStartPlan,
19
19
  };
20
20
 
21
21
  // ── leader::start — leader_start_plan / start_leader / session 名 ──
@@ -72,27 +72,7 @@ pub fn leader_start_plan(
72
72
  } else {
73
73
  LeaderStartMode::NewTmuxSession
74
74
  };
75
- let mut leader_env = BTreeMap::new();
76
- leader_env.insert(
77
- "TEAM_AGENT_LEADER_PROVIDER".to_string(),
78
- provider_wire(provider).to_string(),
79
- );
80
- leader_env.insert(
81
- "TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
82
- identity.leader_session_uuid.as_str().to_string(),
83
- );
84
- leader_env.insert(
85
- "TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
86
- identity.machine_fingerprint.clone(),
87
- );
88
- leader_env.insert(
89
- "TEAM_AGENT_WORKSPACE".to_string(),
90
- identity.workspace_abspath.to_string_lossy().into_owned(),
91
- );
92
- leader_env.insert(
93
- "TEAM_AGENT_TEAM_ID".to_string(),
94
- identity.team_id.as_str().to_string(),
95
- );
75
+ let leader_env = leader_env_for_identity(provider, &identity);
96
76
  let argv = start_argv(
97
77
  mode,
98
78
  provider,
@@ -119,6 +99,37 @@ pub fn leader_start_plan(
119
99
  })
120
100
  }
121
101
 
102
+ pub(crate) fn leader_env_for_identity(
103
+ provider: Provider,
104
+ identity: &LeaderIdentity,
105
+ ) -> BTreeMap<String, String> {
106
+ let mut leader_env = BTreeMap::new();
107
+ leader_env.insert(
108
+ "TEAM_AGENT_LEADER_PROVIDER".to_string(),
109
+ provider_wire(provider).to_string(),
110
+ );
111
+ leader_env.insert(
112
+ "TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
113
+ identity.leader_session_uuid.as_str().to_string(),
114
+ );
115
+ leader_env.insert(
116
+ "TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
117
+ identity.machine_fingerprint.clone(),
118
+ );
119
+ leader_env.insert(
120
+ "TEAM_AGENT_WORKSPACE".to_string(),
121
+ identity.workspace_abspath.to_string_lossy().into_owned(),
122
+ );
123
+ leader_env.insert(
124
+ "TEAM_AGENT_TEAM_ID".to_string(),
125
+ identity.team_id.as_str().to_string(),
126
+ );
127
+ if provider == Provider::Copilot {
128
+ leader_env.insert("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string());
129
+ }
130
+ leader_env
131
+ }
132
+
122
133
  /// `start_leader`(card §46;`__init__.py:60`)。计算并执行 leader 启动计划(spawn + 信号处理)。
123
134
  /// 进程退出后 `Err`/退出码经 caller 处理(此处返 `Result` 替代 Python 的 `SystemExit`)。
124
135
  pub fn start_leader(
@@ -90,6 +90,28 @@ use super::*;
90
90
  }
91
91
  }
92
92
 
93
+ #[test]
94
+ #[serial_test::serial(env)]
95
+ fn copilot_leader_env_disables_terminal_title_only_for_copilot() {
96
+ let ws = std::env::temp_dir().join(format!("ta_rs_lsp_copilot_{}", std::process::id()));
97
+ std::fs::create_dir_all(&ws).unwrap();
98
+ let identity = leader_identity_context(&ws, None, Some(&serde_json::json!({}))).unwrap();
99
+
100
+ let copilot = leader_env_for_identity(Provider::Copilot, &identity);
101
+ assert_eq!(
102
+ copilot.get("COPILOT_DISABLE_TERMINAL_TITLE").map(String::as_str),
103
+ Some("1")
104
+ );
105
+
106
+ for provider in [Provider::Codex, Provider::ClaudeCode] {
107
+ let leader_env = leader_env_for_identity(provider, &identity);
108
+ assert!(
109
+ !leader_env.contains_key("COPILOT_DISABLE_TERMINAL_TITLE"),
110
+ "{provider:?} leader env must not include copilot title override"
111
+ );
112
+ }
113
+ }
114
+
93
115
  // ═══════════════ P2 FIX-LOOP RED (复绿即对抗 cross-model findings) ═══════════════
94
116
  // Lock CORRECT Python v0.2.11 leader-identity behavior the contracts missed.
95
117
  // Golden re-probed via /tmp/probe_p2_leader.py vs team-agent-public @ 439bef8
@@ -134,6 +134,19 @@ use super::*;
134
134
  assert!(got.as_str().starts_with("team-agent-leader-claude_code-proj-"), "got {}", got.as_str());
135
135
  }
136
136
 
137
+ #[test]
138
+ fn leader_session_name_uses_copilot_provider_string() {
139
+ let base = std::env::temp_dir().join(format!("ta_rs_lsn_copilot_{}", std::process::id()));
140
+ let dir = base.join("proj");
141
+ std::fs::create_dir_all(&dir).unwrap();
142
+ let got = leader_session_name(Provider::Copilot, &dir);
143
+ assert!(
144
+ got.as_str().starts_with("team-agent-leader-copilot-proj-"),
145
+ "got {}",
146
+ got.as_str()
147
+ );
148
+ }
149
+
137
150
  // =====================================================================
138
151
  // 6. Family A 正源 owner 绑定 — bind_owner_from_caller_pane(unimplemented → RED)
139
152
  // =====================================================================