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, splitContextGoal } from "./agent-store.ts"; import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts"; const call = (p: Partial): 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) => ({ 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(); }); });