@team-agent/installer 0.5.53 → 0.5.55

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 (40) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +94 -3
  4. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  5. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  6. package/crates/team-agent/src/cli/send.rs +1 -0
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  12. package/crates/team-agent/src/cli/types.rs +1 -0
  13. package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
  14. package/crates/team-agent/src/db/message_store.rs +31 -2
  15. package/crates/team-agent/src/db/migration.rs +7 -6
  16. package/crates/team-agent/src/db/schema.rs +18 -5
  17. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  18. package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
  19. package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
  20. package/crates/team-agent/src/mcp_server/tools.rs +71 -0
  21. package/crates/team-agent/src/mcp_server/types.rs +4 -0
  22. package/crates/team-agent/src/mcp_server/wire.rs +42 -4
  23. package/crates/team-agent/src/messaging/delivery.rs +2 -0
  24. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  25. package/crates/team-agent/src/messaging/leader_receiver.rs +32 -0
  26. package/crates/team-agent/src/messaging/mod.rs +2 -1
  27. package/crates/team-agent/src/messaging/persist.rs +68 -2
  28. package/crates/team-agent/src/messaging/presentation.rs +307 -0
  29. package/crates/team-agent/src/messaging/results.rs +130 -3
  30. package/crates/team-agent/src/messaging/selftest.rs +1 -0
  31. package/crates/team-agent/src/messaging/send.rs +54 -2
  32. package/crates/team-agent/src/messaging/tests/runtime.rs +117 -1
  33. package/crates/team-agent/src/messaging/types.rs +2 -0
  34. package/crates/team-agent/src/messaging/watchers.rs +13 -7
  35. package/crates/team-agent/src/provider/session/capture.rs +74 -0
  36. package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
  37. package/crates/team-agent/src/provider/session_scan/common.rs +14 -86
  38. package/package.json +4 -4
  39. package/schemas/result-envelope.schema.json +10 -0
  40. package/skills/team-agent/SKILL.md +3 -0
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.53"
578
+ version = "0.5.55"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.53"
12
+ version = "0.5.55"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -317,8 +317,11 @@ fn command_help(command: Option<&str>) -> String {
317
317
  Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
318
318
  Some("send") => concat!(
319
319
  "usage: team-agent send TO MESSAGE... ",
320
- "[--workspace WORKSPACE] [--team TEAM] [--json]\n\n",
321
- "TO is a logical recipient; send returns after the message is persisted."
320
+ "[--workspace WORKSPACE] [--team TEAM] ",
321
+ "[--presentation-sink leader|casefile|silent --message-class CLASS [--case-id CASE]] ",
322
+ "[--json]\n\n",
323
+ "TO is a logical recipient; send returns after the message is persisted. ",
324
+ "Presentation changes live display only; every sink remains durable."
322
325
  )
323
326
  .to_string(),
324
327
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
@@ -775,6 +778,9 @@ struct ParsedArgs {
775
778
  socket: Option<String>,
776
779
  thread_id: Option<String>,
777
780
  message_id: Option<String>,
781
+ presentation_sink: Option<String>,
782
+ message_class: Option<String>,
783
+ case_id: Option<String>,
778
784
  content: Option<String>,
779
785
  primary_error: Option<String>,
780
786
  agent_id: Option<String>,
@@ -865,6 +871,9 @@ fn parse_args(args: &[String]) -> ParsedArgs {
865
871
  "--socket" => parsed.socket = next_arg(args, &mut i),
866
872
  "--thread-id" => parsed.thread_id = next_arg(args, &mut i),
867
873
  "--message-id" => parsed.message_id = next_arg(args, &mut i),
874
+ "--presentation-sink" => parsed.presentation_sink = next_arg(args, &mut i),
875
+ "--message-class" => parsed.message_class = next_arg(args, &mut i),
876
+ "--case-id" => parsed.case_id = next_arg(args, &mut i),
868
877
  "--content" => parsed.content = next_arg(args, &mut i),
869
878
  "--primary-error" => parsed.primary_error = next_arg(args, &mut i),
870
879
  "--result-json" => parsed.result_json = next_arg(args, &mut i),
@@ -881,6 +890,17 @@ fn parse_args(args: &[String]) -> ParsedArgs {
881
890
  other if other.starts_with("--to-leader=") => {
882
891
  parsed.to_leader = Some(other.trim_start_matches("--to-leader=").to_string());
883
892
  }
893
+ other if other.starts_with("--presentation-sink=") => {
894
+ parsed.presentation_sink =
895
+ Some(other.trim_start_matches("--presentation-sink=").to_string());
896
+ }
897
+ other if other.starts_with("--message-class=") => {
898
+ parsed.message_class =
899
+ Some(other.trim_start_matches("--message-class=").to_string());
900
+ }
901
+ other if other.starts_with("--case-id=") => {
902
+ parsed.case_id = Some(other.trim_start_matches("--case-id=").to_string());
903
+ }
884
904
  other if other.starts_with("--provider=") => {
885
905
  parsed.provider = Some(other.trim_start_matches("--provider=").to_string());
886
906
  }
@@ -1029,6 +1049,23 @@ fn send_args(args: &[String], cwd: &Path) -> Result<SendArgs, CliError> {
1029
1049
  };
1030
1050
  let message_start = usize::from(target.is_some());
1031
1051
  let workspace = workspace(&parsed, cwd);
1052
+ let presentation_value = if parsed.presentation_sink.is_some()
1053
+ || parsed.message_class.is_some()
1054
+ || parsed.case_id.is_some()
1055
+ {
1056
+ Some(serde_json::json!({
1057
+ "sink": parsed.presentation_sink.clone(),
1058
+ "class": parsed.message_class.clone(),
1059
+ "case_id": parsed.case_id.clone(),
1060
+ }))
1061
+ } else {
1062
+ None
1063
+ };
1064
+ let (presentation, presentation_error) =
1065
+ crate::messaging::presentation::normalize_presentation(presentation_value.as_ref());
1066
+ if let Some(error) = presentation_error {
1067
+ return Err(CliError::Usage(format!("invalid presentation: {error}")));
1068
+ }
1032
1069
  Ok(SendArgs {
1033
1070
  target,
1034
1071
  message: parsed
@@ -1049,6 +1086,7 @@ fn send_args(args: &[String], cwd: &Path) -> Result<SendArgs, CliError> {
1049
1086
  confirm_human: false,
1050
1087
  json: parsed.json,
1051
1088
  message_id: None,
1089
+ presentation,
1052
1090
  pane: parsed.pane.clone(),
1053
1091
  to_name: parsed.to_name.clone(),
1054
1092
  to_leader: parsed.to_leader.clone(),
@@ -1111,11 +1149,22 @@ fn validate_send_flags(args: &[String]) -> Result<(), CliError> {
1111
1149
  "--timeout",
1112
1150
  "--confirm-human",
1113
1151
  "--message-id",
1152
+ "--presentation-sink",
1153
+ "--message-class",
1154
+ "--case-id",
1114
1155
  "--json",
1115
1156
  "-h",
1116
1157
  "--help",
1117
1158
  ];
1118
- const ALLOWED_PREFIXES: &[&str] = &["--team=", "--pane=", "--to-name=", "--to-leader="];
1159
+ const ALLOWED_PREFIXES: &[&str] = &[
1160
+ "--team=",
1161
+ "--pane=",
1162
+ "--to-name=",
1163
+ "--to-leader=",
1164
+ "--presentation-sink=",
1165
+ "--message-class=",
1166
+ "--case-id=",
1167
+ ];
1119
1168
  if let Some(flag) = args.iter().find(|arg| {
1120
1169
  arg.starts_with('-')
1121
1170
  && !ALLOWED.contains(&arg.as_str())
@@ -2015,6 +2064,48 @@ mod tests {
2015
2064
  let _ = std::fs::remove_dir_all(&cwd);
2016
2065
  }
2017
2066
 
2067
+ #[test]
2068
+ fn send_presentation_flags_map_to_the_typed_request() {
2069
+ let cwd = tmp_workspace();
2070
+ let args = send_args(
2071
+ &cli_argv(&[
2072
+ "leader",
2073
+ "progress",
2074
+ "--presentation-sink",
2075
+ "casefile",
2076
+ "--message-class",
2077
+ "progress",
2078
+ "--case-id",
2079
+ "case-9",
2080
+ ]),
2081
+ &cwd,
2082
+ )
2083
+ .unwrap();
2084
+ assert_eq!(
2085
+ args.presentation.sink,
2086
+ crate::messaging::presentation::PresentationSink::Casefile
2087
+ );
2088
+ assert_eq!(
2089
+ args.presentation.class,
2090
+ crate::messaging::presentation::PresentationClass::Progress
2091
+ );
2092
+ assert_eq!(args.presentation.case_id.as_deref(), Some("case-9"));
2093
+ }
2094
+
2095
+ #[test]
2096
+ fn send_presentation_flags_fail_closed_when_incomplete() {
2097
+ let cwd = tmp_workspace();
2098
+ let error = send_args(
2099
+ &cli_argv(&["leader", "progress", "--presentation-sink", "casefile"]),
2100
+ &cwd,
2101
+ )
2102
+ .unwrap_err();
2103
+ assert!(matches!(
2104
+ error,
2105
+ CliError::Usage(message) if message == "invalid presentation: missing_class"
2106
+ ));
2107
+ }
2108
+
2018
2109
  #[test]
2019
2110
  fn send_to_name_positionals_are_message_not_target() {
2020
2111
  let cwd = tmp_workspace();
@@ -25,6 +25,7 @@ pub fn send_options_from_args(args: &SendArgs) -> SendOptions {
25
25
  watch_result: args.watch_result,
26
26
  team: args.team.as_ref().map(|s| TeamKey::new(s.clone())),
27
27
  message_id: args.message_id.clone(),
28
+ presentation: args.presentation.clone(),
28
29
  ..SendOptions::default()
29
30
  }
30
31
  }
@@ -285,6 +285,7 @@ pub(super) fn first_target(target: &MessageTarget) -> String {
285
285
  pub(super) fn delivery_status_wire(status: DeliveryStatus) -> &'static str {
286
286
  match status {
287
287
  DeliveryStatus::Delivered => "delivered",
288
+ DeliveryStatus::StoredOnly => "stored_only",
288
289
  DeliveryStatus::Failed => "failed",
289
290
  DeliveryStatus::Queued => "queued",
290
291
  DeliveryStatus::Blocked => "blocked",
@@ -169,6 +169,7 @@ pub fn send_to_canonical_leader_target(
169
169
  confirm_human: false,
170
170
  json: true,
171
171
  message_id: None,
172
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
172
173
  pane: None,
173
174
  to_name: None,
174
175
  to_leader: None,
@@ -155,7 +155,7 @@ pub(crate) struct CommandSpec {
155
155
  #[rustfmt::skip]
156
156
  pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
157
157
  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 },
158
- 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 },
158
+ 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] [--presentation-sink leader|casefile|silent --message-class CLASS [--case-id CASE]] [--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 },
159
159
  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 },
160
160
  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 },
161
161
  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 },
@@ -361,7 +361,7 @@ fn cleanup_orphans_dryrun_golden_envelope() {
361
361
  // ── fix_schema: golden fix_schema_layout diagnosis envelope (schema_migration.py:258) ─────────────
362
362
  // {ok,status,db_path,schema_version,user_version,layout_diffs,recommended_action,would_backup_path,
363
363
  // fixed,rebuilds}. RUST mod.rs:538-540 stub {ok,fixed:false}. db_path/would_backup_path are
364
- // path/clock-derived; lock the deterministic fields. SCHEMA_VERSION==3. RED. ─────────────────────
364
+ // path/clock-derived; lock the deterministic fields against the canonical SCHEMA_VERSION. ────────
365
365
  #[test]
366
366
  fn fix_schema_golden_layout_diagnosis_envelope() {
367
367
  let ws = tmp_workspace();
@@ -370,8 +370,8 @@ fn fix_schema_golden_layout_diagnosis_envelope() {
370
370
  assert_eq!(v["status"], json!("missing"), "missing db: status missing");
371
371
  assert_eq!(
372
372
  v["schema_version"],
373
- json!(3),
374
- "golden schema_version == SCHEMA_VERSION (3)"
373
+ json!(crate::db::schema::SCHEMA_VERSION),
374
+ "golden schema_version == canonical SCHEMA_VERSION"
375
375
  );
376
376
  assert_eq!(v["user_version"], json!(0), "missing db user_version == 0");
377
377
  assert_eq!(
@@ -49,6 +49,7 @@ fn cli_send_persists_real_message_row() {
49
49
  confirm_human: false,
50
50
  json: true,
51
51
  message_id: None,
52
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
52
53
  pane: None,
53
54
  to_name: None,
54
55
  to_leader: None,
@@ -157,6 +157,7 @@ fn named_send_args(
157
157
  confirm_human: false,
158
158
  json: true,
159
159
  message_id: None,
160
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
160
161
  pane: pane.map(str::to_string),
161
162
  to_name: to_name.map(str::to_string),
162
163
  to_leader: None,
@@ -468,6 +468,7 @@ fn send_args_fixture() -> SendArgs {
468
468
  confirm_human: false,
469
469
  json: false,
470
470
  message_id: None,
471
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
471
472
  pane: None,
472
473
  to_name: None,
473
474
  to_leader: None,
@@ -323,6 +323,7 @@ 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
+ pub presentation: crate::messaging::presentation::PresentationRequest,
326
327
  /// Deprecated compatibility input. Public send refuses pane identity and
327
328
  /// requires a logical recipient so persistence always precedes delivery.
328
329
  pub pane: Option<String>,
@@ -365,16 +365,16 @@ fn orphan_self_terminate_false_when_workspace_exists() {
365
365
 
366
366
  #[test]
367
367
  fn metadata_ok_requires_all_three_to_match() {
368
- // metadata.py:37-43 — pid protocol_version==2 schema_version==3.
369
- let good = meta(555, PROTOCOL_VERSION, GOLDEN_SCHEMA_VERSION);
368
+ // metadata.py:37-43 — pid, protocol version, and current message schema all match.
369
+ let good = meta(555, PROTOCOL_VERSION, crate::db::schema::SCHEMA_VERSION);
370
370
  assert!(coordinator_metadata_ok(Some(&good), Pid(555)));
371
371
  // pid 不符 → false。
372
372
  assert!(!coordinator_metadata_ok(Some(&good), Pid(999)));
373
373
  // protocol_version 不符(bump 触发 restart_incompatible)→ false。
374
- let bad_proto = meta(555, 1, GOLDEN_SCHEMA_VERSION);
374
+ let bad_proto = meta(555, 1, crate::db::schema::SCHEMA_VERSION);
375
375
  assert!(!coordinator_metadata_ok(Some(&bad_proto), Pid(555)));
376
376
  // schema_version 不符 → false(不可静默继续旧 schema 写库,card §89)。
377
- let bad_schema = meta(555, PROTOCOL_VERSION, 2);
377
+ let bad_schema = meta(555, PROTOCOL_VERSION, crate::db::schema::SCHEMA_VERSION - 1);
378
378
  assert!(!coordinator_metadata_ok(Some(&bad_schema), Pid(555)));
379
379
  }
380
380
 
@@ -85,6 +85,7 @@ pub struct NotificationClaimParams<'a> {
85
85
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
86
86
  pub enum MessageRowStatus {
87
87
  Accepted,
88
+ StoredOnly,
88
89
  QueuedUntilLeaderAttach,
89
90
  QueuedCoordinatorUnavailable,
90
91
  }
@@ -93,6 +94,7 @@ impl MessageRowStatus {
93
94
  pub const fn as_str(self) -> &'static str {
94
95
  match self {
95
96
  Self::Accepted => "accepted",
97
+ Self::StoredOnly => "stored_only",
96
98
  Self::QueuedUntilLeaderAttach => "queued_until_leader_attach",
97
99
  Self::QueuedCoordinatorUnavailable => "queued_coordinator_unavailable",
98
100
  }
@@ -111,6 +113,7 @@ pub struct PersistMessageInput<'a> {
111
113
  pub requires_ack: bool,
112
114
  pub status: MessageRowStatus,
113
115
  pub content: &'a str,
116
+ pub presentation: &'a str,
114
117
  pub error: Option<&'a str>,
115
118
  }
116
119
 
@@ -182,9 +185,9 @@ impl MessageStore {
182
185
  conn.execute(
183
186
  "insert into messages(
184
187
  message_id, owner_team_id, task_id, sender, recipient, reply_to, requires_ack,
185
- status, content, artifact_refs, created_at, updated_at, delivered_at,
188
+ status, content, presentation, artifact_refs, created_at, updated_at, delivered_at,
186
189
  acknowledged_at, error, delivery_attempts
187
- ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, '[]', ?10, ?10, null, null, ?11, 0)",
190
+ ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, '[]', ?11, ?11, null, null, ?12, 0)",
188
191
  params![
189
192
  message_id,
190
193
  input.owner_team_id,
@@ -195,6 +198,7 @@ impl MessageStore {
195
198
  if input.requires_ack { 1 } else { 0 },
196
199
  input.status.as_str(),
197
200
  input.content,
201
+ input.presentation,
198
202
  now,
199
203
  input.error,
200
204
  ],
@@ -226,6 +230,7 @@ impl MessageStore {
226
230
  requires_ack,
227
231
  status: MessageRowStatus::Accepted,
228
232
  content,
233
+ presentation: r#"{"sink":"leader","class":"message"}"#,
229
234
  error: None,
230
235
  })
231
236
  }
@@ -259,6 +264,7 @@ impl MessageStore {
259
264
  requires_ack,
260
265
  status: MessageRowStatus::Accepted,
261
266
  content,
267
+ presentation: r#"{"sink":"leader","class":"message"}"#,
262
268
  error: None,
263
269
  })
264
270
  }
@@ -809,6 +815,29 @@ mod tests {
809
815
  assert_eq!(status_of(&read(&s), &mid), "failed");
810
816
  }
811
817
 
818
+ #[test]
819
+ fn claim_for_delivery_never_claims_stored_only_presentation() {
820
+ let s = store();
821
+ let mid = s
822
+ .persist_message(PersistMessageInput {
823
+ message_id: None,
824
+ owner_team_id: Some("team-a"),
825
+ task_id: None,
826
+ sender: "worker",
827
+ recipient: "leader",
828
+ reply_to: None,
829
+ requires_ack: false,
830
+ status: MessageRowStatus::StoredOnly,
831
+ content: "casefile evidence",
832
+ presentation: r#"{"sink":"casefile","class":"stage_result"}"#,
833
+ error: None,
834
+ })
835
+ .unwrap();
836
+ assert!(!s.claim_for_delivery(&mid).unwrap());
837
+ assert_eq!(status_of(&read(&s), &mid), "stored_only");
838
+ assert_eq!(col_i64(&read(&s), &mid, "delivery_attempts"), 0);
839
+ }
840
+
812
841
  // ───────────────────────────── mark state machine ─────────────────────────────
813
842
 
814
843
  #[test]
@@ -26,6 +26,7 @@ pub const MANAGED_TABLE_LAYOUTS: &[(&str, &[&str])] = &[
26
26
  "requires_ack",
27
27
  "status",
28
28
  "content",
29
+ "presentation",
29
30
  "artifact_refs",
30
31
  "created_at",
31
32
  "updated_at",
@@ -121,7 +122,7 @@ pub const MANAGED_TABLE_LAYOUTS: &[(&str, &[&str])] = &[
121
122
 
122
123
  /// rebuild / 建缺表用的 DDL 模板(`schema_migration.py:CREATE_TABLE_SQL`,`__TABLE__` 占位)。
123
124
  const CREATE_TABLE_TEMPLATES: &[(&str, &str)] = &[
124
- ("messages", "create table if not exists __TABLE__ (\n message_id text primary key,\n owner_team_id text,\n task_id text,\n sender text,\n recipient text,\n reply_to text,\n requires_ack integer,\n status text,\n content text,\n artifact_refs text,\n created_at text,\n updated_at text,\n delivered_at text,\n acknowledged_at text,\n error text,\n delivery_attempts integer not null default 0\n )"),
125
+ ("messages", "create table if not exists __TABLE__ (\n message_id text primary key,\n owner_team_id text,\n task_id text,\n sender text,\n recipient text,\n reply_to text,\n requires_ack integer,\n status text,\n content text,\n presentation text not null default '{\"sink\":\"leader\",\"class\":\"message\"}',\n artifact_refs text,\n created_at text,\n updated_at text,\n delivered_at text,\n acknowledged_at text,\n error text,\n delivery_attempts integer not null default 0\n )"),
125
126
  ("results", "create table if not exists __TABLE__ (\n result_id text primary key,\n owner_team_id text,\n task_id text not null,\n agent_id text not null,\n envelope text not null,\n status text not null,\n created_at text not null\n )"),
126
127
  ("scheduled_events", "create table if not exists __TABLE__ (\n id integer primary key,\n owner_team_id text,\n due_at text not null,\n target text not null,\n kind text not null,\n payload_json text not null,\n status text not null,\n created_at text not null,\n fired_at text,\n result_json text\n )"),
127
128
  ("delivery_tokens", "create table if not exists __TABLE__ (\n message_id text primary key,\n unique_token text not null,\n injected_at text not null,\n visible_at text,\n consumed_at text,\n failed_at text,\n failure_reason text\n )"),
@@ -641,11 +642,11 @@ mod tests {
641
642
  assert_eq!(m1, "t");
642
643
  drop(conn);
643
644
 
644
- // 迁移后 diagnosis = ok / user_version=3
645
+ // 迁移后 diagnosis = ok / current user_version。
645
646
  let after = schema_diagnosis(&path, SCHEMA_VERSION).unwrap();
646
647
  assert!(after.ok);
647
648
  assert_eq!(after.status, "ok");
648
- assert_eq!(after.user_version, 3);
649
+ assert_eq!(after.user_version, SCHEMA_VERSION);
649
650
 
650
651
  // 备份文件已写(team.db.pre-migration-*-from-v1.bak)。
651
652
  let runtime = path.parent().unwrap();
@@ -670,7 +671,7 @@ mod tests {
670
671
  let d = schema_diagnosis(&path, SCHEMA_VERSION).unwrap();
671
672
  assert!(d.ok);
672
673
  assert_eq!(d.status, "ok");
673
- assert_eq!(d.user_version, 3);
674
+ assert_eq!(d.user_version, SCHEMA_VERSION);
674
675
  }
675
676
 
676
677
  #[test]
@@ -788,7 +789,7 @@ mod tests {
788
789
  initialize_schema(&conn, Some(&path)).unwrap();
789
790
  let cols = table_layout(&conn, "messages").unwrap();
790
791
  assert!(!cols.iter().any(|c| c == "legacy_junk"), "废列应被丢弃");
791
- assert_eq!(cols.len(), 16);
792
+ assert_eq!(cols.len(), 17);
792
793
  assert_eq!(table_count(&conn, "messages").unwrap(), 1);
793
794
  }
794
795
 
@@ -846,7 +847,7 @@ mod tests {
846
847
  rebuilds,
847
848
  } => {
848
849
  assert!(diagnosis.ok);
849
- assert_eq!(diagnosis.user_version, 3);
850
+ assert_eq!(diagnosis.user_version, SCHEMA_VERSION);
850
851
  assert_eq!(rebuilds.len(), 8);
851
852
  }
852
853
  other => panic!("expected Fixed, got {other:?}"),
@@ -12,11 +12,11 @@ use rusqlite::Connection;
12
12
  use crate::db::DbError;
13
13
 
14
14
  /// `schema.py:90`。
15
- pub const SCHEMA_VERSION: i64 = 3;
15
+ pub const SCHEMA_VERSION: i64 = 4;
16
16
 
17
17
  /// 8 张表的 DDL(逐字照搬 `schema.py:initialize_schema` 的内联建表;含 `if not exists`)。
18
18
  /// 顺序与 Python 一致(leader_notification_log 在 ensure 块后创建)。
19
- const CREATE_MESSAGES: &str = "create table if not exists messages (\n message_id text primary key,\n owner_team_id text,\n task_id text,\n sender text,\n recipient text,\n reply_to text,\n requires_ack integer,\n status text,\n content text,\n artifact_refs text,\n created_at text,\n updated_at text,\n delivered_at text,\n acknowledged_at text,\n error text,\n delivery_attempts integer not null default 0\n )";
19
+ const CREATE_MESSAGES: &str = "create table if not exists messages (\n message_id text primary key,\n owner_team_id text,\n task_id text,\n sender text,\n recipient text,\n reply_to text,\n requires_ack integer,\n status text,\n content text,\n presentation text not null default '{\"sink\":\"leader\",\"class\":\"message\"}',\n artifact_refs text,\n created_at text,\n updated_at text,\n delivered_at text,\n acknowledged_at text,\n error text,\n delivery_attempts integer not null default 0\n )";
20
20
  const CREATE_RESULTS: &str = "create table if not exists results (\n result_id text primary key,\n owner_team_id text,\n task_id text not null,\n agent_id text not null,\n envelope text not null,\n status text not null,\n created_at text not null\n )";
21
21
  const CREATE_SCHEDULED_EVENTS: &str = "create table if not exists scheduled_events (\n id integer primary key,\n owner_team_id text,\n due_at text not null,\n target text not null,\n kind text not null,\n payload_json text not null,\n status text not null,\n created_at text not null,\n fired_at text,\n result_json text\n )";
22
22
  const CREATE_DELIVERY_TOKENS: &str = "create table if not exists delivery_tokens (\n message_id text primary key,\n unique_token text not null,\n injected_at text not null,\n visible_at text,\n consumed_at text,\n failed_at text,\n failure_reason text\n )";
@@ -47,6 +47,7 @@ const MESSAGE_COLUMNS: &[&str] = &[
47
47
  "requires_ack",
48
48
  "status",
49
49
  "content",
50
+ "presentation",
50
51
  "artifact_refs",
51
52
  "created_at",
52
53
  "updated_at",
@@ -264,6 +265,10 @@ pub fn initialize_schema(
264
265
  "owner_team_id",
265
266
  "alter table messages add column owner_team_id text",
266
267
  ),
268
+ (
269
+ "presentation",
270
+ "alter table messages add column presentation text not null default '{\"sink\":\"leader\",\"class\":\"message\"}'",
271
+ ),
267
272
  ],
268
273
  )?;
269
274
  ensure_table_columns(
@@ -354,13 +359,13 @@ mod tests {
354
359
  }
355
360
 
356
361
  #[test]
357
- fn user_version_is_three() {
362
+ fn user_version_is_four() {
358
363
  let conn = fresh();
359
364
  let v: i64 = conn
360
365
  .query_row("pragma user_version", [], |r| r.get(0))
361
366
  .unwrap();
362
367
  assert_eq!(v, SCHEMA_VERSION);
363
- assert_eq!(v, 3);
368
+ assert_eq!(v, 4);
364
369
  }
365
370
 
366
371
  #[test]
@@ -378,6 +383,7 @@ mod tests {
378
383
  "requires_ack",
379
384
  "status",
380
385
  "content",
386
+ "presentation",
381
387
  "artifact_refs",
382
388
  "created_at",
383
389
  "updated_at",
@@ -394,7 +400,7 @@ mod tests {
394
400
  // initialize_schema 二次调用(if not exists / 无缺列)→ 不报错、schema 不变 + 索引仍在。
395
401
  let conn = fresh();
396
402
  initialize_schema(&conn, None).unwrap();
397
- assert_eq!(table_layout(&conn, "messages").unwrap().len(), 16);
403
+ assert_eq!(table_layout(&conn, "messages").unwrap().len(), 17);
398
404
  let idx: i64 = conn
399
405
  .query_row(
400
406
  "select count(*) from sqlite_master where type='index' and sql is not null",
@@ -422,6 +428,13 @@ mod tests {
422
428
  ("requires_ack", "INTEGER", 0, None, 0),
423
429
  ("status", "TEXT", 0, None, 0),
424
430
  ("content", "TEXT", 0, None, 0),
431
+ (
432
+ "presentation",
433
+ "TEXT",
434
+ 1,
435
+ Some("'{\"sink\":\"leader\",\"class\":\"message\"}'"),
436
+ 0,
437
+ ),
425
438
  ("artifact_refs", "TEXT", 0, None, 0),
426
439
  ("created_at", "TEXT", 0, None, 0),
427
440
  ("updated_at", "TEXT", 0, None, 0),
@@ -267,6 +267,7 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
267
267
  confirm_human: false,
268
268
  json: true,
269
269
  message_id: Some("phase-golden-message".to_string()),
270
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
270
271
  pane: None,
271
272
  to_name: None,
272
273
  to_leader: None,
@@ -104,6 +104,12 @@ pub fn normalize_report_envelope(env: &Value) -> NormalizedReportEnvelope {
104
104
  let summary = text_field(env, "summary").unwrap_or_else(|| "completed".to_string());
105
105
  let task_id = text_field(env, "task_id").unwrap_or_else(|| "manual".to_string());
106
106
  let agent_id = text_field(env, "agent_id").unwrap_or_else(|| "unknown".to_string());
107
+ let (presentation_request, presentation_error) =
108
+ crate::messaging::presentation::normalize_report_presentation(env.get("presentation"));
109
+ let presentation = crate::messaging::presentation::decide_presentation(
110
+ &presentation_request,
111
+ crate::messaging::presentation::PresentationSource::ReportResult,
112
+ );
107
113
  NormalizedReportEnvelope {
108
114
  schema_version: "result_envelope_v1".to_string(),
109
115
  task_id: TaskId::new(task_id),
@@ -115,6 +121,8 @@ pub fn normalize_report_envelope(env: &Value) -> NormalizedReportEnvelope {
115
121
  risks: normalize_risks(env.get("risks")),
116
122
  artifacts: normalize_artifacts(env.get("artifacts")),
117
123
  next_actions: normalize_next_actions(env.get("next_actions")),
124
+ presentation,
125
+ presentation_error,
118
126
  }
119
127
  }
120
128
 
@@ -79,7 +79,7 @@ fn tools_contract_has_thirteen_tools_in_order() {
79
79
  .unwrap();
80
80
  assert_eq!(
81
81
  send["description"],
82
- json!("Send a message to a teammate, the leader, or '*' for all other team members. Provide only target and content; Team Agent fills sender, task id, ack policy, and delivery metadata.")
82
+ json!("Send a message to a teammate, the leader, or '*' for all other team members. Team Agent fills identity and delivery metadata; optional presentation routing is durable and never drops the message.")
83
83
  );
84
84
  assert_eq!(send["inputSchema"]["additionalProperties"], json!(false));
85
85
  assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));