From 51ce93b9bee77d5da9ea6c4795b0dd51a75f1402 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 11 Jul 2026 19:05:38 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=B2=BE=E7=AE=80=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=91=98=E8=A6=81=E7=8A=B6=E6=80=81=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/hwlab-cli/caserun.test.ts | 12 +- tools/hwlab-cli/kafka-regenerate.test.ts | 81 ++++++++++++- tools/src/hwlab-cli/kafka-regenerate.ts | 2 + tools/src/hwlab-cli/trace-renderer.ts | 100 +++++++++++----- web/hwlab-cloud-web/scripts/check.ts | 4 +- .../components/agent/TraceTimeline.test.ts | 64 ++++++++++ .../src/components/agent/TraceTimeline.vue | 113 +++++++++++++++--- 7 files changed, 323 insertions(+), 53 deletions(-) diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 1a2f7725..f98cd383 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -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, /-
\n 已运行 hwpod build --spec \.hwlab\/hwpod-spec\.yaml<\/summary>/u); + assert.match(aggregate, /-
\n 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, /已运行 hwpod workspace cat projects\/01_baseline\/Middlewares\/Arm-2D\/Library\/include\/arm_2d_type\.\.\.<\/summary>/u); + assert.match(aggregate, /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(/([\s\S]*?)<\/summary>/gu)) assert.doesNotMatch(summary, /\n|stdout:|stderr:|exitCode=|grep -A 20/u); - assert.doesNotMatch(aggregate, /.*助手最终消息/u); + for (const [, summary] of aggregate.matchAll(/]*>([\s\S]*?)<\/summary>/gu)) assert.doesNotMatch(summary, /\n|stdout:|stderr:|exitCode=|grep -A 20/u); + assert.doesNotMatch(aggregate, /]*>.*助手最终消息/u); assert.match(aggregate, /## Final Response/u); assert.match(aggregate, /## 最后 Diff/u); assert.match(aggregate, /Implement ARM-2D integration/u); diff --git a/tools/hwlab-cli/kafka-regenerate.test.ts b/tools/hwlab-cli/kafka-regenerate.test.ts index 3da91498..5b0b091c 100644 --- a/tools/hwlab-cli/kafka-regenerate.test.ts +++ b/tools/hwlab-cli/kafka-regenerate.test.ts @@ -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, /\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(/]*>([\s\S]*?)<\/summary>/gu)].map((match) => match[1]); + assert.equal(visibleSummaries.length, 4); + for (const [index, summary] of visibleSummaries.entries()) { + assert.equal(summary, `${rows[index]!.header} ${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" }, [ diff --git a/tools/src/hwlab-cli/kafka-regenerate.ts b/tools/src/hwlab-cli/kafka-regenerate.ts index d10ed36c..d39bee37 100644 --- a/tools/src/hwlab-cli/kafka-regenerate.ts +++ b/tools/src/hwlab-cli/kafka-regenerate.ts @@ -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), diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 6fce9610..9ef07ad7 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -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; type TraceClockFormatter = (value: string) => string; export type TraceSequenceAuthority = "projected" | "source"; @@ -64,7 +68,7 @@ export function traceDisplayRows(trace: Record = {}, 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 = {}, 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 = {}, 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 = [ + `${escapeHtml(row.header || `trace row ${index}`)}`, + summary ? escapeHtml(summary) : null + ].filter(Boolean).join(" "); return [ `-
`, - ` ${escapeHtml(`已运行 ${traceToolSummary(row)}`)}`, + ` ${visibleSummary}`, ``, 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, 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, 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 | null { return nonEmptyString(response?.text ?? response?.content ?? response?.message); } -function traceCompletionSummaryRow(trace: Record, 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, rows: Tr return hiddenProgress.length > 0 ? traceNoiseSummaryRow(trace, hiddenProgress, options) : null; } -function traceDisplayRow(trace: Record, 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, 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, ">"); } +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, 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(":"); diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 18568f72..7e54d99f 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -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"); diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.test.ts b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.test.ts index 0bb7b62d..b0960b61 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.test.ts +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.test.ts @@ -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 {} diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index 018c9c53..47f14d59 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -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 { @@ -175,11 +168,11 @@ function firstNonEmptyString(...values: unknown[]): string | null {

显示最近 {{ renderedRows.length }} / {{ readableRows.length }} 行

    -
  1. -
    - +
  2. +
    + {{ row.header }} - {{ toolCommandPreview(row) }} + {{ toolCommandPreview(row) }}
    {{ row.body }}
    @@ -198,3 +191,95 @@ function firstNonEmptyString(...values: unknown[]): string | null {
    + +