- Remove the "换掉的标识" mapping list under humanized reports; the raw
report text is kept only as "原文" in the result line's 技术信息 (no mapping).
- Session stats line above the overview bar, always shown: tool calls, MCP
calls (only when any), tokens used and left to the budget (K/M + percent),
cost; live while running, fixed after the run ends.
- Live turn status: "思考中 / 调用工具中 / 回答中 · 已调用 N 次工具、M 次 MCP";
after the turn the count merges into each progress line ("调用 N 次工具").
- agent.spawn calls answered with an existing same-goal child (reused=true)
are not counted in "派出了 N 个子会话".
Screenshots (desktop + mobile): apps/web/test/screenshots/ag4/ (gitignored).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
445 lines
19 KiB
TypeScript
445 lines
19 KiB
TypeScript
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 } from "~/api/agent.ts";
|
||
import { qk } from "~/lib/query-keys.ts";
|
||
import { AgentConversation, contextLabel, DeniedLines, ResultLine, 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, 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<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: "巡检" }),
|
||
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("</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");
|
||
});
|
||
});
|