Files
UHD_Media_Tool/apps/web/app/components/agent/agent-helpers.test.ts
T
rootandClaude Opus 5.5 57346d438c fix(web): replayed tool call after resume reuses its card (duplicate React key)
A suspended agent.propose_options / approval call is replayed on resume with the
same toolCallId; foldEvents pushed a second item with the same key, which also
inflated the tool call meter. Also use 「」 in the report footnote.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:41:44 +08:00

114 lines
4.4 KiB
TypeScript

import type { AgentRunEvent } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx";
import { contextFromPath, contextPrefix, 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 });
});
});