import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { AgentRunEvent } from "@umt/contracts"; import { createElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; import type { AgentRunView, GoalReport } from "~/api/agent.ts"; import { qk } from "~/lib/query-keys.ts"; import { AgentConversation, claimedCounts, contextLabel, DeniedLines, ResultLine, reportMismatch, resultFacts, StepSummary, } from "./agent-conversation.tsx"; import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx"; import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx"; import { arrangeHistory } from "./agent-session-list.tsx"; import { type ActivityItem, activitySpan, activitySummary, buildConversation, deniedReason, } from "./conversation-model.ts"; import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts"; import { type AgentStreamState, foldEvents, latestPlan, type OptionsView, type ToolCallView, } from "./use-agent-stream.ts"; // AGUI-3 组件测试:进度行聚合、隐藏步骤清单 / 方案调用、拒绝一行、方案一键选定、历史置顶与子会话、指令。 // 渲染用 react-dom/server(没有 DOM 环境),交互逻辑放在纯函数里单测;绑定批准见服务端 e2e(e12-interaction)。 const AT = "2026-09-29T08:00:00.000Z"; const at = (s: number) => new Date(Date.parse(AT) + s * 1000).toISOString(); let seq = 0; const ev = (s: number, body: Record) => ({ runId: "r1", seq: seq++, at: at(s), ...body }) as unknown as AgentRunEvent; const tool = (s: number, id: string, cap: string, out: string, isError = false): AgentRunEvent[] => [ ev(s, { type: "tool_call_start", toolCallId: id, capabilityId: cap, inputSummary: "{}", recovery: false }), ev(s + 1, { type: "tool_call_end", toolCallId: id, capabilityId: cap, outputSummary: out, isError, recovery: false, }), ]; const call = (p: Partial): ToolCallView => ({ toolCallId: "c1", capabilityId: "site.list", inputSummary: null, outputSummary: null, status: "succeeded", reason: null, startedAt: AT, endedAt: at(1), recovery: false, ...p, }); /** 旧会话形状的事件:步骤清单与方案都作为工具调用出现、授权拒绝、最终正文(9883534f 的样子) */ function legacyRun(): AgentRunEvent[] { seq = 0; return [ ev(0, { type: "run_started", goal: "检查站点、下载器和媒体服务器", resumed: false }), ...tool(1, "p1", "agent.plan", '{"steps":[],"revision":1}'), ev(2, { type: "plan_updated", revision: 1, steps: [ { id: "s1", title: "看站点", status: "running", note: null }, { id: "s2", title: "核对下载任务", status: "pending", note: null }, ], }), ev(3, { type: "text_delta", delta: "先看站点列表。" }), ...tool(4, "t1", "site.list", '{"items":[{"name":"憨憨"}]}'), ...tool(6, "t2", "download.inspect", '{"summary":"没有进行中的下载"}'), ...tool(8, "t3", "download.inspect", '{"summary":"没有进行中的下载"}'), ...tool(10, "t4", "inventory.query", '{"items":[]}'), ...tool( 12, "t5", "site.health_check", '{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权"}}', true, ), ...tool(14, "p2", "agent.plan", '{"steps":[],"revision":2}'), ev(16, { type: "thinking_delta", delta: "下载器没接入。" }), ev(17, { type: "text_delta", delta: "下载器和媒体服务器都还没接入,所以没法检查。站点「憨憨」正常,在做种 24 个。", }), ev(41, { type: "run_completed", finalText: "", usage: { totalTokens: 1000, costUsd: 0.01 } }), ]; } const liveOf = (events: AgentRunEvent[]): AgentStreamState => ({ events, items: foldEvents(events), plan: latestPlan(events), usage: null, finished: true, error: null, connected: "idle", retry: () => {}, }); const RUN = { id: "00000000-0000-4000-8000-000000000001", createdAt: AT, updatedAt: at(41), taskId: null, parentRunId: null, trigger: "user", goal: "[上下文 作品:沙丘:第二部 · entityId=74beab51-0000-4000-8000-000000000000] 检查站点、下载器和媒体服务器", status: "succeeded", provider: "opencode-go", model: "deepseek-v4.1-flash", thinkingLevel: "medium", scope: { interactive: true }, budget: { maxToolCalls: 20 }, usage: {}, evidenceVersion: null, sessionState: null, summary: null, toolCallCount: 5, lastEventSeq: 0, error: null, cancelRequestedAt: null, startedAt: AT, finishedAt: at(41), createdBy: "admin", dedupeKey: null, depth: 0, spawnReason: null, } as unknown as AgentRunView; function render(node: ReactNode, admin = true): string { const client = new QueryClient(); if (admin) client.setQueryData(qk.session, { actor: { type: "admin" }, expiresAt: "2099-01-01T00:00:00.000Z" }); return renderToStaticMarkup(createElement(QueryClientProvider, { client }, createElement(MemoryRouter, null, node))); } /** 去掉标签,只看文字 */ const text = (html: string) => html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " "); describe("隐藏:步骤清单与方案不作为工具调用出现", () => { it("新旧事件里的 agent.plan 调用都被滤掉,步骤清单照样取到", () => { const events = legacyRun(); const items = foldEvents(events); const caps = items.flatMap((i) => (i.kind === "tool" ? [i.call.capabilityId] : [])); expect(caps).not.toContain("agent.plan"); expect(caps).toEqual(["site.list", "download.inspect", "download.inspect", "inventory.query", "site.health_check"]); expect(latestPlan(events)?.steps).toHaveLength(2); }); it("方案调用只留方案卡", () => { seq = 0; const items = foldEvents([ ev(0, { type: "tool_call_start", toolCallId: "o1", capabilityId: "agent.propose_options", inputSummary: "{}", recovery: false, }), ev(1, { type: "options_proposed", invocationId: "o1", decisionId: "00000000-0000-4000-8000-00000000d001", question: "收哪个?", options: [], recommendedId: null, stepId: null, expiresAt: at(600), timeoutAction: "recommended", }), ]); expect(items.map((i) => i.kind)).toEqual(["options"]); }); }); describe("聚合:进度行用能力元数据的人话计数", () => { it("同一动作合并对象,数字前后空格,被拒绝的不算", () => { const calls = [ call({ capabilityId: "site.list" }), call({ capabilityId: "download.inspect" }), call({ capabilityId: "download.inspect" }), call({ capabilityId: "inventory.query" }), call({ capabilityId: "release.search" }), call({ capabilityId: "release.search" }), call({ capabilityId: "site.health_check", status: "denied", reason: "不在白名单" }), call({ capabilityId: "agent.plan" }), ]; expect(activitySummary(calls)).toBe("查了站点、2 个下载任务和媒体库,搜了 2 次资源"); expect(activitySummary([call({ capabilityId: "download.inspect" }), call({ capabilityId: "site.list" })])).toBe( "查了 1 个下载任务和站点", ); // DL-3:不再说"生成了 1 份下载计划" expect(activitySummary([call({ capabilityId: "download.plan" })])).toBe("准备下载 1 个资源"); }); it("一回合的全部调用聚成一行(1 次也折叠),展开前看不到能力 ID", () => { const activity: ActivityItem[] = [{ kind: "tool", key: "c1", call: call({ capabilityId: "site.list" }) }]; const html = render(createElement(StepSummary, { activity, running: false })); const line = text(html.slice(0, html.indexOf(""))); expect(line).toContain("查了站点"); expect(line).toContain("1 秒"); expect(line).not.toContain("site.list"); }); it("旧会话整段:一行进度 + 正文 + 一行没做,不出现回合头、步骤清单调用和模型原文副本", () => { const html = render(createElement(AgentConversation, { run: RUN, live: liveOf(legacyRun()) })); const t = text(html); expect(html.match(/data-slot="step-summary"/g)).toHaveLength(1); expect(t).toContain("查了站点、2 个下载任务和媒体库"); expect(t).toContain("1 项没做"); expect(t).toContain("站点「憨憨」正常,在做种 24 个。"); expect(t).toContain("没做:检测站点,需要你批准"); expect(t).not.toContain("更新步骤清单"); expect(t).not.toContain("模型说的"); expect(t).not.toMatch(/智能体\s+\d{2}:\d{2}/); // 过程中的话收进进度行,不在正文里 const body = t.slice(t.indexOf("1 项没做")); expect(body.indexOf("先看站点列表")).toBeLessThan(body.indexOf("下载器和媒体服务器都还没接入")); // 上下文标签只显示名称 expect(t).toContain("沙丘:第二部"); expect(t).not.toContain("entityId="); }); }); describe("拒绝:缩成一行", () => { const denied = call({ toolCallId: "t5", capabilityId: "site.health_check", status: "failed", outputSummary: '{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权"}}', }); it("授权拒绝 → 需要你批准 + [批准并重试];白名单拦截没有按钮", () => { expect(deniedReason(denied)).toEqual({ text: "需要你批准", approvable: true }); expect(deniedReason(call({ status: "denied", reason: "不在白名单" }))).toEqual({ text: "本次没有开放", approvable: false, }); const t = text( render(createElement(DeniedLines, { runId: RUN.id, calls: [denied, { ...denied, toolCallId: "t6" }] })), ); expect(t.match(/没做:/g)).toHaveLength(1); expect(t).toContain("没做:检测站点,需要你批准"); expect(t).toContain("批准并重试"); // 不是管理员会话:只写一行,不给按钮 expect(text(render(createElement(DeniedLines, { runId: RUN.id, calls: [denied] }), false))).not.toContain( "批准并重试", ); }); it("原因里提到「单次审批」的普通授权拒绝仍算没做", () => { seq = 0; const items = foldEvents( tool( 0, "h1", "site.health_check", '{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权或有效的单次审批"}}', true, ), ); const [turn] = buildConversation(items); expect(turn?.kind === "turn" && turn.denied.map((c) => c.capabilityId)).toEqual(["site.health_check"]); }); it("已转成审批卡的拒绝不算没做", () => { const items = foldEvents([ ...tool( 0, "s1", "download.submit", '{"error":{"code":"unauthorized","message":"已在步骤处发起审批,等待管理员批准"}}', true, ), ev(3, { type: "approval_requested", approvalId: "00000000-0000-4000-8000-00000000a001", decisionId: null, invocationId: "s1", capabilityId: "download.submit", planType: "acquisition_plan", planId: "00000000-0000-4000-8000-00000000b001", payloadHash: "h", expectedRevision: 1, riskLevel: "high", summary: "下载《沙丘2》", stepId: null, expiresAt: at(3600), }), ]); const [turn] = buildConversation(items); expect(turn?.kind === "turn" && turn.denied).toEqual([]); }); }); describe("方案:一键选定", () => { const view: OptionsView = { decisionId: "00000000-0000-4000-8000-00000000d001", question: "《沙丘2》1080p 怎么收?", options: [ { id: "b", title: "先修站点", summary: "站点登录过期,先刷新", risks: [], estimatedCost: null, action: null }, { id: "a", title: "提交下载", summary: "用刚生成的计划", risks: ["缓存卷会到 86%", "免费还剩 6 小时"], estimatedCost: "12 GB", action: { capabilityId: "download.submit", planId: "00000000-0000-4000-8000-00000000b001", label: "提交下载", detail: "《沙丘2》· 12.0 GB", approves: true, }, }, { id: "c", title: "先不下", summary: "等 REMUX", risks: [], estimatedCost: null, action: null }, ], recommendedId: "a", stepId: null, expiresAt: at(1800), timeoutAction: "recommended", resolved: null, }; it("推荐项第一位;动作要点来自服务端的计划摘要", () => { expect(orderOptions(view.options, "a").map((o) => o.id)).toEqual(["a", "b", "c"]); // 标题已经是动作名:只写对象和大小 expect(optionPoint(view.options[1]!)).toBe("《沙丘2》· 12.0 GB"); expect(optionPoint({ ...view.options[1]!, title: "收这个" })).toBe("提交下载 · 《沙丘2》· 12.0 GB"); expect(optionPoint(view.options[0]!)).toBeNull(); }); it("每个方案一个按钮(没有单选框和「选定」),推荐项是主按钮;风险折叠成「2 条风险」,都不要是链接", () => { const html = render(createElement(OptionsCard, { runId: RUN.id, view, waiting: true })); expect(html).not.toContain('type="radio"'); const buttons = [...html.matchAll(/]*>([\s\S]*?)<\/button>/g)]; const labels = buttons.map((m) => text(m[1] ?? "").trim()); expect(labels[0]).toContain("提交下载"); expect(labels[0]).toContain("12.0 GB"); expect(labels[0]).toContain("(推荐)"); expect(labels[0]).toContain("选定即执行"); expect(buttons[0]?.[0]).toContain("pill-primary"); expect(labels.slice(1, 3).map((l) => l.split(" ")[0])).toEqual(["先修站点", "先不下"]); expect(labels).toContain("2 条风险"); expect(labels).toContain("都不要…"); expect(labels.some((l) => l === "选定")).toBe(false); expect(html).not.toContain(" { const t = text( render( createElement(OptionsCard, { runId: RUN.id, view: { ...view, resolved: { outcome: "chosen", choice: "a", reason: null, by: "user" } }, waiting: true, }), ), ); expect(t).toContain("已选:提交下载 · 《沙丘2》· 12.0 GB"); expect(t).not.toContain("先修站点"); }); }); describe("历史:等你处理置顶,子会话收在父会话下", () => { const r = (id: string, p: Partial) => ({ ...RUN, id, ...p }) as AgentRunView; const runs = [ r("a", { goal: "巡检" }), r("b", { goal: "子任务", parentRunId: "a", trigger: "subagent" }), r("c", { goal: "收沙丘", status: "waiting", summary: "等待审批" }), r("d", { goal: "失败的", status: "failed" }), r("e", { goal: "孤儿子会话", parentRunId: "zzz", trigger: "subagent" }), r("f", { goal: "等下载", status: "waiting", summary: "等待下载" }), ]; it("全部:等你处理置顶;子会话挂到父会话;父会话不在列表的单列", () => { const { pinned, rows } = arrangeHistory(runs, { q: "", view: "all" }); expect(pinned.map((x) => x.run.id)).toEqual(["c"]); expect(rows.map((x) => x.run.id)).toEqual(["a", "d", "e", "f"]); expect(rows[0]?.children.map((x) => x.id)).toEqual(["b"]); }); it("等你处理 / 失败 / 条数上限", () => { expect(arrangeHistory(runs, { q: "", view: "pending" }).rows.map((x) => x.run.id)).toEqual(["c"]); expect(arrangeHistory(runs, { q: "", view: "failed" }).rows.map((x) => x.run.id)).toEqual(["d"]); const limited = arrangeHistory(runs, { q: "", view: "all" }, 3); expect(limited.pinned.length + limited.rows.length).toBe(3); }); }); describe("输入区:/ 指令与上下文标签", () => { it("/先给方案 只作用于这一次", () => { expect(goalFromInput("/先给方案 把沙丘收进来")).toEqual({ goal: "把沙丘收进来", opts: { proposeFirst: true } }); expect(goalFromInput("把沙丘收进来")).toEqual({ goal: "把沙丘收进来", opts: { proposeFirst: false } }); expect(goalFromInput("/先给方案")).toBeNull(); expect(parseCommand("/重跑", RUN_COMMANDS)).toEqual({ command: "/重跑", rest: "" }); expect(parseCommand("/先给方案x", RUN_COMMANDS).command).toBeNull(); }); it("上下文标签显示名称,只有 ID 时不显示", () => { expect(contextLabel("作品:沙丘:第二部 · entityId=74beab51")).toBe("沙丘:第二部"); expect(contextLabel("entityId=74beab51")).toBeNull(); expect(contextLabel(null)).toBeNull(); }); }); describe("会话统计与本轮状态(AG-4)", () => { const humanized = (): AgentRunEvent[] => { seq = 0; return [ ev(0, { type: "run_started", goal: "检查站点", resumed: false }), ...tool(1, "t1", "site.list", '{"items":[{"name":"憨憨"}]}'), ev(3, { type: "text_delta", delta: "site.list 返回站点 4e71672f-0000-4000-8000-000000000000 正常,没有 invalid_input。", }), ev(9, { type: "run_completed", finalText: "", usage: { totalTokens: 1000, costUsd: 0.01 } }), ]; }; it("正文里不再列「换掉的标识」;原始汇报只在结果行的技术信息「原文」里", () => { const live = liveOf(humanized()); const t = text(render(createElement(AgentConversation, { run: RUN, live }))); expect(t).not.toContain("换掉的标识"); expect(t).not.toContain("正文原文"); expect(t).not.toContain("→ 列出站点"); const result = render(createElement(ResultLine, { run: RUN, report: null, live })); expect(text(result)).toContain("原文"); expect(result).toContain("4e71672f-0000-4000-8000-000000000000"); expect(result).not.toContain("换掉的标识"); }); it("结束后本轮调用次数并入进度行", () => { const activity: ActivityItem[] = [ { kind: "tool", key: "c1", call: call({ capabilityId: "site.list" }) }, { kind: "tool", key: "c2", call: call({ toolCallId: "c2", capabilityId: "inventory.query" }) }, ]; const html = render(createElement(StepSummary, { activity, running: false })); expect(text(html.slice(0, html.indexOf("")))).toContain("调用 2 次工具"); }); it("运行中:思考状态与本轮已调用次数放在一行", () => { const events = humanized().slice(0, 3); const live = { ...liveOf(events), finished: false }; const t = text(render(createElement(AgentConversation, { run: { ...RUN, status: "running" }, live }))); expect(t).toContain("思考中 · 已调用 1 次工具"); }); it("概况条上方的会话统计:工具调用、token(K)、剩余、费用", () => { const live = liveOf(humanized()); const snapshot = { run: { ...RUN, toolCallCount: 1, budget: { maxTokens: 200_000 }, usage: { totalTokens: 45_200, costUsd: 0.03 } }, report: null, messages: [], } as never; const t = text(render(createElement(RunStatsLine, { snapshot, live }))); expect(t).toContain("工具调用 1 次 · token 45.2K · 剩 154.8K(77%) · 费用 $0.03"); }); }); describe("实际结果行(FIX-9):只列系统核实的事实", () => { const ID = (n: number) => `00000000-0000-4000-8000-00000000010${n}`; const report = (o: Partial): GoalReport => ({ state: "no_actions", summary: "本次没有提交下载或整理,结论见模型汇报。", submitted: 0, imported: 0, inProgress: 0, failed: 0, plans: [], downloads: [], organization: [], modelText: null, generatedAt: AT, ...o, }); const done = report({ state: "completed", summary: "已提交下载 2 个", submitted: 2, imported: 1, inProgress: 1, downloads: [ { downloadTaskId: ID(1), planId: null, status: "completed", name: "A" }, { downloadTaskId: ID(2), planId: null, status: "downloading", name: "B" }, ], organization: [ { recordId: ID(3), status: "succeeded", downloadTaskId: ID(1), mediaEntityId: null, targetPath: "/m/A" }, ], }); const withText = (t: string) => liveOf([ ev(0, { type: "run_started", goal: "g", resumed: false }), ev(1, { type: "text_delta", delta: t }), ev(2, { type: "run_completed", finalText: "", usage: { costUsd: 0.01 } }), ]); it("没有动作:一行小字 + 时长 + 费用,不再有「说明」「汇总」「未经核查」", () => { const html = render( createElement(ResultLine, { run: { ...RUN, usage: { costUsd: 0.01 } }, report: report({}), live: withText("都正常。"), }), ); const t = text(html); expect(t).toContain("没有提交下载或整理 · 41 秒 · 约 $0.01"); expect(t).not.toContain("说明"); expect(t).not.toContain("汇总"); expect(t).not.toContain("未经核查"); expect(t).not.toContain("实际结果:"); expect(t).not.toContain("结论见模型汇报"); }); it("统计行已显示费用时不重复", () => { const t = text( render( createElement(ResultLine, { run: { ...RUN, usage: { costUsd: 0.01 } }, report: report({}), live: withText("x"), showCost: false, }), ), ); expect(t).toContain("没有提交下载或整理 · 41 秒"); expect(t).not.toContain("$0.01"); }); it("有动作:列事实,单条记录链到记录本身,多条链到列表", () => { const html = render( createElement(ResultLine, { run: RUN, report: done, live: withText("提交了 2 个下载,整理了 1 个。") }), ); expect(text(html).replace(/\s*、\s*/g, "、")).toContain("提交了 2 个下载、完成 1 次整理、1 个还在进行 · 41 秒"); expect(html).toContain(`href="/console/organize/${ID(3)}"`); expect(html).toContain('href="/console/downloads"'); expect(html).not.toContain("与汇报不一致"); }); it("数字与汇报不一致:行尾加标记,悬停和技术信息里说明", () => { const html = render(createElement(ResultLine, { run: RUN, report: done, live: withText("已提交了 3 个下载。") })); expect(html).toContain('data-slot="report-mismatch"'); expect(html).toContain("汇报说提交了 3 个下载,系统记录是 2 个"); }); it("汇报没写数字时不判不一致", () => { expect(reportMismatch(done, "下载都安排好了。")).toEqual([]); expect(claimedCounts("整理了 4 部电影")).toEqual({ organized: 4 }); expect(resultFacts(null)).toEqual([{ text: "汇总中…" }]); }); }); describe("进度行时长(FIX-9):从这一轮开始算到结论正文", () => { const quick: ActivityItem[] = [ { kind: "tool", key: "c1", call: call({ capabilityId: "site.list", startedAt: at(6), endedAt: at(6) }) }, ]; it("只有一个瞬时工具调用时不再是 0 秒", () => { expect(activitySpan(quick)).toBe(0); expect(activitySpan(quick, { since: at(0), until: at(7) })).toBe(7000); const t = text(render(createElement(StepSummary, { activity: quick, running: false, since: at(0), until: at(7) }))); expect(t).toContain("· 7 秒"); }); it("没有工具调用、只有思考时也有时长;没有步骤为 null", () => { expect(activitySpan([{ kind: "thinking", key: "k", text: "想", at: at(2) }], { since: at(0) })).toBe(2000); expect(activitySpan([])).toBeNull(); }); it("整段会话:第一轮从会话开始算", () => { const events = [ ev(0, { type: "run_started", goal: "g", resumed: false }), ...tool(5, "t1", "site.list", '{"items":[]}'), ev(7, { type: "text_delta", delta: "都正常。" }), ev(8, { type: "run_completed", finalText: "", usage: {} }), ]; const t = text(render(createElement(AgentConversation, { run: RUN, live: liveOf(events) }))); expect(t).toMatch(/· 7 秒/); }); }); describe("步骤明细(FIX-9):能力 ID 换成能力标题", () => { it("失败原因与工具之间的话里的能力 ID 走能力标题", () => { const activity: ActivityItem[] = [ { kind: "note", key: "n1", text: "site.health_check 被拒,改用 site.list。", at: AT }, { kind: "tool", key: "c1", call: call({ capabilityId: "download.inspect", status: "failed", outputSummary: '{"error":{"code":"x","message":"site.health_check 调用失败"}}', }), }, ]; const t = text(render(createElement(StepSummary, { activity, running: false }))); expect(t).not.toContain("site.health_check"); expect(t).not.toContain("site.list"); expect(t).toContain("检测站点 被拒"); }); });