Merge pull request #2503 from pikasTech/fix/2499-tool-summary
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
fix: 精简工具调用摘要状态展示
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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" }, [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return escapeHtml(value).replace(/"/gu, """).replace(/'/gu, "'");
|
||||
}
|
||||
|
||||
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(":");
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user