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:
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("沙丘:第二部");
|
||||
|
||||
Reference in new issue
Block a user