diff --git a/features/agent/components/agent-message-details.tsx b/features/agent/components/agent-message-details.tsx index 4fbbc8a..917ab9c 100644 --- a/features/agent/components/agent-message-details.tsx +++ b/features/agent/components/agent-message-details.tsx @@ -37,6 +37,8 @@ type TodoStatus = AgentTodoUpdate["todos"][number]["status"]; type PermissionStatus = AgentPermissionRequest["status"]; type QuestionStatus = AgentQuestionRequest["status"]; +const STREAMING_PROGRESS_LIMIT = 3; + const progressStatusMeta: Record = { running: { label: "进行中", @@ -155,7 +157,7 @@ export function AgentMessageProgress({ return null; } - return ; + return ; } function AgentNestedBlock({ @@ -198,30 +200,35 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) { function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) { const [expanded, setExpanded] = useState(false); - const visibleProgress = expanded ? progress : progress.slice(-1); - const listMode = expanded ? "expanded" : "summary"; + const visibleProgress = running + ? progress.slice(-STREAMING_PROGRESS_LIMIT) + : expanded + ? progress + : []; + const listMode = running ? "streaming" : expanded ? "expanded" : "collapsed"; return (
@@ -229,7 +236,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru {visibleProgress.length ? ( ( @@ -264,7 +279,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru {item.title} {item.detail ? ( - + {item.detail} ) : null} @@ -272,14 +287,23 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru {progressStatusMeta[item.status].label} diff --git a/features/agent/components/agent-operational-brief.tsx b/features/agent/components/agent-operational-brief.tsx index 857f261..b25d121 100644 --- a/features/agent/components/agent-operational-brief.tsx +++ b/features/agent/components/agent-operational-brief.tsx @@ -28,7 +28,7 @@ export function AgentOperationalBrief({ return (
-
+
@@ -38,9 +38,6 @@ export function AgentOperationalBrief({ {brief.task}

- - Agent 推断 -
diff --git a/tests/browser/agent-streaming-stability.e2e.ts b/tests/browser/agent-streaming-stability.e2e.ts index 20fc1d1..5986baf 100644 --- a/tests/browser/agent-streaming-stability.e2e.ts +++ b/tests/browser/agent-streaming-stability.e2e.ts @@ -7,6 +7,8 @@ const STREAM_CHUNK_DELAY_MS = 20; let streamServer: Server; let streamServerUrl: string; +let failProgress: (() => void) | null = null; +let releaseStream: (() => void) | null = null; test.beforeAll(async () => { streamServer = createServer((_request, response) => { @@ -36,6 +38,30 @@ test.beforeAll(async () => { started_at: Date.now() } }); + send({ + type: "data-progress", + id: "inspect-rainfall", + data: { + id: "inspect-rainfall", + phase: "evidence", + status: "completed", + title: "正在核对降雨过程与汇水区响应关系", + detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。", + started_at: Date.now() + } + }); + send({ + type: "data-progress", + id: "inspect-level", + data: { + id: "inspect-level", + phase: "evidence", + status: "completed", + title: "正在核对液位连续性", + detail: "排除缺测与异常峰值。", + started_at: Date.now() + } + }); send({ type: "data-todo_update", id: "stream-stability-plan", @@ -53,24 +79,11 @@ test.beforeAll(async () => { send({ type: "text-start", id: "answer" }); let index = 0; - const timer = setInterval(() => { + let timer: ReturnType | null = null; + const sendNextTextChunk = () => { const delta = chunks[index]; if (delta !== undefined) { index += 1; - if (index === 3) { - send({ - type: "data-progress", - id: "assess-risk", - data: { - id: "assess-risk", - phase: "analysis", - status: "running", - title: "正在判断溢流风险", - detail: "结合流量突变与液位趋势形成判断。", - started_at: Date.now() - } - }); - } send({ type: "text-delta", id: "answer", delta }); send({ type: "data-stream_token", @@ -84,7 +97,10 @@ test.beforeAll(async () => { return; } - clearInterval(timer); + if (timer) { + clearInterval(timer); + timer = null; + } send({ type: "data-progress", id: "assess-risk", @@ -100,9 +116,50 @@ test.beforeAll(async () => { send({ type: "text-end", id: "answer" }); send({ type: "finish", finishReason: "stop" }); response.end(); - }, STREAM_CHUNK_DELAY_MS); + }; - response.on("close", () => clearInterval(timer)); + while (index < 3 && index < chunks.length) { + sendNextTextChunk(); + } + send({ + type: "data-progress", + id: "assess-risk", + data: { + id: "assess-risk", + phase: "analysis", + status: "running", + title: "正在判断溢流风险", + detail: "结合流量突变与液位趋势形成判断。", + started_at: Date.now() + } + }); + + failProgress = () => { + send({ + type: "data-progress", + id: "inspect-level", + data: { + id: "inspect-level", + phase: "evidence", + status: "error", + title: "液位连续性核对失败", + detail: "监测数据读取超时。", + ended_at: Date.now() + } + }); + }; + + releaseStream = () => { + if (!timer) { + timer = setInterval(sendNextTextChunk, STREAM_CHUNK_DELAY_MS); + } + }; + + response.on("close", () => { + if (timer) { + clearInterval(timer); + } + }); }); await new Promise((resolve) => streamServer.listen(0, "127.0.0.1", resolve)); @@ -204,13 +261,55 @@ test("stream completion and the plan card remain positionally stable", async ({ }); await sendButton.click(); + const liveProgress = page.getByRole("list", { name: "最近执行进度" }); + await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible(); + await expect(liveProgress.locator("li")).toHaveCount(3); + await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0); + + const liveProgressRowHeights = await liveProgress.locator("li").evaluateAll((rows) => + rows.map((row) => row.getBoundingClientRect().height) + ); + expect(liveProgressRowHeights.every((height) => Math.abs(height - 48) <= 0.01)).toBe(true); + + await page.evaluate(() => { + const state = window as typeof window & { __agentFailureMotionObserved?: boolean }; + const startedAt = performance.now(); + state.__agentFailureMotionObserved = false; + + const sample = () => { + const badge = document.querySelector('[data-progress-status="error"]'); + if (badge && getComputedStyle(badge).transform !== "none") { + state.__agentFailureMotionObserved = true; + } + if (!state.__agentFailureMotionObserved && performance.now() - startedAt < 1_000) { + requestAnimationFrame(sample); + } + }; + + requestAnimationFrame(sample); + }); + triggerProgressFailure(); + await expect(liveProgress.getByText("液位连续性核对失败", { exact: true })).toBeVisible(); + await expect.poll(() => + page.evaluate(() => + (window as typeof window & { __agentFailureMotionObserved?: boolean }).__agentFailureMotionObserved ?? false + ) + ).toBe(true); + resumeStream(); + await expect(page.getByText("分析完成", { exact: true }).filter({ visible: true }).first()).toBeVisible({ timeout: 10_000 }); const progressToggle = page.getByRole("button", { name: /执行进度/ }).filter({ visible: true }).first(); - await expect(progressToggle).toContainText("共 2 条"); - await expect(page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true }).first()).toBeVisible(); - await page.waitForTimeout(400); + await expect(progressToggle).toContainText("共 4 条"); + await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0); + await expect(page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true })).toHaveCount(0); + await expect.poll(async () => + page.evaluate(() => { + const scroll = document.querySelector(".agent-conversation-scroll"); + return scroll ? Math.abs((scroll.scrollHeight - scroll.clientHeight) - scroll.scrollTop) : Number.POSITIVE_INFINITY; + }) + ).toBeLessThanOrEqual(2); const samples = await page.evaluate(() => (window as typeof window & { @@ -259,6 +358,7 @@ test("stream completion and the plan card remain positionally stable", async ({ ).toBeLessThanOrEqual(2); await progressToggle.click(); + await expect(page.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(4); await expect(page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()).toBeVisible(); }); @@ -282,3 +382,23 @@ function splitIntoCodePointChunks(content: string, chunkSize: number) { } return chunks; } + +function resumeStream() { + if (!releaseStream) { + throw new Error("Stream checkpoint was not reached"); + } + + const resume = releaseStream; + releaseStream = null; + resume(); +} + +function triggerProgressFailure() { + if (!failProgress) { + throw new Error("Progress failure checkpoint was not reached"); + } + + const fail = failProgress; + failProgress = null; + fail(); +}