import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentRunNode, AgentUsageTotals } from "@umt/contracts"; import type { AgentRunView, AgentSessionOptions, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts"; // Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。 type Step = { delayMs: number; body: AgentRunEventBody }; type Entry = { run: AgentRunView; steps: Step[]; startedAt: number; abortedAt: number | null; appended: RunMessage[]; /** 挂起等用户(没有结束事件时状态为 waiting,summary 用它) */ waiting?: string | null; }; /** 子会话事件镜像进父会话(和服务端 subagents.ts 一样:spawned → 每条工具 / 开始 / 结束事件 → completed) */ function mirrorChild(parent: Step[], child: Entry, offsetMs: number): Step[] { const out: Step[] = [...parent]; const at = (d: number) => offsetMs + d + 100; out.push({ delayMs: offsetMs, body: { type: "subagent_spawned", childRunId: child.run.id, goal: child.run.goal, mode: "execute", depth: 1, capabilities: ["site.list", "site.health_check"], budget: {}, }, }); child.steps.forEach((st, i) => { const b = st.body; if (b.type === "tool_call_start" || b.type === "tool_call_end" || b.type === "run_started") { out.push({ delayMs: at(st.delayMs), body: { type: "subagent_event", childRunId: child.run.id, childSeq: i, childType: b.type, ...(b.type === "run_started" ? {} : { capabilityId: b.capabilityId }), summary: "", }, }); } if (b.type === "run_completed" || b.type === "run_failed") { out.push({ delayMs: at(st.delayMs) + 50, body: { type: "subagent_completed", childRunId: child.run.id, status: b.type === "run_completed" ? "succeeded" : "failed", summary: b.type === "run_completed" ? b.finalText : b.message, usage: b.usage, toolCalls: child.steps.filter((x) => x.body.type === "tool_call_end").length, durationMs: st.delayMs, }, }); } }); return out.sort((a, b) => a.delayMs - b.delayMs); } const store = new Map(); let seeded = false; const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`; const iso = (ms: number) => new Date(ms).toISOString(); const USAGE: AgentUsageTotals = { inputTokens: 18_420, outputTokens: 1_236, cacheReadTokens: 12_800, cacheWriteTokens: 2_048, totalTokens: 19_656, costUsd: 0.042, }; function chunks(text: string, size = 6): string[] { const out: string[] = []; for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size)); return out; } const MOCK_DECISION = "00000000-0000-4000-8000-00000000d001"; const MOCK_APPROVAL = "00000000-0000-4000-8000-00000000a001"; const MOCK_PLAN = "00000000-0000-4000-8000-00000000b001"; function script(goal: string, opts: AgentSessionOptions): Step[] { const s: Step[] = []; let t = 0; const push = (dt: number, body: AgentRunEventBody) => { t += dt; s.push({ delayMs: t, body }); }; const say = (text: string) => { for (const c of chunks(text)) push(60, { type: "text_delta", delta: c }); }; const steps: AgentPlanStep[] = [ { id: "s1", title: "查本地库存和收录版本", status: "running", note: null }, { id: "s2", title: "在站点搜 4K 候选", status: "pending", note: null }, { id: "s3", title: "选资源并准备下载", status: "pending", note: null }, { id: "s4", title: "提交下载", status: "pending", note: null }, ]; let revision = 0; const plan = (id: string, status: AgentPlanStep["status"], next?: string) => { for (const st of steps) { if (st.id === id) st.status = status; if (next && st.id === next) st.status = "running"; } push(150, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision }); }; push(0, { type: "run_started", goal, resumed: false }); push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision }); say("先确认本地库存与收录版本,再去站点找候选。"); push(400, { type: "tool_call_start", toolCallId: "t1", capabilityId: "inventory.query", inputSummary: '{"query":"沙丘:第二部","year":2024}', recovery: false, }); push(900, { type: "tool_call_end", toolCallId: "t1", capabilityId: "inventory.query", isError: false, outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务", recovery: false, }); plan("s1", "done", "s2"); push(300, { type: "tool_call_start", toolCallId: "t2", capabilityId: "release.search", inputSummary: '{"site":"audiences","q":"Dune Part Two 2160p","passkey":"[REDACTED]"}', recovery: false, }); push(1200, { type: "tool_call_end", toolCallId: "t2", capabilityId: "release.search", isError: false, outputSummary: "3 条候选:① Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.DV-FraMeSToR 78.4 GB 免费 · 做种 41;② 2160p WEB-DL DDP5.1 Atmos 24.1 GB;③ 2160p x265 10bit 18.9 GB", recovery: false, }); push(300, { type: "compaction", phase: "start", reason: "threshold" }); push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" }); say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。"); plan("s2", "done", "s3"); if (opts.proposeFirst) { push(300, { type: "options_proposed", invocationId: "mock-options", decisionId: MOCK_DECISION, question: "收哪一个 4K 版本?", options: [ { id: "a", title: "① REMUX 78.4 GB", summary: "画质最好,符合 archival 的硬条件;免费还剩 6 小时。", risks: ["缓存卷使用率会到 86%"], estimatedCost: "78.4 GB", }, { id: "b", title: "② WEB-DL 24.1 GB", summary: "体积小三分之二,不满足 archival 的 REMUX 条件。", risks: ["之后可能还要升级"], estimatedCost: "24.1 GB", }, ], recommendedId: "a", stepId: "s3", expiresAt: iso(Date.now() + 1_800_000), timeoutAction: "recommended", }); push(200, { type: "run_completed", finalText: "", usage: USAGE }); return s; } push(300, { type: "tool_call_start", toolCallId: "t3", capabilityId: "download.plan", inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}', recovery: false, }); push(800, { type: "tool_call_end", toolCallId: "t3", capabilityId: "download.plan", isError: false, outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)", recovery: false, }); plan("s3", "done", "s4"); push(300, { type: "tool_call_rejected", toolCallId: "t4", toolName: "qb_delete_torrent", capabilityId: "download.cleanup", reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)", }); push(300, { type: "tool_call_start", toolCallId: "t5", capabilityId: "download.submit", inputSummary: '{"planId":"…9a2e"}', recovery: false, }); push(1000, { type: "tool_call_end", toolCallId: "t5", capabilityId: "download.submit", isError: false, outputSummary: '{"error":{"code":"unauthorized","message":"已在步骤处发起审批,等待管理员批准"}}', recovery: false, }); push(200, { type: "approval_requested", approvalId: MOCK_APPROVAL, decisionId: null, invocationId: "mock-submit", capabilityId: "download.submit", planType: "acquisition_plan", planId: MOCK_PLAN, payloadHash: "sha256:9a2e…", expectedRevision: 1, riskLevel: "high", summary: "下载《沙丘:第二部》:Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX(78.4 GB)", stepId: "s4", expiresAt: iso(Date.now() + 86_400_000), }); say("提交下载需要审批,已在「提交下载」这一步发起。批准后继续。"); push(200, { type: "run_completed", finalText: "", usage: USAGE }); return s; } function baseRun( id: string, goal: string, opts: AgentSessionOptions, createdAt: number, status: AgentRunView["status"], ) { return { id, createdAt: iso(createdAt), updatedAt: iso(createdAt), taskId: null, parentRunId: null, trigger: "user", goal, status, provider: "anthropic", model: "claude-sonnet", thinkingLevel: "medium", scope: { proposeFirst: opts.proposeFirst, interactive: true }, budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 }, usage: {}, evidenceVersion: null, sessionState: null, summary: null, toolCallCount: 0, lastEventSeq: 0, error: null, cancelRequestedAt: null, startedAt: iso(createdAt), finishedAt: null, createdBy: "admin", dedupeKey: null, depth: 0, spawnReason: null, } satisfies AgentRunView; } function add(goal: string, opts: AgentSessionOptions, startedAt: number, n: number): Entry { const id = uuid(n); const e: Entry = { run: baseRun(id, goal, opts, startedAt, "running"), steps: script(goal, opts), startedAt, abortedAt: null, appended: [], }; store.set(id, e); return e; } // ── AGUI-3 演示:审查报告的 3 个示例(9883534f 巡检汇报、31d16a1a 方案 + 审批),截图 before / after 共用 ── const at0 = (steps: [number, AgentRunEventBody][]): Step[] => { let t = 0; return steps.map(([dt, body]) => { t += dt; return { delayMs: t, body }; }); }; const toolPair = ( id: string, cap: string, input: string, out: string, isError = false, ): [number, AgentRunEventBody][] => [ [120, { type: "tool_call_start", toolCallId: id, capabilityId: cap, inputSummary: input, recovery: false }], [380, { type: "tool_call_end", toolCallId: id, capabilityId: cap, isError, outputSummary: out, recovery: false }], ]; const textSteps = (text: string): [number, AgentRunEventBody][] => chunks(text, 40).map((c) => [20, { type: "text_delta", delta: c }]); /** 旧汇报格式(改 prompt 之前模型写的):函数名、字段名、UUID、6 列表格 */ export const LEGACY_REPORT = `## 巡检结果 已调用 \`site.list(includeDisabled=true)\` 与 \`download.inspect\`、\`inventory.query\` 核对,结论如下: | 站点 ID | 名称 | enabled | loginStatus | seeding | health_check | | --- | --- | --- | --- | --- | --- | | 062e5c51-9d1e-4c55-a5a2-3f7b1f3e0c11 | 憨憨 | true | ok | 24 | unauthorized | - \`download.inspect\` 返回 downloader=null:下载器未配置(qb-main 未登记)。 - \`inventory.query\` 返回 mediaServer=null:媒体服务器未接入。 - \`site.health_check\` 被拒绝(缺少 site.health_check 的持续授权),未能检测连通性。 下一步:配置下载器后再调用 download.inspect;授权 site.health_check 后重试。`; /** 新汇报格式(AGUI-3 prompt):第一句结论、用名字、≤10 行 */ export const NEW_REPORT = "下载器和媒体服务器都还没接入,所以没法检查。站点「憨憨」正常,在做种 24 个。\n\n- 接入下载器:设置 → 下载器\n- 接入媒体服务器:设置 → 媒体服务器"; function checkupSteps(goal: string, report: string): Step[] { const plan = (rev: number, s1: AgentPlanStep["status"], s2: AgentPlanStep["status"], s3: AgentPlanStep["status"]) => [ [ 80, { type: "plan_updated", revision: rev, steps: [ { id: "s1", title: "看站点状态", status: s1, note: null }, { id: "s2", title: "核对下载器", status: s2, note: null }, { id: "s3", title: "核对媒体服务器", status: s3, note: null }, ], }, ], ] as [number, AgentRunEventBody][]; return at0([ [0, { type: "run_started", goal, resumed: false }], ...toolPair("p1", "agent.plan", '{"steps":[{"id":"s1"}]}', '{"revision":1}'), ...plan(1, "running", "pending", "pending"), [60, { type: "thinking_delta", delta: "先列站点,再看下载器和媒体服务器有没有接入。" }], ...toolPair("c1", "site.list", '{"includeDisabled":true}', '{"items":[{"name":"憨憨","seeding":24}]}'), ...toolPair( "c2", "site.health_check", '{"siteId":"062e5c51-9d1e-4c55-a5a2-3f7b1f3e0c11"}', '{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权或有效的单次审批"}}', true, ), ...toolPair("p2", "agent.plan", '{"steps":[{"id":"s1","status":"done"}]}', '{"revision":2}'), ...plan(2, "done", "running", "pending"), ...toolPair("c3", "download.inspect", '{"status":"downloading"}', '{"summary":"没有配置下载器"}'), ...toolPair("c4", "download.inspect", '{"status":"stalled"}', '{"summary":"没有配置下载器"}'), [60, { type: "thinking_delta", delta: "下载器没配置。再看媒体库。" }], ...toolPair("c5", "inventory.query", "{}", '{"items":[],"mediaServer":null}'), ...toolPair("p3", "agent.plan", '{"steps":[{"id":"s3","status":"done"}]}', '{"revision":3}'), ...plan(3, "done", "done", "done"), ...textSteps(report), [200, { type: "run_completed", finalText: report, usage: { ...USAGE, costUsd: 0.014, totalTokens: 320_700 } }], ]); } /** 例 3:方案绑定计划(选定即批准),选了之后直接提交 */ const DUNE_PLAN = "00000000-0000-4000-8000-00000000b003"; const DUNE_DECISION = "00000000-0000-4000-8000-00000000d003"; function optionSteps(goal: string): Step[] { return at0([ [0, { type: "run_started", goal, resumed: false }], ...toolPair("p1", "agent.plan", "{}", '{"revision":1}'), [ 60, { type: "plan_updated", revision: 1, steps: [ { id: "s1", title: "找 1080p 资源", status: "done", note: null }, { id: "s2", title: "准备下载", status: "done", note: null }, { id: "s3", title: "等你选方案", status: "running", note: null }, { id: "s4", title: "提交下载", status: "pending", note: null }, ], }, ], ...toolPair("c1", "release.search", '{"q":"Dune Part Two 1080p"}', '{"items":[1,2]}'), ...toolPair("c2", "download.plan", `{"sourceReleaseId":"…"}`, `{"plan":{"id":"${DUNE_PLAN}","status":"draft"}}`), ...textSteps("找到 1 个合适的 1080p 资源,站点「憨憨」的登录明天过期。"), ...toolPair("p2", "agent.propose_options", "{}", '{"outcome":"pending"}'), [ 80, { type: "options_proposed", invocationId: "mock-dune-options", decisionId: DUNE_DECISION, question: "《沙丘:第二部》1080p 怎么收?", options: [ { id: "a", title: "提交下载", summary: "Dune.Part.Two.2024.1080p.BluRay.x264-GRP,免费,做种 41", risks: ["站点登录明天过期,之后下载可能断", "缓存卷会用到 86%"], estimatedCost: "12 GB", action: { capabilityId: "download.submit", planId: DUNE_PLAN, label: "提交下载", detail: "《沙丘:第二部》· 12.0 GB", approves: true, }, }, { id: "b", title: "先修站点", summary: "先刷新「憨憨」的登录,再提交", risks: [], estimatedCost: null, action: null, }, { id: "c", title: "先不下", summary: "等 REMUX 出现再说", risks: [], estimatedCost: null, action: null }, ], recommendedId: "a", stepId: "s3", expiresAt: iso(Date.now() + 1_800_000), timeoutAction: "recommended", }, ], ]); } /** 选定方案后的续跑:选了带动作的方案 → 直接提交,不出审批卡 */ function afterChoice(e: Entry, optionId: string | null) { const base = Date.now() - e.startedAt; const done: [number, AgentRunEventBody][] = optionId === "a" ? [ [ 100, { type: "options_resolved", invocationId: "mock-dune-options", decisionId: DUNE_DECISION, outcome: "chosen", choice: "a", reason: null, by: "user", }, ], ...toolPair("c3", "download.submit", `{"planId":"${DUNE_PLAN}"}`, '{"taskId":"…","status":"submitted"}'), ...textSteps("已提交《沙丘:第二部》1080p(12 GB),下载完成后自动整理。"), [100, { type: "run_completed", finalText: "", usage: USAGE }], ] : [ [ 100, { type: "options_resolved", invocationId: "mock-dune-options", decisionId: DUNE_DECISION, outcome: optionId ? "chosen" : "rejected", choice: optionId, reason: null, by: "user", }, ], ...textSteps(optionId === "b" ? "好,先不提交,等站点登录刷新后再说。" : "好,这次先不下。"), [100, { type: "run_completed", finalText: "", usage: USAGE }], ]; let t = base; for (const [dt, body] of done) { t += dt; e.steps.push({ delayMs: t, body }); } e.waiting = null; } /** mock 的方案作答 */ export function mockChoose(runId: string, optionId: string | null): RunSnapshot { seed(); const e = store.get(runId); if (e?.waiting === "等待选择方案") afterChoice(e, optionId); return mockDetail(runId); } function seed() { if (seeded) return; seeded = true; const now = Date.now(); const fixture = (n: number, goal: string, steps: Step[], ago: number, extra: Partial = {}) => { const e: Entry = { run: baseRun(uuid(n), goal, { proposeFirst: false }, now - ago, "running"), steps, startedAt: now - ago, abortedAt: null, appended: [], ...extra, }; e.run = { ...e.run, provider: "opencode-go", model: "deepseek-v4.1-flash" }; store.set(e.run.id, e); return e; }; const CHECKUP = "检查站点、下载器和媒体服务器是否正常"; fixture(11, `${CHECKUP}(旧汇报格式)`, checkupSteps(CHECKUP, LEGACY_REPORT), 40 * 60_000); const parent = fixture(12, CHECKUP, checkupSteps(CHECKUP, NEW_REPORT), 30 * 60_000); const child = fixture( 14, "看站点「憨憨」最近的抓取记录", checkupSteps("看站点「憨憨」最近的抓取记录", "「憨憨」最近 24 小时抓取 96 次,全部成功。"), 29 * 60_000, ); child.run = { ...child.run, parentRunId: parent.run.id, trigger: "subagent", depth: 1 }; { // 父会话第一次看站点之后派出子会话(子会话比父会话晚 1 分钟开始) const spawnAt = parent.steps.findIndex( (x) => x.body.type === "tool_call_end" && x.body.capabilityId === "site.list", ); const base = parent.steps[spawnAt]?.delayMs ?? 1000; parent.steps = mirrorChild( [ ...parent.steps.slice(0, spawnAt + 1), ...at0([ [base + 50, { type: "text_delta", delta: "派一个子会话去看「憨憨」的抓取记录。" }], [ 60, { type: "tool_call_start", toolCallId: "sp1", capabilityId: "agent.spawn", inputSummary: '{"goal":"看站点「憨憨」最近的抓取记录"}', recovery: false, }, ], [ 60, { type: "tool_call_end", toolCallId: "sp1", capabilityId: "agent.spawn", isError: false, outputSummary: `{"runId":"${child.run.id}"}`, recovery: false, }, ], ]), ...parent.steps.slice(spawnAt + 1).map((x) => ({ ...x, delayMs: x.delayMs + 60_000 })), ], child, base + 200, ); } // 运行中的会话:派出两个子会话,一个很快查完,一个还在跑(看子会话卡片的实时样子) { const goal = "检查所有站点的登录状态和最近的抓取"; const live = fixture(15, goal, [], 0); const quick = fixture(16, "看站点「憨憨」最近的抓取记录", [], 0); const slow = fixture(17, "逐个检测站点「观众」「憨憨」的登录状态", [], 0); for (const c of [quick, slow]) c.run = { ...c.run, parentRunId: live.run.id, trigger: "subagent", depth: 1 }; quick.startedAt = live.startedAt + 2_000; slow.startedAt = live.startedAt + 2_100; quick.steps = at0([ [0, { type: "run_started", goal: quick.run.goal, resumed: false }], ...toolPair("q1", "site.list", "{}", '{"items":[{"name":"憨憨"}]}'), [2_000, { type: "text_delta", delta: "「憨憨」最近 24 小时抓取 96 次,全部成功。" }], ...toolPair("q2", "site.list", "{}", '{"items":[{"name":"憨憨"}]}'), [ 3_000, { type: "run_completed", finalText: "「憨憨」最近 24 小时抓取 96 次,全部成功。", usage: { ...USAGE, totalTokens: 8_200, costUsd: 0.006 }, }, ], ]); slow.steps = at0([ [0, { type: "run_started", goal: slow.run.goal, resumed: false }], ...toolPair("w1", "site.list", "{}", '{"items":[{"name":"观众"},{"name":"憨憨"}]}'), [400, { type: "text_delta", delta: "先测「观众」。" }], [ 100, { type: "tool_call_start", toolCallId: "w2", capabilityId: "site.health_check", inputSummary: '{"site":"观众"}', recovery: false, }, ], [ 600_000, { type: "tool_call_end", toolCallId: "w2", capabilityId: "site.health_check", isError: false, outputSummary: "{}", recovery: false, }, ], [1_000, { type: "run_completed", finalText: "两个站点登录都正常。", usage: USAGE }], ]); const spawn = (id: string, g: string, out: string): [number, AgentRunEventBody][] => [ [ 100, { type: "tool_call_start", toolCallId: id, capabilityId: "agent.spawn", inputSummary: `{"goal":"${g}"}`, recovery: false, }, ], [ 100, { type: "tool_call_end", toolCallId: id, capabilityId: "agent.spawn", isError: false, outputSummary: `{"runId":"${out}"}`, recovery: false, }, ], ]; let steps: Step[] = at0([ [0, { type: "run_started", goal, resumed: false }], ...textSteps("两件事互不相关,各派一个子会话去查。"), ...spawn("sa", quick.run.goal, quick.run.id), ...spawn("sb", slow.run.goal, slow.run.id), [620_000, { type: "text_delta", delta: "两个站点登录都正常,「憨憨」最近抓取全部成功。" }], [100, { type: "run_completed", finalText: "两个站点登录都正常。", usage: USAGE }], ]); steps = mirrorChild(steps, quick, 2_000); steps = mirrorChild(steps, slow, 2_100); live.steps = steps; } fixture( 13, "[上下文 作品:沙丘:第二部 · entityId=74beab51-0000-4000-8000-000000000000] 把 1080p 收进来", optionSteps("把沙丘:第二部的 1080p 收进来"), 20 * 60_000, { waiting: "等待选择方案" }, ); add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1); add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2); const failed = add("巡检:检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3); failed.run = { ...failed.run, trigger: "schedule" }; const evt = add( "新资源到达:看看 奥本海默 的 REMUX 是否值得替换现有版本", { proposeFirst: false }, now - 3 * 86_400_000, 4, ); evt.run = { ...evt.run, trigger: "event" }; failed.steps = [ { delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } }, { delayMs: 100, body: { type: "tool_call_start", toolCallId: "x1", capabilityId: "task.inspect", inputSummary: '{"instance":"qb-main"}', recovery: false, }, }, { delayMs: 200, body: { type: "tool_call_end", toolCallId: "x1", capabilityId: "task.inspect", isError: true, outputSummary: "下载器连接超时(qb-main 10.0.0.12:8080)", recovery: false, }, }, { delayMs: 300, body: { type: "run_failed", reason: "budget", budget: "duration", message: "超出单次运行时长预算(600 秒)", usage: { ...USAGE, totalTokens: 4_210, inputTokens: 3_900, outputTokens: 310, costUsd: 0.009 }, }, }, ]; } function revealed(e: Entry, now = Date.now()): AgentRunEvent[] { const cut = e.abortedAt ?? now; const out: AgentRunEvent[] = []; let seq = 0; for (const st of e.steps) { if (e.startedAt + st.delayMs > cut) break; out.push({ ...st.body, runId: e.run.id, seq: seq++, at: iso(e.startedAt + st.delayMs) } as AgentRunEvent); } if (e.abortedAt && !out.some((x) => x.type === "run_completed" || x.type === "run_failed")) { out.push({ type: "run_failed", reason: "aborted", message: "用户中止", usage: USAGE, runId: e.run.id, seq: seq++, at: iso(e.abortedAt), }); } return out; } function view(e: Entry): AgentRunView { const evs = revealed(e); const end = evs.find((x) => x.type === "run_completed" || x.type === "run_failed"); const tools = evs.filter((x) => x.type === "tool_call_start").length; const text = evs .filter((x) => x.type === "text_delta") .map((x) => (x.type === "text_delta" ? x.delta : "")) .join(""); const status: AgentRunView["status"] = !end ? e.waiting && evs.length === e.steps.length ? "waiting" : "running" : end.type === "run_completed" ? "succeeded" : end.reason === "budget" ? "budget_exceeded" : end.reason === "aborted" ? "cancelled" : "failed"; const frac = end ? 1 : Math.min(1, evs.length / Math.max(1, e.steps.length)); return { ...e.run, status, toolCallCount: tools, lastEventSeq: evs.length ? evs[evs.length - 1]!.seq : 0, usage: end ? { ...end.usage, durationMs: new Date(end.at).getTime() - e.startedAt } : { inputTokens: Math.round(USAGE.inputTokens * frac), outputTokens: Math.round(USAGE.outputTokens * frac), totalTokens: Math.round(USAGE.totalTokens * frac), costUsd: +(USAGE.costUsd * frac).toFixed(4), durationMs: Date.now() - e.startedAt, }, summary: end ? (end.type === "run_failed" ? end.message : text) : status === "waiting" ? (e.waiting ?? null) : null, error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null, finishedAt: end ? end.at : null, updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt, }; } export function MOCK_RUNS(): AgentRunView[] { seed(); return [...store.values()].map(view).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } export function mockDetail(id: string, op?: { abort?: boolean; append?: string }): RunSnapshot { seed(); const e = store.get(id); if (!e) throw Object.assign(new Error("会话不存在(mock)"), { code: "not_found" }); if (op?.abort && !e.abortedAt) e.abortedAt = Date.now(); if (op?.append) { // 追加消息:模拟 Agent 回一句确认 const at = Date.now() - e.startedAt; e.appended.push({ id: uuid(9000 + e.appended.length), runId: id, createdAt: iso(Date.now()), updatedAt: iso(Date.now()), text: op.append, status: "pending", createdBy: "user:mock", consumedAt: null, }); const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed"); const reply: Step[] = chunks(`收到补充:「${op.append}」,按此调整。`).map((c, i) => ({ delayMs: at + 200 + i * 60, body: { type: "text_delta", delta: c }, })); if (last >= 0 && e.startedAt + e.steps[last]!.delayMs > Date.now()) e.steps.splice(last, 0, ...reply); e.steps.sort((a, b) => a.delayMs - b.delayMs); } const run = view(e); const children = [...store.values()].filter((c) => c.run.parentRunId === id).map((c) => node(view(c))); return { run, messages: e.appended, report: report(e, run), children, ...(run.parentRunId && store.get(run.parentRunId) ? { parent: node(view(store.get(run.parentRunId)!)) } : {}), }; } function node(r: AgentRunView): AgentRunNode { return { id: r.id, parentRunId: r.parentRunId, goal: r.goal, status: r.status, depth: r.depth, spawnReason: r.spawnReason, toolCallCount: r.toolCallCount, usage: r.usage, summary: r.summary, capabilities: [], budget: r.budget, createdAt: r.createdAt, finishedAt: r.finishedAt, }; } /** 服务端目标汇报的 mock:只按"业务表"口径给数,模型文本单独放 modelText */ function report(e: Entry, run: AgentRunView): GoalReport | null { if (!run.finishedAt) return null; const evs = revealed(e); const submitted = evs.some((x) => x.type === "tool_call_end" && x.capabilityId === "download.submit" && !x.isError); const text = evs .filter((x) => x.type === "text_delta") .map((x) => (x.type === "text_delta" ? x.delta : "")) .join(""); return { state: submitted ? "waiting" : run.status === "succeeded" ? "no_actions" : "failed", summary: submitted ? "1 个下载已进入下载器(qb-main),尚未整理;整理以整理记录 succeeded 为准" : run.status === "succeeded" ? "本次没有产生下载或整理动作" : "运行未完成,没有产生业务动作", submitted: submitted ? 1 : 0, imported: 0, inProgress: submitted ? 1 : 0, failed: 0, plans: submitted ? [{ planId: uuid(7001), status: "submitted", slotId: uuid(7003), sourceReleaseId: uuid(7004) }] : [], downloads: submitted ? [ { downloadTaskId: uuid(7002), planId: uuid(7001), status: "downloading", name: run.goal.includes("1080p") ? "Dune.Part.Two.2024.1080p.BluRay.x264-GRP" : "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX", }, ] : [], organization: [], modelText: text || null, generatedAt: run.finishedAt, }; } export function mockEventsAfter(id: string, after: number): AgentRunEvent[] { seed(); const e = store.get(id); if (!e) return []; return revealed(e).filter((x) => x.seq > after); } export function mockCreateRun(goal: string, opts: AgentSessionOptions): AgentRunView { seed(); const e = add(goal, opts, Date.now(), store.size + 1); return view(e); }