fix(v02): unify web trace markdown rendering (#858)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 21:33:01 +08:00
committed by GitHub
parent 84e649c294
commit 412f95f1a5
9 changed files with 222 additions and 10 deletions
+91 -3
View File
@@ -5,7 +5,7 @@ export interface TraceEventRow {
header: string;
body: string | null;
terminal?: boolean;
bodyFormat?: "markdown";
bodyFormat?: "markdown" | "text";
}
type TraceEvent = Record<string, unknown>;
@@ -16,8 +16,14 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, 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<string, unknown> = {}, events: Tr
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event));
}
function traceToolCallRow(trace: Record<string, unknown>, 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))}...`;
+2
View File
@@ -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");
@@ -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(<MessageTracePanel trace={trace} defaultOpen />);
assert.match(html, /data-body-format="markdown"/u);
assert.match(html, /<table>/u);
assert.match(html, /<code>D601-F103-V2<\/code>/u);
assert.doesNotMatch(html, /<pre class="message-trace-body message-copy-markdown" data-body-format="markdown"/u);
});
test("message trace panel exposes running status for live trace animation", () => {
const trace: RunnerTrace = {
traceId: "trc_running_row",
status: "running",
events: [
{ seq: 1, label: "agentrun:request:accepted", status: "accepted" }
],
eventCount: 1
};
const html = renderToStaticMarkup(<MessageTracePanel trace={trace} />);
assert.match(html, /data-trace-status="running"/u);
assert.match(html, /open=""/u);
});
@@ -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 (
<details className={`message-trace tone-border-${toneClass(last.tone)}`} data-trace-ui-key={trace.traceId ?? "trace"} data-trace-mode="all" data-trace-update-key={pulseKey} open={open} onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
<details className={`message-trace tone-border-${toneClass(last.tone)}`} data-trace-ui-key={trace.traceId ?? "trace"} data-trace-status={trace.status ?? "source"} data-trace-mode="all" data-trace-update-key={pulseKey} open={open} onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
<summary className="message-trace-summary">
<span>Trace · {trace.status ?? "unknown"} · {traceCountText(trace, events.length, rows.length, noiseCount)}</span>
<span className={`message-trace-last tone-${toneClass(last.tone)}`}></span>
@@ -75,7 +76,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
<span className={`message-trace-tone tone-${toneClass(row.tone)}`} aria-hidden="true"></span>
<span className="message-trace-label">{row.header}</span>
</header>
{row.body ? <pre className="message-trace-body message-copy-markdown" data-body-format={row.bodyFormat ?? "text"}>{row.body}</pre> : null}
{row.body ? <TraceRowBody row={row} /> : null}
</li>
))}
</ol>
@@ -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 (
<MessageMarkdown className="message-trace-body message-trace-markdown message-copy-markdown" data-body-format="markdown">
{row.body}
</MessageMarkdown>
);
}
return <pre className="message-trace-body message-copy-markdown" data-body-format="text">{row.body}</pre>;
}
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(":");
@@ -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 (
<div className={className}>
<div className={className} {...props}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@@ -26,6 +26,28 @@ test("mergeTraceResults derives final response from trace assistant event when t
assert.equal(merged.traceEvents?.length, 3);
});
test("mergeTraceResults keeps trace assistant markdown ahead of flattened terminal text", () => {
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;
@@ -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,
@@ -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<string, unknown>[] = [
{ 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);
});
+19 -2
View File
@@ -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; }