From f57be313113110b838710dbc13fab2377c4f0b02 Mon Sep 17 00:00:00 2001 From: coso Date: Sat, 21 Mar 2026 00:07:58 +0800 Subject: [PATCH] fix: sync agent chat updates and remove pr gate --- .github/workflows/pr-gate.yml | 136 ------------------ RELEASE_NOTES.md | 2 +- docs/test/e2e-tests.md | 2 +- docs/test/testing-strategy-2026.md | 4 +- .../agent/chat/AgentChatHomeShell.tsx | 64 +-------- .../agent/chat/AgentChatWorkspace.tsx | 86 ++--------- .../components/HarnessStatusPanel.test.tsx | 18 +++ .../chat/components/HarnessStatusPanel.tsx | 2 + .../chat/components/StreamingRenderer.tsx | 100 +++++++++---- .../chat/hooks/agentStreamRuntimeHandler.ts | 63 +++++++- .../agent/chat/hooks/useAgentStream.ts | 80 +++++++---- .../chat/hooks/useAsterAgentChat.test.tsx | 106 ++++++++++++++ .../hooks/useSelectedTeamPreference.test.tsx | 119 +++++++++++++++ .../chat/hooks/useSelectedTeamPreference.ts | 68 +++++++++ src/components/agent/chat/index.test.tsx | 102 +++++++++++++ src/components/agent/chat/types.ts | 2 +- .../agent/chat/utils/agentRuntimeStatus.ts | 42 ++++++ .../agent/chat/utils/teamStorage.ts | 16 +++ src/lib/api/agentStream.ts | 2 +- 19 files changed, 681 insertions(+), 333 deletions(-) delete mode 100644 .github/workflows/pr-gate.yml create mode 100644 src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx create mode 100644 src/components/agent/chat/hooks/useSelectedTeamPreference.ts diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml deleted file mode 100644 index f611a9393..000000000 --- a/.github/workflows/pr-gate.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: PR Gate - -on: - pull_request: - paths: - - ".github/workflows/**" - - "package.json" - - "package-lock.json" - - "src/**" - - "src-tauri/**" - - "scripts/**" - - "eslint.config.js" - - "tsconfig.json" - - "tsconfig.node.json" - - "tailwind.config.js" - - "postcss.config.js" - - "vite.config.ts" - - "index.html" - push: - branches: - - main - paths: - - ".github/workflows/**" - - "package.json" - - "package-lock.json" - - "src/**" - - "src-tauri/**" - - "scripts/**" - - "eslint.config.js" - - "tsconfig.json" - - "tsconfig.node.json" - - "tailwind.config.js" - - "postcss.config.js" - - "vite.config.ts" - - "index.html" - workflow_dispatch: - -permissions: - contents: read - -env: - CARGO_NET_RETRY: 10 - RUSTUP_MAX_RETRIES: 10 - CARGO_TERM_COLOR: always - -jobs: - frontend: - name: Frontend Verify - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Lint - run: npm run lint - - - name: Typecheck - run: npm run typecheck - - - name: Run Vitest - run: npm test - - bridge: - name: Bridge & Contracts Verify - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run bridge tests - run: npm run test:bridge - - - name: Run command contracts - run: npm run test:contracts - - rust: - name: Rust Verify - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Linux dependencies - run: | - sudo apt update - sudo apt install -y \ - libwebkit2gtk-4.1-dev \ - build-essential \ - curl \ - wget \ - file \ - libxdo-dev \ - libssl-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - libasound2-dev - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - with: - workspaces: src-tauri - shared-key: pr-gate-rust-ubuntu - cache-on-failure: true - - - name: Run cargo test - run: cargo test --manifest-path "src-tauri/Cargo.toml" - - - name: Run cargo clippy - run: cargo clippy --manifest-path "src-tauri/Cargo.toml" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d052b3efd..28fd043d3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,7 +6,7 @@ - **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 与治理报告更新,发布前自检链路更清晰 +- **治理与测试基建升级**:本地校验脚本、命令契约检查、workspace smoke 与治理报告继续完善,发布前自检链路更清晰 - **Provider / 模型兼容性继续补强**:补充 Novita 与多种 OpenAI/Claude 兼容 provider 细节,推理内容与工具调用适配继续完善 ### ⚠️ 兼容性说明 diff --git a/docs/test/e2e-tests.md b/docs/test/e2e-tests.md index f57529e66..b6b1a532d 100644 --- a/docs/test/e2e-tests.md +++ b/docs/test/e2e-tests.md @@ -112,7 +112,7 @@ npm run bridge:health -- --timeout-ms 120000 - 假设 `tauri-driver` 仍是推荐路径 - 假设浏览器 E2E 已进入 CI 标准门禁 -当前 PR 门禁以 `.github/workflows/pr-gate.yml` 为准;完整浏览器主链路 smoke 仍属于后续建设项,详见 `docs/test/testing-strategy-2026.md`。 +当前浏览器主链路 smoke 仍属于后续建设项,详见 `docs/test/testing-strategy-2026.md`。 ## 7. 给后续 Agent 的交接要求 diff --git a/docs/test/testing-strategy-2026.md b/docs/test/testing-strategy-2026.md index 524e175d0..5a6a79177 100644 --- a/docs/test/testing-strategy-2026.md +++ b/docs/test/testing-strategy-2026.md @@ -14,7 +14,6 @@ - `package.json`:当前统一测试命令入口 - `scripts/local-ci.mjs`:当前本地智能校验入口 - `scripts/report-legacy-surfaces.mjs`:当前 legacy / compat 回流护栏 -- `.github/workflows/pr-gate.yml`:当前 PR 自动门禁入口 ### compat @@ -39,11 +38,10 @@ - 桥接基础测试已经存在:`src/lib/dev-bridge/safeInvoke.test.ts`、`src/lib/tauri-mock/core.test.ts` - legacy 治理护栏已经存在:`npm run governance:legacy-report` - 旧权限表面治理护栏已经补齐:`src/lib/governance/legacyToolPermissionGuard.test.ts` + `npm run governance:legacy-report` -- 跨层命令契约检查基础版已经落地:`npm run test:contracts` 已进入 `scripts/local-ci.mjs` 与 `.github/workflows/pr-gate.yml` +- 跨层命令契约检查基础版已经落地:`npm run test:contracts` 已进入 `scripts/local-ci.mjs` - 命令契约延期例外已经收口:`agent_terminal_command_response`、`agent_term_scrollback_response` 已退出 `runtimeGatewayCommands`,改为 `dead-candidate` 治理监控 - 首条自包含 smoke 已落地:`npm run smoke:workspace-ready` 可自动校验 DevBridge 就绪、默认 workspace 获取、目录修复与路径回查 - 测试文档事实源已经收口:`docs/test/README.md`、`docs/test/e2e-tests.md`、`docs/aiprompts/playwright-e2e.md` 已按“索引 / 总览 / 详细事实源”分层 -- PR 自动门禁已经补齐:`.github/workflows/pr-gate.yml` 已覆盖前端、bridge、Rust 三类基础检查 ## 3. 当前仍未解决的问题优先级 diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx index fa83cb8fa..421fdae22 100644 --- a/src/components/agent/chat/AgentChatHomeShell.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.tsx @@ -30,16 +30,7 @@ import { } from "./utils/chatToolPreferences"; import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations"; import { normalizeProjectId } from "./utils/topicProjectResolution"; -import { - createTeamDefinitionFromPreset, - listBuiltinTeamDefinitions, - type TeamDefinition, -} from "./utils/teamDefinitions"; -import { - loadCustomTeams, - loadSelectedTeamReference, - persistSelectedTeam, -} from "./utils/teamStorage"; +import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference"; const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ "general", @@ -161,22 +152,6 @@ function normalizeInitialTheme(value?: string): ThemeType { return "general"; } -function resolvePersistedSelectedTeam(theme?: string | null): TeamDefinition | null { - const selection = loadSelectedTeamReference(theme); - if (!selection) { - return null; - } - - if (selection.source === "builtin") { - return ( - listBuiltinTeamDefinitions().find((team) => team.id === selection.id) || - null - ); - } - - return loadCustomTeams().find((team) => team.id === selection.id) || null; -} - function scheduleDeferredHomeEnhancement(task: () => void): () => void { if (typeof window === "undefined") { return () => undefined; @@ -302,9 +277,6 @@ export function AgentChatHomeShell({ const [creationMode, setCreationMode] = useState( initialCreationMode ?? "guided", ); - const [selectedTeam, setSelectedTeam] = useState(() => - resolvePersistedSelectedTeam(initialTheme), - ); const [chatToolPreferences, setChatToolPreferences] = useState(() => loadChatToolPreferences(normalizedEntryTheme), @@ -329,6 +301,11 @@ export function AgentChatHomeShell({ const [skills, setSkills] = useState([]); const [skillsLoading, setSkillsLoading] = useState(false); const [browserAssistLoading, setBrowserAssistLoading] = useState(false); + const { + selectedTeam, + setSelectedTeam: handleSelectTeam, + enableSuggestedTeam: handleEnableSuggestedTeam, + } = useSelectedTeamPreference(activeTheme); useEffect(() => { setActiveTheme(normalizeInitialTheme(initialTheme)); @@ -365,14 +342,6 @@ export function AgentChatHomeShell({ saveChatToolPreferences(chatToolPreferences, activeTheme); }, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]); - useEffect(() => { - setSelectedTeam(resolvePersistedSelectedTeam(activeTheme)); - }, [activeTheme]); - - useEffect(() => { - persistSelectedTeam(selectedTeam, activeTheme); - }, [activeTheme, selectedTeam]); - useEffect(() => { const nextPreferences = resolvePersistedProviderModel(currentProjectId); setProviderTypeState(nextPreferences.providerType); @@ -615,27 +584,6 @@ export function AgentChatHomeShell({ [activeTheme, chatToolPreferences, handleEnterWorkspace], ); - const handleEnableSuggestedTeam = useCallback((suggestedPresetId?: string) => { - const resolvedPresetId = suggestedPresetId?.trim(); - if (!resolvedPresetId) { - return; - } - - const suggestedTeam = createTeamDefinitionFromPreset(resolvedPresetId); - if (suggestedTeam) { - persistSelectedTeam(suggestedTeam, activeTheme); - setSelectedTeam(suggestedTeam); - } - }, [activeTheme]); - - const handleSelectTeam = useCallback( - (team: TeamDefinition | null) => { - persistSelectedTeam(team, activeTheme); - setSelectedTeam(team); - }, - [activeTheme], - ); - return ( diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index b531a01a2..5451113bf 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -273,18 +273,7 @@ import { buildGeneralAgentSystemPrompt, resolveAgentChatMode, } from "./utils/generalAgentPrompt"; -import { - buildTeamDefinitionLabel, - buildTeamDefinitionSummary, - createTeamDefinitionFromPreset, - listBuiltinTeamDefinitions, - type TeamDefinition, -} from "./utils/teamDefinitions"; -import { - loadCustomTeams, - loadSelectedTeamReference, - persistSelectedTeam, -} from "./utils/teamStorage"; +import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference"; import { areBrowserAssistSessionStatesEqual, clearBrowserAssistSessionState, @@ -362,22 +351,6 @@ function normalizeInitialTheme(value?: string): ThemeType { return "general"; } -function resolvePersistedSelectedTeam(theme?: string | null): TeamDefinition | null { - const selection = loadSelectedTeamReference(theme); - if (!selection) { - return null; - } - - if (selection.source === "builtin") { - return ( - listBuiltinTeamDefinitions().find((team) => team.id === selection.id) || - null - ); - } - - return loadCustomTeams().find((team) => team.id === selection.id) || null; -} - function shouldPreserveGeneralArtifact(artifact: Artifact): boolean { return artifact.meta.persistOutsideMessages === true; } @@ -2591,9 +2564,6 @@ export function AgentChatWorkspace({ // 内容创作相关状态 const [activeTheme, setActiveTheme] = useState(normalizedEntryTheme); - const [selectedTeam, setSelectedTeam] = useState(() => - resolvePersistedSelectedTeam(normalizedEntryTheme), - ); const [creationMode, setCreationMode] = useState( initialCreationMode ?? "guided", ); @@ -2741,6 +2711,14 @@ export function AgentChatWorkspace({ setThemeWorkbenchCreationTaskEvents, ] = useState([]); const documentEditorFocusedRef = useRef(false); + const { + selectedTeam, + setSelectedTeam: handleSelectTeam, + enableSuggestedTeam: handleEnableSuggestedTeam, + preferredTeamPresetId, + selectedTeamLabel, + selectedTeamSummary, + } = useSelectedTeamPreference(activeTheme); useEffect(() => { setActiveContentTarget(projectId, contentId, canvasState?.type ?? null); @@ -2850,52 +2828,6 @@ export function AgentChatWorkspace({ // 工作流状态(仅在内容创作模式下使用) const mappedTheme = activeTheme as ThemeType; - const preferredTeamPresetId = useMemo( - () => - selectedTeam?.presetId?.trim() || - (selectedTeam?.source === "builtin" ? selectedTeam.id : undefined), - [selectedTeam], - ); - const selectedTeamLabel = useMemo( - () => buildTeamDefinitionLabel(selectedTeam) || undefined, - [selectedTeam], - ); - const selectedTeamSummary = useMemo( - () => buildTeamDefinitionSummary(selectedTeam) || undefined, - [selectedTeam], - ); - - useEffect(() => { - setSelectedTeam(resolvePersistedSelectedTeam(activeTheme)); - }, [activeTheme]); - - useEffect(() => { - persistSelectedTeam(selectedTeam, activeTheme); - }, [activeTheme, selectedTeam]); - - const handleSelectTeam = useCallback( - (team: TeamDefinition | null) => { - persistSelectedTeam(team, activeTheme); - setSelectedTeam(team); - }, - [activeTheme], - ); - - const handleEnableSuggestedTeam = useCallback( - (suggestedPresetId?: string) => { - const resolvedPresetId = suggestedPresetId?.trim(); - if (!resolvedPresetId) { - return; - } - - const suggestedTeam = createTeamDefinitionFromPreset(resolvedPresetId); - if (suggestedTeam) { - persistSelectedTeam(suggestedTeam, activeTheme); - setSelectedTeam(suggestedTeam); - } - }, - [activeTheme], - ); useEffect(() => { setRuntimeStyleSelection({ diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index a3b51b25d..f123fdc54 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -400,6 +400,24 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain("等待首个模型事件"); }); + it("runtimeStatus 为 failed 时应展示失败阶段与失败详情", () => { + renderPanel({ + harnessState: createHarnessState({ + runtimeStatus: { + phase: "failed", + title: "当前执行失败", + detail: "429 rate limit", + checkpoints: ["已保留当前回合过程"], + }, + }), + }); + + expect(document.body.textContent).toContain("执行阶段"); + expect(document.body.textContent).toContain("失败"); + expect(document.body.textContent).toContain("当前执行失败"); + expect(document.body.textContent).toContain("429 rate limit"); + }); + it("存在 selectedTeam 时应在工作台展示当前 Team 配置", () => { renderPanel({ selectedTeamLabel: "前端联调团队", diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 632a738f9..078dd8b34 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -544,6 +544,8 @@ function formatRuntimePhaseLabel( return "建回合中"; case "context": return "装载上下文"; + case "failed": + return "失败"; default: return runtimeStatus.phase; } diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index a8e80db9b..b5ea25495 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -8,6 +8,7 @@ import React, { memo, useMemo, useState, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { + AlertTriangle, ChevronDown, ExternalLink, FileText, @@ -567,37 +568,88 @@ const RUNTIME_PHASE_LABELS: Record = { preparing: "准备中", routing: "回合建立中", context: "上下文装载中", + failed: "执行失败", }; const AgentRuntimeStatusBlock: React.FC<{ status: AgentRuntimeStatus }> = ({ status, -}) => ( -
-
-
- +}) => { + const failed = status.phase === "failed"; + + return ( +
+
+
+ {failed ? ( + + ) : ( + + )} +
+
+ {status.title} +
+
+ {failed ? ( + + ) : ( + + )} + {RUNTIME_PHASE_LABELS[status.phase]} +
-
{status.title}
-
- - {RUNTIME_PHASE_LABELS[status.phase]} +
+ {status.detail}
+ {status.checkpoints && status.checkpoints.length > 0 ? ( +
+ {status.checkpoints.map((item) => ( + + {item} + + ))} +
+ ) : null}
-
{status.detail}
- {status.checkpoints && status.checkpoints.length > 0 ? ( -
- {status.checkpoints.map((item) => ( - - {item} - - ))} -
- ) : null} -
-); + ); +}; /** * 流式消息渲染组件 diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts index 6c720abe5..592c777fd 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts @@ -28,6 +28,11 @@ import { handleToolStartEvent, } from "./agentStreamEventProcessor"; import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; +import { + buildFailedAgentMessageContent, + buildFailedAgentRuntimeStatus, + formatAgentRuntimeStatusSummary, +} from "../utils/agentRuntimeStatus"; type MessageParts = NonNullable; @@ -156,6 +161,49 @@ export function handleTurnStreamEvent({ appendThinkingToParts, } = callbacks; + const markFailedTimelineState = (errorMessage: string) => { + const failedAt = new Date().toISOString(); + const failedRuntimeStatus = buildFailedAgentRuntimeStatus(errorMessage); + + setThreadTurns((prev) => { + const runningTurn = + prev.find((turn) => turn.id === optimisticTurnId) || + [...prev] + .reverse() + .find( + (turn) => + turn.thread_id === activeSessionId && turn.status === "running", + ); + + if (!runningTurn) { + return prev; + } + + return upsertThreadTurnState(prev, { + ...runningTurn, + status: "failed", + error_message: errorMessage, + completed_at: runningTurn.completed_at || failedAt, + updated_at: failedAt, + }); + }); + + setThreadItems((prev) => { + const optimisticItem = prev.find((item) => item.id === optimisticItemId); + if (!optimisticItem || optimisticItem.type !== "turn_summary") { + return prev; + } + + return upsertThreadItemState(prev, { + ...optimisticItem, + status: "failed", + completed_at: optimisticItem.completed_at || failedAt, + updated_at: failedAt, + text: formatAgentRuntimeStatusSummary(failedRuntimeStatus), + }); + }); + }; + switch (data.type) { case "thread_started": break; @@ -411,9 +459,8 @@ export function handleTurnStreamEvent({ break; } - case "error": - clearOptimisticItem(); - clearOptimisticTurn(); + case "error": { + markFailedTimelineState(data.message); removeQueuedTurnState(requestState.queuedTurnId ? [requestState.queuedTurnId] : []); finishRequestLog(requestState, { eventType: "chat_request_error", @@ -421,6 +468,7 @@ export function handleTurnStreamEvent({ error: data.message, }); observer?.onError?.(data.message); + const failedRuntimeStatus = buildFailedAgentRuntimeStatus(data.message); if ( data.message.includes("429") || data.message.toLowerCase().includes("rate limit") @@ -435,9 +483,11 @@ export function handleTurnStreamEvent({ ? { ...updateMessageArtifactsStatus(msg, "error"), isThinking: false, - content: - requestState.accumulatedContent || `错误: ${data.message}`, - runtimeStatus: undefined, + content: buildFailedAgentMessageContent( + data.message, + requestState.accumulatedContent || msg.content, + ), + runtimeStatus: failedRuntimeStatus, } : msg, ), @@ -445,6 +495,7 @@ export function handleTurnStreamEvent({ clearActiveStreamIfMatch(eventName); disposeListener(); break; + } case "warning": { if (data.code === WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE) { diff --git a/src/components/agent/chat/hooks/useAgentStream.ts b/src/components/agent/chat/hooks/useAgentStream.ts index 3a7b5d5a8..386d4de3d 100644 --- a/src/components/agent/chat/hooks/useAgentStream.ts +++ b/src/components/agent/chat/hooks/useAgentStream.ts @@ -42,8 +42,11 @@ import { upsertThreadTurnState, } from "./agentThreadState"; import { + buildFailedAgentMessageContent, + buildFailedAgentRuntimeStatus, buildInitialAgentRuntimeStatus, buildWaitingAgentRuntimeStatus, + formatAgentRuntimeStatusSummary, } from "../utils/agentRuntimeStatus"; import { handleTurnStreamEvent } from "./agentStreamRuntimeHandler"; @@ -210,22 +213,6 @@ export function useAgentStream(options: UseAgentStreamOptions) { [], ); - const buildRuntimeStatusSummary = useCallback( - (status?: Message["runtimeStatus"]): string => { - if (!status?.title) { - return "Agent 正在准备执行"; - } - - const lines = [status.title.trim()]; - if (status.detail?.trim()) { - lines.push(status.detail.trim()); - } - - return lines.join("\n\n"); - }, - [], - ); - const sendMessage = useCallback( async ( content: string, @@ -436,6 +423,45 @@ export function useAgentStream(options: UseAgentStreamOptions) { setCurrentTurnId((prev) => (prev === optimisticTurnId ? null : prev)); }; + const markOptimisticFailure = (errorMessage: string) => { + if (expectingQueue) { + return; + } + + const failedAt = new Date().toISOString(); + const failedRuntimeStatus = buildFailedAgentRuntimeStatus(errorMessage); + + setThreadTurns((prev) => { + const currentTurn = prev.find((turn) => turn.id === optimisticTurnId); + if (!currentTurn) { + return prev; + } + + return upsertThreadTurnState(prev, { + ...currentTurn, + status: "failed", + error_message: errorMessage, + completed_at: currentTurn.completed_at || failedAt, + updated_at: failedAt, + }); + }); + + setThreadItems((prev) => { + const currentItem = prev.find((item) => item.id === optimisticItemId); + if (!currentItem || currentItem.type !== "turn_summary") { + return prev; + } + + return upsertThreadItemState(prev, { + ...currentItem, + status: "failed", + completed_at: currentItem.completed_at || failedAt, + updated_at: failedAt, + text: formatAgentRuntimeStatusSummary(failedRuntimeStatus), + }); + }); + }; + const disposeListener = () => { const registered = listenerMapRef.current.get(eventName); if (registered) { @@ -469,7 +495,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { started_at: optimisticStartedAt, updated_at: optimisticStartedAt, type: "turn_summary", - text: buildRuntimeStatusSummary(assistantMsg.runtimeStatus), + text: formatAgentRuntimeStatusSummary(assistantMsg.runtimeStatus), }), ); setCurrentTurnId(optimisticTurnId); @@ -534,7 +560,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { started_at: optimisticStartedAt, updated_at: new Date().toISOString(), type: "turn_summary", - text: buildRuntimeStatusSummary(waitingRuntimeStatus), + text: formatAgentRuntimeStatusSummary(waitingRuntimeStatus), }), ); } @@ -653,6 +679,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { } console.error("[AsterChat] 发送失败:", error); const errMsg = error instanceof Error ? error.message : String(error); + const failedRuntimeStatus = buildFailedAgentRuntimeStatus(errMsg); observer?.onError?.(errMsg); if ( errMsg.includes("429") || @@ -664,16 +691,20 @@ export function useAgentStream(options: UseAgentStreamOptions) { } else { toast.error(`发送失败: ${error}`); } - clearOptimisticItem(); - clearOptimisticTurn(); + markOptimisticFailure(errMsg); removeQueuedTurnState( requestState.queuedTurnId ? [requestState.queuedTurnId] : [], ); setMessages((prev) => - prev.filter( - (msg) => - msg.id !== assistantMsgId && - (!expectingQueue || !userMsgId || msg.id !== userMsgId), + prev.map((msg) => + msg.id === assistantMsgId + ? { + ...updateMessageArtifactsStatus(msg, "error"), + isThinking: false, + content: buildFailedAgentMessageContent(errMsg, msg.content), + runtimeStatus: failedRuntimeStatus, + } + : msg, ), ); clearActiveStreamIfMatch(eventName); @@ -686,7 +717,6 @@ export function useAgentStream(options: UseAgentStreamOptions) { [ activeStreamRef, buildQueuedRuntimeStatus, - buildRuntimeStatusSummary, clearActiveStreamIfMatch, ensureSession, executionStrategy, diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index 9e43c79a5..bc01e0e57 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -920,6 +920,48 @@ describe("useAsterAgentChat thread timeline", () => { } }); + it("submitTurn 失败时应保留失败回合与失败消息,而不是清空当前过程", async () => { + const workspaceId = "ws-thread-submit-failed"; + seedSession(workspaceId, "session-thread-submit-failed"); + mockSubmitAgentRuntimeTurn.mockRejectedValueOnce(new Error("429 rate limit")); + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + + await act(async () => { + await harness + .getValue() + .sendMessage("帮我开始执行", [], false, false, false, "react"); + }); + + const assistantMessage = [...harness.getValue().messages] + .reverse() + .find((msg) => msg.role === "assistant"); + + expect(assistantMessage?.content).toContain("执行失败:429 rate limit"); + expect(assistantMessage?.runtimeStatus).toMatchObject({ + phase: "failed", + title: "当前执行失败", + }); + expect(harness.getValue().turns).toEqual([ + expect.objectContaining({ + status: "failed", + error_message: "429 rate limit", + }), + ]); + expect(harness.getValue().threadItems).toEqual([ + expect.objectContaining({ + type: "turn_summary", + status: "failed", + }), + ]); + expect(mockToast.warning).toHaveBeenCalledWith("请求过于频繁,请稍后重试"); + } finally { + harness.unmount(); + } + }); + it("应接收 turn/item 生命周期事件并写入运行态", async () => { const workspaceId = "ws-thread-timeline"; seedSession(workspaceId, "session-thread-timeline"); @@ -1005,6 +1047,70 @@ describe("useAsterAgentChat thread timeline", () => { harness.unmount(); } }); + + it("stream error 事件时应保留失败消息与失败回合", async () => { + const workspaceId = "ws-thread-stream-error"; + seedSession(workspaceId, "session-thread-stream-error"); + const harness = mountHook(workspaceId); + const stream = captureTurnStream(); + + try { + await flushEffects(); + + await act(async () => { + await harness + .getValue() + .sendMessage("请开始处理", [], false, false, false, "react"); + }); + + act(() => { + stream.emit({ + type: "turn_started", + turn: { + id: "turn-stream-error-1", + thread_id: "session-thread-stream-error", + prompt_text: "请开始处理", + status: "running", + started_at: "2026-03-20T10:00:00.000Z", + created_at: "2026-03-20T10:00:00.000Z", + updated_at: "2026-03-20T10:00:00.000Z", + }, + }); + stream.emit({ + type: "error", + message: "模型执行失败", + }); + }); + + const assistantMessage = [...harness.getValue().messages] + .reverse() + .find((msg) => msg.role === "assistant"); + + expect(assistantMessage?.content).toContain("执行失败:模型执行失败"); + expect(assistantMessage?.runtimeStatus).toMatchObject({ + phase: "failed", + title: "当前执行失败", + }); + expect(harness.getValue().turns).toEqual([ + expect.objectContaining({ + id: "turn-stream-error-1", + status: "failed", + error_message: "模型执行失败", + }), + ]); + expect(harness.getValue().threadItems).toEqual([ + expect.objectContaining({ + id: expect.stringContaining("turn-summary:"), + type: "turn_summary", + status: "failed", + turn_id: "turn-stream-error-1", + }), + ]); + expect(mockToast.error).toHaveBeenCalledWith("响应错误: 模型执行失败"); + } finally { + harness.unmount(); + } + }); }); describe("useAsterAgentChat runtime routing", () => { diff --git a/src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx b/src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx new file mode 100644 index 000000000..770381c79 --- /dev/null +++ b/src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx @@ -0,0 +1,119 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { TeamDefinition } from "../utils/teamDefinitions"; +import { createTeamDefinitionFromPreset } from "../utils/teamDefinitions"; +import { useSelectedTeamPreference } from "./useSelectedTeamPreference"; +import { + loadSelectedTeamReference, + persistSelectedTeam, +} from "../utils/teamStorage"; + +interface HookHarness { + getValue: () => ReturnType; + rerender: (theme?: string | null) => void; + unmount: () => void; +} + +function mountHook(initialTheme?: string | null): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent({ theme }: { theme?: string | null }) { + hookValue = useSelectedTeamPreference(theme); + return null; + } + + const render = (theme?: string | null) => { + act(() => { + root.render(); + }); + }; + + render(initialTheme); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useSelectedTeamPreference", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("切换 theme 时应读取对应 Team,而不是把旧主题选择写回新主题", async () => { + const engineeringTeam = createTeamDefinitionFromPreset( + "code-triage-team", + ) as TeamDefinition; + const researchTeam = createTeamDefinitionFromPreset( + "research-team", + ) as TeamDefinition; + + persistSelectedTeam(engineeringTeam, "general"); + persistSelectedTeam(researchTeam, "knowledge"); + + const harness = mountHook("general"); + + try { + await flushEffects(); + expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team"); + + harness.rerender("knowledge"); + await flushEffects(); + + expect(harness.getValue().selectedTeam?.id).toBe("research-team"); + expect(loadSelectedTeamReference("general")).toEqual({ + id: "code-triage-team", + source: "builtin", + }); + expect(loadSelectedTeamReference("knowledge")).toEqual({ + id: "research-team", + source: "builtin", + }); + + act(() => { + harness.getValue().setSelectedTeam(null); + }); + + expect(loadSelectedTeamReference("knowledge")).toBeNull(); + expect(loadSelectedTeamReference("general")).toEqual({ + id: "code-triage-team", + source: "builtin", + }); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useSelectedTeamPreference.ts b/src/components/agent/chat/hooks/useSelectedTeamPreference.ts new file mode 100644 index 000000000..4b5b4e80d --- /dev/null +++ b/src/components/agent/chat/hooks/useSelectedTeamPreference.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { TeamDefinition } from "../utils/teamDefinitions"; +import { + buildTeamDefinitionLabel, + buildTeamDefinitionSummary, + createTeamDefinitionFromPreset, +} from "../utils/teamDefinitions"; +import { + persistSelectedTeam, + resolvePersistedSelectedTeam, +} from "../utils/teamStorage"; + +export function useSelectedTeamPreference(theme?: string | null) { + const [selectedTeam, setSelectedTeamState] = useState( + () => resolvePersistedSelectedTeam(theme), + ); + + useEffect(() => { + setSelectedTeamState(resolvePersistedSelectedTeam(theme)); + }, [theme]); + + const setSelectedTeam = useCallback( + (team: TeamDefinition | null) => { + persistSelectedTeam(team, theme); + setSelectedTeamState(team); + }, + [theme], + ); + + const enableSuggestedTeam = useCallback( + (suggestedPresetId?: string) => { + const resolvedPresetId = suggestedPresetId?.trim(); + if (!resolvedPresetId) { + return; + } + + const suggestedTeam = createTeamDefinitionFromPreset(resolvedPresetId); + if (suggestedTeam) { + setSelectedTeam(suggestedTeam); + } + }, + [setSelectedTeam], + ); + + const preferredTeamPresetId = useMemo( + () => + selectedTeam?.presetId?.trim() || + (selectedTeam?.source === "builtin" ? selectedTeam.id : undefined), + [selectedTeam], + ); + const selectedTeamLabel = useMemo( + () => buildTeamDefinitionLabel(selectedTeam) || undefined, + [selectedTeam], + ); + const selectedTeamSummary = useMemo( + () => buildTeamDefinitionSummary(selectedTeam) || undefined, + [selectedTeam], + ); + + return { + selectedTeam, + setSelectedTeam, + enableSuggestedTeam, + preferredTeamPresetId, + selectedTeamLabel, + selectedTeamSummary, + }; +} diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 1ccca3c80..960a771ba 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -12,6 +12,7 @@ const { mockUseThemeContextWorkspace, mockUseTopicBranchBoard, mockUseTeamWorkspaceRuntime, + mockUseCompatSubagentRuntime, mockGetProject, mockGetDefaultProject, mockGetOrCreateDefaultProject, @@ -48,6 +49,7 @@ const { mockUseThemeContextWorkspace: vi.fn(), mockUseTopicBranchBoard: vi.fn(), mockUseTeamWorkspaceRuntime: vi.fn(), + mockUseCompatSubagentRuntime: vi.fn(), mockGetProject: vi.fn(), mockGetDefaultProject: vi.fn(), mockGetOrCreateDefaultProject: vi.fn(), @@ -148,6 +150,7 @@ vi.mock("./hooks", () => ({ useThemeContextWorkspace: mockUseThemeContextWorkspace, useTopicBranchBoard: mockUseTopicBranchBoard, useTeamWorkspaceRuntime: mockUseTeamWorkspaceRuntime, + useCompatSubagentRuntime: mockUseCompatSubagentRuntime, })); vi.mock("./hooks/useSessionFiles", () => ({ @@ -983,6 +986,15 @@ beforeEach(() => { liveActivityBySessionId: {}, activityRefreshVersionBySessionId: {}, }); + mockUseCompatSubagentRuntime.mockReturnValue({ + isRunning: false, + progress: null, + events: [], + result: null, + error: null, + recentActivity: [], + hasSignals: false, + }); mockCanvasWorkbenchLayoutState.renderPreview = false; mockJotaiState.artifacts = []; @@ -4357,4 +4369,94 @@ describe("AgentChatPage legacy 问卷 A2UI", () => { "action-request-req-action-required", ); }); + + it("真实 action_required 已提交后,输入区应显示补充信息确认提示而不是继续停留在表单态", async () => { + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [ + { + id: "msg-user-submitted", + role: "user", + content: "继续推进当前任务", + timestamp: new Date("2026-03-15T09:02:00.000Z"), + }, + { + id: "msg-assistant-submitted", + role: "assistant", + content: "已收到补充信息,正在继续推进。", + timestamp: new Date("2026-03-15T09:02:10.000Z"), + actionRequests: [ + { + requestId: "req-submitted-action", + actionType: "ask_user", + prompt: "请选择执行模式", + questions: [{ question: "你希望如何执行?" }], + status: "submitted", + submittedResponse: '{"answer":"自动执行(Auto)"}', + submittedUserData: { + answer: "自动执行(Auto)", + }, + }, + ], + }, + ], + isSending: false, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [ + { + id: "topic-a", + title: "话题 A", + updatedAt: Date.now(), + }, + ], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + workspacePathMissing: false, + fixWorkspacePathAndRetry: vi.fn(), + dismissWorkspacePathError: vi.fn(), + }; + }, + ); + + renderPage({ + projectId: "project-action-required-submitted", + theme: "general", + lockTheme: true, + }); + await flushEffects(10); + + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + pendingA2UIForm?: { + id?: string; + } | null; + a2uiSubmissionNotice?: { + title?: string; + summary?: string; + } | null; + } + | undefined; + + expect(latestInputbarProps?.pendingA2UIForm ?? null).toBeNull(); + expect(latestInputbarProps?.a2uiSubmissionNotice).toMatchObject({ + title: "补充信息已确认", + summary: "自动执行(Auto)", + }); + }); }); diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index b928abbd3..b170c6b6f 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -142,7 +142,7 @@ export interface WriteArtifactContext { } export interface AgentRuntimeStatus { - phase: "preparing" | "routing" | "context"; + phase: "preparing" | "routing" | "context" | "failed"; title: string; detail: string; checkpoints?: string[]; diff --git a/src/components/agent/chat/utils/agentRuntimeStatus.ts b/src/components/agent/chat/utils/agentRuntimeStatus.ts index 8bc2f680d..b1aa16738 100644 --- a/src/components/agent/chat/utils/agentRuntimeStatus.ts +++ b/src/components/agent/chat/utils/agentRuntimeStatus.ts @@ -15,6 +15,11 @@ function buildExecutionLabel(strategy: AsterExecutionStrategy): string { } } +function normalizeRuntimeErrorDetail(errorMessage: string): string { + const detail = errorMessage.trim(); + return detail || "执行链路返回失败,请查看详情后重试。"; +} + export function buildInitialAgentRuntimeStatus(options: { executionStrategy: AsterExecutionStrategy; webSearch?: boolean; @@ -82,3 +87,40 @@ export function buildActionResumeRuntimeStatus(): AgentRuntimeStatus { checkpoints: ["补充信息已确认", "已唤醒当前执行链路", "等待下一条执行事件"], }; } + +export function buildFailedAgentRuntimeStatus( + errorMessage: string, +): AgentRuntimeStatus { + return { + phase: "failed", + title: "当前执行失败", + detail: normalizeRuntimeErrorDetail(errorMessage), + checkpoints: ["已保留当前回合过程", "可修正问题后重试", "如需继续可补充更明确的输入"], + }; +} + +export function buildFailedAgentMessageContent( + errorMessage: string, + partialContent?: string, +): string { + const failureText = `执行失败:${normalizeRuntimeErrorDetail(errorMessage)}`; + const trimmedPartialContent = partialContent?.trim(); + return trimmedPartialContent + ? `${trimmedPartialContent}\n\n${failureText}` + : failureText; +} + +export function formatAgentRuntimeStatusSummary( + status?: AgentRuntimeStatus | null, +): string { + if (!status?.title) { + return "Agent 正在准备执行"; + } + + const lines = [status.title.trim()]; + if (status.detail?.trim()) { + lines.push(status.detail.trim()); + } + + return lines.join("\n\n"); +} diff --git a/src/components/agent/chat/utils/teamStorage.ts b/src/components/agent/chat/utils/teamStorage.ts index fbd45eb1a..401cce7ed 100644 --- a/src/components/agent/chat/utils/teamStorage.ts +++ b/src/components/agent/chat/utils/teamStorage.ts @@ -1,5 +1,6 @@ import type { TeamDefinition } from "./teamDefinitions"; import { + createTeamDefinitionFromPreset, buildTeamSelectionReference, normalizeTeamDefinition, type TeamSelectionReference, @@ -109,3 +110,18 @@ export function loadSelectedTeamReference( return null; } } + +export function resolvePersistedSelectedTeam( + theme?: string | null, +): TeamDefinition | null { + const selection = loadSelectedTeamReference(theme); + if (!selection) { + return null; + } + + if (selection.source === "builtin") { + return createTeamDefinitionFromPreset(selection.id); + } + + return loadCustomTeams().find((team) => team.id === selection.id) || null; +} diff --git a/src/lib/api/agentStream.ts b/src/lib/api/agentStream.ts index 278cf4c37..d54fafd1e 100644 --- a/src/lib/api/agentStream.ts +++ b/src/lib/api/agentStream.ts @@ -371,7 +371,7 @@ export interface StreamEventContextTrace { } export interface StreamRuntimeStatusPayload { - phase: "preparing" | "routing" | "context"; + phase: "preparing" | "routing" | "context" | "failed"; title: string; detail: string; checkpoints?: string[];