- 抽屉和会话页渲染同一套 GoalComposer / AgentRunPanel;抽屉标题即最近会话切换, 头部只有新对话、在页面打开、关闭;去掉右下角悬浮按钮和最小化小窗 - 会话列表合一:对话与巡检混排带类型小标签,搜索 + 一个状态下拉,只列顶层会话 (GET /agent/runs?roots=true,新增可选参数) - 子会话卡片:由镜像的 subagent_* 事件折成,显示任务、状态、当前步骤、用时、调用次数、费用, 可展开看过程、可打开;顶部「N 个子会话运行中」跳到卡片;subagent_completed 加可选 durationMs - 回答去内部字段:汇报规则禁止照抄键值与字段表;展示层 stripInternalFields 把字段行 / 字段表移进技术信息 - 开场建议按页面给 2 条完整问法、点了直接发;指令只留 /先给方案,/重跑 改成「重新运行」按钮 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
876 lines
31 KiB
TypeScript
876 lines
31 KiB
TypeScript
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<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 };
|
||
{
|
||
// 父会话第一次看站点之后派出子会话(子会话比父会话晚 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);
|
||
}
|