diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b694999a4..d052b3efd 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,6 +3,7 @@ ### ✨ 主要更新 - **Team Workspace 正式成型**:Agent 聊天页新增 Team Workspace 主工作台、建议栏、Dock 与 Home Shell,围绕多代理协作视图重组交互结构 +- **Team 配置与发布稳定性收尾**:补齐 Team Selector 自定义 Team 配置链路、当前 Team 展示与相关测试,并修复通知、Provider Runtime 与前端类型兼容问题,确保 `v0.92.0` 可稳定构建发布 - **运行态与工具可视化增强**:`ToolCallDisplay`、Harness 状态面板、Runtime Strip、执行日志与子代理时间线继续增强,工具调用与运行态反馈更完整 - **Aster Agent 运行时继续收口**:Rust 侧补齐 session store、subagent control、agent tools inventory / execution、runtime queue 及命令桥接,统一现役 Agent Runtime 路径 - **治理与测试基建升级**:新增 `pr-gate`、本地校验脚本、命令契约检查、workspace smoke 与治理报告更新,发布前自检链路更清晰 diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 91859bb32..b531a01a2 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -9267,6 +9267,9 @@ export function AgentChatWorkspace({ compatSubagentRuntime={compatSubagentRuntime} environment={harnessEnvironment} childSubagentSessions={childSubagentSessions} + selectedTeamLabel={selectedTeamLabel} + selectedTeamSummary={selectedTeamSummary} + selectedTeamRoles={selectedTeam?.roles} toolInventory={toolInventory} toolInventoryLoading={toolInventoryLoading} toolInventoryError={toolInventoryError} @@ -9290,6 +9293,9 @@ export function AgentChatWorkspace({ isThemeWorkbench, refreshToolInventory, compatSubagentRuntime, + selectedTeam?.roles, + selectedTeamLabel, + selectedTeamSummary, toolInventory, toolInventoryError, toolInventoryLoading, @@ -9789,6 +9795,9 @@ export function AgentChatWorkspace({ compatSubagentRuntime={compatSubagentRuntime} environment={harnessEnvironment} childSubagentSessions={childSubagentSessions} + selectedTeamLabel={selectedTeamLabel} + selectedTeamSummary={selectedTeamSummary} + selectedTeamRoles={selectedTeam?.roles} toolInventory={toolInventory} toolInventoryLoading={toolInventoryLoading} toolInventoryError={toolInventoryError} @@ -9807,6 +9816,9 @@ export function AgentChatWorkspace({ variant="embedded" isSending={isSending} runtimeStatusTitle={activeRuntimeStatusTitle} + selectedTeamLabel={selectedTeamLabel} + selectedTeamSummary={selectedTeamSummary} + selectedTeamRoleCount={selectedTeam?.roles.length || 0} /> } onOpenSubagentSession={handleOpenSubagentSession} @@ -9832,6 +9844,9 @@ export function AgentChatWorkspace({ mappedTheme, refreshToolInventory, compatSubagentRuntime, + selectedTeam?.roles, + selectedTeamLabel, + selectedTeamSummary, toolInventory, toolInventoryError, toolInventoryLoading, diff --git a/src/components/agent/chat/components/AgentRuntimeStrip.tsx b/src/components/agent/chat/components/AgentRuntimeStrip.tsx index f65ab8252..f6b226834 100644 --- a/src/components/agent/chat/components/AgentRuntimeStrip.tsx +++ b/src/components/agent/chat/components/AgentRuntimeStrip.tsx @@ -19,6 +19,9 @@ interface AgentRuntimeStripProps { variant?: "standalone" | "embedded"; isSending?: boolean; runtimeStatusTitle?: string | null; + selectedTeamLabel?: string | null; + selectedTeamSummary?: string | null; + selectedTeamRoleCount?: number; } const THEME_LABELS: Record = { @@ -48,9 +51,14 @@ export const AgentRuntimeStrip: React.FC = ({ variant = "standalone", isSending = false, runtimeStatusTitle = null, + selectedTeamLabel = null, + selectedTeamSummary = null, + selectedTeamRoleCount = 0, }) => { const themeLabel = THEME_LABELS[activeTheme?.trim().toLowerCase() || ""] || "通用对话"; + const hasSelectedTeam = + Boolean(selectedTeamLabel?.trim()) || selectedTeamRoleCount > 0; const capabilities = useMemo( () => [ @@ -190,6 +198,13 @@ export const AgentRuntimeStrip: React.FC = ({
通用 Agent
{themeLabel} + {toolPreferences.subagent ? ( + + {hasSelectedTeam + ? `Team · ${selectedTeamLabel || `${selectedTeamRoleCount} 角色`}` + : "Team mode"} + + ) : null}
{capabilities.map((item) => ( @@ -206,6 +221,19 @@ export const AgentRuntimeStrip: React.FC = ({ ))}
+ {toolPreferences.subagent ? ( +
+ 当前 Team + + {" "} + ·{" "} + {selectedTeamSummary?.trim() || + (hasSelectedTeam + ? `已配置 ${selectedTeamRoleCount} 个角色,运行时可按需委派。` + : "已开启 Team mode,本轮可选择或自定义 Team。")} + +
+ ) : null}
{statusItems.map((item) => ( diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index 7a8a90249..a3b51b25d 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -144,7 +144,7 @@ function createToolInventory(): AgentRuntimeToolInventory { { name: "write", profiles: ["core"], - capabilities: ["filesystem"], + capabilities: ["workspace_io"], lifecycle: "current", source: "aster_builtin", permission_plane: "parameter_restricted", @@ -159,7 +159,7 @@ function createToolInventory(): AgentRuntimeToolInventory { { name: "tool_search", profiles: ["core"], - capabilities: ["discovery"], + capabilities: ["web_search"], lifecycle: "current", source: "lime_injected", permission_plane: "session_allowlist", @@ -400,6 +400,31 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain("等待首个模型事件"); }); + it("存在 selectedTeam 时应在工作台展示当前 Team 配置", () => { + renderPanel({ + selectedTeamLabel: "前端联调团队", + selectedTeamSummary: "分析、实现、验证三段式推进。", + selectedTeamRoles: [ + { + id: "explorer", + label: "分析", + summary: "负责定位问题、澄清范围。", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["repo-exploration", "source-grounding"], + }, + ], + }); + + expect(document.body.textContent).toContain("当前 Team"); + expect(document.body.textContent).toContain("当前 Team 配置"); + expect(document.body.textContent).toContain("前端联调团队"); + expect(document.body.textContent).toContain("分析、实现、验证三段式推进。"); + expect(document.body.textContent).toContain("画像 code-explorer"); + expect(document.body.textContent).toContain("Role explorer"); + expect(document.body.textContent).toContain("repo-exploration"); + }); + it("存在真实 child session 时应优先展示 Team 会话摘要,并将旧 scheduler 降级为兼容轨迹", () => { renderPanel({ childSubagentSessions: [ diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 96ea7a180..632a738f9 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -75,6 +75,7 @@ import { summarizeSearchQuerySemantics, } from "../utils/searchQueryGrouping"; import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime"; +import type { TeamRoleDefinition } from "../utils/teamDefinitions"; interface HarnessEnvironmentSummary { skillsCount: number; @@ -113,6 +114,9 @@ interface HarnessStatusPanelProps { description?: string; toggleLabel?: string; leadContent?: ReactNode; + selectedTeamLabel?: string | null; + selectedTeamSummary?: string | null; + selectedTeamRoles?: TeamRoleDefinition[] | null; } interface PreviewDialogState { @@ -135,6 +139,7 @@ type FileDisplayMode = "timeline" | "grouped"; type ToolInventoryFilterValue = "all" | "runtime" | "persisted" | "default"; type HarnessSectionKey = + | "team_config" | "runtime" | "inventory" | "approvals" @@ -1336,6 +1341,9 @@ export function HarnessStatusPanel({ description = "展示最近文件活动、工具输出、审批与上下文装载情况。", toggleLabel = "详情", leadContent, + selectedTeamLabel = null, + selectedTeamSummary = null, + selectedTeamRoles = [], }: HarnessStatusPanelProps) { const [expanded, setExpanded] = useState(true); const isDialogLayout = layout === "dialog"; @@ -1391,6 +1399,9 @@ export function HarnessStatusPanel({ [childSubagentSessions], ); const hasCompatSchedulerSignals = compatSubagentRuntime.hasSignals; + const hasSelectedTeamConfig = Boolean(selectedTeamLabel?.trim()) || + Boolean(selectedTeamSummary?.trim()) || + (selectedTeamRoles?.length ?? 0) > 0; const fileFilterOptions = useMemo( () => @@ -1529,6 +1540,10 @@ export function HarnessStatusPanel({ const availableSections = useMemo(() => { const sections: HarnessSectionNavItem[] = []; + if (hasSelectedTeamConfig) { + sections.push({ key: "team_config", label: "当前 Team" }); + } + if (harnessState.runtimeStatus) { sections.push({ key: "runtime", label: "当前阶段" }); } @@ -1581,6 +1596,7 @@ export function HarnessStatusPanel({ harnessState.plan.phase, harnessState.recentFileEvents.length, harnessState.runtimeStatus, + hasSelectedTeamConfig, hasCompatSchedulerSignals, realTeamSummary.total, ]); @@ -1599,6 +1615,22 @@ export function HarnessStatusPanel({ }); } + if (hasSelectedTeamConfig) { + cards.push({ + sectionKey: "team_config", + title: "当前 Team", + value: + selectedTeamLabel?.trim() || + `${selectedTeamRoles?.length || 0} 个角色`, + hint: + selectedTeamSummary?.trim() || + ((selectedTeamRoles?.length || 0) > 0 + ? `已配置 ${selectedTeamRoles?.length || 0} 个角色` + : "当前回合已启用 Team 配置"), + icon: Workflow, + }); + } + if (harnessState.activeFileWrites.length > 0) { cards.push({ sectionKey: "writes", @@ -1695,6 +1727,7 @@ export function HarnessStatusPanel({ environment.contextEnabled, environment.contextItemsCount, hasToolInventorySection, + hasSelectedTeamConfig, harnessState.activeFileWrites, harnessState.pendingApprovals.length, harnessState.plan.items, @@ -1708,6 +1741,9 @@ export function HarnessStatusPanel({ realTeamSummary.running, realTeamSummary.settled, realTeamSummary.total, + selectedTeamLabel, + selectedTeamRoles?.length, + selectedTeamSummary, toolInventory, toolInventoryError, toolInventoryLoading, @@ -2048,6 +2084,78 @@ export function HarnessStatusPanel({ ))}
) : null} + {hasSelectedTeamConfig ? ( +
0 + ? `${selectedTeamRoles.length} 个角色` + : undefined + } + registerRef={registerSectionRef} + > +
+
+
+ + {selectedTeamLabel || "当前已启用 Team"} +
+ {selectedTeamSummary ? ( +
+ {selectedTeamSummary} +
+ ) : ( +
+ 当前回合会优先参考所选 Team 的角色分工来决定是否委派子代理。 +
+ )} +
+ + {selectedTeamRoles && selectedTeamRoles.length > 0 ? ( +
+ {selectedTeamRoles.map((role, index) => ( +
+
+
+ {role.label} +
+ {role.profileId ? ( + + 画像 {role.profileId} + + ) : null} + {role.roleKey ? ( + + Role {role.roleKey} + + ) : null} +
+
+ {role.summary} +
+ {role.skillIds && role.skillIds.length > 0 ? ( +
+ {role.skillIds.map((skillId) => ( + + {skillId} + + ))} +
+ ) : null} +
+ ))} +
+ ) : null} +
+
+ ) : null} {harnessState.runtimeStatus ? (
) => void; } -const SecondaryControlsRow = styled.div.attrs({ - "data-testid": "inputbar-secondary-controls", -})` +const SecondaryControlsRow = styled.div` position: absolute; right: 8px; bottom: calc(100% + 8px); @@ -78,7 +76,7 @@ export const InputbarOverlayShell: React.FC = ({ /> ) : null} {taskFiles.length > 0 || overlayAccessory ? ( - + void; } -const Area = styled.div.attrs({ - "data-testid": "task-files-panel-area", -})` +const Area = styled.div` position: relative; display: flex; justify-content: flex-end; @@ -80,7 +78,7 @@ export function TaskFilesPanel({ } return ( - + = ({ return `Team · ${selectedTeam.label.trim()}`; }, [selectedTeam?.label, triggerLabel]); + const selectedRoleCount = selectedTeam?.roles.length || 0; + return ( - - - - - + + + + + {open ? ( + + 加载中... + + } + > + { + onSelectTeam(team); + setOpen(false); + }} + onClose={() => setOpen(false)} + /> + + ) : null} + + + ); }; diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx new file mode 100644 index 000000000..5a157424b --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx @@ -0,0 +1,161 @@ +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamSelectorPanel } from "./TeamSelectorPanel"; +import { + createTeamDefinitionFromPreset, + type TeamDefinition, +} from "../../../utils/teamDefinitions"; + +const { mockToast } = vi.hoisted(() => ({ + mockToast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("sonner", () => ({ + toast: mockToast, +})); + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function renderPanel( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const defaultProps: ComponentProps = { + onSelectTeam: vi.fn(), + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return { container }; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function setInputValue( + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null, + value: string, +) { + if (!element) { + throw new Error("未找到目标输入元素"); + } + + act(() => { + const prototype = + element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : element instanceof HTMLSelectElement + ? HTMLSelectElement.prototype + : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +describe("TeamSelectorPanel", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + }); + + afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + localStorage.clear(); + vi.clearAllMocks(); + }); + + it("应保存自定义 Team 的 profileId、roleKey 与 skillIds", async () => { + const onSelectTeam = vi.fn(); + const selectedTeam = createTeamDefinitionFromPreset( + "code-triage-team", + ) as TeamDefinition; + const { container } = renderPanel({ + selectedTeam, + onSelectTeam, + }); + + await flushEffects(); + + const createButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("新建自定义 Team"), + ); + + expect(createButton).toBeTruthy(); + + act(() => { + createButton?.click(); + }); + + await flushEffects(); + + setInputValue( + container.querySelector( + '[data-testid="team-role-profile-select-0"]', + ) as HTMLSelectElement | null, + "research-analyst", + ); + setInputValue( + container.querySelector( + '[data-testid="team-role-role-key-input-0"]', + ) as HTMLInputElement | null, + "research-lead", + ); + setInputValue( + container.querySelector( + '[data-testid="team-role-skill-ids-input-0"]', + ) as HTMLInputElement | null, + "source-grounding, structured-writing", + ); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("保存 Team"), + ); + + expect(saveButton).toBeTruthy(); + + act(() => { + saveButton?.click(); + }); + + const savedTeam = onSelectTeam.mock.calls[0]?.[0] as TeamDefinition | undefined; + + expect(savedTeam).toBeTruthy(); + expect(savedTeam?.source).toBe("custom"); + expect(savedTeam?.roles[0]?.profileId).toBe("research-analyst"); + expect(savedTeam?.roles[0]?.roleKey).toBe("research-lead"); + expect(savedTeam?.roles[0]?.skillIds).toEqual([ + "source-grounding", + "structured-writing", + ]); + expect(mockToast.success).toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx index 02144c44f..43a324c1d 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -16,6 +16,12 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { + BUILTIN_TEAM_PROFILE_OPTIONS, + BUILTIN_TEAM_SKILL_OPTIONS, + getBuiltinTeamProfileOption, + getBuiltinTeamSkillOption, +} from "../../../utils/teamPresets"; import { buildTeamDefinitionSummary, cloneTeamDefinitionAsCustom, @@ -105,6 +111,13 @@ function matchTeamQuery(team: TeamDefinition, query: string): boolean { ); } +function parseSkillIdsInput(value: string): string[] { + return value + .split(/[\n,,]/) + .map((item) => item.trim()) + .filter(Boolean); +} + function TeamCard({ team, selected, @@ -186,6 +199,31 @@ function TeamCard({ {role.label} · {role.summary} + {role.profileId || role.roleKey || role.skillIds?.length ? ( +
+ {role.profileId ? ( + + 画像 ·{" "} + {getBuiltinTeamProfileOption(role.profileId)?.label || + role.profileId} + + ) : null} + {role.roleKey ? ( + + Role · {role.roleKey} + + ) : null} + {role.skillIds?.map((skillId) => ( + + 技能 ·{" "} + {getBuiltinTeamSkillOption(skillId)?.label || skillId} + + ))} +
+ ) : null} ))} @@ -294,6 +332,36 @@ export const TeamSelectorPanel: React.FC = ({ const currentSelectionSummary = buildTeamDefinitionSummary(selectedTeam); + const updateDraftRole = ( + roleIndex: number, + updater: (role: TeamRoleDefinition) => TeamRoleDefinition, + ) => { + setDraft((current) => + current + ? { + ...current, + roles: current.roles.map((item, index) => + index === roleIndex ? updater(item) : item, + ), + } + : current, + ); + }; + + const toggleDraftRoleSkill = (roleIndex: number, skillId: string) => { + updateDraftRole(roleIndex, (role) => { + const currentSkillIds = role.skillIds || []; + const nextSkillIds = currentSkillIds.includes(skillId) + ? currentSkillIds.filter((item) => item !== skillId) + : [...currentSkillIds, skillId]; + + return { + ...role, + skillIds: nextSkillIds, + }; + }); + }; + const handleStartCreate = (base?: TeamDefinition | null) => { setDraft(base ? buildDraftFromTeam(cloneTeamDefinitionAsCustom(base)) : createBlankDraft(activeTheme)); }; @@ -639,6 +707,7 @@ export const TeamSelectorPanel: React.FC = ({ id: `role-${current.roles.length + 1}`, label: "", summary: "", + skillIds: [], }, ], } @@ -657,6 +726,15 @@ export const TeamSelectorPanel: React.FC = ({ key={`${role.id}-${index}`} className="rounded-2xl border border-slate-200 bg-white p-3" > + {(() => { + const selectedProfile = getBuiltinTeamProfileOption( + role.profileId, + ); + const resolvedSkillIds = role.skillIds || []; + const suggestedSkillIds = selectedProfile?.skillIds || []; + + return ( + <>
角色 {index + 1} @@ -685,21 +763,10 @@ export const TeamSelectorPanel: React.FC = ({ - setDraft((current) => - current - ? { - ...current, - roles: current.roles.map((item, roleIndex) => - roleIndex === index - ? { - ...item, - label: event.target.value, - } - : item, - ), - } - : current, - ) + updateDraftRole(index, (item) => ({ + ...item, + label: event.target.value, + })) } placeholder="角色名称,例如:分析" className="border-slate-200 bg-white" @@ -707,26 +774,144 @@ export const TeamSelectorPanel: React.FC = ({