From b48d5b874f1c0a338ffa2ea1ce0ce717720b0e2b Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 22 Mar 2026 00:50:45 +0800 Subject: [PATCH] fix: include full v0.93.0 release changes --- src-tauri/crates/agent/src/session_store.rs | 21 + .../crates/agent/src/subagent_profiles.rs | 20 +- src-tauri/src/commands/aster_agent_cmd/dto.rs | 4 + src-tauri/src/commands/aster_agent_cmd/mod.rs | 6 + .../aster_agent_cmd/prompt_context.rs | 200 ++++++--- .../run_metadata/request_metadata.rs | 6 + .../commands/aster_agent_cmd/runtime_turn.rs | 400 +++++++++++++++++- .../aster_agent_cmd/subagent_runtime.rs | 20 +- .../src/commands/aster_agent_cmd/tests.rs | 192 ++++++++- .../tool_runtime/subagent_tools.rs | 14 + .../agent/chat/AgentChatWorkspace.tsx | 57 ++- .../components/TeamWorkbenchSummaryPanel.tsx | 2 +- .../components/TeamWorkspaceBoard.test.tsx | 109 +++++ .../chat/components/TeamWorkspaceBoard.tsx | 319 +++++++++++--- .../hooks/useRuntimeTeamFormation.test.tsx | 127 ++++-- .../chat/hooks/useRuntimeTeamFormation.ts | 126 +++--- .../chat/utils/harnessRequestMetadata.test.ts | 58 +++ .../chat/utils/harnessRequestMetadata.ts | 55 ++- src/lib/api/agentRuntime.ts | 6 + 19 files changed, 1507 insertions(+), 235 deletions(-) diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 114012cf7..202360569 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -99,6 +99,10 @@ pub struct ChildSubagentSession { #[serde(skip_serializing_if = "Option::is_none")] pub created_from_turn_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub blueprint_role_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub blueprint_role_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub profile_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub profile_name: Option, @@ -135,6 +139,10 @@ pub struct SubagentParentContext { #[serde(skip_serializing_if = "Option::is_none")] pub created_from_turn_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub blueprint_role_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub blueprint_role_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub profile_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub profile_name: Option, @@ -379,6 +387,8 @@ fn build_child_subagent_session_summary( role_hint: normalize_optional_text(metadata.role_hint), origin_tool: normalize_optional_text(Some(metadata.origin_tool)), created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id), + blueprint_role_id: customization.blueprint_role_id, + blueprint_role_label: customization.blueprint_role_label, profile_id: customization.profile_id, profile_name: customization.profile_name, role_key: customization.role_key, @@ -441,6 +451,8 @@ fn build_subagent_parent_context( task_summary: normalize_optional_nonempty_body(metadata.task_summary), origin_tool: normalize_optional_text(Some(metadata.origin_tool)), created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id), + blueprint_role_id: customization.blueprint_role_id, + blueprint_role_label: customization.blueprint_role_label, profile_id: customization.profile_id, profile_name: customization.profile_name, role_key: customization.role_key, @@ -1281,6 +1293,8 @@ mod tests { Some("turn-9"), ); session.extension_data = SubagentCustomizationState { + blueprint_role_id: Some("runtime-explorer".to_string()), + blueprint_role_label: Some("分析".to_string()), profile_id: Some("code-explorer".to_string()), profile_name: Some("代码分析员".to_string()), role_key: Some("explorer".to_string()), @@ -1303,6 +1317,11 @@ mod tests { let summary = build_child_subagent_session_summary(None, session) .expect("child summary should exist"); + assert_eq!( + summary.blueprint_role_id.as_deref(), + Some("runtime-explorer") + ); + assert_eq!(summary.blueprint_role_label.as_deref(), Some("分析")); assert_eq!(summary.profile_id.as_deref(), Some("code-explorer")); assert_eq!(summary.profile_name.as_deref(), Some("代码分析员")); assert_eq!(summary.role_key.as_deref(), Some("explorer")); @@ -1439,6 +1458,8 @@ mod tests { role_hint: Some("explorer".to_string()), origin_tool: Some("spawn_agent".to_string()), created_from_turn_id: Some("turn-1".to_string()), + blueprint_role_id: None, + blueprint_role_label: None, profile_id: None, profile_name: None, role_key: None, diff --git a/src-tauri/crates/agent/src/subagent_profiles.rs b/src-tauri/crates/agent/src/subagent_profiles.rs index d5bc155a0..87d2b011d 100644 --- a/src-tauri/crates/agent/src/subagent_profiles.rs +++ b/src-tauri/crates/agent/src/subagent_profiles.rs @@ -43,6 +43,10 @@ pub struct TeamPresetSummary { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)] pub struct SubagentCustomizationState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blueprint_role_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blueprint_role_label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -89,7 +93,9 @@ impl SubagentCustomizationState { } pub fn is_empty(&self) -> bool { - self.profile_id.is_none() + self.blueprint_role_id.is_none() + && self.blueprint_role_label.is_none() + && self.profile_id.is_none() && self.profile_name.is_none() && self.role_key.is_none() && self.team_preset_id.is_none() @@ -369,6 +375,18 @@ pub fn build_subagent_customization_prompt( builtin_team_preset_label_by_id(team_preset_id).unwrap_or(team_preset_id); header_lines.push(format!("- 团队预设:{preset_label} ({team_preset_id})")); } + if let Some(blueprint_role_label) = customization.blueprint_role_label.as_deref() { + let blueprint_role_id_suffix = customization + .blueprint_role_id + .as_deref() + .map(|role_id| format!(" ({role_id})")) + .unwrap_or_default(); + header_lines.push(format!( + "- 蓝图角色:{blueprint_role_label}{blueprint_role_id_suffix}" + )); + } else if let Some(blueprint_role_id) = customization.blueprint_role_id.as_deref() { + header_lines.push(format!("- 蓝图角色 ID:{blueprint_role_id}")); + } if let Some(profile_name) = customization.profile_name.as_deref() { let profile_id_suffix = customization .profile_id diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index df23d7b85..e5184e005 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -250,6 +250,10 @@ pub struct AgentRuntimeSpawnSubagentRequest { pub reasoning_effort: Option, #[serde(default, alias = "forkContext")] pub fork_context: bool, + #[serde(default, alias = "blueprintRoleId")] + pub blueprint_role_id: Option, + #[serde(default, alias = "blueprintRoleLabel")] + pub blueprint_role_label: Option, #[serde(default, alias = "profileId")] pub profile_id: Option, #[serde(default, alias = "profileName")] diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 890a21d5a..a61cbbd5e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -333,6 +333,12 @@ use run_metadata::{ resolve_social_run_artifact_descriptor, }; pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor}; +#[cfg(test)] +pub(crate) use runtime_turn::{ + build_runtime_prepared_team_spawn_message, parse_runtime_prepared_team_roles, + plan_runtime_prepared_team_actions, RuntimePreparedTeamAction, RuntimePreparedTeamRole, + RuntimePreparedTeamSessionCandidate, +}; pub(crate) use session_runtime::{ delete_runtime_session_internal, persist_session_provider_routing, resolve_session_provider_selector, diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index dc16c010c..22dcd2d6d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -173,6 +173,76 @@ pub(crate) fn merge_system_prompt_with_elicitation_context( } } +fn render_team_roles(role_items: &[serde_json::Value]) -> Vec { + role_items + .iter() + .filter_map(|value| { + let object = value.as_object()?; + let label = object + .get("label") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let summary = object + .get("summary") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("负责当前分工。"); + let role_id_suffix = object + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!(" / id: {value}")) + .unwrap_or_default(); + let profile_suffix = object + .get("profile_id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!(" / profile: {value}")) + .unwrap_or_default(); + let role_key_suffix = object + .get("role_key") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!(" / roleKey: {value}")) + .unwrap_or_default(); + let skill_suffix = object + .get("skill_ids") + .and_then(serde_json::Value::as_array) + .map(|items| { + items + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + }) + .filter(|items| !items.is_empty()) + .map(|items| format!(" / skills: {}", items.join(", "))) + .unwrap_or_default(); + + Some(format!( + " - {label}:{summary}{role_id_suffix}{profile_suffix}{role_key_suffix}{skill_suffix}" + )) + }) + .collect() +} + +fn describe_turn_team_reason(reason: &str) -> &'static str { + match reason { + "runtime_team_prepared" => "GUI 已完成本轮 Team 预编队", + "runtime_team_generation_failed" => "GUI 尝试预编队失败,当前回合降级为单 Agent", + "subagent_disabled" => "当前回合未开启 Team 模式", + "turn_purpose_override" => "当前回合属于特定目的任务,不走 Team 预编队", + "single_agent_direct" => "当前回合判断为直接单 Agent 执行更合适", + _ => "GUI 已记录本轮 Team 判定", + } +} + pub(crate) fn build_team_preference_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -201,6 +271,16 @@ pub(crate) fn build_team_preference_system_prompt( request_metadata, &["selected_team_roles", "selectedTeamRoles"], ); + let turn_team_decision = extract_harness_string( + request_metadata, + &["turn_team_decision", "turnTeamDecision"], + ); + let turn_team_reason = + extract_harness_string(request_metadata, &["turn_team_reason", "turnTeamReason"]); + let turn_team_blueprint = extract_harness_nested_object( + request_metadata, + &["turn_team_blueprint", "turnTeamBlueprint"], + ); if !subagent_mode_enabled { return None; @@ -241,68 +321,84 @@ pub(crate) fn build_team_preference_system_prompt( } if let Some(role_items) = selected_team_roles { - let rendered_roles = role_items - .iter() - .filter_map(|value| { - let object = value.as_object()?; - let label = object - .get("label") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty())?; - let summary = object - .get("summary") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("负责当前分工。"); - let profile_suffix = object - .get("profile_id") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| format!(" / profile: {value}")) - .unwrap_or_default(); - let role_key_suffix = object - .get("role_key") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| format!(" / roleKey: {value}")) - .unwrap_or_default(); - let skill_suffix = object - .get("skill_ids") - .and_then(serde_json::Value::as_array) - .map(|items| { - items - .iter() - .filter_map(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .collect::>() - }) - .filter(|items| !items.is_empty()) - .map(|items| format!(" / skills: {}", items.join(", "))) - .unwrap_or_default(); - - Some(format!( - " - {label}:{summary}{profile_suffix}{role_key_suffix}{skill_suffix}" - )) - }) - .collect::>(); - + let rendered_roles = render_team_roles(role_items); if !rendered_roles.is_empty() { lines.push("- 当前 Team 角色参考:".to_string()); lines.extend(rendered_roles); lines.push( - "- 如果你决定调用 spawn_agent,请优先把上述 profile / roleKey / skillIds 映射到对应结构化字段,保持 GUI Team 画布与实际分工一致。" + "- 如果你决定调用 spawn_agent,请优先把上述 profile / roleKey / skillIds 映射到对应结构化字段;若该角色带有 id,优先同步写入 blueprintRoleId / blueprintRoleLabel,保持 GUI Team 画布与实际分工一致。" .to_string(), ); } } + match turn_team_decision.as_deref() { + Some("team_prepared") => { + lines.push( + "- 当前回合已经在 GUI 发送前完成 Team 预编队;这是一份本轮执行契约,不是事后建议。" + .to_string(), + ); + if let Some(reason) = turn_team_reason.as_deref() { + lines.push(format!( + "- GUI 判定:{}。", + describe_turn_team_reason(reason) + )); + } + + if let Some(blueprint) = turn_team_blueprint { + let blueprint_label = blueprint + .get("label") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let blueprint_description = blueprint + .get("description") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let rendered_roles = blueprint + .get("roles") + .and_then(serde_json::Value::as_array) + .map(|items| render_team_roles(items)) + .unwrap_or_default(); + + if let Some(label) = blueprint_label { + lines.push(format!("- 本轮 Team 蓝图:{label}。")); + } + if let Some(description) = blueprint_description { + lines.push(format!("- 本轮蓝图说明:{description}")); + } + if !rendered_roles.is_empty() { + lines.push("- 本轮 Team 角色分工:".to_string()); + lines.extend(rendered_roles); + } + } + + lines.push( + "- 如果你决定调用 spawn_agent / send_input,应在本轮开局阶段优先按上述蓝图启动角色,并把蓝图里的 id / label 映射到 blueprintRoleId / blueprintRoleLabel,让各角色承担自己的输出,不要等主 agent 整轮完成后再补做 team。" + .to_string(), + ); + } + Some("single_agent") => { + lines.push( + "- 当前回合未在 GUI 中预编队 Team,本轮默认按单 Agent 直接推进。".to_string(), + ); + if let Some(reason) = turn_team_reason.as_deref() { + lines.push(format!( + "- GUI 判定:{}。", + describe_turn_team_reason(reason) + )); + } + lines.push( + "- 除非执行中出现明确的拆分必要性,否则不要为了形式化 team 而推迟主任务。" + .to_string(), + ); + } + _ => {} + } + lines.push( - "- spawn_agent 支持这些结构化字段:teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。" + "- spawn_agent 支持这些结构化字段:blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。" .to_string(), ); lines.push( diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs index ae68342a1..13b3a4b0a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs @@ -93,6 +93,12 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( ("selectedTeamSummary", "selected_team_summary"), ("selected_team_roles", "selected_team_roles"), ("selectedTeamRoles", "selected_team_roles"), + ("turn_team_decision", "turn_team_decision"), + ("turnTeamDecision", "turn_team_decision"), + ("turn_team_reason", "turn_team_reason"), + ("turnTeamReason", "turn_team_reason"), + ("turn_team_blueprint", "turn_team_blueprint"), + ("turnTeamBlueprint", "turn_team_blueprint"), ("browser_requirement", "browser_requirement"), ("browserRequirement", "browser_requirement"), ("browser_requirement_reason", "browser_requirement_reason"), diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index 711edb8f1..02e7bfac7 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -1,5 +1,352 @@ use super::*; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimePreparedTeamRole { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) summary: Option, + pub(crate) profile_id: Option, + pub(crate) role_key: Option, + pub(crate) skill_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimePreparedTeamSessionCandidate { + pub(crate) blueprint_role_id: String, + pub(crate) session_id: String, + pub(crate) status_kind: SubagentRuntimeStatusKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RuntimePreparedTeamAction { + Spawn(RuntimePreparedTeamRole), + Resume { + role: RuntimePreparedTeamRole, + session_id: String, + }, +} + +fn normalize_runtime_team_role_string(value: Option<&serde_json::Value>) -> Option { + normalize_optional_text( + value + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + ) +} + +fn normalize_runtime_team_role_id_fragment(value: &str) -> Option { + let normalized = value.split_whitespace().collect::>().join("-"); + let normalized = normalized.trim_matches('-').to_string(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +fn normalize_runtime_team_skill_ids(value: Option<&serde_json::Value>) -> Vec { + let Some(items) = value.and_then(serde_json::Value::as_array) else { + return Vec::new(); + }; + + let mut seen = HashSet::new(); + let mut skill_ids = Vec::new(); + for item in items { + let Some(skill_id) = normalize_runtime_team_role_string(Some(item)) else { + continue; + }; + if seen.insert(skill_id.clone()) { + skill_ids.push(skill_id); + } + } + + skill_ids +} + +fn parse_runtime_prepared_team_role( + role_value: &serde_json::Value, + index: usize, +) -> Option { + let role = role_value.as_object()?; + let profile_id = normalize_runtime_team_role_string(role.get("profile_id")) + .or_else(|| normalize_runtime_team_role_string(role.get("profileId"))); + let role_key = normalize_runtime_team_role_string(role.get("role_key")) + .or_else(|| normalize_runtime_team_role_string(role.get("roleKey"))); + let label = normalize_runtime_team_role_string(role.get("label")) + .or_else(|| profile_id.clone()) + .or_else(|| role_key.clone()) + .unwrap_or_else(|| format!("角色 {}", index + 1)); + let id = normalize_runtime_team_role_string(role.get("id")) + .or_else(|| { + profile_id + .as_deref() + .and_then(normalize_runtime_team_role_id_fragment) + .map(|fragment| format!("profile-{fragment}")) + }) + .or_else(|| { + role_key + .as_deref() + .and_then(normalize_runtime_team_role_id_fragment) + .map(|fragment| format!("role-{fragment}")) + }) + .or_else(|| { + normalize_runtime_team_role_id_fragment(&label) + .map(|fragment| format!("lane-{fragment}")) + }) + .unwrap_or_else(|| format!("runtime-team-role-{}", index + 1)); + + Some(RuntimePreparedTeamRole { + id, + label, + summary: normalize_runtime_team_role_string(role.get("summary")), + profile_id, + role_key, + skill_ids: normalize_runtime_team_skill_ids( + role.get("skill_ids").or_else(|| role.get("skillIds")), + ), + }) +} + +pub(crate) fn parse_runtime_prepared_team_roles( + request_metadata: Option<&serde_json::Value>, +) -> Vec { + if extract_harness_string( + request_metadata, + &["turn_team_decision", "turnTeamDecision"], + ) + .as_deref() + != Some("team_prepared") + { + return Vec::new(); + } + + let Some(blueprint) = extract_harness_nested_object( + request_metadata, + &["turn_team_blueprint", "turnTeamBlueprint"], + ) else { + return Vec::new(); + }; + let Some(role_values) = blueprint.get("roles").and_then(serde_json::Value::as_array) else { + return Vec::new(); + }; + + let mut seen = HashSet::new(); + let mut roles = Vec::new(); + for (index, role_value) in role_values.iter().enumerate() { + let Some(role) = parse_runtime_prepared_team_role(role_value, index) else { + continue; + }; + if seen.insert(role.id.clone()) { + roles.push(role); + } + } + + roles +} + +fn runtime_prepared_team_session_rank(status_kind: SubagentRuntimeStatusKind) -> u8 { + match status_kind { + SubagentRuntimeStatusKind::Running => 7, + SubagentRuntimeStatusKind::Queued => 6, + SubagentRuntimeStatusKind::Idle => 5, + SubagentRuntimeStatusKind::Completed => 4, + SubagentRuntimeStatusKind::Failed => 3, + SubagentRuntimeStatusKind::Aborted => 2, + SubagentRuntimeStatusKind::Closed => 1, + SubagentRuntimeStatusKind::NotFound => 0, + } +} + +pub(crate) fn plan_runtime_prepared_team_actions( + roles: &[RuntimePreparedTeamRole], + existing_candidates: &[RuntimePreparedTeamSessionCandidate], +) -> Vec { + let mut existing_by_role_id: HashMap<&str, &RuntimePreparedTeamSessionCandidate> = + HashMap::new(); + + for candidate in existing_candidates { + let replace = existing_by_role_id + .get(candidate.blueprint_role_id.as_str()) + .map(|current| { + runtime_prepared_team_session_rank(candidate.status_kind) + > runtime_prepared_team_session_rank(current.status_kind) + }) + .unwrap_or(true); + if replace { + existing_by_role_id.insert(candidate.blueprint_role_id.as_str(), candidate); + } + } + + roles + .iter() + .filter_map(|role| match existing_by_role_id.get(role.id.as_str()) { + Some(candidate) if candidate.status_kind == SubagentRuntimeStatusKind::Closed => { + Some(RuntimePreparedTeamAction::Resume { + role: role.clone(), + session_id: candidate.session_id.clone(), + }) + } + Some(candidate) if candidate.status_kind != SubagentRuntimeStatusKind::NotFound => None, + _ => Some(RuntimePreparedTeamAction::Spawn(role.clone())), + }) + .collect() +} + +pub(crate) fn build_runtime_prepared_team_spawn_message( + role: &RuntimePreparedTeamRole, + user_message: &str, +) -> String { + let mut sections = vec![format!("你是本轮 Team 中的「{}」角色。", role.label)]; + if let Some(summary) = role.summary.as_deref() { + sections.push(format!("你的职责:{summary}")); + } + sections.push( + "请直接在当前子会话输出你的执行过程与结果,不要把具体产出留给父会话代写。".to_string(), + ); + sections.push(format!("当前用户任务:\n{}", user_message.trim())); + sections.push("只处理当前角色范围内的工作;如果依赖其他角色,请明确指出协作边界。".to_string()); + sections.join("\n\n") +} + +async fn collect_runtime_prepared_team_candidates( + parent_session_id: &str, +) -> Result, String> { + let child_sessions = list_subagent_child_sessions(parent_session_id) + .await + .map_err(|error| format!("读取 runtime team child sessions 失败: {error}"))?; + let mut candidates = Vec::new(); + + for child_session in child_sessions { + let Some(customization) = SubagentCustomizationState::from_session(&child_session) else { + continue; + }; + let Some(blueprint_role_id) = normalize_optional_text(customization.blueprint_role_id) + else { + continue; + }; + let status_kind = load_subagent_runtime_status(&child_session.id) + .await + .map(|status| status.kind) + .unwrap_or(SubagentRuntimeStatusKind::NotFound); + candidates.push(RuntimePreparedTeamSessionCandidate { + blueprint_role_id, + session_id: child_session.id, + status_kind, + }); + } + + Ok(candidates) +} + +async fn maybe_prepare_runtime_team_sessions( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + request: &AsterChatRequest, +) -> Result<(), String> { + let roles = parse_runtime_prepared_team_roles(request.metadata.as_ref()); + if roles.is_empty() { + return Ok(()); + } + + let existing_candidates = collect_runtime_prepared_team_candidates(&request.session_id).await?; + let actions = plan_runtime_prepared_team_actions(&roles, &existing_candidates); + if actions.is_empty() { + tracing::info!( + "[AsterAgent][RuntimeTeam] session={} 所有 blueprint 角色已就绪,无需新增预拉起", + request.session_id + ); + return Ok(()); + } + + tracing::info!( + "[AsterAgent][RuntimeTeam] session={} 开始按 blueprint 预拉起角色: total_roles={}, pending_actions={}", + request.session_id, + roles.len(), + actions.len() + ); + + let runtime = SubagentControlRuntime::new( + app.clone(), + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); + + for action in actions { + match action { + RuntimePreparedTeamAction::Spawn(role) => { + tracing::info!( + "[AsterAgent][RuntimeTeam] session={} 自动预拉起角色: role_id={}, label={}", + request.session_id, + role.id, + role.label + ); + agent_runtime_spawn_subagent_internal( + &runtime, + AgentRuntimeSpawnSubagentRequest { + parent_session_id: request.session_id.clone(), + message: build_runtime_prepared_team_spawn_message(&role, &request.message), + agent_type: Some(role.label.clone()), + model: None, + reasoning_effort: None, + fork_context: false, + blueprint_role_id: Some(role.id.clone()), + blueprint_role_label: Some(role.label.clone()), + profile_id: role.profile_id.clone(), + profile_name: None, + role_key: role.role_key.clone(), + skill_ids: role.skill_ids.clone(), + skill_directories: Vec::new(), + team_preset_id: None, + theme: None, + system_overlay: None, + output_contract: None, + }, + ) + .await + .map_err(|error| { + format!( + "自动预拉起 team 角色失败: role_id={}, label={}, error={error}", + role.id, role.label + ) + })?; + } + RuntimePreparedTeamAction::Resume { role, session_id } => { + tracing::info!( + "[AsterAgent][RuntimeTeam] session={} 恢复已关闭角色 lane: role_id={}, label={}, child_session={}", + request.session_id, + role.id, + role.label, + session_id + ); + agent_runtime_resume_subagent_internal( + &runtime, + AgentRuntimeResumeSubagentRequest { id: session_id.clone() }, + ) + .await + .map_err(|error| { + format!( + "恢复已关闭 team 角色失败: role_id={}, label={}, child_session={}, error={error}", + role.id, role.label, session_id + ) + })?; + } + } + } + + Ok(()) +} + async fn execute_aster_chat_request( app: &AppHandle, state: &AsterAgentState, @@ -604,21 +951,50 @@ async fn execute_aster_chat_request( session_config_builder.build() }; + let runtime_turn_initialized = { + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + match agent + .ensure_runtime_turn_initialized( + &runtime_status_session_config, + Some(request.message.clone()), + ) + .await + { + Ok(_) => true, + Err(error) => { + tracing::warn!( + "[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}", + error + ); + false + } + } + }; + + if runtime_turn_initialized { + maybe_prepare_runtime_team_sessions( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + &request, + ) + .await?; + } else { + tracing::warn!( + "[AsterAgent][RuntimeTeam] 跳过本轮 team 预拉起,因为父会话 runtime turn 尚未就绪: session={}", + session_id + ); + } + // 获取 Agent Arc 并保持 guard 在整个流处理期间存活 let guard = agent_arc.read().await; let agent = guard.as_ref().ok_or("Agent not initialized")?; - if let Err(error) = agent - .ensure_runtime_turn_initialized( - &runtime_status_session_config, - Some(request.message.clone()), - ) - .await - { - tracing::warn!( - "[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}", - error - ); - } let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses( &request, diff --git a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs index 899f72f27..365cfd22d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -153,9 +153,11 @@ fn normalize_optional_vec(values: &[String]) -> Vec { fn build_subagent_session_name( message: &str, agent_type: Option<&str>, + blueprint_role_label: Option<&str>, profile_name: Option<&str>, ) -> String { normalize_optional_text(agent_type.map(ToString::to_string)) + .or_else(|| normalize_optional_text(blueprint_role_label.map(ToString::to_string))) .or_else(|| normalize_optional_text(profile_name.map(ToString::to_string))) .or_else(|| build_subagent_task_summary(message)) .unwrap_or_else(|| "子代理".to_string()) @@ -166,6 +168,7 @@ fn resolve_subagent_role_hint( customization: Option<&SubagentCustomizationState>, ) -> Option { normalize_optional_text(request.agent_type.clone()) + .or_else(|| customization.and_then(|state| state.blueprint_role_label.clone())) .or_else(|| customization.and_then(|state| state.profile_name.clone())) .or_else(|| customization.and_then(|state| state.role_key.clone())) } @@ -210,6 +213,8 @@ fn build_local_subagent_skill_payload( pub(crate) fn build_subagent_customization_state( request: &AgentRuntimeSpawnSubagentRequest, ) -> Result, String> { + let blueprint_role_id = normalize_optional_text(request.blueprint_role_id.clone()); + let blueprint_role_label = normalize_optional_text(request.blueprint_role_label.clone()); let profile_id = normalize_optional_text(request.profile_id.clone()); let profile = profile_id .as_deref() @@ -250,6 +255,8 @@ pub(crate) fn build_subagent_customization_state( } let state = SubagentCustomizationState { + blueprint_role_id, + blueprint_role_label, profile_id, profile_name: normalize_optional_text(request.profile_name.clone()) .or_else(|| profile.map(|descriptor| descriptor.name.to_string())), @@ -602,7 +609,14 @@ async fn create_runtime_subagent_session( let session = SessionManager::create_session( parent_session.working_dir.clone(), - build_subagent_session_name(&message, request.agent_type.as_deref(), profile_name), + build_subagent_session_name( + &message, + request.agent_type.as_deref(), + customization + .as_ref() + .and_then(|state| state.blueprint_role_label.as_deref()), + profile_name, + ), SessionType::SubAgent, ) .await @@ -720,6 +734,8 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( "reasoning_effort": request.reasoning_effort, "fork_context": request.fork_context, "origin_tool": "spawn_agent", + "blueprint_role_id": customization.as_ref().and_then(|state| state.blueprint_role_id.clone()), + "blueprint_role_label": customization.as_ref().and_then(|state| state.blueprint_role_label.clone()), "profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()), "profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()), "role_key": customization.as_ref().and_then(|state| state.role_key.clone()), @@ -788,6 +804,8 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( "subagent": { "origin_tool": "send_input", "interrupt": request.interrupt, + "blueprint_role_id": customization.as_ref().and_then(|state| state.blueprint_role_id.clone()), + "blueprint_role_label": customization.as_ref().and_then(|state| state.blueprint_role_label.clone()), "profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()), "profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()), "role_key": customization.as_ref().and_then(|state| state.role_key.clone()), diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 9e446950b..c5dea6cc3 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -1249,6 +1249,61 @@ mod tests { assert!(prompt.contains("映射到对应结构化字段")); } + #[test] + fn test_build_team_preference_system_prompt_renders_turn_team_contract() { + let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": true, + "turn_team_decision": "team_prepared", + "turn_team_reason": "runtime_team_prepared", + "turn_team_blueprint": { + "label": "本轮调试 Team", + "description": "先分析,再实现,最后验证。", + "roles": [ + { + "id": "runtime-explorer", + "label": "分析", + "summary": "负责定位问题。", + "profile_id": "code-explorer", + "role_key": "explorer", + "skill_ids": ["repo-exploration"] + }, + { + "label": "执行", + "summary": "负责提交修复。" + } + ] + } + } + }))) + .expect("team prompt should exist"); + + assert!(prompt.contains("发送前完成 Team 预编队")); + assert!(prompt.contains("本轮调试 Team")); + assert!(prompt.contains("先分析,再实现,最后验证。")); + assert!(prompt.contains("分析:负责定位问题。")); + assert!(prompt.contains("id: runtime-explorer")); + assert!(prompt.contains("blueprintRoleId")); + assert!(prompt.contains("让各角色承担自己的输出")); + assert!(prompt.contains("不要等主 agent 整轮完成后再补做 team")); + } + + #[test] + fn test_build_team_preference_system_prompt_renders_single_agent_turn_decision() { + let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": true, + "turn_team_decision": "single_agent", + "turn_team_reason": "single_agent_direct", + } + }))) + .expect("team prompt should exist"); + + assert!(prompt.contains("当前回合未在 GUI 中预编队 Team")); + assert!(prompt.contains("直接单 Agent 执行更合适")); + assert!(prompt.contains("不要为了形式化 team 而推迟主任务")); + } + #[test] fn test_build_subagent_customization_state_applies_profile_defaults() { let customization = build_subagent_customization_state(&AgentRuntimeSpawnSubagentRequest { @@ -1258,6 +1313,8 @@ mod tests { model: None, reasoning_effort: None, fork_context: false, + blueprint_role_id: Some("runtime-explorer".to_string()), + blueprint_role_label: Some("分析".to_string()), profile_id: Some("code-explorer".to_string()), profile_name: None, role_key: None, @@ -1271,6 +1328,11 @@ mod tests { .expect("build customization state") .expect("customization should exist"); + assert_eq!( + customization.blueprint_role_id.as_deref(), + Some("runtime-explorer") + ); + assert_eq!(customization.blueprint_role_label.as_deref(), Some("分析")); assert_eq!(customization.profile_name.as_deref(), Some("代码分析员")); assert_eq!(customization.role_key.as_deref(), Some("explorer")); assert_eq!( @@ -1293,6 +1355,8 @@ mod tests { fn test_build_subagent_customization_system_prompt_renders_builtin_configuration() { let prompt = build_subagent_customization_system_prompt(Some(&SubagentCustomizationState { + blueprint_role_id: Some("runtime-explorer".to_string()), + blueprint_role_label: Some("分析".to_string()), profile_id: Some("code-explorer".to_string()), profile_name: Some("代码分析员".to_string()), role_key: Some("explorer".to_string()), @@ -1313,12 +1377,133 @@ mod tests { .expect("prompt should exist"); assert!(prompt.contains("【Subagent 定制配置】")); + assert!(prompt.contains("蓝图角色:分析 (runtime-explorer)")); assert!(prompt.contains("代码分析员")); assert!(prompt.contains("代码排障团队")); assert!(prompt.contains("仓库探索")); assert!(prompt.contains("输出问题定位、证据与影响面。")); } + #[test] + fn test_parse_runtime_prepared_team_roles_from_metadata() { + let roles = parse_runtime_prepared_team_roles(Some(&serde_json::json!({ + "harness": { + "turn_team_decision": "team_prepared", + "turn_team_blueprint": { + "roles": [ + { + "id": "runtime-explorer", + "label": "分析", + "summary": "负责定位问题。", + "profile_id": "code-explorer", + "role_key": "explorer", + "skill_ids": ["repo-exploration", "repo-exploration", " "] + }, + { + "label": "执行 角色", + "summary": "负责提交修复。" + }, + { + "id": "runtime-explorer", + "label": "重复分析" + } + ] + } + } + }))); + + assert_eq!(roles.len(), 2); + assert_eq!(roles[0].id, "runtime-explorer"); + assert_eq!(roles[0].label, "分析"); + assert_eq!(roles[0].profile_id.as_deref(), Some("code-explorer")); + assert_eq!(roles[0].role_key.as_deref(), Some("explorer")); + assert_eq!(roles[0].skill_ids, vec!["repo-exploration".to_string()]); + + assert_eq!(roles[1].id, "lane-执行-角色"); + assert_eq!(roles[1].label, "执行 角色"); + assert_eq!(roles[1].summary.as_deref(), Some("负责提交修复。")); + } + + #[test] + fn test_plan_runtime_prepared_team_actions_skips_existing_and_resumes_closed_lane() { + let roles = vec![ + RuntimePreparedTeamRole { + id: "runtime-explorer".to_string(), + label: "分析".to_string(), + summary: Some("负责定位问题。".to_string()), + profile_id: Some("code-explorer".to_string()), + role_key: Some("explorer".to_string()), + skill_ids: vec!["repo-exploration".to_string()], + }, + RuntimePreparedTeamRole { + id: "runtime-executor".to_string(), + label: "执行".to_string(), + summary: Some("负责提交修复。".to_string()), + profile_id: Some("code-executor".to_string()), + role_key: Some("executor".to_string()), + skill_ids: vec![], + }, + RuntimePreparedTeamRole { + id: "runtime-verifier".to_string(), + label: "验证".to_string(), + summary: Some("负责回归验证。".to_string()), + profile_id: Some("code-verifier".to_string()), + role_key: Some("verifier".to_string()), + skill_ids: vec![], + }, + ]; + let existing_candidates = vec![ + RuntimePreparedTeamSessionCandidate { + blueprint_role_id: "runtime-explorer".to_string(), + session_id: "child-explorer".to_string(), + status_kind: SubagentRuntimeStatusKind::Running, + }, + RuntimePreparedTeamSessionCandidate { + blueprint_role_id: "runtime-executor".to_string(), + session_id: "child-executor-old".to_string(), + status_kind: SubagentRuntimeStatusKind::Closed, + }, + RuntimePreparedTeamSessionCandidate { + blueprint_role_id: "runtime-executor".to_string(), + session_id: "child-executor-stale".to_string(), + status_kind: SubagentRuntimeStatusKind::NotFound, + }, + ]; + + let actions = plan_runtime_prepared_team_actions(&roles, &existing_candidates); + assert_eq!(actions.len(), 2); + + assert!(matches!( + &actions[0], + RuntimePreparedTeamAction::Resume { role, session_id } + if role.id == "runtime-executor" && session_id == "child-executor-old" + )); + assert!(matches!( + &actions[1], + RuntimePreparedTeamAction::Spawn(role) if role.id == "runtime-verifier" + )); + } + + #[test] + fn test_build_runtime_prepared_team_spawn_message_emphasizes_lane_output() { + let message = build_runtime_prepared_team_spawn_message( + &RuntimePreparedTeamRole { + id: "runtime-explorer".to_string(), + label: "分析".to_string(), + summary: Some("负责定位问题。".to_string()), + profile_id: Some("code-explorer".to_string()), + role_key: Some("explorer".to_string()), + skill_ids: vec!["repo-exploration".to_string()], + }, + "修正 team runtime 的执行顺序", + ); + + assert!(message.contains("当前子会话输出")); + assert!(message.contains("修正 team runtime 的执行顺序")); + assert!(message.contains("负责定位问题。")); + assert!(message.contains("不要把具体产出留给父会话代写")); + } + #[test] fn test_normalize_shell_command_params_accepts_cmd_alias() { let input = serde_json::json!({ @@ -1360,8 +1545,9 @@ mod tests { let input = serde_json::json!({ "path": "/memories/preferences.md" }); - let normalized: serde_json::Value = normalize_params_for_durable_memory_support("read", &input) - .expect("normalize read params"); + let normalized: serde_json::Value = + normalize_params_for_durable_memory_support("read", &input) + .expect("normalize read params"); let expected = tmp .path() .join("preferences.md") @@ -1385,7 +1571,7 @@ mod tests { }); let normalized: serde_json::Value = normalize_params_for_durable_memory_support("glob", &input) - .expect("normalize glob params"); + .expect("normalize glob params"); let expected_root = tmp.path().to_string_lossy().to_string(); assert_eq!( diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs index 19a3207e7..5aa3f9c36 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs @@ -412,6 +412,8 @@ impl Tool for SubAgentTaskTool { model: input.model.clone(), reasoning_effort: None, fork_context: false, + blueprint_role_id: None, + blueprint_role_label: None, profile_id: None, profile_name: None, role_key: None, @@ -511,6 +513,8 @@ struct SpawnAgentToolInput { model: Option, reasoning_effort: Option, fork_context: Option, + blueprint_role_id: Option, + blueprint_role_label: Option, profile_id: Option, profile_name: Option, role_key: Option, @@ -569,6 +573,14 @@ impl Tool for SpawnAgentTool { "type": "boolean", "description": "保留字段,当前仅记录到 metadata" }, + "blueprintRoleId": { + "type": "string", + "description": "可选本轮 Team 蓝图角色 id;当 GUI 已预编队 Team 时,优先传入对应角色 id,便于真实成员接管画布泳道" + }, + "blueprintRoleLabel": { + "type": "string", + "description": "可选本轮 Team 蓝图角色标签,例如 分析 / 执行 / 验证" + }, "profileId": { "type": "string", "description": "可选内置 profile id,例如 code-explorer / code-executor / code-verifier" @@ -629,6 +641,8 @@ impl Tool for SpawnAgentTool { model: input.model, reasoning_effort: input.reasoning_effort, fork_context: input.fork_context.unwrap_or(false), + blueprint_role_id: input.blueprint_role_id, + blueprint_role_label: input.blueprint_role_label, profile_id: input.profile_id, profile_name: input.profile_name, role_key: input.role_key, diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 07e1a6a79..5145b148b 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -3350,7 +3350,7 @@ export function AgentChatWorkspace({ const { runtimeTeamState, clearRuntimeTeamState, - handleRuntimeTeamAfterSend, + prepareRuntimeTeamBeforeSend, } = useRuntimeTeamFormation({ activeTheme, projectId, @@ -6219,6 +6219,46 @@ export function AgentChatWorkspace({ } } + const preparedRuntimeTeamState = await prepareRuntimeTeamBeforeSend({ + input: sourceText, + providerType, + model: effectiveModel, + executionStrategy: sendExecutionStrategy ?? executionStrategy, + purpose: sendOptions?.purpose, + subagentEnabled: effectiveToolPreferences.subagent, + }); + const turnTeamBlueprint = + preparedRuntimeTeamState?.status === "formed" && + preparedRuntimeTeamState.members.length > 0 + ? { + label: preparedRuntimeTeamState.label?.trim() || undefined, + description: + preparedRuntimeTeamState.summary?.trim() || undefined, + roles: preparedRuntimeTeamState.members.map((member) => ({ + id: member.id, + label: member.label, + summary: member.summary, + profileId: member.profileId, + roleKey: member.roleKey, + skillIds: [...member.skillIds], + })), + } + : undefined; + const turnTeamDecision = + preparedRuntimeTeamState?.status === "formed" + ? "team_prepared" + : "single_agent"; + const turnTeamReason = + preparedRuntimeTeamState?.status === "formed" + ? "runtime_team_prepared" + : preparedRuntimeTeamState?.status === "failed" + ? "runtime_team_generation_failed" + : !effectiveToolPreferences.subagent + ? "subagent_disabled" + : sendOptions?.purpose + ? "turn_purpose_override" + : "single_agent_direct"; + setInput(""); setMentionedCharacters([]); // 清空引用的角色 @@ -6256,6 +6296,9 @@ export function AgentChatWorkspace({ selectedTeamLabel, selectedTeamSummary, selectedTeamRoles: selectedTeam?.roles, + turnTeamDecision, + turnTeamReason, + turnTeamBlueprint, }), }, }; @@ -6288,14 +6331,6 @@ export function AgentChatWorkspace({ finalizeAfterSendSuccess(sendBoundary); - handleRuntimeTeamAfterSend({ - input: sourceText, - providerType, - model: effectiveModel, - executionStrategy: sendExecutionStrategy ?? executionStrategy, - purpose: sendOptions?.purpose, - }); - return true; } catch (error) { rollbackAfterSendFailure(sendBoundary); @@ -6332,9 +6367,9 @@ export function AgentChatWorkspace({ selectedTeamSummary, providerType, finalizeAfterSendSuccess, - handleRuntimeTeamAfterSend, isBlockedByBrowserPreflight, maybeStartBrowserTaskPreflight, + prepareRuntimeTeamBeforeSend, resolveSendBoundary, resolveSendProviderContext, rollbackAfterSendFailure, @@ -10396,7 +10431,7 @@ export function AgentChatWorkspace({ selectedTeamLabel || "Team Workbench", subtitle: hasRealTeamGraph - ? "画布展示多成员实时协作,右侧侧栏展示总览与角色结构。" + ? "主对话保留调度记录,画布按角色分别展示执行过程与结果。" : runtimeTeamState?.status === "forming" ? "正在根据本轮任务准备 Team,随后会在画布中展示成员编队。" : runtimeTeamState?.status === "formed" diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx index 0e5bbbf12..314bca3da 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx @@ -196,7 +196,7 @@ export function TeamWorkbenchSummaryPanel({ ? runtimeTeamState.errorMessage?.trim() || "本轮 Team 准备失败,可继续在当前对话中推进。" : runtimeSummaryText || - "这里展示团队总览、运行密度和最近动态;详细协作过程在左侧主画布查看。"} + "这里展示团队总览与运行密度;主对话只保留调度记录,角色执行正文在左侧 Team 画布查看。"}

{!hasRealTeamGraph && runtimeTeamState ? (
{ }); expect(container.textContent).toContain("Team Workspace"); + expect(container.textContent).toContain("角色执行画布"); + expect(container.textContent).toContain( + "主对话只保留调度记录,每个角色在自己的泳道中输出过程与结果。", + ); expect(container.textContent).toContain("研究员"); expect(container.textContent).toContain("执行器"); expect(container.textContent).toContain("2 位成员已加入"); @@ -313,6 +317,20 @@ describe("TeamWorkspaceBoard", () => { expect(container.textContent).toContain("命令输出"); expect(container.textContent).toContain("已对 3 个来源完成去重校验。"); + const researcherLane = container.querySelector( + '[data-testid="team-workspace-member-lane-child-1"]', + ); + const executorLane = container.querySelector( + '[data-testid="team-workspace-member-lane-child-2"]', + ); + expect(researcherLane?.textContent).toContain("角色输出"); + expect(researcherLane?.textContent).toContain( + "回复:已完成竞品摘要与数据来源梳理。", + ); + expect(executorLane?.textContent).toContain( + "计划:先整理落地步骤,再生成第一版实施清单。", + ); + const openButton = Array.from(container.querySelectorAll("button")).find( (element) => element.textContent?.includes("查看对话"), ); @@ -325,6 +343,82 @@ describe("TeamWorkspaceBoard", () => { expect(onOpenSubagentSession).toHaveBeenCalledWith("child-1"); }); + it("真实成员存在时应优先按蓝图角色顺序排列泳道", async () => { + const container = await renderBoard({ + childSubagentSessions: [ + { + id: "child-executor", + name: "执行成员", + created_at: 1_710_000_010, + updated_at: 1_710_000_120, + session_type: "sub_agent", + runtime_status: "queued", + latest_turn_status: "queued", + task_summary: "负责提交修复。", + role_hint: "executor", + blueprint_role_id: "runtime-executor", + blueprint_role_label: "执行", + profile_id: "code-executor", + role_key: "executor", + }, + { + id: "child-explorer", + name: "分析成员", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + task_summary: "负责定位问题。", + role_hint: "explorer", + blueprint_role_id: "runtime-explorer", + blueprint_role_label: "分析", + profile_id: "code-explorer", + role_key: "explorer", + }, + ], + runtimeTeamState: { + requestId: "runtime-formed-ordered", + status: "formed", + label: "修复 Team", + summary: "分析、执行协同推进。", + members: [ + { + id: "runtime-explorer", + label: "分析", + summary: "先定位问题。", + roleKey: "explorer", + profileId: "code-explorer", + skillIds: ["repo-exploration"], + status: "planned", + }, + { + id: "runtime-executor", + label: "执行", + summary: "再提交修复。", + roleKey: "executor", + profileId: "code-executor", + skillIds: ["bounded-implementation"], + status: "planned", + }, + ], + blueprint: null, + updatedAt: Date.now(), + }, + }); + + const laneIds = Array.from( + container.querySelectorAll('[data-testid^="team-workspace-member-lane-"]'), + ).map((element) => element.getAttribute("data-testid")); + + expect(laneIds).toEqual([ + "team-workspace-member-lane-child-explorer", + "team-workspace-member-lane-child-executor", + ]); + expect(container.textContent).toContain("蓝图 · 分析"); + expect(container.textContent).toContain("蓝图 · 执行"); + }); + it("子线程视角应展示父会话、最近过程并支持切换 sibling", async () => { const onOpenSubagentSession = vi.fn(); const onReturnToParentSession = vi.fn(); @@ -420,6 +514,7 @@ describe("TeamWorkspaceBoard", () => { }); expect(container.textContent).toContain("主线程总览"); + expect(container.textContent).toContain("角色执行画布"); expect(container.textContent).toContain("实现代理"); expect(container.textContent).toContain("检索代理"); expect(container.textContent).toContain("队列 1"); @@ -436,6 +531,20 @@ describe("TeamWorkspaceBoard", () => { "工具 browser_snapshot:页面已刷新为最新状态并生成差异截图。", ); + const currentLane = container.querySelector( + '[data-testid="team-workspace-member-lane-child-current"]', + ); + const siblingLane = container.querySelector( + '[data-testid="team-workspace-member-lane-child-sibling-1"]', + ); + expect(currentLane?.textContent).toContain("角色输出"); + expect(currentLane?.textContent).toContain( + "推理:先检查 team runtime 的控制面状态,再决定是否等待。", + ); + expect(siblingLane?.textContent).toContain( + "工具 browser_snapshot:页面已刷新为最新状态并生成差异截图。", + ); + const returnButton = Array.from(container.querySelectorAll("button")).find( (element) => element.textContent?.includes("返回父会话"), ); diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.tsx index 9454796f5..83f08573b 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.tsx @@ -100,6 +100,8 @@ interface TeamSessionCard { model?: string; originTool?: string; createdFromTurnId?: string; + blueprintRoleId?: string; + blueprintRoleLabel?: string; profileId?: string; profileName?: string; roleKey?: string; @@ -588,6 +590,8 @@ function buildCurrentChildSession( sessionType: "sub_agent", originTool: subagentParentContext.origin_tool, createdFromTurnId: subagentParentContext.created_from_turn_id, + blueprintRoleId: subagentParentContext.blueprint_role_id, + blueprintRoleLabel: subagentParentContext.blueprint_role_label, profileId: subagentParentContext.profile_id, profileName: subagentParentContext.profile_name, roleKey: subagentParentContext.role_key, @@ -640,6 +644,136 @@ function dedupeSessions( return result; } +function normalizeComparableText(value?: string | null): string { + return value?.trim().toLocaleLowerCase() || ""; +} + +function resolveSessionBlueprintRoleId( + session: TeamSessionCard, + runtimeRoles: Array<{ + id: string; + label?: string | null; + profileId?: string; + roleKey?: string; + }>, + usedRoleIds: Set, +): string | null { + const explicitRoleId = session.blueprintRoleId?.trim(); + if ( + explicitRoleId && + !usedRoleIds.has(explicitRoleId) && + runtimeRoles.some((role) => role.id === explicitRoleId) + ) { + return explicitRoleId; + } + + const sessionBlueprintRoleLabel = normalizeComparableText( + session.blueprintRoleLabel, + ); + const sessionRoleKey = normalizeComparableText( + session.roleKey || session.roleHint, + ); + const sessionProfileId = normalizeComparableText(session.profileId); + const sessionName = normalizeComparableText(session.name); + + const candidates = runtimeRoles + .filter((role) => !usedRoleIds.has(role.id)) + .map((role) => { + let score = 0; + if ( + sessionBlueprintRoleLabel && + normalizeComparableText(role.label) === sessionBlueprintRoleLabel + ) { + score += 8; + } + if (sessionRoleKey && normalizeComparableText(role.roleKey) === sessionRoleKey) { + score += 4; + } + if ( + sessionProfileId && + normalizeComparableText(role.profileId) === sessionProfileId + ) { + score += 3; + } + if (sessionName && normalizeComparableText(role.label) === sessionName) { + score += 2; + } + return { + roleId: role.id, + score, + }; + }) + .filter((candidate) => candidate.score > 0) + .sort((left, right) => right.score - left.score); + + if (candidates.length === 0) { + return null; + } + if ( + candidates.length > 1 && + candidates[0]?.score === candidates[1]?.score + ) { + return null; + } + + return candidates[0]?.roleId ?? null; +} + +function orderSessionsByRuntimeRoles( + sessions: TeamSessionCard[], + runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null, +): TeamSessionCard[] { + if (sessions.length <= 1 || !runtimeTeamState) { + return sessions; + } + + const runtimeRoles = ( + runtimeTeamState.members.length > 0 + ? runtimeTeamState.members + : runtimeTeamState.blueprint?.roles ?? [] + ).map((role) => ({ + id: role.id, + label: role.label, + profileId: role.profileId, + roleKey: role.roleKey, + })); + + if (runtimeRoles.length === 0) { + return sessions; + } + + const roleOrder = new Map(runtimeRoles.map((role, index) => [role.id, index])); + const usedRoleIds = new Set(); + + return [...sessions] + .map((session, index) => { + const matchedRoleId = resolveSessionBlueprintRoleId( + session, + runtimeRoles, + usedRoleIds, + ); + if (matchedRoleId) { + usedRoleIds.add(matchedRoleId); + } + return { + session, + index, + matchedRoleId, + roleOrder: + matchedRoleId !== null + ? (roleOrder.get(matchedRoleId) ?? Number.MAX_SAFE_INTEGER) + : Number.MAX_SAFE_INTEGER, + }; + }) + .sort((left, right) => { + if (left.roleOrder !== right.roleOrder) { + return left.roleOrder - right.roleOrder; + } + return left.index - right.index; + }) + .map((entry) => entry.session); +} + function buildBoardHeadline(params: { hasRealTeamGraph: boolean; isChildSession: boolean; @@ -730,6 +864,39 @@ function buildRuntimeFormationEmptyDetail( } } +function buildSessionLaneEmptyState(params: { + session?: TeamSessionCard | null; + previewState?: SessionActivityPreviewState | null; +}) { + const { session, previewState } = params; + + if (previewState?.status === "error") { + return previewState.errorMessage?.trim() || "同步角色输出失败"; + } + + if (previewState?.status === "loading") { + return "正在同步该角色的最新输出..."; + } + + if (session?.runtimeStatus === "queued") { + return "已进入队列,等待 orchestrator 拉起执行。"; + } + + if (session?.runtimeStatus === "running") { + return "角色已开始执行,最新过程会持续刷新到这里。"; + } + + if (session?.runtimeStatus === "completed") { + return "该角色已完成当前任务,等待 orchestrator 汇总或继续交接。"; + } + + if (session?.runtimeStatus === "failed" || session?.runtimeStatus === "aborted") { + return "该角色执行失败,可在下方焦点区查看细节并决定是否恢复。"; + } + + return "该角色暂未产出可展示的正文内容。"; +} + export function TeamWorkspaceBoard({ className, embedded = false, @@ -833,6 +1000,8 @@ export function TeamWorkspaceBoard({ model: session.model, originTool: session.origin_tool, createdFromTurnId: session.created_from_turn_id, + blueprintRoleId: session.blueprint_role_id, + blueprintRoleLabel: session.blueprint_role_label, profileId: session.profile_id, profileName: session.profile_name, roleKey: session.role_key, @@ -1159,6 +1328,16 @@ export function TeamWorkspaceBoard({ ); }; + const memberCanvasSessions = useMemo( + () => + orderSessionsByRuntimeRoles( + isChildSession + ? dedupeSessions([currentChildSession, ...visibleSessions]) + : visibleSessions, + runtimeTeamState, + ), + [currentChildSession, isChildSession, runtimeTeamState, visibleSessions], + ); const railSessions = useMemo( () => dedupeSessions( @@ -1184,7 +1363,7 @@ export function TeamWorkspaceBoard({ useEffect(() => { const defaultSelectedId = isChildSession ? currentSessionId ?? railSessions[0]?.id ?? null - : visibleSessions[0]?.id ?? railSessions[0]?.id ?? null; + : memberCanvasSessions[0]?.id ?? railSessions[0]?.id ?? null; if (!selectedSessionId) { setSelectedSessionId(defaultSelectedId); @@ -1199,17 +1378,17 @@ export function TeamWorkspaceBoard({ if ( !isChildSession && selectedSessionId === orchestratorSession?.id && - visibleSessions.length > 0 + memberCanvasSessions.length > 0 ) { - setSelectedSessionId(visibleSessions[0]?.id ?? defaultSelectedId); + setSelectedSessionId(memberCanvasSessions[0]?.id ?? defaultSelectedId); } }, [ currentSessionId, isChildSession, + memberCanvasSessions, orchestratorSession?.id, railSessions, selectedSessionId, - visibleSessions, ]); const selectedSession = useMemo( @@ -1728,6 +1907,18 @@ export function TeamWorkspaceBoard({ }, [onSendSubagentInput, selectedSession, selectedSessionInputMessage], ); + const memberCanvasTitle = "角色执行画布"; + const memberCanvasSubtitle = hasRealTeamGraph + ? isChildSession + ? "当前成员与同组角色在各自泳道中输出过程与结果,主对话仅保留调度记录。" + : `${visibleSessions.length} 位成员已加入,主对话只保留调度记录,每个角色在自己的泳道中输出过程与结果。` + : runtimeTeamState?.status === "forming" + ? "正在准备本轮角色泳道,真实成员接入后会在这里独立输出。" + : runtimeTeamState?.status === "formed" + ? "本轮编队已完成,等待真实角色接入后在各自泳道中开始执行。" + : runtimeTeamState?.status === "failed" + ? "本轮编队失败,暂时无法生成角色泳道。" + : "等待成员加入后,这里会按角色展开各自的输出泳道。"; if ( !subagentParentContext && @@ -1825,6 +2016,9 @@ export function TeamWorkspaceBoard({ const selectedPresetOption = getTeamPresetOption(selectedSession?.teamPresetId); const selectedSkills = selectedSession?.skills ?? []; const selectedMetadata = [ + selectedSession?.blueprintRoleLabel + ? `蓝图角色 ${selectedSession.blueprintRoleLabel}` + : null, selectedSession?.sessionType ? resolveSessionTypeLabel(selectedSession.sessionType) : null, @@ -1884,20 +2078,6 @@ export function TeamWorkspaceBoard({ const timelineEntryClassName = embedded ? "border-l-2 border-slate-200 pl-3" : "rounded-[14px] border border-slate-200 bg-white p-3"; - const railTitle = isChildSession ? "同组成员轨道" : "团队成员轨道"; - const railSubtitle = isChildSession - ? siblingCount > 0 - ? `当前成员与 ${siblingCount} 位同组成员协作` - : "当前只有一位成员" - : hasRealTeamGraph - ? `${visibleSessions.length} 位成员已加入` - : runtimeTeamState?.status === "forming" - ? "正在准备本轮成员" - : runtimeTeamState?.status === "formed" - ? `${runtimeMembers.length} 个成员待启动` - : runtimeTeamState?.status === "failed" - ? "Team 准备失败" - : "等待成员加入"; return (
- {railTitle} + {memberCanvasTitle}
- {railSubtitle} + {memberCanvasSubtitle}
{selectedSession ? ( @@ -2141,17 +2321,17 @@ export function TeamWorkspaceBoard({
) : null} - {railSessions.length > 0 ? ( + {memberCanvasSessions.length > 0 ? (
- {railSessions.map((session) => { + {memberCanvasSessions.map((session) => { const meta = resolveStatusMeta(session.runtimeStatus); const selected = selectedSession?.id === session.id; const runtimeDetail = buildRuntimeDetailSummary(session); @@ -2160,10 +2340,17 @@ export function TeamWorkspaceBoard({ sessionActivityPreviewById[session.id]?.entries, ACTIVITY_TIMELINE_ENTRY_LIMIT, ); - const cardActivityPreview = buildCardActivityPreview( - buildActivityPreviewFromEntry(mergedCardEntries[0]) ?? - sessionActivityPreviewById[session.id]?.preview, - ); + const previewState = sessionActivityPreviewById[session.id] ?? null; + const cardActivityPreview = buildActivityPreviewFromEntry( + mergedCardEntries[0], + ) + ?? previewState?.preview + ?? null; + const laneEntries = mergedCardEntries.slice(0, 3); + const laneEmptyState = buildSessionLaneEmptyState({ + session, + previewState, + }); return (