diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index ba7a00dc..3bb8e1d8 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -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({ {head} {denied ? · {denied} 项没做 : null} {failed ? · {failed} 项失败 : null} + {counted ? · {counted} : null} {span !== null && !running ? · {durationLabel(span)} : null} @@ -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; + // 本轮的实时状态(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 ? ( - - - {run.status === "waiting" - ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` - : run.status === "queued" - ? "排队中…" - : "正在处理…"} + + + + {typingState} + {turnCount ? ` · ${turnCount}` : "…"} + ) : null; @@ -521,7 +543,6 @@ export function AgentConversation({ {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} {human ? : t.text} - {human?.replaced.length ? : null} ); })} @@ -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 ( - - {uniq.map((r) => ( - - {r.from} → {r.to || "(省略)"} - - ))} - - ), - 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 ( @@ -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 }, ]} diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index 9a4a0cee..c761d35c 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -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 ( + + {/* 窄屏折行,不截断(费用在最后) */} + + {parts.map((t, i) => ( + + {i ? "· " : ""} + {t} + + ))} + + + ); +} + export function RunOverviewBar({ snapshot, status, @@ -338,6 +379,7 @@ export function AgentRunPanel({ ) : null} + { 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("")))).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"); + }); +}); diff --git a/apps/web/app/components/agent/conversation-model.ts b/apps/web/app/components/agent/conversation-model.ts index 1f697237..babd2d12 100644 --- a/apps/web/app/components/agent/conversation-model.ts +++ b/apps/web/app/components/agent/conversation-model.ts @@ -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 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[]): { + 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; +} diff --git a/apps/web/app/components/agent/report-humanize.test.ts b/apps/web/app/components/agent/report-humanize.test.ts index 28a58db6..255833c6 100644 --- a/apps/web/app/components/agent/report-humanize.test.ts +++ b/apps/web/app/components/agent/report-humanize.test.ts @@ -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(); + }); +});
+ {parts.map((t, i) => ( + + {i ? "· " : ""} + {t} + + ))} +