Files
UHD_Media_Tool/apps/web/app/components/agent/agent-session-ui.test.ts
T
rootandClaude Opus 5.5 1ade49fe8e chore(release): v0.3.41
智能体抽屉读 ?open= 上下文、回复链接用抽屉地址;抽屉打开不亮焦点框;语句统计重复 key。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:39:24 +08:00

727 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, runTypeLabel } from "./agent-session-list.tsx";
import { subagentChipText } from "./agent-subagents.tsx";
import {
type ActivityItem,
activitySpan,
activitySummary,
buildConversation,
deniedReason,
subagentElapsed,
subagentResultLine,
subagentState,
subagentViews,
} from "./conversation-model.ts";
import { COMMANDS, parseCommand } 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<string, unknown>) =>
({ 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>): 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("</button>")));
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(/<button[^>]*>([\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("<textarea");
});
it("选完收成一行", () => {
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<AgentRunView>) => ({ ...RUN, id, ...p }) as AgentRunView;
const runs = [
r("a", { goal: "巡检", trigger: "schedule" }),
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: "等待下载" }),
r("g", { goal: "跑着的", status: "running" }),
];
it("全部:等你处理置顶;对话和巡检混排;子会话(包括父会话不在列表里的)都不出现", () => {
const { pinned, rows } = arrangeHistory(runs, { q: "", view: "all" });
expect(pinned.map((x) => x.id)).toEqual(["c"]);
expect(rows.map((x) => x.id)).toEqual(["a", "d", "f", "g"]);
});
it("状态下拉:等你处理 / 进行中 / 失败;条数上限", () => {
expect(arrangeHistory(runs, { q: "", view: "pending" }).rows.map((x) => x.id)).toEqual(["c"]);
expect(arrangeHistory(runs, { q: "", view: "active" }).rows.map((x) => x.id)).toEqual(["f", "g"]);
expect(arrangeHistory(runs, { q: "", view: "failed" }).rows.map((x) => x.id)).toEqual(["d"]);
const limited = arrangeHistory(runs, { q: "", view: "all" }, 3);
expect(limited.pinned.length + limited.rows.length).toBe(3);
});
it("类型小标签只给自动发起的会话", () => {
expect(runTypeLabel({ trigger: "schedule" })).toBe("巡检");
expect(runTypeLabel({ trigger: "event" })).toBe("事件");
expect(runTypeLabel({ trigger: "user" })).toBeNull();
});
});
describe("子会话:在父会话里派出的位置实时显示", () => {
const CHILD = "00000000-0000-4000-8000-0000000000c1";
const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 900 };
const spawned = (): AgentRunEvent[] => {
seq = 0;
return [
ev(0, { type: "run_started", goal: "查站点", resumed: false }),
ev(1, { type: "text_delta", delta: "派一个子会话去查。" }),
...tool(2, "s1", "agent.spawn", `{"childRunId":"${CHILD}"}`),
ev(4, {
type: "subagent_spawned",
childRunId: CHILD,
goal: "检查站点「观众」的登录状态",
mode: "execute",
depth: 1,
capabilities: ["site.health_check"],
budget: {},
}),
ev(5, { type: "subagent_event", childRunId: CHILD, childSeq: 0, childType: "run_started", summary: "开始" }),
ev(6, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 1,
childType: "tool_call_start",
capabilityId: "site.list",
summary: "{}",
}),
ev(7, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 2,
childType: "tool_call_end",
capabilityId: "site.list",
summary: "{}",
}),
ev(8, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 3,
childType: "tool_call_start",
capabilityId: "site.health_check",
summary: "{}",
}),
];
};
const done = (): AgentRunEvent[] => [
...spawned(),
ev(20, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 4,
childType: "tool_call_end",
capabilityId: "site.health_check",
summary: "{}",
}),
ev(21, {
type: "subagent_completed",
childRunId: CHILD,
status: "succeeded",
summary:
"本次没有提交下载或整理,结论见模型汇报。\n 模型汇报(未经核查):站点「观众」登录正常。\n证据:childRunId=x",
usage: { ...usage, costUsd: 0.02 },
toolCalls: 2,
durationMs: 17000,
}),
ev(22, { type: "text_delta", delta: "站点「观众」登录正常。" }),
];
it("运行中:当前工具、调用次数;卡片切开进度行", () => {
const items = foldEvents(spawned());
const sub = items.find((i) => i.kind === "subagent");
expect(sub?.kind === "subagent" && sub.view).toMatchObject({
childRunId: CHILD,
goal: "检查站点「观众」的登录状态",
status: null,
currentTool: "site.health_check",
toolCalls: 1,
});
// 卡片跟在派出它的那段进度行下面,不另起一段
const turn = buildConversation(items)[0];
expect(turn?.kind === "turn" && turn.parts.map((p) => p.kind)).toEqual(["segment"]);
const seg = turn?.kind === "turn" ? turn.parts[0] : undefined;
expect(seg?.kind === "segment" && seg.subagents.map((v) => v.childRunId)).toEqual([CHILD]);
expect(subagentChipText(subagentViews(items).map((v) => subagentState(v)))).toBe("1 个子会话运行中");
});
it("运行中卡片:正在做什么、用时、调用次数,每秒更新", () => {
const html = text(
render(createElement(AgentConversation, { run: { ...RUN, status: "running" }, live: liveOf(spawned()) })),
);
expect(html).toContain("子会话");
expect(html).toContain("检查站点「观众」的登录状态");
expect(html).toContain("运行中");
expect(html).toMatch(/正在检测站点/);
expect(html).toContain("1 次调用");
expect(html).toContain("查看过程");
expect(html).toContain("打开会话");
});
it("结束后:状态、汇报第一句、用时、调用次数和费用;快照里的状态优先于未结束的镜像", () => {
const live = liveOf(done());
const html = text(render(createElement(AgentConversation, { run: RUN, live })));
expect(html).toContain("已完成");
expect(html).toContain("站点「观众」登录正常。");
expect(html).not.toContain("childRunId");
expect(html).toMatch(/17 秒 · 2 次调用 · \$0\.02/);
const v = subagentViews(live.items)[0]!;
expect(subagentState(v)).toBe("done");
expect(subagentState({ status: null }, "waiting")).toBe("waiting");
expect(subagentState({ status: null }, "cancelled")).toBe("stopped");
expect(subagentState({ status: "failed" }, "running")).toBe("failed");
expect(subagentElapsed({ startedAt: at(0), endedAt: null, durationMs: null }, Date.parse(at(65)))).toBe(65000);
expect(subagentChipText(["done", "failed"])).toBe("2 个子会话");
expect(subagentChipText([])).toBeNull();
expect(subagentResultLine("没有动作。")).toBe("没有动作。");
expect(subagentResultLine(null)).toBeNull();
});
});
describe("输入区:/ 指令与上下文标签", () => {
it("/先给方案 只作用于这一次", () => {
expect(goalFromInput("/先给方案 把沙丘收进来")).toEqual({ goal: "把沙丘收进来", opts: { proposeFirst: true } });
expect(goalFromInput("把沙丘收进来")).toEqual({ goal: "把沙丘收进来", opts: { proposeFirst: false } });
expect(goalFromInput("/先给方案")).toBeNull();
// 只有一个指令;原来的 /重跑 是会话顶部的「重新运行」按钮,发出去就是普通消息
expect(parseCommand("/重跑")).toEqual({ command: null, rest: "/重跑" });
expect(parseCommand("/先给方案x").command).toBeNull();
expect(COMMANDS.map((c) => c.name)).toEqual(["/先给方案"]);
});
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("</button>")))).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>): 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?open=${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("检测站点 被拒");
});
});