Merge pull request #2503 from pikasTech/fix/2499-tool-summary
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

fix: 精简工具调用摘要状态展示
This commit is contained in:
Lyon
2026-07-12 01:34:44 +08:00
committed by GitHub
7 changed files with 323 additions and 53 deletions
+6 -6
View File
@@ -779,8 +779,8 @@ test("case aggregate writes one registry markdown entry without grading or broad
renderedRowCount: 4,
rows: [
{ rowId: "row-1", seq: 1, header: "请求接受", body: "CaseRun started" },
{ rowId: "row-2", seq: 2, header: "ok commandExecution", body: "hwpod build --spec .hwlab/hwpod-spec.yaml\nexitCode=0" },
{ rowId: "row-3", seq: 3, header: "ok commandExecution", body: `${longTraceCommand} stdout: first line\nsecond line exitCode=0` },
{ rowId: "tool:hwpod-build", seq: 2, tone: "ok", header: "00:00:02 hwpod duration=00:00:01 exit=0", body: "hwpod build --spec .hwlab/hwpod-spec.yaml\nexitCode=0" },
{ rowId: "tool:hwpod-cat", seq: 3, tone: "ok", header: "00:00:03 hwpod duration=00:00:01 exit=0", body: `${longTraceCommand} stdout: first line\nsecond line exitCode=0` },
{ rowId: "row-4", seq: 4, header: "助手最终消息", terminal: true, bodyFormat: "markdown", body: "Build completed" }
]
});
@@ -834,12 +834,12 @@ test("case aggregate writes one registry markdown entry without grading or broad
assert.match(aggregate, /## Trace/u);
assert.doesNotMatch(aggregate, / 2\. ok commandExecution/u);
assert.match(aggregate, /\*\*CaseRun started\*\*/u);
assert.match(aggregate, /- <details>\n <summary> hwpod build --spec \.hwlab\/hwpod-spec\.yaml<\/summary>/u);
assert.match(aggregate, /- <details>\n <summary aria-label="[^"]+" title="[^"]+"><code>00:00:02 hwpod duration=00:00:01 exit=0<\/code> hwpod build --spec \.hwlab\/hwpod-spec\.yaml<\/summary>/u);
assert.match(aggregate, / ```text\n hwpod build --spec \.hwlab\/hwpod-spec\.yaml\n exitCode=0[\s\S]* <\/details>/u);
assert.match(aggregate, /<summary> hwpod workspace cat projects\/01_baseline\/Middlewares\/Arm-2D\/Library\/include\/arm_2d_type\.\.\.<\/summary>/u);
assert.match(aggregate, /<summary aria-label="[^"]+" title="[^"]+"><code>00:00:03 hwpod duration=00:00:01 exit=0<\/code> hwpod workspace cat projects\/01_baseline\/Middlewares\/Arm-2D\/Library\/include\/arm_2d_type/u);
assert.match(aggregate, / ```text\n hwpod workspace cat [^\n]+\n stdout:\n first line\n second line\n exitCode=0[\s\S]* <\/details>\n\n\*\*Build completed\*\*\n\n## Final Response/u);
for (const [, summary] of aggregate.matchAll(/<summary>([\s\S]*?)<\/summary>/gu)) assert.doesNotMatch(summary, /\n|stdout:|stderr:|exitCode=|grep -A 20/u);
assert.doesNotMatch(aggregate, /<summary>.*/u);
for (const [, summary] of aggregate.matchAll(/<summary[^>]*>([\s\S]*?)<\/summary>/gu)) assert.doesNotMatch(summary, /\n|stdout:|stderr:|exitCode=|grep -A 20/u);
assert.doesNotMatch(aggregate, /<summary[^>]*>.*/u);
assert.match(aggregate, /## Final Response/u);
assert.match(aggregate, /## Diff/u);
assert.match(aggregate, /Implement ARM-2D integration/u);
+79 -2
View File
@@ -7,7 +7,7 @@ import { test } from "bun:test";
import { queryKafkaEventStream } from "../../internal/cloud/kafka-event-bridge.ts";
import { mapAgentRunRecordsToHwlabDebugEvents, renderKafkaCliText, runKafkaCli } from "../src/hwlab-cli/kafka-regenerate.ts";
import { traceDisplayRows } from "../src/hwlab-cli/trace-renderer.ts";
import { renderTraceRowsMarkdown, traceDisplayRows, traceToolSummary } from "../src/hwlab-cli/trace-renderer.ts";
const SESSION_ID = "ses_agentrun_5ec4e141_6abc_466d_9afe_049f7c0ac105";
const HWLAB_SESSION_ID = "ses_5ec4e141-6abc-466d-9afe-049f7c0ac105";
@@ -86,7 +86,10 @@ test("offline HWLAB JSONL uses the shared Web pipeline and renders final respons
assert.equal(result.payload.validation.allMatchedEventsApplied, true);
assert.equal(result.payload.validation.finalResponseOutsideTrace, true);
const toolRow = result.payload.render.rows.find((row: any) => row.rowId.startsWith("tool:"));
assert.match(toolRow.header, /ok rg duration=00:00:01 exit=0/u);
assert.match(toolRow.header, /^\d{2}:\d{2}:\d{2} rg duration=00:00:01 exit=0$/u);
assert.doesNotMatch(toolRow.header, /\b(?:ok|fail|run|running)\b|total=/u);
assert.equal(toolRow.toolState, "success");
assert.equal(toolRow.statusLabel, "工具调用成功");
assert.match(toolRow.preview, /rg -n target src/u);
assert.match(toolRow.preview, /src\/main\.ts:42:target/u);
const markdown = await readFile(outputFile, "utf8");
@@ -96,6 +99,8 @@ test("offline HWLAB JSONL uses the shared Web pipeline and renders final respons
assert.match(markdown, /target trace: trc_render_trace_target/u);
assert.match(markdown, /## /u);
assert.match(markdown, /## /u);
assert.match(markdown, /<summary aria-label="[^"]+" title="[^"]+"><code>\d{2}:\d{2}:\d{2} rg duration=00:00:01 exit=0<\/code> [\s\S]*rg -n target src[\s\S]*src\/main\.ts:42:target<\/summary>/u);
assert.doesNotMatch(markdown, /total=|/u);
assert.equal(result.markdownOutput?.trim(), markdown.trim());
});
@@ -162,6 +167,78 @@ test("shared row model removes late assistant progress after the same item compl
assert.equal((result.markdownOutput?.match(new RegExp(duplicateText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gu")) ?? []).length, 1);
});
test("shared tool summary model keeps status semantic and visible content status-free", () => {
const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, [
{
sourceSeq: 1,
sourceEventId: "evt_tool_running",
type: "tool_call",
status: "running",
label: "agentrun:tool:commandExecution:started",
toolName: "commandExecution",
itemId: "tool_running",
command: "bun test active-fixture",
createdAt: "2026-07-10T11:00:01.000Z"
},
{
sourceSeq: 2,
sourceEventId: "evt_tool_success",
type: "tool_call",
status: "completed",
label: "agentrun:tool:commandExecution:completed",
toolName: "commandExecution",
itemId: "tool_success",
command: "rg -n success src",
durationMs: 1234,
exitCode: 0,
stdoutSummary: "src/main.ts:1:success",
createdAt: "2026-07-10T11:00:02.000Z"
},
{
sourceSeq: 3,
sourceEventId: "evt_tool_failure",
type: "tool_call",
status: "completed",
label: "agentrun:tool:commandExecution:completed",
toolName: "commandExecution",
itemId: "tool_failure",
command: "bun test failure-fixture",
durationMs: 2345,
exitCode: 1,
stderrSummary: "failure output",
createdAt: "2026-07-10T11:00:03.000Z"
},
{
sourceSeq: 4,
sourceEventId: "evt_tool_output_chunk",
type: "tool_call",
status: "output_chunk",
label: "item/commandExecution/outputDelta",
outputSummary: "streamed output",
createdAt: "2026-07-10T11:00:04.000Z"
}
]).filter((row) => row.rowId.startsWith("tool:"));
assert.deepEqual(rows.map((row) => row.toolState), ["running", "success", "failure", "running"]);
assert.deepEqual(rows.map((row) => row.tone), ["warn", "ok", "blocked", "warn"]);
assert.deepEqual(rows.map((row) => row.statusLabel), ["工具调用中", "工具调用成功", "工具调用失败", "工具调用中"]);
assert.ok(rows.every((row) => !/\b(?:ok|fail|run|running)\b|total=/u.test(row.header)));
assert.match(rows[1]!.header, /rg duration=00:00:01 exit=0$/u);
assert.match(rows[2]!.header, /bun duration=00:00:02 exit=1$/u);
assert.doesNotMatch(rows[3]!.header, /commandExecution|outputDelta/u);
const markdown = renderTraceRowsMarkdown(rows);
const visibleSummaries = [...markdown.matchAll(/<summary[^>]*>([\s\S]*?)<\/summary>/gu)].map((match) => match[1]);
assert.equal(visibleSummaries.length, 4);
for (const [index, summary] of visibleSummaries.entries()) {
assert.equal(summary, `<code>${rows[index]!.header}</code> ${traceToolSummary(rows[index]!)}`);
assert.doesNotMatch(summary, /\b(?:ok|fail|run|running)\b|total=/u);
}
assert.match(markdown, /aria-label="/u);
assert.match(markdown, /aria-label="/u);
assert.match(markdown, /aria-label="/u);
});
test("outer final response stays out of the Trace fallback when no completion event exists", () => {
const finalText = "Hi. What do you want to work on?";
const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, [
+2
View File
@@ -528,6 +528,8 @@ export async function renderHwlabKafkaTrace(parsed: ParsedArgs, dependencies: Re
seq: row.seq,
sequenceAuthority: row.sequenceAuthority,
tone: row.tone,
toolState: row.toolState ?? null,
statusLabel: row.statusLabel ?? null,
header: row.header,
preview: row.preview ?? null,
body: fullRows ? row.body : boundedText(row.body, 500),
+70 -30
View File
@@ -3,6 +3,8 @@ export interface TraceEventRow {
seq: number | null;
sequenceAuthority: TraceSequenceAuthority;
tone: "ok" | "blocked" | "warn" | "source";
toolState?: TraceToolState;
statusLabel?: string;
header: string;
body: string | null;
preview?: string | null;
@@ -10,6 +12,8 @@ export interface TraceEventRow {
bodyFormat?: "markdown" | "text";
}
export type TraceToolState = "running" | "success" | "failure" | "neutral";
type TraceEvent = Record<string, unknown>;
type TraceClockFormatter = (value: string) => string;
export type TraceSequenceAuthority = "projected" | "source";
@@ -64,7 +68,7 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
if (renderedToolIdentities.has(identity)) continue;
renderedToolIdentities.add(identity);
}
rows.push(traceToolCallRow(effectiveTrace, event, displayOptions));
rows.push(traceToolCallRow(event, displayOptions));
continue;
}
if (isRequestTraceEvent(event)) {
@@ -94,9 +98,9 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
completionEvent ??= event;
continue;
}
rows.push(traceDisplayRow(effectiveTrace, event, displayOptions));
rows.push(traceDisplayRow(event, displayOptions));
}
if (completionEvent) rows.push(traceCompletionSummaryRow(effectiveTrace, completionEvent, displayOptions));
if (completionEvent) rows.push(traceCompletionSummaryRow(completionEvent, displayOptions));
const progressRow = traceBackendProgressSummaryRow(effectiveTrace, rows, orderedEvents, displayOptions);
if (progressRow) rows.push(progressRow);
if (rows.length > 0) return rows;
@@ -105,7 +109,7 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
.filter((event, index) => !isSupersededTraceItemLifecycleEvent(event, orderedEvents, index))
.filter((event) => !isSuppressedTraceEvent(event))
.filter((event) => !isOuterFinalResponseTraceEvent(effectiveTrace, event, displayOptions, terminalAssistantFingerprints))
.map((event) => traceDisplayRow(effectiveTrace, event, displayOptions));
.map((event) => traceDisplayRow(event, displayOptions));
}
function isOuterFinalResponseTraceEvent(
@@ -137,9 +141,15 @@ export function renderTraceRowsMarkdown(rows: TraceEventRow[] = []): string {
export function renderTraceRowMarkdown(row: TraceEventRow, index = 1): string {
if (!isToolTraceRow(row)) return renderTraceMessageRowMarkdown(row);
const body = renderTraceToolDetailBody(row) || row.header || `trace row ${index}`;
const summary = traceToolSummary(row);
const accessibleLabel = traceToolAccessibilityLabel(row);
const visibleSummary = [
`<code>${escapeHtml(row.header || `trace row ${index}`)}</code>`,
summary ? escapeHtml(summary) : null
].filter(Boolean).join(" ");
return [
`- <details>`,
` <summary>${escapeHtml(`已运行 ${traceToolSummary(row)}`)}</summary>`,
` <summary aria-label="${escapeHtmlAttribute(accessibleLabel)}" title="${escapeHtmlAttribute(accessibleLabel)}">${visibleSummary}</summary>`,
``,
indentTraceMarkdownBlock(traceMarkdownFence(row.bodyFormat === "markdown" ? "markdown" : "text", body)),
``,
@@ -195,20 +205,25 @@ function isToolTraceRow(row: TraceEventRow): boolean {
return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
}
function traceToolSummary(row: TraceEventRow): string {
if (row.preview) return compactTraceOneLine(row.preview, 90);
export function traceToolSummary(row: TraceEventRow): string {
if (row.preview) return compactTraceOneLine(row.preview, 140);
const firstLine = traceMarkdownPreviewLines(row.body).find((line) => !/^(stdout|stderr|exitCode|rowId|terminal):/iu.test(line.trim()));
return compactTraceOneLine(stripTraceToolOutputFromSummary(firstLine || row.header || "tool call"), 90);
return compactTraceOneLine(stripTraceToolOutputFromSummary(firstLine || row.header || "tool call"), 140);
}
export function traceToolAccessibilityLabel(row: TraceEventRow): string {
const statusLabel = row.statusLabel ?? traceToolStatusLabel(traceToolStateFromTone(row.tone));
return `${statusLabel}${[row.header, traceToolSummary(row)].filter(Boolean).join("")}`;
}
function stripTraceToolOutputFromSummary(value: string): string {
return value.replace(/\s+(stdout:|stderr:|exitCode=).*$/isu, "").trim();
}
function traceToolCallRow(trace: Record<string, unknown>, event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
function traceToolCallRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
const command = cleanShellCommand(event.command);
const outputPreview = traceEventText(event, ["outputSummary", "stdoutSummary", "stderrSummary", "output", "outputText"]);
const status = traceStatusToken(event);
const toolState = traceToolState(event);
const toolName = traceToolName(event, command);
const body = traceToolCallBody(event, command);
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
@@ -219,10 +234,10 @@ function traceToolCallRow(trace: Record<string, unknown>, event: TraceEvent, opt
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
sequenceAuthority: options.sequenceAuthority,
tone: traceEventTone(event),
toolState,
statusLabel: traceToolStatusLabel(toolState),
header: [
traceEventClock(event, options),
`total=${formatTraceDuration(traceRelativeMs(trace, event))}`,
status,
toolName,
duration !== null ? `duration=${formatTraceDuration(duration)}` : null,
exitCode !== null ? `exit=${exitCode}` : null
@@ -244,7 +259,12 @@ function traceToolPreview(command: string | null, output: string | null): string
function traceToolName(event: TraceEvent, command: string | null): string {
const direct = nonEmptyString(event.toolName);
if (direct && !isProtocolToolLifecycleName(direct) && !isGenericToolName(direct)) return direct;
const label = readableTraceLabel(event).replace(/^agentrun:tool:/u, "").replace(/^tool:/u, "").replace(/^item\//u, "");
const label = String(event.label ?? "")
.replace(/^agentrun:tool:/u, "")
.replace(/^tool:/u, "")
.replace(/^item\//u, "")
.replace(/\/outputDelta$/iu, "")
.replace(/:(?:started|running|completed|succeeded|failed|blocked|error|timeout|canceled|cancelled)$/iu, "");
if (label && !isProtocolToolLifecycleName(label) && !isGenericToolName(label)) return label;
const commandName = traceToolNameFromCommand(command);
if (commandName) return commandName;
@@ -255,11 +275,11 @@ function traceToolName(event: TraceEvent, command: string | null): string {
}
function isGenericToolName(value: unknown): boolean {
return /^(commandExecution|tool|shell|command|unknown)$/iu.test(String(value ?? "").trim());
return /^(commandExecution|tool|tool_call|tool-call|shell|command|unknown)$/iu.test(String(value ?? "").trim());
}
function isProtocolToolLifecycleName(value: unknown): boolean {
return /^(?:item\/)?(?:started|completed)$/iu.test(String(value ?? "").trim()) || /^item\/(?:started|completed)$/iu.test(String(value ?? "").trim());
return /^(?:item\/)?(?:started|running|completed|succeeded|failed|blocked|error|timeout|canceled|cancelled)$/iu.test(String(value ?? "").trim());
}
function traceToolNameFromCommand(command: string | null): string | null {
@@ -364,14 +384,14 @@ function traceFinalResponseText(trace: Record<string, unknown>): string | null {
return nonEmptyString(response?.text ?? response?.content ?? response?.message);
}
function traceCompletionSummaryRow(trace: Record<string, unknown>, event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
function traceCompletionSummaryRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
return {
rowId: `trace-completion:${eventKey ?? "turn"}`,
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
sequenceAuthority: options.sequenceAuthority,
tone: traceEventTone(event),
header: `${traceEventClock(event, options)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))}`,
header: `${traceEventClock(event, options)} 轮次完成`,
body: null
};
}
@@ -402,7 +422,7 @@ function traceBackendProgressSummaryRow(trace: Record<string, unknown>, rows: Tr
return hiddenProgress.length > 0 ? traceNoiseSummaryRow(trace, hiddenProgress, options) : null;
}
function traceDisplayRow(trace: Record<string, unknown>, event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
function traceDisplayRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
const label = readableTraceLabel(event);
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
return {
@@ -410,7 +430,7 @@ function traceDisplayRow(trace: Record<string, unknown>, event: TraceEvent, opti
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
sequenceAuthority: options.sequenceAuthority,
tone: traceEventTone(event),
header: `${traceEventClock(event, options)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${traceStatusToken(event)} ${label}`.trim(),
header: `${traceEventClock(event, options)} ${label}`.trim(),
body: traceDisplayBody(event)
};
}
@@ -464,7 +484,7 @@ function isToolTraceEvent(event: TraceEvent): boolean {
function isToolStartTraceEvent(event: TraceEvent): boolean {
const status = String(event.status ?? "").toLowerCase();
const label = String(event.label ?? "");
return ["started", "running", "inprogress", "in-progress"].includes(status) || /:started$|\/started$/u.test(label);
return ["started", "running", "inprogress", "in-progress", "output_chunk"].includes(status) || /:started$|\/started$/u.test(label);
}
function isToolCompleteTraceEvent(event: TraceEvent): boolean {
@@ -599,6 +619,10 @@ function escapeHtml(value: string): string {
return String(value).replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
}
function escapeHtmlAttribute(value: string): string {
return escapeHtml(value).replace(/"/gu, "&quot;").replace(/'/gu, "&#39;");
}
function traceMarkdownFence(language: string, body: string): string {
const fence = body.includes("```") ? "````" : "```";
return `${fence}${language}\n${body.trimEnd()}\n${fence}`;
@@ -607,12 +631,35 @@ function traceMarkdownFence(language: string, body: string): string {
function traceStatusToken(event: TraceEvent): string {
const rawStatus = String(event.status ?? "");
const status = rawStatus.toLowerCase();
if (status === "completed" || status === "succeeded") return "ok";
if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event.errorCode) return "fail";
if (["started", "running", "accepted", "inprogress", "in-progress"].includes(status)) return "run";
const exitCode = numberOrNull(event.exitCode);
if (["failed", "blocked", "error", "timeout", "canceled", "cancelled"].includes(status) || event.errorCode || (exitCode !== null && exitCode !== 0)) return "fail";
if (status === "completed" || status === "succeeded" || exitCode === 0) return "ok";
if (["started", "running", "accepted", "inprogress", "in-progress", "output_chunk"].includes(status)) return "run";
return rawStatus || "event";
}
function traceToolState(event: TraceEvent): TraceToolState {
const status = traceStatusToken(event);
if (status === "ok") return "success";
if (status === "fail") return "failure";
if (status === "run") return "running";
return "neutral";
}
function traceToolStateFromTone(tone: TraceEventRow["tone"]): TraceToolState {
if (tone === "ok") return "success";
if (tone === "blocked") return "failure";
if (tone === "warn") return "running";
return "neutral";
}
function traceToolStatusLabel(state: TraceToolState): string {
if (state === "success") return "工具调用成功";
if (state === "failure") return "工具调用失败";
if (state === "running") return "工具调用中";
return "工具调用状态未知";
}
function traceEventTone(event: TraceEvent): TraceEventRow["tone"] {
const status = traceStatusToken(event);
if (status === "ok") return "ok";
@@ -650,13 +697,6 @@ function traceEventTimestampMs(event: TraceEvent | null | undefined): number | n
return Number.isFinite(parsed) ? parsed : null;
}
function traceRelativeMs(trace: Record<string, unknown>, event: TraceEvent): number {
if (typeof event.elapsedMs === "number") return event.elapsedMs;
const start = Date.parse(String(trace.startedAt ?? trace.createdAt ?? ""));
const current = Date.parse(String(traceEventTimestamp(event) ?? ""));
return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start);
}
function formatTraceDuration(ms: number): string {
const seconds = Math.floor(Math.max(0, Number(ms) || 0) / 1000);
return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), seconds % 60].map((part) => String(part).padStart(2, "0")).join(":");
+3 -1
View File
@@ -96,6 +96,7 @@ const workbenchScrollRuntimeSource = readWeb("src/composables/useWorkbenchScroll
const workbenchErrorRuntimeSource = readWeb("src/utils/workbench-error-runtime.ts");
const workbenchHealthRuntimeSource = readWeb("src/utils/workbench-health.ts");
const traceTimelineSource = readWeb("src/components/agent/TraceTimeline.vue");
const traceRendererSource = fs.readFileSync(path.resolve(rootDir, "..", "..", "tools/src/hwlab-cli/trace-renderer.ts"), "utf8");
const messageTraceDebugSource = readWeb("src/components/workbench/MessageTraceDebugPanel.vue");
const conversationPanelSource = readWeb("src/components/workbench/ConversationPanel.vue");
const serverWorkbenchRealtimeHttpSource = fs.readFileSync(path.resolve(rootDir, "..", "..", "internal/cloud/server-workbench-realtime-http.ts"), "utf8");
@@ -227,7 +228,8 @@ assertIncludes(appSource, "border-collapse: collapse;", "Markdown tables must us
assertIncludes(appSource, "grid-template-columns: minmax(0, 1fr);", "Trace rows must give the readable body the full content width");
assertIncludes(traceTimelineSource, "toolCommandPreview", "Trace tool summaries must expose command previews in the collapsed single-line row");
assertIncludes(traceTimelineSource, "trace-tool-command-preview", "Trace tool command preview must render inside the collapsed summary");
assertIncludes(traceTimelineSource, "row.preview", "Trace tool command preview must use request-side row.preview instead of response body text");
assertIncludes(traceTimelineSource, "traceToolSummary(row)", "Trace tool command preview must use the shared summary model");
assertIncludes(traceRendererSource, "if (row.preview)", "Shared trace tool summary must prefer request-side row.preview over response body text");
assertIncludes(appSource, "text-overflow: ellipsis;", "Trace tool command preview must keep long commands visually clipped on one line");
assertIncludes(appSource, "const primaryAction = computed", "Workbench composer must derive a single primary send/cancel/steer action");
assertIncludes(appSource, "primaryAction.value === \"cancel\"", "Composer primary button must switch to cancel while a turn is running and the draft is empty");
@@ -133,6 +133,46 @@ describe("TraceTimeline sequence authority", () => {
expect(row.attributes("data-projected-seq")).toBe("8");
wrapper.unmount();
});
it("keeps a tool row stable while projecting visual and accessibility state", async () => {
const wrapper = mount(TraceTimeline, {
props: {
autoExpanded: true,
trace: toolTrace("running")
}
});
await nextTick();
const runningRow = wrapper.get('[data-row-id="tool:tool_bun_test"]');
const stableElement = runningRow.element;
const runningDetails = runningRow.get(".trace-tool-details");
const runningSummary = runningDetails.get("summary");
expect(runningRow.attributes("data-tool-state")).toBe("running");
expect(runningRow.attributes("data-event-status")).toBe("warn");
expect(runningDetails.attributes("aria-busy")).toBe("true");
expect(runningSummary.attributes("aria-label")).toContain("工具调用中:");
expect(runningSummary.text()).toContain("bun");
expect(runningSummary.text()).not.toContain("agentrun:tool");
expect(runningSummary.attributes("title")).toBe(runningSummary.attributes("aria-label"));
expect(runningSummary.text()).not.toMatch(/\b(?:ok|fail|run|running)\b|total=|/u);
await wrapper.setProps({ trace: toolTrace("completed") });
await nextTick();
const completedRow = wrapper.get('[data-row-id="tool:tool_bun_test"]');
const completedDetails = completedRow.get(".trace-tool-details");
const completedSummary = completedDetails.get("summary");
expect(completedRow.element).toBe(stableElement);
expect(completedRow.attributes("data-tool-state")).toBe("success");
expect(completedRow.attributes("data-event-status")).toBe("ok");
expect(completedDetails.attributes("aria-busy")).toBeUndefined();
expect(completedSummary.attributes("aria-label")).toContain("工具调用成功:");
expect(completedSummary.text()).not.toContain("agentrun:tool");
expect(completedSummary.attributes("aria-label")).toContain("duration=00:00:01 exit=0");
expect(completedSummary.text()).toContain("duration=00:00:01 exit=0");
expect(completedSummary.text()).not.toMatch(/\b(?:ok|fail|run|running)\b|total=|/u);
wrapper.unmount();
});
});
function liveTrace(status: "running" | "completed") {
@@ -145,6 +185,30 @@ function liveTrace(status: "running" | "completed") {
};
}
function toolTrace(status: "running" | "completed") {
const completed = status === "completed";
return {
traceId: "trc_tool_state",
eventSource: "hwlab-kafka-sse" as const,
status,
eventCount: 1,
events: [{
sourceEventId: completed ? "evt_tool_completed" : "evt_tool_running",
runId: "run_tool_state",
sourceSeq: completed ? 2 : 1,
source: "agentrun.kafka",
type: "tool_call",
label: completed ? "agentrun:tool:completed" : "agentrun:tool:started",
status,
toolName: "commandExecution",
itemId: "tool_bun_test",
command: "bun test tool-state",
createdAt: completed ? "2026-07-10T11:00:02.000Z" : "2026-07-10T11:00:01.000Z",
...(completed ? { durationMs: 1234, exitCode: 0, stdoutSummary: "1 test passed" } : {})
}]
};
}
class FakeResizeObserver {
observe(): void {}
unobserve(): void {}
@@ -6,7 +6,7 @@ import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime";
import { formatDisplayClock } from "@/config/runtime";
import { readWorkbenchString, writeWorkbenchString } from "@/utils/workbench-storage-runtime";
import { traceDisplayRows, type TraceEventRow, type TraceFinalResponsePlacement } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
import { traceDisplayRows, traceToolAccessibilityLabel, traceToolSummary, type TraceEventRow, type TraceFinalResponsePlacement } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
const props = defineProps<{
trace?: RunnerTrace | null;
@@ -49,7 +49,6 @@ const emptyTraceLabel = computed(() => {
if (events.value.length > 0 || (props.trace?.fullTraceLoaded === true && eventCount.value > 0)) return "暂无可读 Trace";
return props.trace?.fullTraceLoaded === true ? "思考中..." : "加载中...";
});
const toolPreviewLimit = 140;
let programmaticToggleTarget: boolean | null = null;
watch(() => [eventCount.value, props.trace?.status, readableRows.value.length, expanded.value], () => {
@@ -82,6 +81,7 @@ function traceExpansionScope(): string {
}
function rowKey(row: TraceEventRow, index: number): string {
if (rowIsTool(row)) return `${row.rowId}-${row.sequenceAuthority}`;
return `${row.rowId}-${row.sequenceAuthority}-${row.seq ?? "no-seq"}-${index}`;
}
@@ -123,14 +123,7 @@ function rowIsTool(row: TraceEventRow): boolean {
function toolCommandPreview(row: TraceEventRow): string | null {
if (!rowIsTool(row)) return null;
const preview = singleLinePreview(row.preview);
if (!preview) return null;
return preview.length > toolPreviewLimit ? `${preview.slice(0, toolPreviewLimit - 3).trimEnd()}...` : preview;
}
function singleLinePreview(value: unknown): string | null {
const preview = String(value ?? "").replace(/\u0000/gu, "").replace(/\r\n|\r|\n/gu, " ").replace(/\s+/gu, " ").trim();
return preview || null;
return traceToolSummary(row) || null;
}
function traceRecord(trace: RunnerTrace | null | undefined): Record<string, unknown> {
@@ -175,11 +168,11 @@ function firstNonEmptyString(...values: unknown[]): string | null {
<div v-if="expanded" class="trace-disclosure-body">
<p v-if="hiddenRowCount > 0" class="trace-window-notice">显示最近 {{ renderedRows.length }} / {{ readableRows.length }} </p>
<ol v-if="renderedRows.length > 0" ref="listRef" @scroll="onScroll">
<li v-for="(row, index) in renderedRows" :key="rowKey(row, index)" class="trace-render-row" data-testid="trace-render-row" :data-trace-id="trace.traceId || undefined" :data-event-seq="row.seq ?? undefined" :data-event-seq-authority="row.sequenceAuthority" :data-projected-seq="row.sequenceAuthority === 'projected' ? row.seq ?? undefined : undefined" :data-event-kind="row.rowId" :data-event-status="row.tone" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'" :data-row-id="row.rowId" :aria-posinset="hiddenRowCount + index + 1">
<details v-if="rowIsTool(row)" class="trace-tool-details">
<summary>
<li v-for="(row, index) in renderedRows" :key="rowKey(row, index)" class="trace-render-row" data-testid="trace-render-row" :data-trace-id="trace.traceId || undefined" :data-event-seq="row.seq ?? undefined" :data-event-seq-authority="row.sequenceAuthority" :data-projected-seq="row.sequenceAuthority === 'projected' ? row.seq ?? undefined : undefined" :data-event-kind="row.rowId" :data-event-status="row.tone" :data-tool-state="row.toolState ?? undefined" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'" :data-row-id="row.rowId" :aria-posinset="hiddenRowCount + index + 1">
<details v-if="rowIsTool(row)" class="trace-tool-details" :aria-busy="row.toolState === 'running' ? 'true' : undefined">
<summary :aria-label="traceToolAccessibilityLabel(row)" :title="traceToolAccessibilityLabel(row)" aria-live="polite" aria-atomic="true">
<code>{{ row.header }}</code>
<span v-if="toolCommandPreview(row)" class="trace-tool-command-preview" :title="row.preview ?? undefined">{{ toolCommandPreview(row) }}</span>
<span v-if="toolCommandPreview(row)" class="trace-tool-command-preview">{{ toolCommandPreview(row) }}</span>
</summary>
<MessageMarkdown v-if="row.body && row.bodyFormat === 'markdown'" class="trace-row-body trace-row-markdown" :source="row.body" />
<pre v-else-if="row.body" class="trace-row-body">{{ row.body }}</pre>
@@ -198,3 +191,95 @@ function firstNonEmptyString(...values: unknown[]): string | null {
</details>
</section>
</template>
<style scoped>
.trace-render-row[data-row-kind="tool"] {
--trace-tool-accent: #64748b;
--trace-tool-background: #f8fafc;
--trace-tool-foreground: #334155;
}
.trace-render-row[data-row-kind="tool"][data-tool-state="success"] {
--trace-tool-accent: #15803d;
--trace-tool-background: #f0fdf4;
--trace-tool-foreground: #166534;
}
.trace-render-row[data-row-kind="tool"][data-tool-state="failure"] {
--trace-tool-accent: #b91c1c;
--trace-tool-background: #fef2f2;
--trace-tool-foreground: #991b1b;
}
.trace-render-row[data-row-kind="tool"][data-tool-state="running"] {
--trace-tool-accent: #0369a1;
--trace-tool-background: #f0f9ff;
--trace-tool-foreground: #075985;
}
.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary {
box-sizing: border-box;
height: 26px;
min-height: 26px;
border-left: 3px solid var(--trace-tool-accent);
border-radius: 3px;
background: var(--trace-tool-background);
color: var(--trace-tool-foreground);
padding: 3px 6px;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary::before {
border-left-color: currentColor;
}
.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary code {
width: 42%;
max-width: 42%;
flex: 0 0 42%;
background: transparent;
color: inherit;
}
.trace-render-row[data-row-kind="tool"] .trace-tool-command-preview {
color: inherit;
}
.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary:focus-visible {
outline: 2px solid var(--trace-tool-accent);
outline-offset: 2px;
}
.trace-render-row[data-row-kind="tool"][data-tool-state="running"] .trace-tool-details > summary {
animation: trace-tool-running-pulse 1.4s ease-in-out infinite;
}
@keyframes trace-tool-running-pulse {
0%,
100% {
box-shadow: inset 0 0 0 1px transparent;
}
50% {
background: #e0f2fe;
box-shadow: inset 0 0 0 1px #7dd3fc;
}
}
@media (prefers-reduced-motion: reduce) {
.trace-render-row[data-row-kind="tool"][data-tool-state="running"] .trace-tool-details > summary {
animation: none;
}
}
@media (forced-colors: active) {
.trace-render-row[data-row-kind="tool"] .trace-tool-details > summary {
border: 1px solid CanvasText;
border-left-width: 3px;
}
.trace-render-row[data-row-kind="tool"][data-tool-state="running"] .trace-tool-details > summary {
outline: 1px dashed Highlight;
}
}
</style>