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:
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;
|
||||
// 本轮的实时状态(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">
|
||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||||
{run.status === "waiting"
|
||||
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
||||
: run.status === "queued"
|
||||
? "排队中…"
|
||||
: "正在处理…"}
|
||||
<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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user