feat(web): session stats line, live turn status, drop replaced-ID list (AG-4)

- Remove the "换掉的标识" mapping list under humanized reports; the raw
  report text is kept only as "原文" in the result line's 技术信息 (no mapping).
- Session stats line above the overview bar, always shown: tool calls, MCP
  calls (only when any), tokens used and left to the budget (K/M + percent),
  cost; live while running, fixed after the run ends.
- Live turn status: "思考中 / 调用工具中 / 回答中 · 已调用 N 次工具、M 次 MCP";
  after the turn the count merges into each progress line ("调用 N 次工具").
- agent.spawn calls answered with an existing same-goal child (reused=true)
  are not counted in "派出了 N 个子会话".

Screenshots (desktop + mobile): apps/web/test/screenshots/ag4/ (gitignored).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 17:58:15 +08:00
1 parent 793b87e3b6
commit 919dc02200
5 files changed
+263 -39

No files matched your search

@@ -54,10 +54,13 @@ import {
activitySummary,
authDenial,
buildConversation,
callCounts,
callCountText,
capabilityTitle,
deniedReason,
durationLabel,
isDenied,
lastReportText,
runningText,
toolSummary,
} from "./conversation-model.ts";
@@ -271,6 +274,7 @@ export function StepSummary({
const denied = calls.filter(isDenied).length;
const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length;
const span = activitySpan(activity);
const counted = running ? "" : callCountText(callCounts(calls), "调用");
const thinking = activity.some((a) => a.kind === "thinking");
const head = current
? `${done ? `${done},` : ""}${runningText(current)}…`
@@ -292,6 +296,7 @@ export function StepSummary({
<span className="min-w-0 truncate">{head}</span>
{denied ? <span className="shrink-0">· {denied} 项没做</span> : null}
{failed ? <span className="shrink-0 text-destructive">· {failed} 项失败</span> : null}
{counted ? <span className="shrink-0 tabular-nums">· {counted}</span> : null}
{span !== null && !running ? <span className="shrink-0 tabular-nums">· {durationLabel(span)}</span> : null}
<ChevronRight aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-90")} />
</button>
@@ -473,15 +478,32 @@ export function AgentConversation({
// 等选方案 / 等审批时卡片本身就是提示,不再显示"等待中…"
const lastPart = lastBlock?.kind === "turn" ? lastBlock.parts.at(-1) : undefined;
const cardWaiting = run.status === "waiting" && (lastPart?.kind === "options" || lastPart?.kind === "approval");
const showTyping = active && tail?.kind !== "text" && tail?.kind !== "thinking" && !toolRunning && !cardWaiting;
const typing = showTyping ? (
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
{run.status === "waiting"
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
// 本轮的实时状态(AG-4):「思考中 · 已调用 3 次工具、1 次 MCP」;本轮结束后计数并入各段进度行
const turnCalls =
lastBlock?.kind === "turn"
? lastBlock.parts.flatMap((p) =>
p.kind === "segment" ? p.activity.flatMap((a) => (a.kind === "tool" ? [a.call] : [])) : [],
)
: [];
const turnCount = callCountText(callCounts(turnCalls));
const showTyping = active && !cardWaiting;
const typingState =
run.status === "waiting"
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}`
: run.status === "queued"
? "排队中…"
: "正在处理…"}
? "排队中"
: toolRunning
? "调用工具中"
: tail?.kind === "text"
? "回答中"
: "思考中";
const typing = showTyping ? (
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground" data-slot="turn-status">
<MessageTyping label={typingState} />
<span className="tabular-nums">
{typingState}
{turnCount ? ` · ${turnCount}` : "…"}
</span>
</span>
) : null;
@@ -521,7 +543,6 @@ export function AgentConversation({
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
{human ? <Markdown text={human.text} /> : t.text}
</StreamingResponse>
{human?.replaced.length ? <ReplacedIds replaced={human.replaced} /> : null}
</div>
);
})}
@@ -565,32 +586,6 @@ export function AgentConversation({
);
}
/** 汇报里被换掉的原文(ID、能力、原因码),收在技术信息里 */
function ReplacedIds({ replaced }: { replaced: { from: string; to: string }[] }) {
const uniq = [...new Map(replaced.map((r) => [r.from, r] as const)).values()];
return (
<TechInfo
title="正文原文"
className="mt-1"
items={[
{
label: "换掉的标识",
value: (
<ul className="space-y-0.5">
{uniq.map((r) => (
<li key={r.from}>
{r.from} → {r.to || "(省略)"}
</li>
))}
</ul>
),
wide: true,
},
]}
/>
);
}
// ── 实际结果 ──
/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */
@@ -655,6 +650,8 @@ export function ResultLine({
]
: [];
const bad = report?.state === "failed" || report?.state === "partial";
const last = lastReportText(live.items);
const rawReport = last && humanizeReport(last).replaced.length ? last : null;
const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null;
return (
<section aria-label="实际结果" className="flex w-full min-w-0 flex-col gap-1 border-t border-border/60 pt-2">
@@ -701,6 +698,8 @@ export function ResultLine({
{ label: "费用上限", value: run.budget.maxCostUsd ? formatUsd(run.budget.maxCostUsd) : null },
{ label: "时长上限", value: run.budget.maxDurationSec ? `${run.budget.maxDurationSec} 秒` : null },
{ label: "汇总", value: report?.summary ?? null, wide: true, mono: false },
// 正文里的 ID / 能力名 / 原因码已换成人话;排查用的汇报原文只放这里
{ label: "原文", value: rawReport, wide: true, mono: false },
{ label: "会话 ID", value: run.id, wide: true },
{ label: "原始错误", value: run.error ? JSON.stringify(run.error) : null, wide: true },
]}
@@ -42,7 +42,7 @@ import {
import { RunPlan } from "./agent-interaction.tsx";
import { shortGoalTitle } from "./agent-store.ts";
import { SubagentTree } from "./agent-subagent-tree.tsx";
import { stepProgress } from "./conversation-model.ts";
import { callCounts, sessionStatsParts, stepProgress } from "./conversation-model.ts";
import {
NEW_RUN_COMMANDS,
PROPOSE_FIRST,
@@ -140,6 +140,47 @@ export function GoalComposer({
* 顶部概况条:一行「步骤 4/5 · 当前:核对下载任务」;展开看步骤清单、子会话和技术信息(模型、预算、用量、ID)。
* 不 sticky、不压在会话上;没有步骤和子会话时只在运行中显示状态。
*/
/**
* 会话统计(AG-4):概况条上方恒定一行小字——工具调用、MCP、已用 token、距上限、费用;
* 运行中随事件流实时更新,结束后固定为快照里的最终值。
*/
export function RunStatsLine({
snapshot,
live,
className,
}: {
snapshot: RunSnapshot;
live: AgentStreamState;
className?: string;
}) {
const r = snapshot.run;
const u = { ...r.usage, ...(live.usage ?? {}) };
const n = callCounts(live.items.flatMap((i) => (i.kind === "tool" ? [i.call] : [])));
const parts = sessionStatsParts(
{
toolCalls: Math.max(r.toolCallCount - n.mcp, n.tools),
mcpCalls: n.mcp,
totalTokens: u.totalTokens ?? null,
maxTokens: r.budget.maxTokens ?? null,
costUsd: u.costUsd ?? null,
},
{ tokens: formatTokens, usd: formatUsd },
);
return (
<section aria-label="会话统计" className={cn("shrink-0", className)}>
{/* 窄屏折行,不截断(费用在最后) */}
<p data-slot="run-stats" className="flex flex-wrap gap-x-1.5 px-1 text-meta text-muted-foreground tabular-nums">
{parts.map((t, i) => (
<span key={t} className="whitespace-nowrap">
{i ? "· " : ""}
{t}
</span>
))}
</p>
</section>
);
}
export function RunOverviewBar({
snapshot,
status,
@@ -338,6 +379,7 @@ export function AgentRunPanel({
</StatusBadge>
</div>
) : null}
<RunStatsLine snapshot={run.data} live={live} className={column} />
<RunOverviewBar
snapshot={run.data}
status={shownStatus}
+52 -2
View File
@@ -6,9 +6,9 @@ import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { AgentRunView } from "~/api/agent.ts";
import { qk } from "~/lib/query-keys.ts";
import { AgentConversation, contextLabel, DeniedLines, StepSummary } from "./agent-conversation.tsx";
import { AgentConversation, contextLabel, DeniedLines, ResultLine, StepSummary } from "./agent-conversation.tsx";
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
import { goalFromInput } from "./agent-run-panel.tsx";
import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx";
import { arrangeHistory } from "./agent-session-list.tsx";
import { type ActivityItem, activitySummary, buildConversation, deniedReason } from "./conversation-model.ts";
import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts";
@@ -392,3 +392,53 @@ describe("输入区:/ 指令与上下文标签", () => {
expect(contextLabel(null)).toBeNull();
});
});
describe("会话统计与本轮状态(AG-4)", () => {
const humanized = (): AgentRunEvent[] => {
seq = 0;
return [
ev(0, { type: "run_started", goal: "检查站点", resumed: false }),
...tool(1, "t1", "site.list", '{"items":[{"name":"憨憨"}]}'),
ev(3, {
type: "text_delta",
delta: "site.list 返回站点 4e71672f-0000-4000-8000-000000000000 正常,没有 invalid_input。",
}),
ev(9, { type: "run_completed", finalText: "", usage: { totalTokens: 1000, costUsd: 0.01 } }),
];
};
it("正文里不再列「换掉的标识」;原始汇报只在结果行的技术信息「原文」里", () => {
const live = liveOf(humanized());
const t = text(render(createElement(AgentConversation, { run: RUN, live })));
expect(t).not.toContain("换掉的标识");
expect(t).not.toContain("正文原文");
expect(t).not.toContain("→ 列出站点");
const result = render(createElement(ResultLine, { run: RUN, report: null, live }));
expect(text(result)).toContain("原文");
expect(result).toContain("4e71672f-0000-4000-8000-000000000000");
expect(result).not.toContain("换掉的标识");
});
it("结束后本轮调用次数并入进度行", () => {
const activity: ActivityItem[] = [
{ kind: "tool", key: "c1", call: call({ capabilityId: "site.list" }) },
{ kind: "tool", key: "c2", call: call({ toolCallId: "c2", capabilityId: "inventory.query" }) },
];
const html = render(createElement(StepSummary, { activity, running: false }));
expect(text(html.slice(0, html.indexOf("</button>")))).toContain("调用 2 次工具");
});
it("运行中:思考状态与本轮已调用次数放在一行", () => {
const events = humanized().slice(0, 3);
const live = { ...liveOf(events), finished: false };
const t = text(render(createElement(AgentConversation, { run: { ...RUN, status: "running" }, live })));
expect(t).toContain("思考中 · 已调用 1 次工具");
});
it("概况条上方的会话统计:工具调用、token(K)、剩余、费用", () => {
const live = liveOf(humanized());
const snapshot = {
run: { ...RUN, toolCallCount: 1, budget: { maxTokens: 200_000 }, usage: { totalTokens: 45_200, costUsd: 0.03 } },
report: null,
messages: [],
} as never;
const t = text(render(createElement(RunStatsLine, { snapshot, live })));
expect(t).toContain("工具调用 1 次 · token 45.2K · 剩 154.8K(77%) · 费用 $0.03");
});
});
@@ -60,6 +60,10 @@ const cjkJoin = (a: string, b: string) => (/^\d/.test(b) ? `${a} ${b}` : `${a}${
const joinNouns = (xs: string[]) =>
xs.length <= 1 ? (xs[0] ?? "") : cjkJoin(`${xs.slice(0, -1).join("、")}和`, xs.at(-1) ?? "");
/** agent.spawn 的结果是"已有同目标子会话"(输出 reused = true;摘要可能被截断,按字段文本判定) */
export const reusedSpawn = (outputSummary: string | null | undefined): boolean =>
!!outputSummary && /"reused"\s*:\s*true/.test(outputSummary);
/**
* 「查了 1 个站点、2 个下载任务和媒体库,搜了 3 次资源」:按能力元数据的 verb / noun 计数,
* 同一动作的对象合并,动作按第一次出现排序;被拒绝、失败和隐藏的调用不计入(失败的在进度行另计「N 项失败」,
@@ -71,6 +75,8 @@ export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "stat
const a = activityOf(c.capabilityId);
if (a.hidden) continue;
if (c.status === "denied" || c.status === "failed" || authDenial(c)) continue;
// 同目标去重返回已有子会话(AG-4):没有新派出,不计「派出了 N 个子会话」
if (c.capabilityId === "agent.spawn" && reusedSpawn(c.outputSummary)) continue;
let g = groups.find((x) => x.verb === a.verb);
if (!g) {
g = { verb: a.verb, bare: !!a.bare, nouns: [] };
@@ -298,3 +304,65 @@ export function activitySpan(activity: ActivityItem[]): number | null {
export function isLongReport(text: string): boolean {
return text.length > 600 || text.split("\n").length > 12;
}
// ── 调用计数与会话统计(AG-4) ──
/** MCP 工具:外部 MCP 服务的工具名(`mcp:` / `mcp__` 前缀或 `域__动作`);内置能力不算 */
export const isMcpTool = (id: string): boolean =>
!(id in CAPABILITY_CATALOG) && (/^mcp(?::|__|\.)/i.test(id) || id.includes("__"));
/** 已发起的调用数(被拒绝的没执行,不算;运行中的算) */
export function callCounts(calls: Pick<ToolCallView, "capabilityId" | "status" | "outputSummary">[]): {
tools: number;
mcp: number;
} {
let tools = 0;
let mcp = 0;
for (const c of calls) {
if (c.status === "denied" || authDenial(c)) continue;
if (isMcpTool(c.capabilityId)) mcp++;
else tools++;
}
return { tools, mcp };
}
/** 「已调用 3 次工具、1 次 MCP」(结束后的进度行用「调用」);都为 0 时空串 */
export function callCountText(n: { tools: number; mcp: number }, verb = "已调用"): string {
const parts = [n.tools ? `${n.tools} 次工具` : "", n.mcp ? `${n.mcp} 次 MCP` : ""].filter(Boolean);
return parts.length ? `${verb} ${parts.join("、")}` : "";
}
/**
* 会话统计一行:「工具调用 12 次 · token 45K · 剩 155K(77%) · 费用 $0.03」。
* MCP 只在有调用时写;没设 token 上限时不写剩余。数字用 K / M 简写(formatTokens 由调用方传入,保持纯函数)。
*/
export function sessionStatsParts(
s: {
toolCalls: number;
mcpCalls: number;
totalTokens: number | null;
maxTokens: number | null;
costUsd: number | null;
},
fmt: { tokens(n: number): string; usd(n: number): string },
): string[] {
const out = [`工具调用 ${s.toolCalls} 次`];
if (s.mcpCalls) out.push(`MCP ${s.mcpCalls} 次`);
const used = s.totalTokens ?? 0;
out.push(`token ${fmt.tokens(used)}`);
if (s.maxTokens && s.maxTokens > 0) {
const left = Math.max(0, s.maxTokens - used);
out.push(`剩 ${fmt.tokens(left)}(${Math.round((left / s.maxTokens) * 100)}%)`);
}
out.push(`费用 ${fmt.usd(s.costUsd ?? 0)}`);
return out;
}
/** 最后一段结论正文(汇报原文);没有时 null */
export function lastReportText(items: readonly StreamItem[]): string | null {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i];
if (it?.kind === "text" && it.text.trim()) return it.text;
}
return null;
}
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { activitySummary, stepProgress } from "./conversation-model.ts";
import {
activitySummary,
callCounts,
callCountText,
isMcpTool,
lastReportText,
sessionStatsParts,
stepProgress,
} from "./conversation-model.ts";
import { collectNames, humanizeReport } from "./report-humanize.ts";
import type { StreamItem, ToolCallView } from "./use-agent-stream.ts";
@@ -94,6 +102,20 @@ describe("activitySummary(系统事实计数)", () => {
];
expect(activitySummary(calls)).toBe("派出了 2 个子会话");
});
it("同目标去重返回已有子会话的调用不计入(AG-4)", () => {
const calls = [
{ capabilityId: "agent.spawn", status: "succeeded" as const, outputSummary: '{"runId":"a"}' },
{
capabilityId: "agent.spawn",
status: "succeeded" as const,
outputSummary: '{"reused":true,"message":"已有同目标子会话,仍在运行,未新建","runId":"a"…',
},
];
expect(activitySummary(calls)).toBe("派出了 1 个子会话");
// 只剩去重调用时不写派出
expect(activitySummary(calls.slice(1))).toBe("");
});
});
describe("stepProgress(步骤口径)", () => {
@@ -111,3 +133,46 @@ describe("stepProgress(步骤口径)", () => {
expect(stepProgress([s("done"), s("done")]).status).toBe("全部完成");
});
});
describe("调用计数与会话统计(AG-4)", () => {
const call = (capabilityId: string, status: "running" | "succeeded" | "failed" | "denied" = "succeeded") => ({
capabilityId,
status,
outputSummary: status === "failed" ? '{"error":{"code":"unauthorized","message":"需要审批"}}' : "{}",
});
it("内置能力算工具,MCP 工具单独计;被拒绝的不算,运行中的算", () => {
expect(isMcpTool("site.list")).toBe(false);
expect(isMcpTool("mcp__github__search")).toBe(true);
expect(isMcpTool("files__read")).toBe(true);
const n = callCounts([
call("site.list"),
call("release.search", "running"),
call("download.submit", "denied"),
call("download.submit", "failed"),
call("mcp__fetch__get"),
]);
expect(n).toEqual({ tools: 2, mcp: 1 });
expect(callCountText(n)).toBe("已调用 2 次工具、1 次 MCP");
expect(callCountText({ tools: 3, mcp: 0 }, "调用")).toBe("调用 3 次工具");
expect(callCountText({ tools: 0, mcp: 0 })).toBe("");
});
it("会话统计一行:token 用 K 简写,有上限时写剩余与百分比,MCP 为 0 不写", () => {
const fmt = { tokens: (n: number) => `${Math.round(n / 100) / 10}K`, usd: (n: number) => `$${n.toFixed(2)}` };
expect(
sessionStatsParts({ toolCalls: 12, mcpCalls: 0, totalTokens: 45_000, maxTokens: 200_000, costUsd: 0.03 }, fmt),
).toEqual(["工具调用 12 次", "token 45K", "剩 155K(78%)", "费用 $0.03"]);
expect(
sessionStatsParts({ toolCalls: 1, mcpCalls: 2, totalTokens: null, maxTokens: null, costUsd: null }, fmt),
).toEqual(["工具调用 1 次", "MCP 2 次", "token 0K", "费用 $0.00"]);
});
it("汇报原文取最后一段正文", () => {
expect(
lastReportText([
{ kind: "text", key: "t1", text: "先查一下", at: "" },
{ kind: "thinking", key: "th", text: "…", at: "" },
{ kind: "text", key: "t2", text: "结论", at: "" },
]),
).toBe("结论");
expect(lastReportText([])).toBeNull();
});
});