@team-agent/installer 0.5.48 → 0.5.50

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.
@@ -153,7 +153,7 @@ pub(crate) struct CommandSpec {
153
153
  #[rustfmt::skip]
154
154
  pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
155
155
  CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
156
- CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "send a message/task", usage: "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME | --to-name agent | --to-name team/agent | --to-name workspace::team/agent] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
156
+ CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for a logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
157
157
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
158
158
  CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
159
159
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
@@ -41,7 +41,7 @@ fn cli_send_persists_real_message_row() {
41
41
  workspace: ws.clone(),
42
42
  team: None,
43
43
  task: None,
44
- sender: "leader".to_string(),
44
+ sender: TrustedSender::leader(),
45
45
  no_ack: false,
46
46
  no_wait: true,
47
47
  watch_result: false,
@@ -149,7 +149,7 @@ fn named_send_args(
149
149
  workspace: ws.to_path_buf(),
150
150
  team: None,
151
151
  task: None,
152
- sender: "leader".to_string(),
152
+ sender: TrustedSender::leader(),
153
153
  no_ack: false,
154
154
  no_wait: false,
155
155
  watch_result: false,
@@ -222,6 +222,29 @@ fn resolve_name_team_disambiguated() {
222
222
  let _ = std::fs::remove_dir_all(&ws);
223
223
  }
224
224
 
225
+ #[test]
226
+ fn resolve_qualified_name_canonicalizes_legacy_session_alias() {
227
+ let ws = named_ws("legacy-session-alias");
228
+ seed_state(
229
+ &ws,
230
+ state_with_teams(json!({
231
+ "teamdir": worker_team("team-displayname", "qa", "%1", "qa")
232
+ })),
233
+ );
234
+ let transport = OfflineTransport::new().with_targets(vec![pane(
235
+ "team-displayname",
236
+ "qa",
237
+ "%1",
238
+ )]);
239
+
240
+ let resolved = resolve_name_with_transport(&ws, "displayname/qa", &transport)
241
+ .expect("qualified legacy alias must resolve through the canonical selector");
242
+ assert_eq!(resolved.team_key.as_deref(), Some("teamdir"));
243
+ assert_eq!(resolved.agent_id.as_deref(), Some("qa"));
244
+ assert_eq!(resolved.pane_id, "%1");
245
+ let _ = std::fs::remove_dir_all(&ws);
246
+ }
247
+
225
248
  #[test]
226
249
  fn resolve_name_stale_pane() {
227
250
  let ws = named_ws("stale-pane");
@@ -436,7 +459,7 @@ fn resolve_app_server_leader_name_fails_closed_on_transport_conflict() {
436
459
 
437
460
  #[cfg(unix)]
438
461
  #[test]
439
- fn send_to_name_app_server_leader_submits_turn_without_tmux_pane() {
462
+ fn send_to_name_app_server_leader_persists_before_async_delivery() {
440
463
  let ws = named_ws("appserver-leader-send");
441
464
  let fake = crate::app_server_test_support::FakeAppServer::start(
442
465
  "named-send",
@@ -461,11 +484,13 @@ fn send_to_name_app_server_leader_submits_turn_without_tmux_pane() {
461
484
  };
462
485
 
463
486
  assert_eq!(value["ok"], json!(true));
464
- assert_eq!(value["transport_kind"], json!("codex_app_server"));
465
- assert_eq!(fake.received_turns().len(), 1);
466
- assert_eq!(
467
- fake.received_turns()[0]["params"]["clientUserMessageId"],
468
- value["message_id"]
487
+ let message_id = value["message_id"].as_str().expect("persisted message id");
488
+ assert!(message_id.starts_with("msg_"));
489
+ let store = crate::message_store::MessageStore::open(&ws).unwrap();
490
+ assert!(store.message_exists(message_id).unwrap());
491
+ assert!(
492
+ fake.received_turns().is_empty(),
493
+ "send returns after persistence; coordinator owns physical delivery"
469
494
  );
470
495
  let _ = std::fs::remove_dir_all(&ws);
471
496
  }
@@ -90,7 +90,7 @@ fn run_dispatches_status_to_handler_returns_ok() {
90
90
 
91
91
  #[test]
92
92
  fn run_dispatches_send_to_handler_returns_ok() {
93
- // run([send w1 hello --sender leader --workspace <seeded>]) -> cmd_send -> messaging::send_message
93
+ // run([send w1 hello --workspace <seeded>]) -> cmd_send -> messaging::send_message
94
94
  // -> ok (w1 is an in-team agent) -> ExitCode::Ok. w1 must be seeded: golden refuses non-team targets.
95
95
  //
96
96
  // OLD seed: flat `{"agents": {"w1": ...}}`.
@@ -112,8 +112,6 @@ fn run_dispatches_send_to_handler_returns_ok() {
112
112
  "send".to_string(),
113
113
  "w1".to_string(),
114
114
  "hello".to_string(),
115
- "--sender".to_string(),
116
- "leader".to_string(),
117
115
  "--workspace".to_string(),
118
116
  ws.to_string_lossy().to_string(),
119
117
  ];
@@ -230,7 +228,7 @@ fn cli_quick_start_invokes_real_lifecycle_compiles_spec() {
230
228
  };
231
229
  let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
232
230
  // E5: spec compiled to .team/runtime/<team_key>/, NOT the user team dir.
233
- let team_key = team.file_name().unwrap().to_string_lossy().to_string();
231
+ let team_key = "clidelegteam";
234
232
  let workspace = crate::model::paths::team_workspace(&team).unwrap();
235
233
  let runtime_spec = crate::model::paths::runtime_spec_path(&workspace, &team_key);
236
234
  assert!(
@@ -460,7 +460,7 @@ fn send_args_fixture() -> SendArgs {
460
460
  workspace: PathBuf::from("."),
461
461
  team: Some("teamA".into()),
462
462
  task: Some("t-1".into()),
463
- sender: "leader".into(),
463
+ sender: TrustedSender::leader(),
464
464
  no_ack: true,
465
465
  no_wait: true,
466
466
  watch_result: true,
@@ -515,7 +515,7 @@ fn send_options_negates_no_ack_and_no_wait_and_carries_watch() {
515
515
  "watch_result flag MUST pass through into SendOptions"
516
516
  );
517
517
  assert!(!opts.confirm_human);
518
- assert_eq!(opts.sender, "leader");
518
+ assert_eq!(opts.sender.as_str(), "leader");
519
519
  assert_eq!(opts.timeout, 12.5);
520
520
  }
521
521
 
@@ -706,6 +706,48 @@ fn cmd_send_failed_outcome_yields_error_exit() {
706
706
  }
707
707
  }
708
708
 
709
+ #[test]
710
+ fn cmd_send_canonicalizes_legacy_session_alias_before_membership_projection() {
711
+ let ws = deleg_uniq_dir("send-team-alias");
712
+ let _ = crate::message_store::MessageStore::open(&ws).unwrap();
713
+ crate::state::persist::save_runtime_state(
714
+ &ws,
715
+ &json!({
716
+ "active_team_key": "teamdir",
717
+ "team_key": "teamdir",
718
+ "teams": {
719
+ "teamdir": {
720
+ "status": "alive",
721
+ "session_name": "team-displayname",
722
+ "agents": {"w1": {"provider": "codex", "status": "running"}}
723
+ }
724
+ }
725
+ }),
726
+ )
727
+ .unwrap();
728
+ let args = SendArgs {
729
+ workspace: ws,
730
+ target: Some("w1".into()),
731
+ team: Some("displayname".into()),
732
+ task: None,
733
+ no_wait: true,
734
+ watch_result: false,
735
+ json: true,
736
+ ..send_args_fixture()
737
+ };
738
+
739
+ let result = cmd_send(&args).expect("alias selection should reach canonical team send");
740
+ let value = match result.output {
741
+ CmdOutput::Json(value) => value,
742
+ other => panic!("expected JSON send outcome, got {other:?}"),
743
+ };
744
+ assert_ne!(
745
+ value.get("reason").and_then(serde_json::Value::as_str),
746
+ Some("target_not_in_team"),
747
+ "selector accepted the alias, so membership must use its canonical team key: {value}"
748
+ );
749
+ }
750
+
709
751
  // ═══════════════════════════════════════════════════════════════════════════
710
752
  // coordinator.ok — non-compact status carries the FULL coordinator_health (incl. `ok`); compact
711
753
  // strips to {status,pid,metadata_ok,schema_ok} (golden queries.py:77 + compact.py:35; ok =
@@ -824,15 +866,8 @@ fn cmd_send_unknown_task_surfaces_golden_error_envelope_not_silent() {
824
866
  let _ = std::fs::remove_dir_all(&ws);
825
867
  }
826
868
 
827
- // P0 (b') — the SWALLOW guard: `run()` (the CLI process entry) MUST RENDER the send error, not
828
- // discard Err(CliError) via unwrap_or (advisor %7 root cause). Proxy: emit_cli_error WRITES a
829
- // `.team/logs/cli-error-*.log` (and prints the compact envelope) — if run() swallowed, neither
830
- // happens. So a cli-error log containing the BARE "unknown task id: <id>" (no "validation:" prefix)
831
- // + ExitCode::Error proves run() rendered. Drives the real argv→(exit,render) path.
832
- // OLD/NEW: same Bug 1/2 seed sync as cmd_send_unknown_task_*; the render-vs-swallow
833
- // behavior under test is unchanged.
834
869
  #[test]
835
- fn run_send_unknown_task_renders_error_not_silent_swallow() {
870
+ fn run_send_legacy_task_flag_is_internalized_before_persistence() {
836
871
  let ws = std::env::temp_dir().join(format!(
837
872
  "ta-run-sendunk-{}-{}",
838
873
  std::process::id(),
@@ -864,30 +899,21 @@ fn run_send_unknown_task_renders_error_not_silent_swallow() {
864
899
  let code = run(&argv, &ws);
865
900
  assert_eq!(
866
901
  code,
867
- ExitCode::Error,
868
- "run(send --task <unknown>) must exit Error, not Ok"
869
- );
870
- // run() must have RENDERED (emit_cli_error wrote the cli-error log); a swallow leaves none.
871
- let logs_dir = ws.join(".team").join("logs");
872
- let mut found = String::new();
873
- if let Ok(entries) = std::fs::read_dir(&logs_dir) {
874
- for entry in entries.flatten() {
875
- let name = entry.file_name().to_string_lossy().to_string();
876
- if name.starts_with("cli-error-") {
877
- found = std::fs::read_to_string(entry.path()).unwrap_or_default();
878
- break;
879
- }
880
- }
881
- }
882
- assert!(
883
- found.contains("unknown task id: t-unknown"),
884
- "run() must RENDER the send error (cli-error log written with the bare message) — a silent \
885
- swallow (unwrap_or discards Err) leaves no log. got log body: {found:?}"
886
- );
887
- assert!(
888
- !found.contains("validation:"),
889
- "rendered error must be the bare golden message, NO 'validation:' prefix; got {found:?}"
902
+ ExitCode::Ok,
903
+ "legacy --task is sunset-noticed and ignored; public send persists without caller binding"
890
904
  );
905
+ let connection = rusqlite::Connection::open(
906
+ ws.join(".team").join("runtime").join("team.db"),
907
+ )
908
+ .unwrap();
909
+ let task_id: Option<String> = connection
910
+ .query_row(
911
+ "SELECT task_id FROM messages WHERE content = 'go' ORDER BY rowid DESC LIMIT 1",
912
+ [],
913
+ |row| row.get(0),
914
+ )
915
+ .unwrap();
916
+ assert_eq!(task_id, None, "caller-supplied task ids must not reach storage");
891
917
  let _ = std::fs::remove_dir_all(&ws);
892
918
  }
893
919
 
@@ -312,7 +312,7 @@ pub struct SendArgs {
312
312
  pub workspace: PathBuf,
313
313
  pub team: Option<String>,
314
314
  pub task: Option<String>,
315
- pub sender: String,
315
+ pub sender: TrustedSender,
316
316
  pub no_ack: bool,
317
317
  pub no_wait: bool,
318
318
  pub watch_result: bool,
@@ -323,16 +323,12 @@ pub struct SendArgs {
323
323
  /// When set, the store insert uses this id verbatim; a repeat with the same
324
324
  /// id returns a `Duplicate` refusal instead of creating a second row.
325
325
  pub message_id: Option<String>,
326
- /// F1 (0.3.26): `--pane <pane_id>` direct pane-id targeting. Mutually
327
- /// exclusive with `target` / `targets`. When set, the message is injected
328
- /// directly into the specified tmux pane via `transport.inject`, bypassing
329
- /// the agent-name → pane-id resolution + team-membership check. This is
330
- /// the **cross-team communication** primitive: the target pane does not
331
- /// need to be in the sender's team.
326
+ /// Deprecated compatibility input. Public send refuses pane identity and
327
+ /// requires a logical recipient so persistence always precedes delivery.
332
328
  pub pane: Option<String>,
333
329
  /// `--to-name <name>` — stable named addressing. Mutually exclusive with
334
330
  /// `target` / `targets` / `--pane`; resolves workspace/team/agent or leader
335
- /// name to the current live pane before using direct pane injection.
331
+ /// name before delegating to the persisted message primitive.
336
332
  pub to_name: Option<String>,
337
333
  /// E7 (0.5.9 host-leader-registry-design): `--to-leader NAME` resolves
338
334
  /// NAME through `~/.team-agent/leaders`, canonical-validates, and then
@@ -3167,10 +3167,18 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3167
3167
  if !open_display {
3168
3168
  override_spec_display_backend(&mut spec, "none");
3169
3169
  }
3170
- let requested_team = quick_start_requested_team_key(team_id, name)
3171
- .map(str::to_string)
3172
- .or_else(|| spec_team_id(&spec));
3173
3170
  let explicit_team_key = quick_start_requested_team_key(team_id, name).map(str::to_string);
3171
+ let canonical_team_key = explicit_team_key
3172
+ .clone()
3173
+ .or_else(|| spec_team_id(&spec).filter(|team| !team.is_empty()))
3174
+ .unwrap_or_else(|| {
3175
+ runtime_team_key_for_spec(
3176
+ &agents_dir.join("team.spec.yaml"),
3177
+ &spec,
3178
+ &spec_session_name(&spec),
3179
+ )
3180
+ });
3181
+ let requested_team = Some(canonical_team_key.clone());
3174
3182
  let team_depth = quick_start_depth_guard(
3175
3183
  &workspace,
3176
3184
  agents_dir,
@@ -3248,14 +3256,12 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3248
3256
  // spec's runtime.session_name with one derived from the REQUESTED team identity
3249
3257
  // so launch_with_transport (which reads runtime.session_name) spawns into an
3250
3258
  // isolated session per requested team.
3251
- if let Some(requested) = requested_team.as_deref() {
3259
+ if let Some(requested) = explicit_team_key.as_deref() {
3252
3260
  override_spec_session_name(&mut spec, &format!("team-{requested}"));
3253
3261
  }
3254
3262
  let session_name = spec_session_name(&spec);
3255
- // team_key 身份源 = team_dir(agents_dir).name(角色定义目录),不依赖 spec 落点。
3256
- let state_team_key = explicit_team_key.clone().unwrap_or_else(|| {
3257
- runtime_team_key_for_spec(&agents_dir.join("team.spec.yaml"), &spec, &session_name)
3258
- });
3263
+ // team_key 已在入口由显式 id/name 或 compiled spec identity 单次确定。
3264
+ let state_team_key = canonical_team_key;
3259
3265
  warn_ignored_owner_team_id(workspace.as_path(), agents_dir, &state_team_key);
3260
3266
  // E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
3261
3267
  // Bug2:原子写(tmp+rename),避免半截 spec。
@@ -3265,7 +3271,13 @@ pub fn quick_start_with_transport_in_workspace_with_display(
3265
3271
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
3266
3272
  let resolved_spec_path =
3267
3273
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
3268
- let mut state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
3274
+ let mut state = initial_runtime_state(
3275
+ &spec,
3276
+ &resolved_spec_path,
3277
+ &workspace,
3278
+ agents_dir,
3279
+ &state_team_key,
3280
+ );
3269
3281
  // 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
3270
3282
  // generic annotator that writes `state.transport = { kind, source }`
3271
3283
  // for every backend AND (for tmux) preserves the existing
@@ -5104,6 +5116,7 @@ fn initial_runtime_state(
5104
5116
  spec_path: &Path,
5105
5117
  workspace: &Path,
5106
5118
  team_dir: &Path,
5119
+ team_key: &str,
5107
5120
  ) -> serde_json::Value {
5108
5121
  let mut agents = serde_json::Map::new();
5109
5122
  for agent in spec_agent_values(spec) {
@@ -5145,6 +5158,7 @@ fn initial_runtime_state(
5145
5158
  "team_dir".to_string(),
5146
5159
  serde_json::json!(team_dir.to_string_lossy().to_string()),
5147
5160
  );
5161
+ state.insert("team_key".to_string(), serde_json::json!(team_key));
5148
5162
  state.insert(
5149
5163
  "session_name".to_string(),
5150
5164
  serde_json::json!(spec_session_name(spec).as_str()),
@@ -80,11 +80,10 @@ fn layout_pane(
80
80
  }
81
81
 
82
82
  /// E5 spec 迁移:spec 不再落 team_dir,而在 <team_workspace>/.team/runtime/<team_key>/。
83
- /// team_key = team_dir.name(quick_start_team_dir "teamdir");workspace = team_workspace(team)。
83
+ /// quick-start 未显式指定 id 时,team_key 来自 compiled TEAM.md name(`quickteam`)。
84
84
  pub(super) fn quick_start_runtime_spec_path(team: &std::path::Path) -> PathBuf {
85
85
  let workspace = crate::model::paths::team_workspace(team).unwrap();
86
- let team_key = team.file_name().unwrap().to_string_lossy().to_string();
87
- crate::model::paths::runtime_spec_path(&workspace, &team_key)
86
+ crate::model::paths::runtime_spec_path(&workspace, "quickteam")
88
87
  }
89
88
 
90
89
  /// 在 team 自身或其 team_workspace 父下找 .team/runtime/*/team.spec.yaml。run_workspace 解析
@@ -172,6 +171,26 @@ fn quick_start_compiles_real_spec_to_team_spec_yaml() {
172
171
  }
173
172
  }
174
173
 
174
+ #[test]
175
+ fn quick_start_without_team_id_uses_compiled_team_name_as_canonical_key() {
176
+ let team = quick_start_team_dir(QS_VALID_ROLE);
177
+ let workspace = crate::model::paths::team_workspace(&team).unwrap();
178
+ let transport = OfflineTransport::new();
179
+
180
+ let result = quick_start_with_transport(&team, None, true, None, &transport);
181
+ assert!(result.is_ok(), "quick-start fixture must run: {result:?}");
182
+
183
+ let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
184
+ assert_eq!(state["active_team_key"], json!("quickteam"));
185
+ assert_eq!(state["team_key"], json!("quickteam"));
186
+ assert!(state["teams"].get("quickteam").is_some(), "{state}");
187
+ assert!(state["teams"].get("teamdir").is_none(), "{state}");
188
+ assert!(
189
+ crate::model::paths::runtime_spec_path(&workspace, "quickteam").exists(),
190
+ "runtime spec must share the canonical key"
191
+ );
192
+ }
193
+
175
194
  #[test]
176
195
  fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_collect() {
177
196
  let workspace = temp_ws();
@@ -215,8 +234,8 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
215
234
  "input={}",
216
235
  input.display()
217
236
  );
218
- // E5: selector resolves spec_path to .team/runtime/<team_key>/ (team_key="current").
219
- let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "current");
237
+ // E5: selector resolves spec_path to .team/runtime/<canonical team_key>/.
238
+ let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "quickteam");
220
239
  assert_eq!(
221
240
  selected
222
241
  .spec_path
@@ -1319,7 +1338,7 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
1319
1338
  assert_eq!(state["tmux_socket"], json!(endpoint));
1320
1339
  assert_eq!(state["is_external_leader"], json!(false));
1321
1340
  assert_eq!(
1322
- state["teams"]["teamdir"]["is_external_leader"],
1341
+ state["teams"]["quickteam"]["is_external_leader"],
1323
1342
  json!(false)
1324
1343
  );
1325
1344
  }
@@ -2876,7 +2895,7 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
2876
2895
  }
2877
2896
 
2878
2897
  // Stage A — golden launch/core.py:62-71 seeded top-level runtime state in insertion order:
2879
- // spec_path, workspace, team_dir, session_name, leader, agents, tasks, display_backend.
2898
+ // spec_path, workspace, team_dir, team_key, session_name, leader, agents, tasks, display_backend.
2880
2899
  //
2881
2900
  // OLD (Python parity): the top-level shape ended at `display_backend`.
2882
2901
  // NEW (Bug 1/2 — team-in-team state scope, see tests/team_in_team_state_scope_red.rs):
@@ -2889,9 +2908,9 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
2889
2908
  // The post-launch hook drops the top-level owner triple when the launched pane is
2890
2909
  // unbound (empty pane_id), and only the per-team entry retains the binding.
2891
2910
  // R1: topology is explicit; fresh managed teams carry `is_external_leader=false`
2892
- // before the team-in-team suffix. Order remains the golden prefix
2893
- // (spec_path display_backend) + topology marker + new suffix (active_team_key,
2894
- // teams). display_backend stays the resolved backend from display/backend.py:12-29
2911
+ // before the team-in-team suffix. Canonical `team_key` is seeded before owner
2912
+ // binding; the remaining order stays the golden prefix + topology marker + new
2913
+ // suffix (active_team_key, teams). display_backend stays the resolved backend from display/backend.py:12-29
2895
2914
  // (default adaptive), not the raw optional spec field.
2896
2915
  #[test]
2897
2916
  #[serial(env)]
@@ -2924,6 +2943,9 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
2924
2943
  "spec_path",
2925
2944
  "workspace",
2926
2945
  "team_dir",
2946
+ // Seed canonical identity before owner binding so the initial
2947
+ // receiver is written under the same team key as the saved state.
2948
+ "team_key",
2927
2949
  "session_name",
2928
2950
  "leader",
2929
2951
  "agents",
@@ -2937,17 +2959,12 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
2937
2959
  // field); source is `"unknown"` until Phase 2 threads
2938
2960
  // `ResolvedTransport.source` through the launch call site.
2939
2961
  "transport",
2940
- // 0.4.0 refactor: `team_key` added as top-level topology marker
2941
- // alongside active_team_key (refactor-modular-architecture). The
2942
- // owner / leader_receiver / owner_epoch invariants below still
2943
- // hold — those live only under teams[<active_team_key>].
2944
- "team_key",
2945
2962
  "active_team_key",
2946
2963
  "teams",
2947
2964
  ],
2948
2965
  "state.json top-level key order must match golden launch/core.py:62-71 \
2949
2966
  plus R1 topology marker and Bug 1/2 team-in-team suffix \
2950
- (is_external_leader, team_key, active_team_key, teams). \
2967
+ (is_external_leader, active_team_key, teams). \
2951
2968
  Bug 2 owner team-scope (N1/N12/N18/N29) deliberately keeps owner / \
2952
2969
  leader_receiver / owner_epoch OFF the top level — they live ONLY under \
2953
2970
  teams[<active_team_key>] so per-team isolation has a single source of \
@@ -451,7 +451,7 @@ fn team_dir_with_roles(role_docs: &[(&str, &str)]) -> PathBuf {
451
451
  std::fs::create_dir_all(team.join("agents")).unwrap();
452
452
  std::fs::write(
453
453
  team.join("TEAM.md"),
454
- "---\nname: phaseb\nobjective: Phase B.\nprovider: codex\n---\n\nPhase B team.\n",
454
+ "---\nname: teamdir\nobjective: Phase B.\nprovider: codex\nsession_name: team-phaseb\n---\n\nPhase B team.\n",
455
455
  )
456
456
  .unwrap();
457
457
  for (file, role_doc) in role_docs {
@@ -536,7 +536,7 @@ fn add_fixture() -> (PathBuf, PathBuf, PathBuf, OfflineTransport) {
536
536
  quick_start_with_transport_in_workspace_with_display(
537
537
  &workspace,
538
538
  &team,
539
- None,
539
+ Some("teamdir"),
540
540
  true,
541
541
  None,
542
542
  &launch_transport,
@@ -259,7 +259,7 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
259
259
  workspace: workspace.clone(),
260
260
  team: Some(spec.team_key.to_string()),
261
261
  task: None,
262
- sender: "leader".to_string(),
262
+ sender: crate::messaging::TrustedSender::leader(),
263
263
  no_ack: true,
264
264
  no_wait: true,
265
265
  watch_result: false,
@@ -367,7 +367,7 @@ fn two_worker_team_dir(hermetic: &HermeticTestEnv) -> PathBuf {
367
367
  std::fs::create_dir_all(team.join("agents")).unwrap();
368
368
  std::fs::write(
369
369
  team.join("TEAM.md"),
370
- "---\nname: phasegolden\nobjective: Phase golden.\nprovider: codex\n---\n\nPhase golden team.\n",
370
+ "---\nname: teamdir\nobjective: Phase golden.\nprovider: codex\nsession_name: team-phasegolden\n---\n\nPhase golden team.\n",
371
371
  )
372
372
  .unwrap();
373
373
  for id in ["w1", "w2"] {
@@ -88,7 +88,7 @@ use crate::message_store::MessageStore;
88
88
  use crate::state::persist::{load_runtime_state, save_runtime_state};
89
89
 
90
90
  // ── REUSE: step 11 messaging delegate surface ───────────────────────────────
91
- use crate::messaging::{self, DeliveryOutcome, MessageTarget, SendOptions};
91
+ use crate::messaging::{self, DeliveryOutcome, MessageTarget, SendOptions, TrustedSender};
92
92
 
93
93
  pub mod helpers;
94
94
  pub(crate) mod lifecycle_tools;
@@ -155,7 +155,6 @@ fn send_message_worker_recipient_returns_accepted_with_poll_hint() {
155
155
  None,
156
156
  None,
157
157
  None,
158
- None,
159
158
  );
160
159
  match outcome {
161
160
  Ok(SendOutcome::WorkerAccepted {
@@ -185,7 +184,6 @@ fn ordinary_send_assign_shape_has_no_recovery_marker() {
185
184
  None,
186
185
  None,
187
186
  None,
188
- None,
189
187
  )
190
188
  .expect("ordinary send ok")
191
189
  .to_value();
@@ -240,6 +238,22 @@ fn ordinary_send_assign_shape_has_no_recovery_marker() {
240
238
  );
241
239
  }
242
240
 
241
+ #[test]
242
+ fn send_message_without_framework_identity_fails_closed() {
243
+ let ws = seed_current_worker_state("missing-sender-identity");
244
+ let tools = TeamOrchestratorTools::with_identity(&ws, None, Some(TeamKey::new("current")));
245
+ let error = tools
246
+ .send_message(
247
+ &MessageTarget::Single("worker-1".to_string()),
248
+ "must not be attributed to unknown",
249
+ None,
250
+ None,
251
+ None,
252
+ )
253
+ .expect_err("missing framework identity must fail before persistence");
254
+ assert_eq!(error.reason, ToolErrorReason::McpScopeRefused);
255
+ }
256
+
243
257
  #[test]
244
258
  fn recovery_assign_shape_has_structured_marker() {
245
259
  let ws = seed_current_worker_state("recovery-marker");
@@ -304,7 +318,6 @@ fn send_message_worker_recipient_surfaces_dead_coordinator_warning() {
304
318
  None,
305
319
  None,
306
320
  None,
307
- None,
308
321
  )
309
322
  .expect("send returns degraded warning, not an MCP error");
310
323
  let v = outcome.to_value();
@@ -336,7 +349,6 @@ fn send_message_leader_recipient_is_direct_not_accepted() {
336
349
  None,
337
350
  None,
338
351
  None,
339
- None,
340
352
  )
341
353
  .expect("leader send ok");
342
354
  assert!(
@@ -435,7 +447,6 @@ fn send_message_cross_team_peer_surfaces_peer_not_in_scope_error() {
435
447
  None,
436
448
  None,
437
449
  None,
438
- None,
439
450
  )
440
451
  .expect_err("out-of-scope peer must be refused");
441
452
  assert_eq!(err.reason, ToolErrorReason::PeerNotInScope);
@@ -66,6 +66,12 @@
66
66
  );
67
67
  assert_eq!(send["inputSchema"]["additionalProperties"], json!(false));
68
68
  assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));
69
+ for internal in ["sender", "task_id", "requires_ack"] {
70
+ assert!(
71
+ send["inputSchema"]["properties"].get(internal).is_none(),
72
+ "{internal} is framework-owned, not caller-supplied"
73
+ );
74
+ }
69
75
  }
70
76
 
71
77
  #[test]
@@ -17,7 +17,7 @@ use crate::state::persist::{
17
17
  };
18
18
 
19
19
  // ── REUSE: step 11 messaging delegate surface ───────────────────────────────
20
- use crate::messaging::{self, MessageTarget, SendOptions};
20
+ use crate::messaging::{self, MessageTarget, SendOptions, TrustedSender};
21
21
 
22
22
  use super::helpers::{
23
23
  current_reportable_message_for, delivery_outcome_value, direct_message_attribution_for,
@@ -140,7 +140,6 @@ impl TeamOrchestratorTools {
140
140
  Some(task_id),
141
141
  None,
142
142
  None,
143
- None,
144
143
  )?;
145
144
  let mut ok = compact_tool_result(&out.to_value())?;
146
145
  if recovery {
@@ -155,7 +154,7 @@ impl TeamOrchestratorTools {
155
154
  }
156
155
 
157
156
  /// `send_message` (`tools.py:135-183`): C14/C15/C17 scope resolution.
158
- /// - sender = explicit / `TEAM_AGENT_ID` env / `"unknown"` (no candidate scan).
157
+ /// - sender = immutable `TEAM_AGENT_ID` captured when the MCP server starts.
159
158
  /// - `requires_ack` defaults from target (`_requires_ack_for_target`).
160
159
  /// - C23 cross-team pre-refusal ([`Self::refuse_cross_team_peer`]) before any
161
160
  /// runtime call.
@@ -168,7 +167,6 @@ impl TeamOrchestratorTools {
168
167
  to: &MessageTarget,
169
168
  content: &str,
170
169
  task_id: Option<&str>,
171
- sender: Option<&str>,
172
170
  requires_ack: Option<bool>,
173
171
  scope_override: Option<Scope>,
174
172
  ) -> Result<SendOutcome, ToolError> {
@@ -183,10 +181,14 @@ impl TeamOrchestratorTools {
183
181
  if let Some(err) = self.refuse_cross_team_peer(to, None) {
184
182
  return Err(err);
185
183
  }
186
- let sender = sender
187
- .and_then(non_empty_string)
188
- .or_else(|| self.agent_id.as_ref().map(AgentId::as_str))
189
- .unwrap_or("unknown");
184
+ let sender = self.agent_id.clone().ok_or_else(|| {
185
+ ToolError::new(
186
+ ToolErrorReason::McpScopeRefused,
187
+ "send_message requires framework-injected TEAM_AGENT_ID",
188
+ "IdentityError",
189
+ )
190
+ })?;
191
+ let sender = TrustedSender::from_runtime_identity(sender);
190
192
  let ack = requires_ack.unwrap_or_else(|| requires_ack_for_target(to));
191
193
  // C14/C15/C17 scope audit (#230 I-2/I-6 contract): emit mcp.scope_resolved
192
194
  // for every worker-origin send before any routing/delivery — the funnel
@@ -197,7 +199,7 @@ impl TeamOrchestratorTools {
197
199
  "mcp.scope_resolved",
198
200
  serde_json::json!({
199
201
  "tool": "send_message",
200
- "sender": sender,
202
+ "sender": sender.as_str(),
201
203
  "owner_team_id": canonical_owner_team.as_ref().map(TeamKey::as_str),
202
204
  "to": match to {
203
205
  MessageTarget::Single(t) => serde_json::Value::String(t.clone()),
@@ -213,7 +215,7 @@ impl TeamOrchestratorTools {
213
215
  let opts = SendOptions {
214
216
  task_id: task_id.map(TaskId::new),
215
217
  route_task_id: true,
216
- sender: sender.to_string(),
218
+ sender,
217
219
  requires_ack: ack,
218
220
  team: canonical_owner_team,
219
221
  ..SendOptions::default()