Files
UHD_Media_Tool/apps/web/app/components/agent/mock.ts
T
rootandClaude Opus 5.5 6fcf82afb0 feat(web): 智能体对话重做(AGUI-3)
- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话
  聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」),
  展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。
- agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡,
  回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。
- 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠;
  「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。
- 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止,
  / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。
- 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。
- 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。
- 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。
- 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 22:41:34 +08:00

678 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, 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;
};
const store = new Map<string, Entry>();
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<Entry> = {}) => {
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 };
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);
return { run, messages: e.appended, report: report(e, run) };
}
/** 服务端目标汇报的 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);
}