diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 2c06ff6d..1ffc363d 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -5,7 +5,7 @@ export interface TraceEventRow { header: string; body: string | null; terminal?: boolean; - bodyFormat?: "markdown"; + bodyFormat?: "markdown" | "text"; } type TraceEvent = Record; @@ -16,8 +16,14 @@ export function traceDisplayRows(trace: Record = {}, events: Tr let setupRendered = false; let lastAssistantRowIndex = -1; let completionEvent: TraceEvent | null = null; - for (const event of events) { + for (let index = 0; index < events.length; index += 1) { + const event = events[index]; if (!event || isNoisyTraceEvent(event)) continue; + if (isToolTraceEvent(event)) { + if (isSupersededToolStart(event, events, index)) continue; + rows.push(traceToolCallRow(trace, event)); + continue; + } if (isRequestTraceEvent(event)) { if (!requestRendered) { rows.push(traceRequestSummaryRow(event)); @@ -56,6 +62,32 @@ export function traceDisplayRows(trace: Record = {}, events: Tr return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event)); } +function traceToolCallRow(trace: Record, event: TraceEvent): TraceEventRow { + const command = cleanShellCommand(event.command); + const status = traceStatusToken(event); + const toolName = nonEmptyString(event.toolName) ?? readableTraceLabel(event).replace(/^item\//u, ""); + const body = traceToolCallBody(event, command); + return { + rowId: `tool:${event.itemId ?? event.seq ?? `${event.label ?? event.type ?? "tool"}:${event.createdAt ?? "unknown"}`}`, + seq: numberOrNull(event.seq), + tone: traceEventTone(event), + header: `${traceClock(event.createdAt)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${status} ${toolName}`.trim(), + body, + bodyFormat: "text" + }; +} + +function traceToolCallBody(event: TraceEvent, command: string | null): string | null { + const lines = [ + command, + event.stdoutSummary ? `stdout:\n${compactTraceOneLine(event.stdoutSummary, 1200)}` : null, + event.stderrSummary ? `stderr:\n${compactTraceOneLine(event.stderrSummary, 800)}` : null, + command ? null : event.outputSummary, + event.exitCode !== undefined ? `exitCode=${event.exitCode}` : null + ].map(cleanTraceText).filter(Boolean); + return lines.length > 0 ? lines.join("\n").slice(0, 1400) : null; +} + export function traceNoiseEventCount(events: TraceEvent[] = []): number { return Array.isArray(events) ? events.filter((event) => isNoisyTraceEvent(event)).length : 0; } @@ -167,6 +199,36 @@ function isTerminalAssistantTraceEvent(event: TraceEvent): boolean { return event.type === "assistant_message" && (event.status === "completed" || event.final === true || event.terminal === true); } +function isToolTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? ""); + const type = String(event.type ?? ""); + const toolName = String(event.toolName ?? ""); + return type === "tool_call" || toolName === "commandExecution" || /commandExecution|^agentrun:tool:/u.test(label); +} + +function isToolStartTraceEvent(event: TraceEvent): boolean { + const status = String(event.status ?? "").toLowerCase(); + const label = String(event.label ?? ""); + return status === "started" || /:started$|\/started$/u.test(label); +} + +function isToolCompleteTraceEvent(event: TraceEvent): boolean { + const status = String(event.status ?? "").toLowerCase(); + const label = String(event.label ?? ""); + return ["completed", "succeeded", "failed", "blocked", "timeout", "canceled", "cancelled"].includes(status) || /:completed$|\/completed$/u.test(label); +} + +function toolIdentity(event: TraceEvent): string | null { + return nonEmptyString(event.itemId ?? event.id ?? event.command); +} + +function isSupersededToolStart(event: TraceEvent, events: TraceEvent[], index: number): boolean { + if (!isToolStartTraceEvent(event)) return false; + const identity = toolIdentity(event); + if (!identity) return false; + return events.slice(index + 1).some((candidate) => candidate && isToolTraceEvent(candidate) && isToolCompleteTraceEvent(candidate) && toolIdentity(candidate) === identity); +} + function isNoisyTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event) || isAssistantTraceEvent(event)) return false; @@ -185,8 +247,9 @@ function readableTraceLabel(event: TraceEvent): string { } function traceDisplayBody(event: TraceEvent): string | null { + const command = cleanShellCommand(event.command); const lines = [ - event.command ? `command=${compactTraceOneLine(event.command, 900)}` : null, + command, event.stdoutSummary ? `stdout:\n${compactTraceOneLine(event.stdoutSummary, 1200)}` : null, event.stderrSummary ? `stderr:\n${compactTraceOneLine(event.stderrSummary, 800)}` : null, event.outputSummary, @@ -234,6 +297,31 @@ function cleanTraceText(value: unknown): string { return String(value ?? "").replace(/\u0000/gu, "").replace(/\r\n|\r/gu, "\n").replace(/[ \t]{2,}/gu, " ").replace(/\n{3,}/gu, "\n\n").trim(); } +function cleanShellCommand(value: unknown): string | null { + const raw = cleanTraceText(value); + if (!raw) return null; + const stripped = stripShellWrapper(raw); + const unescaped = stripped.replace(/\\(["`$\\])/gu, "$1"); + return compactTraceOneLine(unescaped, 900); +} + +function stripShellWrapper(value: string): string { + const match = value.match(/^\s*(?:\/usr\/bin\/env\s+)?(?:\/bin\/sh|sh|bash|\/bin\/bash)\s+-lc\s+([\s\S]*)$/u); + if (!match) return value; + return unwrapShellArgument(match[1] ?? ""); +} + +function unwrapShellArgument(value: string): string { + const text = value.trim(); + if (text.length >= 2) { + const quote = text[0]; + if ((quote === "'" || quote === '"') && text[text.length - 1] === quote) { + return text.slice(1, -1); + } + } + return text; +} + function compactTraceOneLine(value: unknown, limit = 220): string { const text = cleanTraceText(value).replace(/\s+/gu, " "); return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`; diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index d310bc01..5a9e2568 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -86,6 +86,8 @@ assert.match(css, /\.device-event-panel\s*\{[\s\S]*?max-height:\s*min\(360px,\s* assert.match(css, /\.session-tabs\s*\{[\s\S]*?overflow-x:\s*hidden;/u, "session sidebar must not horizontally scroll"); assert.match(css, /\.message-body p\s*\{\s*white-space:\s*pre-wrap;\s*\}/u, "final response markdown paragraphs must preserve soft line breaks"); assert.match(css, /@keyframes trace-update-pulse/u, "trace updates must have an explicit visual pulse"); +assert.match(css, /@keyframes trace-working-border/u, "running trace frame must show an explicit working animation"); +assert.match(css, /\.message-trace-body\s*\{[\s\S]*?max-height:\s*calc\(4 \* 1\.35em \+ 14px\);/u, "trace/tool output body must be capped to about four lines with internal scroll"); assert.doesNotMatch(appSource, /message-trace-storage-key/u, "trace summary must not expose raw trace ids as visible chips"); assert.doesNotMatch(appSource, /last ·/u, "trace summary must not show raw last-event labels"); diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx new file mode 100644 index 00000000..31bbb90e --- /dev/null +++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.test.tsx @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { MessageTracePanel } from "./MessageTracePanel"; +import type { RunnerTrace } from "../../types/domain"; + +test("message trace panel renders assistant markdown rows through shared markdown renderer", () => { + const trace: RunnerTrace = { + traceId: "trc_markdown_row", + status: "completed", + events: [ + { + seq: 1, + label: "agentrun:assistant:message", + type: "assistant", + status: "completed", + final: true, + replyAuthority: true, + message: "| devicePodId | debug |\n|---|---|\n| `D601-F103-V2` | cmsis-dap |" + } + ], + eventCount: 1 + }; + + const html = renderToStaticMarkup(); + + assert.match(html, /data-body-format="markdown"/u); + assert.match(html, //u); + assert.match(html, /D601-F103-V2<\/code>/u); + assert.doesNotMatch(html, /
 {
+  const trace: RunnerTrace = {
+    traceId: "trc_running_row",
+    status: "running",
+    events: [
+      { seq: 1, label: "agentrun:request:accepted", status: "accepted" }
+    ],
+    eventCount: 1
+  };
+
+  const html = renderToStaticMarkup();
+
+  assert.match(html, /data-trace-status="running"/u);
+  assert.match(html, /open=""/u);
+});
diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
index c3dc7019..874e4164 100644
--- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
+++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
@@ -5,6 +5,7 @@ import type { RunnerTrace, TraceEvent } from "../../types/domain";
 import { formatBeijingTime, formatDuration, toneClass } from "../../utils";
 import { summarizeLastEvent } from "../../state/code-agent-status";
 import { traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer";
+import { MessageMarkdown } from "../shared/MessageMarkdown";
 
 interface MessageTracePanelProps {
   trace: RunnerTrace;
@@ -51,7 +52,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
   const lastUpdatedAt = traceUpdatedAt(trace, events, last.ts);
 
   return (
-    
setOpen((event.currentTarget as HTMLDetailsElement).open)}> +
setOpen((event.currentTarget as HTMLDetailsElement).open)}> Trace · {trace.status ?? "unknown"} · {traceCountText(trace, events.length, rows.length, noiseCount)} 最近更新 @@ -75,7 +76,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra {row.header} - {row.body ?
{row.body}
: null} + {row.body ? : null} ))} @@ -85,6 +86,18 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra ); } +function TraceRowBody({ row }: { row: TraceEventRow }): ReactElement | null { + if (!row.body) return null; + if (row.bodyFormat === "markdown") { + return ( + + {row.body} + + ); + } + return
{row.body}
; +} + function traceUpdateKey(trace: RunnerTrace): string { const eventCount = Number.isInteger(trace.eventCount) ? String(trace.eventCount) : String(trace.events?.length ?? 0); return [trace.traceId ?? "trace", trace.status ?? "unknown", trace.updatedAt ?? "", trace.finishedAt ?? "", eventCount].join(":"); diff --git a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx index fc6acd61..a4068755 100644 --- a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx +++ b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx @@ -5,11 +5,12 @@ import remarkGfm from "remark-gfm"; interface MessageMarkdownProps { children: string; className?: string; + [key: `data-${string}`]: string | undefined; } -export function MessageMarkdown({ children, className = "message-body" }: MessageMarkdownProps): ReactElement { +export function MessageMarkdown({ children, className = "message-body", ...props }: MessageMarkdownProps): ReactElement { return ( -
+
{ + const terminal: AgentChatResultResponse = { + status: "completed", + traceId: "trc_markdown", + assistantText: "当前 `hwlab-cloud-api` profile authority 列了 2 个 device-pod,都 `active`: | devicePodId | debug | |---|---| | `D601-F103-V2` | cmsis-dap | - API: `http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667`" + }; + const markdown = "当前 `hwlab-cloud-api` profile authority 列了 2 个 device-pod,都 `active`:\n\n| devicePodId | debug |\n|---|---|\n| `D601-F103-V2` | cmsis-dap |\n\n- API: `http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667`"; + const trace: TraceSnapshot = { + traceId: "trc_markdown", + status: "completed", + events: [ + { seq: 10, label: "agentrun:assistant:message", type: "assistant", status: "completed", final: true, replyAuthority: true, message: markdown } + ], + eventCount: 1 + }; + + const merged = mergeTraceResults(terminal, trace); + + assert.equal(merged.assistantText, markdown); + assert.match(merged.assistantText ?? "", /\n\n\| devicePodId \| debug \|/u); +}); + test("subscribeToTrace keeps polling result when trace terminal lacks final response text", async () => { const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.window?.setTimeout; diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index d537af8c..e4c6253f 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -78,7 +78,7 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac const terminalEvents = Array.isArray((terminal as { events?: TraceEvent[] }).events) ? ((terminal as { events?: TraceEvent[] }).events ?? []) : []; const events = traceEvents.length >= terminalEvents.length ? traceEvents : terminalEvents; const mergedTrace = { ...trace, events, eventCount: trace.eventCount ?? events.length }; - const assistantText = firstNonEmptyResultText(terminal) ?? assistantTextFromTraceEvents(events); + const assistantText = assistantTextFromTraceEvents(events) ?? firstNonEmptyResultText(terminal); return { ...terminal, traceId: trace.traceId ?? terminal.traceId, diff --git a/web/hwlab-cloud-web/src/state/trace-renderer.test.ts b/web/hwlab-cloud-web/src/state/trace-renderer.test.ts index 9de265c1..1d431c09 100644 --- a/web/hwlab-cloud-web/src/state/trace-renderer.test.ts +++ b/web/hwlab-cloud-web/src/state/trace-renderer.test.ts @@ -44,3 +44,24 @@ test("web trace rows keep every assistant message and mark only the last one ter assert.match(terminalRows[0]?.header ?? "", /助手消息 3\/3,轮次完成/u); assert.doesNotMatch(JSON.stringify(rows), /backend-turn-finished|short-connection polling/u); }); + +test("web trace rows collapse tool start/completed and clean shell command escaping", () => { + const events: Record[] = [ + { seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-04T12:13:58.212Z" }, + { seq: 2, label: "agentrun:tool:item/started", type: "tool_call", toolName: "commandExecution", status: "started", itemId: "call_build", command: "/bin/sh -lc \"hwpod D601-F103-V2:workspace:/projects/01_baseline build start --reason \\\"agent 主动 build 复测\\\" 2>&1 | grep -E '\\\"id\\\":|\\\"status\\\":' | head -10\"", createdAt: "2026-06-04T12:14:00.000Z" }, + { seq: 3, label: "agentrun:tool:item/completed", type: "tool_call", toolName: "commandExecution", status: "completed", itemId: "call_build", command: "/bin/sh -lc \"hwpod D601-F103-V2:workspace:/projects/01_baseline build start --reason \\\"agent 主动 build 复测\\\" 2>&1 | grep -E '\\\"id\\\":|\\\"status\\\":' | head -10\"", stdoutSummary: "{\"id\":\"job_1\"}\n{\"status\":\"running\"}", exitCode: 0, createdAt: "2026-06-04T12:14:01.000Z" } + ]; + + const rows = traceDisplayRows({ startedAt: "2026-06-04T12:13:58.212Z" }, events); + const toolRows = rows.filter((row) => row.rowId.startsWith("tool:")); + const body = toolRows.map((row) => row.body ?? "").join("\n"); + const text = JSON.stringify(toolRows); + + assert.equal(toolRows.length, 1); + assert.match(body, /hwpod D601-F103-V2:workspace:\/projects\/01_baseline build start --reason "agent 主动 build 复测"/u); + assert.match(body, /grep -E '"id":\|"status":'/u); + assert.doesNotMatch(body, /\/bin\/sh -lc|\\"/u); + assert.match(body, /stdout:/u); + assert.match(body, /exitCode=0/u); + assert.doesNotMatch(text, /agentrun:tool:item\/started/u); +}); diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 1b459cd4..258109f2 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -114,7 +114,11 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .message-trace-tone { color: var(--accent); } .message-trace-label { font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; } .message-trace-time { color: var(--muted); font-size: 12px; } -.message-trace-body { margin: 6px 0 0; max-height: 220px; overflow: auto; background: #f3f5f1; padding: 6px 8px; border-radius: 6px; font-size: 12px; } +.message-trace-body { margin: 6px 0 0; max-height: calc(4 * 1.35em + 14px); overflow: auto; background: #f3f5f1; padding: 6px 8px; border-radius: 6px; font-size: 12px; line-height: 1.35; scrollbar-gutter: stable; } +.message-trace-markdown { display: block; font-family: inherit; white-space: normal; } +.message-trace-markdown p, .message-trace-markdown ul, .message-trace-markdown ol, .message-trace-markdown table { margin: 0 0 6px; } +.message-trace-markdown p:last-child, .message-trace-markdown ul:last-child, .message-trace-markdown ol:last-child, .message-trace-markdown table:last-child { margin-bottom: 0; } +.message-trace-markdown code { border-radius: 4px; background: #e7ece8; padding: 1px 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.94em; } .message-trace-events { min-height: 0; max-height: min(312px, 32dvh); overflow: auto; width: 100%; } .message-trace-empty { color: var(--muted); font-size: 12px; padding: 10px; } .message-trace-last { color: var(--muted); font-size: 12px; font-weight: 800; } @@ -192,7 +196,8 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .conversation-list { min-height: 0; display: grid; align-content: start; gap: 8px; overflow: visible; padding: 0 4px 2px 0; } .message-card { border: 1px solid var(--line); border-radius: 6px; background: #fff; padding: 8px; } .message-card.message-user { justify-self: end; width: fit-content; max-width: min(74%, 720px); padding: 0; border-color: #c9d7d2; background: #f7fbf8; } -.message-card.message-agent { display: grid; gap: 6px; width: 100%; } +.message-card.message-agent { display: grid; gap: 6px; width: 100%; border-left-width: 4px; background: linear-gradient(90deg, #fbfffb 0%, #fff 42%); } +.message-card.message-agent .message-final-response { border: 1px solid #dce7df; border-radius: 6px; background: #fbfdf9; padding: 8px 10px; } .message-head { display: none; } .message-body { line-height: 1.45; overflow-wrap: anywhere; } .message-body p { white-space: pre-wrap; } @@ -210,8 +215,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .message-final-response { min-height: 0; } .message-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; } .message-trace { margin-top: 0; border: 1px solid var(--line); border-radius: 6px; padding-top: 0; width: 100%; overflow: hidden; } +.message-trace[data-trace-status="running"] { animation: trace-working-border 1.8s ease-in-out infinite; } .message-trace[data-trace-mode="all"], .message-trace[open] { width: 100%; } .message-trace > summary, .message-trace-summary { margin: 0; padding: 4px 8px; background: #f7f8f4; cursor: pointer; } +.message-trace[data-trace-status="running"] > summary, .message-trace[data-trace-status="running"] .message-trace-summary { background: linear-gradient(90deg, #fff7df, #f7f8f4 42%, #fff7df); background-size: 180% 100%; animation: trace-working-summary 1.8s ease-in-out infinite; } .message-trace-summary { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 28px; color: var(--muted); font-size: 12px; } .message-trace-summary::-webkit-details-marker { display: none; } .message-trace-events { max-height: min(312px, 32dvh); overflow: auto; display: grid; gap: 8px; width: 100%; } @@ -247,6 +254,16 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } 100% { opacity: 0; transform: scale(0.75); box-shadow: 0 0 0 0 rgba(31, 122, 107, 0); } } +@keyframes trace-working-border { + 0%, 100% { box-shadow: inset 0 0 0 0 rgba(154, 98, 0, 0); } + 50% { box-shadow: inset 0 0 0 1px rgba(154, 98, 0, 0.22), 0 0 0 3px rgba(228, 189, 104, 0.18); } +} + +@keyframes trace-working-summary { + 0% { background-position: 0% 50%; } + 100% { background-position: 180% 50%; } +} + .command-bar { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-areas: "profile drafts send clear" "input input input input"; gap: 6px; align-items: end; padding: 8px 10px; border-top: 1px solid var(--line); background: #f7f8f4; } .command-bar .input-shell { grid-area: input; } .command-bar #command-drafts-toggle { grid-area: drafts; }