- 「未经核查」只在最终汇报左栏标题保留一处,删「仅供参考」与数据来源说明; 右栏改叫「实际结果」,去掉「运行已完成」「工具成功 N」徽章,计数只列非零项, 状态值用中文标签,不再显示计划 / 记录短 ID - 工具卡片只标会改数据的动作和中高风险;能力 ID、scope、幂等、耗时、调用 ID、 schema 入口收进技术信息;拒绝说明改成一句「要你批准」 - 用量只显示费用与时长,工具调用数、token、模型、会话 ID 进技术信息(超限时照常显示) - 会话标题 shortGoalTitle:去上下文前缀、UUID、JSON 参数,能力 ID 换中文名,取第一句,40 字截断 - 入口去口号:删「交代目标,边做边汇报」与抽屉里的重复快捷键说明 - 对话组件结构不动 Co-Authored-By: Claude Opus 5.5 <[email protected]>
205 lines
8.1 KiB
TypeScript
205 lines
8.1 KiB
TypeScript
import type { AgentRunEvent } from "@umt/contracts";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
authDenial,
|
|
type ConvEntry,
|
|
groupConversation,
|
|
isDenied,
|
|
isLongReport,
|
|
pairAudit,
|
|
runPlanIds,
|
|
toolSummary,
|
|
} from "./agent-conversation.tsx";
|
|
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
|
|
import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
|
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
|
|
|
|
const call = (p: Partial<ToolCallView>): ToolCallView => ({
|
|
toolCallId: "t1",
|
|
capabilityId: "task.retry",
|
|
inputSummary: null,
|
|
outputSummary: null,
|
|
status: "succeeded",
|
|
reason: null,
|
|
startedAt: "2026-09-27T00:00:00.000Z",
|
|
endedAt: null,
|
|
recovery: false,
|
|
...p,
|
|
});
|
|
|
|
describe("拒绝判定", () => {
|
|
it("授权校验失败(tool_call_end + unauthorized)按拒绝展示", () => {
|
|
const c = call({
|
|
status: "failed",
|
|
outputSummary: '{"error":{"code":"unauthorized","message":"缺少 task.retry 的持续授权"}}',
|
|
});
|
|
expect(authDenial(c)).toBe("缺少 task.retry 的持续授权");
|
|
expect(isDenied(c)).toBe(true);
|
|
});
|
|
it("截断的摘要也能认出授权错误;普通失败不算拒绝", () => {
|
|
expect(authDenial(call({ status: "failed", outputSummary: '{"error":{"code":"forbidden","message":"x"}…' }))).toBe(
|
|
"x",
|
|
);
|
|
expect(isDenied(call({ status: "failed", outputSummary: '{"error":{"code":"internal"}}' }))).toBe(false);
|
|
expect(isDenied(call({ status: "denied", reason: "不在白名单" }))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("计划与审计", () => {
|
|
const plan = "32320000-0000-4000-8000-000700000002";
|
|
it("从提交输入与下载计划输出里收集计划 ID", () => {
|
|
const items: StreamItem[] = [
|
|
{ kind: "tool", key: "a", call: call({ capabilityId: "download.submit", inputSummary: `{"planId":"${plan}"}` }) },
|
|
{
|
|
kind: "tool",
|
|
key: "b",
|
|
call: call({
|
|
toolCallId: "t2",
|
|
capabilityId: "download.plan",
|
|
outputSummary: '{"plan":{"id":"33000000-0000-4000-8000-000000000701","status":"draft"…',
|
|
}),
|
|
},
|
|
];
|
|
expect(runPlanIds(items, null).sort()).toEqual([plan, "33000000-0000-4000-8000-000000000701"]);
|
|
});
|
|
it("同能力的审计按先后与工具调用配对", () => {
|
|
const calls = [
|
|
call({ toolCallId: "a", capabilityId: "download.submit", startedAt: "2026-09-27T00:00:01.000Z" }),
|
|
call({ toolCallId: "b", capabilityId: "download.submit", startedAt: "2026-09-27T00:00:02.000Z" }),
|
|
];
|
|
const audit = ["k1", "k2"].map((k, i) => ({
|
|
capabilityId: "download.submit",
|
|
at: `2026-09-27T00:00:0${i + 1}.500Z`,
|
|
outcome: "succeeded",
|
|
idempotencyKey: k,
|
|
durationMs: 1,
|
|
grantId: null,
|
|
approvalId: null,
|
|
}));
|
|
const m = pairAudit(calls, audit);
|
|
expect(m.get("a")?.idempotencyKey).toBe("k1");
|
|
expect(m.get("b")?.idempotencyKey).toBe("k2");
|
|
});
|
|
});
|
|
|
|
describe("侧栏上下文", () => {
|
|
it("按路由推断上下文并生成前缀,列表里能拆回正文", () => {
|
|
const ctx = contextFromPath("/console/engine/5b4e76e0-f27f-4e7a-8e08-71486387df1d");
|
|
expect(ctx?.taskId).toBe("5b4e76e0-f27f-4e7a-8e08-71486387df1d");
|
|
expect(contextFromPath("/console/sites/hdhome")?.siteId).toBe("hdhome");
|
|
expect(contextFromPath("/console/agent")).toBeNull();
|
|
const goal = `${contextPrefix(ctx)}排查这个任务`;
|
|
expect(splitContextGoal(goal)).toEqual({ context: expect.stringContaining("taskId="), text: "排查这个任务" });
|
|
});
|
|
});
|
|
|
|
describe("事件折叠", () => {
|
|
const ev = (seq: number, body: Record<string, unknown>) =>
|
|
({ runId: "r1", seq, at: "2026-09-28T00:00:00.000Z", ...body }) as unknown as AgentRunEvent;
|
|
it("挂起后恢复重放同一 toolCallId:只保留一张工具卡,状态以重放为准", () => {
|
|
const start = {
|
|
type: "tool_call_start",
|
|
toolCallId: "tc1",
|
|
capabilityId: "agent.propose_options",
|
|
inputSummary: "{}",
|
|
};
|
|
const items = foldEvents([
|
|
ev(1, { ...start, recovery: false }),
|
|
ev(2, { ...start, recovery: true }),
|
|
ev(3, {
|
|
type: "tool_call_end",
|
|
toolCallId: "tc1",
|
|
capabilityId: "agent.propose_options",
|
|
outputSummary: "ok",
|
|
isError: false,
|
|
recovery: true,
|
|
}),
|
|
]);
|
|
const tools = items.filter((i) => i.kind === "tool");
|
|
expect(tools).toHaveLength(1);
|
|
expect(new Set(items.map((i) => i.key)).size).toBe(items.length);
|
|
expect(tools[0]?.kind === "tool" && tools[0].call).toMatchObject({ status: "succeeded", recovery: true });
|
|
});
|
|
});
|
|
|
|
describe("会话排版(AGUI-2)", () => {
|
|
const e = (key: string, role: ConvEntry["role"], foldable = false): ConvEntry => ({
|
|
key,
|
|
at: key,
|
|
role,
|
|
foldable,
|
|
label: foldable ? "查本地库存" : undefined,
|
|
node: null,
|
|
});
|
|
it("同一助手回合归为一组,用户 / 系统条目打断回合", () => {
|
|
const blocks = groupConversation([e("a", "assistant"), e("b", "assistant"), e("c", "user"), e("d", "assistant")]);
|
|
expect(blocks.map((b) => b.kind)).toEqual(["turn", "single", "turn"]);
|
|
const first = blocks[0];
|
|
expect(first?.kind === "turn" && first.parts.length).toBe(2);
|
|
});
|
|
it("连续 ≥3 条可折叠的活动并成一行,不足 3 条原样展示", () => {
|
|
const [t] = groupConversation([
|
|
e("1", "assistant", true),
|
|
e("2", "assistant", true),
|
|
e("3", "assistant", true),
|
|
e("4", "assistant"),
|
|
e("5", "assistant", true),
|
|
e("6", "assistant", true),
|
|
]);
|
|
expect(t?.kind).toBe("turn");
|
|
if (t?.kind !== "turn") return;
|
|
expect(t.parts.map((p) => (p.kind === "fold" ? `fold${p.entries.length}` : p.entry.key))).toEqual([
|
|
"fold3",
|
|
"4",
|
|
"5",
|
|
"6",
|
|
]);
|
|
});
|
|
it("工具卡一行摘要", () => {
|
|
expect(toolSummary({ status: "running", outputSummary: null })).toBe("等待结果…");
|
|
expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条结果");
|
|
expect(toolSummary({ status: "failed", outputSummary: '{"error":{"code":"x","message":"超时"}}' })).toBe("超时");
|
|
expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBe(
|
|
"found: true · count: 2",
|
|
);
|
|
expect(toolSummary({ status: "succeeded", outputSummary: "下载器连接超时" })).toBe("下载器连接超时");
|
|
});
|
|
it("长汇报判定", () => {
|
|
expect(isLongReport("短")).toBe(false);
|
|
expect(isLongReport("x".repeat(601))).toBe(true);
|
|
expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("会话历史筛选", () => {
|
|
const now = new Date("2026-09-29T12:00:00Z").getTime();
|
|
const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来", createdAt: "2026-09-25T12:00:00Z" };
|
|
it("搜索匹配目标或 ID 前缀", () => {
|
|
expect(matchesHistory(run, { q: "沙丘", range: "" }, now)).toBe(true);
|
|
expect(matchesHistory(run, { q: "abcd", range: "" }, now)).toBe(true);
|
|
expect(matchesHistory(run, { q: "奥本海默", range: "" }, now)).toBe(false);
|
|
});
|
|
it("时间范围按创建时间", () => {
|
|
expect(matchesHistory(run, { q: "", range: "7d" }, now)).toBe(true);
|
|
expect(matchesHistory(run, { q: "", range: "today" }, now)).toBe(false);
|
|
expect(rangeStart("", now)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("shortGoalTitle(会话短标题)", () => {
|
|
it("去 UUID、JSON 参数,能力 ID 换中文名,取第一句", () => {
|
|
const t = shortGoalTitle(
|
|
'在浏览器会话 16be7338-1111-4222-8333-944445555666 上直接调用一次 browser.solve_challenge,参数:{"sessionId":"x","mode":"auto"}',
|
|
);
|
|
expect(t).not.toMatch(/16be7338|\{|sessionId/);
|
|
expect(t.startsWith("在浏览器会话上直接调用一次")).toBe(true);
|
|
});
|
|
it("去上下文前缀,超长截断", () => {
|
|
expect(shortGoalTitle("[上下文 作品:沙丘] 把沙丘:第二部的 4K 收进来。然后再看看别的")).toBe(
|
|
"把沙丘:第二部的 4K 收进来",
|
|
);
|
|
expect(shortGoalTitle("啊".repeat(60))).toBe(`${"啊".repeat(40)}…`);
|
|
expect(shortGoalTitle(" ")).toBe("未命名会话");
|
|
});
|
|
});
|