fix: sync agent chat updates and remove pr gate

This commit is contained in:
coso
2026-03-21 00:07:58 +08:00
parent 1ae16a699f
commit f57be31311
19 changed files with 681 additions and 333 deletions
-136
View File
@@ -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"
+1 -1
View File
@@ -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 细节,推理内容与工具调用适配继续完善
### ⚠️ 兼容性说明
+1 -1
View File
@@ -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 的交接要求
+1 -3
View File
@@ -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. 当前仍未解决的问题优先级
@@ -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<CreationMode>(
initialCreationMode ?? "guided",
);
const [selectedTeam, setSelectedTeam] = useState<TeamDefinition | null>(() =>
resolvePersistedSelectedTeam(initialTheme),
);
const [chatToolPreferences, setChatToolPreferences] =
useState<ChatToolPreferences>(() =>
loadChatToolPreferences(normalizedEntryTheme),
@@ -329,6 +301,11 @@ export function AgentChatHomeShell({
const [skills, setSkills] = useState<Skill[]>([]);
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 (
<PageContainer>
<MainArea>
@@ -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<string>(normalizedEntryTheme);
const [selectedTeam, setSelectedTeam] = useState<TeamDefinition | null>(() =>
resolvePersistedSelectedTeam(normalizedEntryTheme),
);
const [creationMode, setCreationMode] = useState<CreationMode>(
initialCreationMode ?? "guided",
);
@@ -2741,6 +2711,14 @@ export function AgentChatWorkspace({
setThemeWorkbenchCreationTaskEvents,
] = useState<ThemeWorkbenchCreationTaskEvent[]>([]);
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({
@@ -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: "前端联调团队",
@@ -544,6 +544,8 @@ function formatRuntimePhaseLabel(
return "建回合中";
case "context":
return "装载上下文";
case "failed":
return "失败";
default:
return runtimeStatus.phase;
}
@@ -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<AgentRuntimeStatus["phase"], string> = {
preparing: "准备中",
routing: "回合建立中",
context: "上下文装载中",
failed: "执行失败",
};
const AgentRuntimeStatusBlock: React.FC<{ status: AgentRuntimeStatus }> = ({
status,
}) => (
<div className="rounded-2xl border border-border/70 bg-muted/30 px-4 py-3">
<div className="mb-2 flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="h-4 w-4" />
}) => {
const failed = status.phase === "failed";
return (
<div
className={cn(
"rounded-2xl border px-4 py-3",
failed
? "border-rose-200 bg-rose-50/90"
: "border-border/70 bg-muted/30",
)}
>
<div className="mb-2 flex items-center gap-2">
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full",
failed ? "bg-rose-100 text-rose-600" : "bg-primary/10 text-primary",
)}
>
{failed ? (
<AlertTriangle className="h-4 w-4" />
) : (
<Sparkles className="h-4 w-4" />
)}
</div>
<div
className={cn(
"text-sm font-medium",
failed ? "text-rose-900" : "text-foreground",
)}
>
{status.title}
</div>
<div
className={cn(
"ml-auto inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs",
failed
? "border-rose-200 bg-white text-rose-700"
: "border-border/70 bg-background/80 text-muted-foreground",
)}
>
{failed ? (
<AlertTriangle className="h-3 w-3" />
) : (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{RUNTIME_PHASE_LABELS[status.phase]}
</div>
</div>
<div className="text-sm font-medium text-foreground">{status.title}</div>
<div className="ml-auto inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/80 px-2.5 py-0.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{RUNTIME_PHASE_LABELS[status.phase]}
<div
className={cn(
"text-sm",
failed ? "text-rose-700" : "text-muted-foreground",
)}
>
{status.detail}
</div>
{status.checkpoints && status.checkpoints.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{status.checkpoints.map((item) => (
<span
key={item}
className={cn(
"rounded-full border px-2.5 py-1 text-xs",
failed
? "border-rose-200 bg-white text-rose-700"
: "border-border/60 bg-background/80 text-muted-foreground",
)}
>
{item}
</span>
))}
</div>
) : null}
</div>
<div className="text-sm text-muted-foreground">{status.detail}</div>
{status.checkpoints && status.checkpoints.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{status.checkpoints.map((item) => (
<span
key={item}
className="rounded-full border border-border/60 bg-background/80 px-2.5 py-1 text-xs text-muted-foreground"
>
{item}
</span>
))}
</div>
) : null}
</div>
);
);
};
/**
* 流式消息渲染组件
@@ -28,6 +28,11 @@ import {
handleToolStartEvent,
} from "./agentStreamEventProcessor";
import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter";
import {
buildFailedAgentMessageContent,
buildFailedAgentRuntimeStatus,
formatAgentRuntimeStatusSummary,
} from "../utils/agentRuntimeStatus";
type MessageParts = NonNullable<Message["contentParts"]>;
@@ -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) {
@@ -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,
@@ -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", () => {
@@ -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<typeof useSelectedTeamPreference>;
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<typeof useSelectedTeamPreference> | null = null;
function TestComponent({ theme }: { theme?: string | null }) {
hookValue = useSelectedTeamPreference(theme);
return null;
}
const render = (theme?: string | null) => {
act(() => {
root.render(<TestComponent theme={theme} />);
});
};
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();
}
});
});
@@ -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<TeamDefinition | null>(
() => 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,
};
}
+102
View File
@@ -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)",
});
});
});
+1 -1
View File
@@ -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[];
@@ -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");
}
@@ -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;
}
+1 -1
View File
@@ -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[];