feat(agent): 抽屉与会话页统一成一个对话界面,子会话在父会话里实时可见

- 抽屉和会话页渲染同一套 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
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 13:56:38 +08:00
1 parent dd0f6e2c09
commit 3692793dff
35 files changed
+1454 -1137

No files matched your search

@@ -18,15 +18,20 @@ import {
} from "./agent-conversation.tsx";
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx";
import { arrangeHistory } from "./agent-session-list.tsx";
import { arrangeHistory, runTypeLabel } from "./agent-session-list.tsx";
import { subagentChipText } from "./agent-subagents.tsx";
import {
type ActivityItem,
activitySpan,
activitySummary,
buildConversation,
deniedReason,
subagentElapsed,
subagentResultLine,
subagentState,
subagentViews,
} from "./conversation-model.ts";
import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts";
import { COMMANDS, parseCommand } from "./slash-commands.ts";
import {
type AgentStreamState,
foldEvents,
@@ -369,28 +374,153 @@ describe("方案:一键选定", () => {
});
});
describe("历史:等你处理置顶,子会话收在父会话下", () => {
describe("会话列表:一个列表,等你处理置顶,子会话不单列", () => {
const r = (id: string, p: Partial<AgentRunView>) => ({ ...RUN, id, ...p }) as AgentRunView;
const runs = [
r("a", { goal: "巡检" }),
r("a", { goal: "巡检", trigger: "schedule" }),
r("b", { goal: "子任务", parentRunId: "a", trigger: "subagent" }),
r("c", { goal: "收沙丘", status: "waiting", summary: "等待审批" }),
r("d", { goal: "失败的", status: "failed" }),
r("e", { goal: "孤儿子会话", parentRunId: "zzz", trigger: "subagent" }),
r("f", { goal: "等下载", status: "waiting", summary: "等待下载" }),
r("g", { goal: "跑着的", status: "running" }),
];
it("全部:等你处理置顶;子会话挂到父会话;父会话不在列表的单列", () => {
it("全部:等你处理置顶;对话和巡检混排;子会话(包括父会话不在列表里的)都不出现", () => {
const { pinned, rows } = arrangeHistory(runs, { q: "", view: "all" });
expect(pinned.map((x) => x.run.id)).toEqual(["c"]);
expect(rows.map((x) => x.run.id)).toEqual(["a", "d", "e", "f"]);
expect(rows[0]?.children.map((x) => x.id)).toEqual(["b"]);
expect(pinned.map((x) => x.id)).toEqual(["c"]);
expect(rows.map((x) => x.id)).toEqual(["a", "d", "f", "g"]);
});
it("等你处理 / 失败 / 条数上限", () => {
expect(arrangeHistory(runs, { q: "", view: "pending" }).rows.map((x) => x.run.id)).toEqual(["c"]);
expect(arrangeHistory(runs, { q: "", view: "failed" }).rows.map((x) => x.run.id)).toEqual(["d"]);
it("状态下拉:等你处理 / 进行中 / 失败;条数上限", () => {
expect(arrangeHistory(runs, { q: "", view: "pending" }).rows.map((x) => x.id)).toEqual(["c"]);
expect(arrangeHistory(runs, { q: "", view: "active" }).rows.map((x) => x.id)).toEqual(["f", "g"]);
expect(arrangeHistory(runs, { q: "", view: "failed" }).rows.map((x) => x.id)).toEqual(["d"]);
const limited = arrangeHistory(runs, { q: "", view: "all" }, 3);
expect(limited.pinned.length + limited.rows.length).toBe(3);
});
it("类型小标签只给自动发起的会话", () => {
expect(runTypeLabel({ trigger: "schedule" })).toBe("巡检");
expect(runTypeLabel({ trigger: "event" })).toBe("事件");
expect(runTypeLabel({ trigger: "user" })).toBeNull();
});
});
describe("子会话:在父会话里派出的位置实时显示", () => {
const CHILD = "00000000-0000-4000-8000-0000000000c1";
const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 900 };
const spawned = (): AgentRunEvent[] => {
seq = 0;
return [
ev(0, { type: "run_started", goal: "查站点", resumed: false }),
ev(1, { type: "text_delta", delta: "派一个子会话去查。" }),
...tool(2, "s1", "agent.spawn", `{"childRunId":"${CHILD}"}`),
ev(4, {
type: "subagent_spawned",
childRunId: CHILD,
goal: "检查站点「观众」的登录状态",
mode: "execute",
depth: 1,
capabilities: ["site.health_check"],
budget: {},
}),
ev(5, { type: "subagent_event", childRunId: CHILD, childSeq: 0, childType: "run_started", summary: "开始" }),
ev(6, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 1,
childType: "tool_call_start",
capabilityId: "site.list",
summary: "{}",
}),
ev(7, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 2,
childType: "tool_call_end",
capabilityId: "site.list",
summary: "{}",
}),
ev(8, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 3,
childType: "tool_call_start",
capabilityId: "site.health_check",
summary: "{}",
}),
];
};
const done = (): AgentRunEvent[] => [
...spawned(),
ev(20, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 4,
childType: "tool_call_end",
capabilityId: "site.health_check",
summary: "{}",
}),
ev(21, {
type: "subagent_completed",
childRunId: CHILD,
status: "succeeded",
summary:
"本次没有提交下载或整理,结论见模型汇报。\n 模型汇报(未经核查):站点「观众」登录正常。\n证据:childRunId=x",
usage: { ...usage, costUsd: 0.02 },
toolCalls: 2,
durationMs: 17000,
}),
ev(22, { type: "text_delta", delta: "站点「观众」登录正常。" }),
];
it("运行中:当前工具、调用次数;卡片切开进度行", () => {
const items = foldEvents(spawned());
const sub = items.find((i) => i.kind === "subagent");
expect(sub?.kind === "subagent" && sub.view).toMatchObject({
childRunId: CHILD,
goal: "检查站点「观众」的登录状态",
status: null,
currentTool: "site.health_check",
toolCalls: 1,
});
// 卡片跟在派出它的那段进度行下面,不另起一段
const turn = buildConversation(items)[0];
expect(turn?.kind === "turn" && turn.parts.map((p) => p.kind)).toEqual(["segment"]);
const seg = turn?.kind === "turn" ? turn.parts[0] : undefined;
expect(seg?.kind === "segment" && seg.subagents.map((v) => v.childRunId)).toEqual([CHILD]);
expect(subagentChipText(subagentViews(items).map((v) => subagentState(v)))).toBe("1 个子会话运行中");
});
it("运行中卡片:正在做什么、用时、调用次数,每秒更新", () => {
const html = text(
render(createElement(AgentConversation, { run: { ...RUN, status: "running" }, live: liveOf(spawned()) })),
);
expect(html).toContain("子会话");
expect(html).toContain("检查站点「观众」的登录状态");
expect(html).toContain("运行中");
expect(html).toMatch(/正在检测站点/);
expect(html).toContain("1 次调用");
expect(html).toContain("查看过程");
expect(html).toContain("打开会话");
});
it("结束后:状态、汇报第一句、用时、调用次数和费用;快照里的状态优先于未结束的镜像", () => {
const live = liveOf(done());
const html = text(render(createElement(AgentConversation, { run: RUN, live })));
expect(html).toContain("已完成");
expect(html).toContain("站点「观众」登录正常。");
expect(html).not.toContain("childRunId");
expect(html).toMatch(/17 秒 · 2 次调用 · \$0\.02/);
const v = subagentViews(live.items)[0]!;
expect(subagentState(v)).toBe("done");
expect(subagentState({ status: null }, "waiting")).toBe("waiting");
expect(subagentState({ status: null }, "cancelled")).toBe("stopped");
expect(subagentState({ status: "failed" }, "running")).toBe("failed");
expect(subagentElapsed({ startedAt: at(0), endedAt: null, durationMs: null }, Date.parse(at(65)))).toBe(65000);
expect(subagentChipText(["done", "failed"])).toBe("2 个子会话");
expect(subagentChipText([])).toBeNull();
expect(subagentResultLine("没有动作。")).toBe("没有动作。");
expect(subagentResultLine(null)).toBeNull();
});
});
describe("输入区:/ 指令与上下文标签", () => {
@@ -398,8 +528,10 @@ describe("输入区:/ 指令与上下文标签", () => {
expect(goalFromInput("/先给方案 把沙丘收进来")).toEqual({ goal: "把沙丘收进来", opts: { proposeFirst: true } });
expect(goalFromInput("把沙丘收进来")).toEqual({ goal: "把沙丘收进来", opts: { proposeFirst: false } });
expect(goalFromInput("/先给方案")).toBeNull();
expect(parseCommand("/重跑", RUN_COMMANDS)).toEqual({ command: "/重跑", rest: "" });
expect(parseCommand("/先给方案x", RUN_COMMANDS).command).toBeNull();
// 只有一个指令;原来的 /重跑 是会话顶部的「重新运行」按钮,发出去就是普通消息
expect(parseCommand("/重跑")).toEqual({ command: null, rest: "/重跑" });
expect(parseCommand("/先给方案x").command).toBeNull();
expect(COMMANDS.map((c) => c.name)).toEqual(["/先给方案"]);
});
it("上下文标签显示名称,只有 ID 时不显示", () => {
expect(contextLabel("作品:沙丘:第二部 · entityId=74beab51")).toBe("沙丘:第二部");