fix(v02): unify web trace final response

This commit is contained in:
Codex Agent
2026-06-04 17:08:10 +08:00
parent 20199b8215
commit 7a58695318
9 changed files with 313 additions and 95 deletions
+16 -6
View File
@@ -15,6 +15,7 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
let requestRendered = false;
let setupRendered = false;
let completionRendered = false;
let pendingAssistantEvent: TraceEvent | null = null;
for (const event of events) {
if (!event || isNoisyTraceEvent(event)) continue;
if (isRequestTraceEvent(event)) {
@@ -32,19 +33,23 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
continue;
}
if (isTerminalAssistantTraceEvent(event)) {
rows.push(traceAssistantSummaryRow(trace, event));
completionRendered = true;
pendingAssistantEvent = event;
continue;
}
if (isAssistantTraceEvent(event)) {
if (!completionRendered) pendingAssistantEvent = event;
continue;
}
if (isCompletionTraceEvent(event)) {
if (!completionRendered) {
rows.push(traceCompletionSummaryRow(trace, event));
rows.push(pendingAssistantEvent ? traceAssistantSummaryRow(trace, pendingAssistantEvent) : traceCompletionSummaryRow(trace, event));
completionRendered = true;
}
continue;
}
rows.push(traceDisplayRow(trace, event));
}
if (!completionRendered && pendingAssistantEvent) rows.push(traceAssistantSummaryRow(trace, pendingAssistantEvent));
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event));
}
@@ -133,19 +138,24 @@ function isSetupTraceEvent(event: TraceEvent): boolean {
function isCompletionTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
return label.startsWith("turn:completed") || label.startsWith("result:completed") || label === "agentrun:backend:turn/completed" || label === "agentrun:result:completed";
return label.startsWith("turn:completed") || label.startsWith("result:completed") || label === "agentrun:backend:turn/completed" || label === "agentrun:terminal:completed" || label === "agentrun:result:completed";
}
function isAssistantTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
return label === "agentrun:assistant:message" || label === "assistant:message" || event.type === "assistant_message" || event.type === "assistant";
}
function isTerminalAssistantTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
if (event.terminal === true || label === "assistant:completed") return true;
if (label === "assistant:completed") return true;
if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true;
return event.type === "assistant_message" && (event.status === "completed" || event.final === true);
}
function isNoisyTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event)) return false;
if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event) || isAssistantTraceEvent(event)) return false;
if (/token_count|outputDelta:chunk/iu.test(label)) return true;
if (/^agentrun:backend:(run-created|command-created|runner-job-created|thread\/status\/changed|thread\/tokenUsage\/updated|account\/rateLimits\/updated|remoteControl\/status\/changed|configWarning|codex-app-server-closed|session-updated|command-terminal|item\/agentMessage:(started|completed)|thread\/goal\/cleared)$/u.test(label)) return true;
return event.type === "event" && !event.outputSummary && !event.message && !event.errorCode;
+26 -1
View File
@@ -17,6 +17,7 @@ import { SettingsView } from "./components/settings/SettingsView";
import { SkillsView } from "./components/skills/SkillsView";
import { GateView } from "./components/gate/GateView";
import { fetchText } from "./services/api/client";
import { firstNonEmptyString } from "./utils";
const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
const SESSION_SIDEBAR_DEFAULT_WIDTH = 330;
@@ -157,7 +158,7 @@ export function App(): ReactElement {
/>
) : null}
<section className="center-workspace">
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => void navigator.clipboard?.writeText(store.activeConversationId ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null}
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} /> : null}
{route === "skills" ? <SkillsView /> : null}
{route === "gate" ? <GateView /> : null}
{route === "help" ? <HelpView help={help} /> : null}
@@ -194,6 +195,30 @@ export function App(): ReactElement {
);
}
function copyTextToClipboard(value: string): void {
if (!value) return;
const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : null;
if (clipboard?.writeText) {
void clipboard.writeText(value).catch(() => fallbackCopyText(value));
return;
}
fallbackCopyText(value);
}
function fallbackCopyText(value: string): void {
if (typeof document === "undefined") return;
const node = document.createElement("textarea");
node.value = value;
node.setAttribute("readonly", "");
node.style.position = "fixed";
node.style.left = "-9999px";
node.style.top = "0";
document.body.appendChild(node);
node.select();
try { document.execCommand("copy"); } catch { /* ignore clipboard fallback failures */ }
node.remove();
}
function StatePill({ tone, children }: { tone: "pending" | "source"; children: string }): ReactElement {
return <span className={`workspace-topbar-pill tone-${tone}`}>{children}</span>;
}
@@ -19,10 +19,9 @@ interface ConversationPanelProps {
onCopySession(): void;
onCancelMessage(messageId: string): void;
onRetryMessage(messageId: string): void;
onReplayTrace(messageId: string): void;
}
export function ConversationPanel({ messages, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement {
export function ConversationPanel({ messages, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement {
return (
<section className="view workbench-view" id="workspace" data-view="workspace" aria-labelledby="workspace-title">
<div className="conversation-column">
@@ -31,10 +30,9 @@ export function ConversationPanel({ messages, availability, live, chatPending, c
<div>
<p className="eyebrow">Agent </p>
<h2 id="workspace-title">Agent </h2>
<p className="conversation-panel-hint" id="workspace-hint" data-workspace-hint> Device Pod summary Code Agent </p>
</div>
<div className="panel-title-actions">
<button className="icon-button" id="copy-session-id" type="button" aria-label="复制 session id" title="复制 session id 到剪贴板,便于在 HWLAB CLI 复现。" onClick={onCopySession}> session id</button>
<button className="icon-button" id="copy-session-id" type="button" aria-label="复制 session id" title="复制当前 Code Agent session id" onClick={onCopySession}> Session</button>
<StateTag id="agent-chat-status" tone={chatPending ? "pending" : "source"}>{chatPending ? "处理中" : "等待输入"}</StateTag>
<CodeAgentSummary availability={availability} live={live} latestMessage={latestAgentMessage(messages)} />
</div>
@@ -50,7 +48,6 @@ export function ConversationPanel({ messages, availability, live, chatPending, c
codeAgentTimeoutMs={codeAgentTimeoutMs}
onCancel={() => onCancelMessage(message.id)}
onRetry={() => onRetryMessage(message.id)}
onReplayTrace={() => onReplayTrace(message.id)}
/>
))}
</div>
@@ -72,6 +69,10 @@ function CodeAgentSummary({ availability, live, latestMessage }: { availability:
const summary = useMemo(() => classifyCodeAgentStatusSummary({ availability, live, latestMessage }), [availability, live, latestMessage]);
const rows = useMemo<DebugRow[]>(() => codeAgentSummaryRows(summary).map((row) => ({ label: row.key, value: row.value, tone: row.tone as DebugRow["tone"] })), [summary]);
const sections: DebugSection[] = [
{ title: "工作区", rows: [
{ label: "webEntry", value: "http://74.48.78.17:19666/" },
{ label: "traceRenderer", value: "hwlab-cli client agent trace --render web" }
] },
{ title: "Code Agent 详情", rows },
{ title: "readiness blockers", rows: summary.readinessBlockers.length > 0
? summary.readinessBlockers.map((blocker) => ({ label: blocker, value: blocker, tone: "blocked" as const }))
@@ -95,7 +96,7 @@ function CodeAgentSummary({ availability, live, latestMessage }: { availability:
);
}
function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement {
function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void }): ReactElement {
const html = renderMessageMarkdown(message.text);
const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`;
if (message.role === "agent") {
@@ -111,7 +112,6 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT
codeAgentTimeoutMs={codeAgentTimeoutMs}
onCancel={onCancel}
onRetry={onRetry}
onReplayTrace={onReplayTrace}
/>
</article>
);
@@ -124,13 +124,12 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT
codeAgentTimeoutMs={codeAgentTimeoutMs}
onCancel={onCancel}
onRetry={onRetry}
onReplayTrace={onReplayTrace}
/>
</article>
);
}
function MessageDebug({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement | null {
function MessageDebug({ message, codeAgentTimeoutMs, onCancel, onRetry }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void }): ReactElement | null {
if (message.role === "user" || message.role === "system") {
if (!message.traceId) return null;
return (
@@ -151,7 +150,7 @@ function MessageDebug({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplay
<footer className="message-meta message-meta-compact">
<StateTag tone={statusLabel(message).tone}>{statusLabel(message).label}</StateTag>
<span>{formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })}</span>
<MessageActions message={message} onCancel={onCancel} onRetry={onRetry} onReplayTrace={onReplayTrace} />
<MessageActions message={message} onCancel={onCancel} onRetry={onRetry} />
<DebugDialog
dialogId={`message-debug-${message.id}`}
title="Agent 消息调试信息"
@@ -7,10 +7,9 @@ interface MessageActionsProps {
message: ChatMessage;
onCancel(): void;
onRetry(): void;
onReplayTrace(): void;
}
export function MessageActions({ message, onCancel, onRetry, onReplayTrace }: MessageActionsProps): ReactElement | null {
export function MessageActions({ message, onCancel, onRetry }: MessageActionsProps): ReactElement | null {
if (message.role !== "agent") return null;
const status = String(message.status ?? "");
const buttons: ReactElement[] = [];
@@ -23,9 +22,6 @@ export function MessageActions({ message, onCancel, onRetry, onReplayTrace }: Me
if (canRetryTerminal(message) && message.retryInput) {
buttons.push(actionButton("重试上一条", "retry", onRetry, "保留 conversation/trace 记录并重新发送上一条输入"));
}
if (message.traceId) {
buttons.push(actionButton("回放 trace", "trace", onReplayTrace, "从 runnerTrace store 重新读取真实事件"));
}
if (buttons.length === 0 && !message.traceReplayStatus) return null;
return (
<div className={`message-actions tone-border-${toneClass(message.status)}`} role="toolbar" aria-label="消息操作">
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { RunnerTrace, TraceEvent } from "../../types/domain";
import { formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils";
import { summarizeLastEvent } from "../../state/code-agent-status";
import { traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer";
interface MessageTracePanelProps {
trace: RunnerTrace;
@@ -11,21 +12,10 @@ interface MessageTracePanelProps {
storageKey?: string;
}
const MAX_VISIBLE_ROWS = 32;
interface DisplayRow {
key: string;
label: string;
body: string;
tone: string;
ts: string | null;
}
export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTracePanelProps): ReactElement {
const traceRunning = isRunningTrace(trace);
const storedOpen = readStoredOpen(storageKey, defaultOpen ?? traceRunning);
const [open, setOpen] = useState<boolean>(storedOpen);
const [follow, setFollow] = useState<boolean>(true);
const listRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -43,52 +33,42 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
}, [open, storageKey]);
useEffect(() => {
if (follow && listRef.current) {
if (traceRunning && open && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [trace, follow, open]);
}, [trace, traceRunning, open]);
const events = Array.isArray(trace.events) ? trace.events : [];
const last = summarizeLastEvent(trace);
const rows = useMemo<DisplayRow[]>(() => displayRows(events), [events]);
const rows = useMemo<TraceEventRow[]>(() => traceDisplayRows(trace, events), [trace, events]);
const noiseCount = useMemo<number>(() => traceNoiseEventCount(events), [events]);
return (
<details className={`message-trace tone-border-${toneClass(last.tone)}`} data-trace-ui-key={trace.traceId ?? "trace"} open={open} onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
<summary className="message-meta">
<span> trace · {trace.status ?? "unknown"} · {rows.length || trace.eventCount || 0} events</span>
<details className={`message-trace tone-border-${toneClass(last.tone)}`} data-trace-ui-key={trace.traceId ?? "trace"} data-trace-mode="all" 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)}`}>last · {last.label}</span>
{last.ts ? <span className="message-trace-time">{formatBeijingTime(last.ts, { withSeconds: true })}</span> : null}
</summary>
<div className="message-trace-toolbar" role="toolbar" aria-label="trace toolbar">
<span className="message-trace-count">{traceCountText(trace, events.length, rows.length)}</span>
<button type="button" className="command-button secondary" onClick={() => setFollow((value) => !value)}>{follow ? "跟随" : "已暂停"}</button>
<button type="button" className="command-button secondary" onClick={() => { setFollow(true); if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight; }}></button>
<span className="message-trace-toolbar-spacer" />
<span className="message-trace-storage-key" title={storageKey ?? "no storage key"}>{shortToken(trace.traceId, 10)}</span>
</div>
</summary>
<div
className="message-trace-events"
data-trace-ui-key={trace.traceId ?? "trace"}
data-trace-status={trace.status ?? "source"}
data-trace-mode="all"
ref={listRef}
onScroll={() => {
const node = listRef.current;
if (!node) return;
if (node.scrollHeight - node.clientHeight - node.scrollTop > 24) setFollow(false);
}}
>
{rows.length === 0 ? (
<p className="message-trace-empty">trace={trace.traceId ?? "pending"}</p>
) : (
<ol className="message-trace-list">
{rows.map((row) => (
<li key={row.key} className={`message-trace-row tone-border-${toneClass(row.tone)}`} data-trace-row-id={row.key}>
<li key={row.rowId} className={`message-trace-row tone-border-${toneClass(row.tone)}`} data-trace-row-id={row.rowId} data-trace-terminal={row.terminal === true ? "true" : undefined}>
<header className="message-trace-line">
<span className={`message-trace-tone tone-${toneClass(row.tone)}`} aria-hidden="true"></span>
<span className="message-trace-label">{row.label}</span>
{row.ts ? <span className="message-trace-time">{formatBeijingTime(row.ts, { withSeconds: true })}</span> : null}
<span className="message-trace-label">{row.header}</span>
</header>
<pre className="message-trace-body message-copy-markdown">{row.body}</pre>
{row.body ? <pre className="message-trace-body message-copy-markdown" data-body-format={row.bodyFormat ?? "text"}>{row.body}</pre> : null}
</li>
))}
</ol>
@@ -98,33 +78,11 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
);
}
function displayRows(events: TraceEvent[]): DisplayRow[] {
if (events.length === 0) return [];
const tail = events.slice(-MAX_VISIBLE_ROWS);
return tail.map((event, index) => {
const key = `${event.traceId ?? "event"}-${event.seq ?? index}-${event.ts ?? index}`;
const label = String(event.label ?? event.kind ?? event.type ?? "event");
const body = eventSummary(event);
const tone = String(event.status ?? event.level ?? "source");
const ts = event.ts ?? null;
return { key, label, body, tone, ts };
});
}
function eventSummary(event: TraceEvent): string {
if (typeof event.text === "string" && event.text.trim()) return event.text;
if (typeof event.message === "string" && event.message.trim()) return event.message;
if (event.payload && typeof event.payload === "object") {
try { return JSON.stringify(event.payload, null, 2); } catch { return String(event.payload); }
}
if (event.evidence && Array.isArray(event.evidence)) return event.evidence.join("\n");
return "(empty event)";
}
function traceCountText(trace: RunnerTrace, events: number, rows: number): string {
function traceCountText(trace: RunnerTrace, events: number, rows: number, noiseCount: number): string {
const rawTotal = Number.isInteger(trace.eventCount) ? trace.eventCount : events;
if (rawTotal === events) return `${rows} 条事件`;
return `${rows}/${rawTotal} 条事件(已折叠)`;
const noise = noiseCount > 0 ? `,去噪 ${noiseCount}` : "";
if (rawTotal === events) return `${rows}/${rawTotal} ${noise}`;
return `${rows}/${events}/${rawTotal}${noise}`;
}
function isRunningTrace(trace: RunnerTrace): boolean {
@@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { mergeTraceResults, subscribeToTrace, type TraceSnapshot } from "./runner-trace";
import type { AgentChatResultResponse, TraceEvent } from "../types/domain";
test("mergeTraceResults derives final response from trace assistant event when trace terminal arrives first", () => {
const terminal: AgentChatResultResponse = {
status: "completed",
traceId: "trc_fixture",
message: "AgentRun result is ready for HWLAB short-connection polling."
};
const events: TraceEvent[] = [
{ seq: 1, label: "agentrun:request:accepted", status: "accepted" },
{ seq: 22, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "你好,真实回复" },
{ seq: 31, label: "agentrun:result:completed", type: "result", status: "completed", message: "AgentRun result is ready for HWLAB short-connection polling." }
];
const trace: TraceSnapshot = { traceId: "trc_fixture", status: "completed", events, eventCount: events.length, lastEventLabel: "agentrun:result:completed" };
const merged = mergeTraceResults(terminal, trace);
assert.equal(merged.assistantText, "你好,真实回复");
assert.equal(merged.eventCount, 3);
assert.equal(merged.runnerTrace?.traceId, "trc_fixture");
assert.equal(merged.runnerTrace?.events?.length, 3);
assert.equal(merged.traceEvents?.length, 3);
});
test("subscribeToTrace keeps polling result when trace terminal lacks final response text", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.window?.setTimeout;
const originalClearTimeout = globalThis.window?.clearTimeout;
const fetches: string[] = [];
const events: TraceEvent[] = [
{ seq: 1, label: "agentrun:request:accepted", status: "accepted" },
{ seq: 2, label: "agentrun:terminal:completed", type: "terminal", status: "completed", message: "AgentRun terminal status completed" }
];
let resultPolls = 0;
globalThis.window = {
...(globalThis.window ?? {}),
setTimeout: ((callback: () => void) => { callback(); return 1; }) as typeof window.setTimeout,
clearTimeout: (() => undefined) as typeof window.clearTimeout
} as typeof window;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
fetches.push(url);
if (url.includes("/result/")) {
resultPolls += 1;
return jsonResponse(resultPolls === 1
? { status: "running", traceId: "trc_terminal_first" }
: { status: "completed", traceId: "trc_terminal_first", assistantText: "最终回复" });
}
if (url.includes("/trace/")) {
return jsonResponse({ status: "completed", traceId: "trc_terminal_first", events, eventCount: events.length });
}
return jsonResponse({});
}) as typeof fetch;
try {
const completed = await new Promise<AgentChatResultResponse>((resolve, reject) => {
void subscribeToTrace({
traceId: "trc_terminal_first",
initial: { status: "running", traceId: "trc_terminal_first", resultUrl: "/v1/agent/chat/result/trc_terminal_first" },
onActivity: () => undefined,
onSnapshot: () => undefined,
onComplete: resolve,
onInfrastructureError: (error) => reject(new Error(error)),
signal: new AbortController().signal,
inactivityTimeoutMs: 1000
}).catch(reject);
});
assert.equal(completed.assistantText, "最终回复");
assert.equal(resultPolls, 2);
assert.deepEqual(fetches, [
"/v1/agent/chat/result/trc_terminal_first",
"/v1/agent/chat/trace/trc_terminal_first",
"/v1/agent/chat/result/trc_terminal_first"
]);
} finally {
globalThis.fetch = originalFetch;
if (globalThis.window) {
globalThis.window.setTimeout = originalSetTimeout ?? globalThis.window.setTimeout;
globalThis.window.clearTimeout = originalClearTimeout ?? globalThis.window.clearTimeout;
}
}
});
test("subscribeToTrace merges terminal result without text with trace assistant content", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.window?.setTimeout;
const originalClearTimeout = globalThis.window?.clearTimeout;
const fetches: string[] = [];
const events: TraceEvent[] = [
{ seq: 1, label: "agentrun:request:accepted", status: "accepted" },
{ seq: 2, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "trace 最终回复" },
{ seq: 3, label: "agentrun:result:completed", type: "result", status: "completed", message: "AgentRun result is ready for HWLAB short-connection polling." }
];
globalThis.window = {
...(globalThis.window ?? {}),
setTimeout: ((callback: () => void) => { callback(); return 1; }) as typeof window.setTimeout,
clearTimeout: (() => undefined) as typeof window.clearTimeout
} as typeof window;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
fetches.push(url);
if (url.includes("/result/")) {
return jsonResponse({ status: "completed", traceId: "trc_result_first", message: "AgentRun result is ready for HWLAB short-connection polling." });
}
if (url.includes("/trace/")) {
return jsonResponse({ status: "completed", traceId: "trc_result_first", events, eventCount: events.length });
}
return jsonResponse({});
}) as typeof fetch;
try {
const completed = await new Promise<AgentChatResultResponse>((resolve, reject) => {
void subscribeToTrace({
traceId: "trc_result_first",
initial: { status: "running", traceId: "trc_result_first", resultUrl: "/v1/agent/chat/result/trc_result_first" },
onActivity: () => undefined,
onSnapshot: () => undefined,
onComplete: resolve,
onInfrastructureError: (error) => reject(new Error(error)),
signal: new AbortController().signal,
inactivityTimeoutMs: 1000
}).catch(reject);
});
assert.equal(completed.assistantText, "trace 最终回复");
assert.equal(completed.runnerTrace?.events?.length, 3);
assert.deepEqual(fetches, [
"/v1/agent/chat/result/trc_result_first",
"/v1/agent/chat/trace/trc_result_first"
]);
} finally {
globalThis.fetch = originalFetch;
if (globalThis.window) {
globalThis.window.setTimeout = originalSetTimeout ?? globalThis.window.setTimeout;
globalThis.window.clearTimeout = originalClearTimeout ?? globalThis.window.clearTimeout;
}
}
});
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
+64 -5
View File
@@ -77,18 +77,53 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
const traceEvents = Array.isArray(trace.events) ? trace.events : [];
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);
return {
...terminal,
traceId: trace.traceId ?? terminal.traceId,
sessionId: trace.sessionId ?? terminal.sessionId,
threadId: trace.threadId ?? terminal.threadId,
agentRun: trace.agentRun ?? terminal.agentRun,
assistantText: assistantText ?? terminal.assistantText,
runnerTrace: terminal.runnerTrace ?? snapshotToRunnerTrace(mergedTrace),
traceEvents: terminal.traceEvents ?? events,
events,
eventCount: trace.eventCount ?? events.length,
lastEventLabel: trace.lastEventLabel ?? terminal.lastEventLabel ?? events.at(-1)?.label
} as AgentChatResultResponse;
}
function firstNonEmptyResultText(result: AgentChatResultResponse): string | null {
const reply = result.reply;
const replyText = typeof reply === "string" ? reply : typeof reply?.content === "string" ? reply.content : null;
return firstNonEmptyString(result.assistantText, replyText);
}
function assistantTextFromTraceEvents(events: TraceEvent[]): string | null {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (!event || !isAssistantTraceEvent(event)) continue;
const text = firstNonEmptyString(event.message, event.text, event.outputSummary);
if (text) return text;
}
return null;
}
function isAssistantTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
return label === "agentrun:assistant:message" || label === "assistant:message" || label === "assistant:completed" || event.type === "assistant" || event.type === "assistant_message";
}
function firstNonEmptyString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value !== "string") continue;
const text = value.trim();
if (text) return text;
}
return null;
}
/**
* Unified trace subscription (HWLAB #802). One entry point used by both
* `submitMessage` (new submit) and `hydrate` (re-attach to a running trace
@@ -140,6 +175,7 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
let lastEventCount = -1;
let lastStatus: string | null = null;
let lastUpdatedAt: string | null = null;
let terminalResultWithoutText: AgentChatResultResponse | null = null;
for (;;) {
if (signal.aborted) return;
@@ -157,8 +193,11 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
// a timeout" in normal operation).
onActivity();
if (isTerminalStatus(resultPolled.data.status)) {
onComplete(resultPolled.data);
return;
if (firstNonEmptyResultText(resultPolled.data)) {
onComplete(resultPolled.data);
return;
}
terminalResultWithoutText = resultPolled.data;
}
} else if (!resultPolled.ok && resultPolled.status >= 500) {
// server hiccup: keep polling, do NOT call onActivity — the
@@ -201,14 +240,19 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
}
if (isTerminalStatus(status)) {
const last = events.at(-1);
onComplete({
const traceTerminal = {
...tracePolled.data,
traceId,
events,
eventCount,
lastEventLabel: String(last?.label ?? last?.type ?? "")
} as AgentChatResultResponse);
return;
} as AgentChatResultResponse;
const terminal = terminalResultWithoutText ?? traceTerminal;
const merged = mergeTraceResults(terminal, snapshotToTraceSnapshot(traceTerminal));
if (firstNonEmptyResultText(merged)) {
onComplete(merged);
return;
}
}
} else if (!tracePolled.ok && tracePolled.status >= 500) {
// server hiccup: keep polling, no activity update.
@@ -219,6 +263,21 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
}
}
function snapshotToTraceSnapshot(result: AgentChatResultResponse): TraceSnapshot {
const events = Array.isArray((result as { events?: TraceEvent[] }).events) ? ((result as { events?: TraceEvent[] }).events ?? []) : [];
return {
traceId: result.traceId,
status: result.status,
sessionId: result.sessionId ?? null,
threadId: result.threadId ?? null,
events,
eventCount: Number(result.eventCount ?? events.length) || 0,
agentRun: result.agentRun,
lastEventLabel: typeof result.lastEventLabel === "string" ? result.lastEventLabel : String(events.at(-1)?.label ?? events.at(-1)?.type ?? ""),
updatedAt: typeof result.updatedAt === "string" ? result.updatedAt : new Date().toISOString()
};
}
export async function replayFullTrace(traceId: string, totalTimeoutMs = 15000): Promise<TraceSnapshot | null> {
const url = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}?projectId=prj_device_pod_workbench`;
const polled = await api.getAgentChatResult(url, totalTimeoutMs);
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { traceDisplayRows } from "../../../../tools/src/hwlab-cli/trace-renderer";
test("web trace rows use assistant content instead of terminal/result boilerplate", () => {
const events: Record<string, unknown>[] = [
{ seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-04T08:31:11.567Z" },
{ seq: 22, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "你好,真实回复", createdAt: "2026-06-04T08:31:27.644Z" },
{ seq: 24, label: "agentrun:backend:turn/completed", status: "running", message: "turn/completed", createdAt: "2026-06-04T08:31:27.715Z" },
{ seq: 25, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "你好,真实回复", createdAt: "2026-06-04T08:31:27.721Z" },
{ seq: 27, label: "agentrun:terminal:completed", type: "result", status: "completed", terminal: true, message: "AgentRun terminal status completed", createdAt: "2026-06-04T08:31:27.736Z" },
{ seq: 31, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, message: "AgentRun result is ready for HWLAB short-connection polling.", createdAt: "2026-06-04T08:31:27.765Z" }
];
const rows = traceDisplayRows({ startedAt: "2026-06-04T08:31:11.567Z" }, events);
const terminalRows = rows.filter((row) => row.terminal === true);
assert.equal(terminalRows.length, 1);
assert.equal(terminalRows[0]?.body, "你好,真实回复");
assert.doesNotMatch(rows.map((row) => row.body ?? "").join("\n"), /AgentRun terminal status completed|short-connection polling/u);
});
+13 -9
View File
@@ -59,11 +59,14 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.is-left-sidebar-collapsed .session-sidebar { display: none; }
.session-sidebar { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 10px; padding: 14px; border-right: 1px solid var(--line); background: #f9faf6; overflow: hidden; }
.session-sidebar-head, .panel-title-row, .right-sidebar-head, .gate-header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.session-sidebar-head, .panel-title-row, .right-sidebar-head, .gate-header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.panel-title-row { flex-wrap: wrap; }
.panel-title-row > div { min-width: 0; }
.session-sidebar h2, .panel-title-row h2, .right-sidebar h2 { margin: 0; font-size: 18px; }
.conversation-panel-hint { margin: 4px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; max-width: 60ch; }
.conversation-panel .panel-title-row { min-height: 32px; }
.conversation-panel .panel-title-row .eyebrow { margin-bottom: 2px; }
.conversation-panel .panel-title-row h2 { font-size: 16px; line-height: 1.1; }
.conversation-panel-hint { display: none; }
.conversation-empty { margin: 12px 0; padding: 16px; border: 1px dashed var(--line); border-radius: 6px; color: var(--muted); font-size: 13px; text-align: center; }
.session-icon-button, .icon-button, .right-sidebar-toggle, .session-delete-button, .command-button.secondary, #device-event-follow, #device-event-jump { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 8px 10px; font-weight: 700; }
.session-tabs { min-height: 0; display: grid; align-content: start; gap: 8px; overflow-x: hidden; overflow-y: auto; }
@@ -97,9 +100,6 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.code-agent-debug-summary { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; flex-wrap: wrap; }
.code-agent-debug-summary .code-agent-summary-label, .code-agent-debug-summary .code-agent-summary-capability { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.message-trace pre, .skill-preview, .help-content, #device-detail-body { white-space: pre-wrap; overflow-wrap: anywhere; }
.message-trace-toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #f7f8f4; }
.message-trace-toolbar-spacer { flex: 1; }
.message-trace-toolbar button { padding: 4px 10px; min-height: 28px; }
.message-trace-count, .message-trace-storage-key { color: var(--muted); font-size: 12px; }
.message-trace-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; padding: 8px 10px; }
.message-trace-row { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: #fff; }
@@ -108,7 +108,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.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-events { min-height: 0; max-height: 360px; overflow: auto; width: 100%; }
.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-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.message-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 6px 8px; border-top: 1px dashed var(--line); flex-wrap: wrap; }
@@ -177,8 +177,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.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-mode="all"], .message-trace[open] { width: 100%; }
.message-trace > summary { margin: 0; padding: 4px 8px; background: #f7f8f4; cursor: pointer; }
.message-trace-events { max-height: min(520px, 54dvh); overflow: auto; display: grid; gap: 8px; width: 100%; }
.message-trace > summary, .message-trace-summary { margin: 0; padding: 4px 8px; background: #f7f8f4; cursor: pointer; }
.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%; }
.code-agent-summary-debug { flex: 0 0 auto; align-self: center; }
.debug-dialog-trigger { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 4px 10px; font-size: 12px; font-weight: 700; cursor: pointer; }
@@ -272,8 +274,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.session-sidebar-resize, .right-sidebar-resize { display: none; }
.center-workspace { grid-column: 2; grid-row: 1; }
.right-sidebar { grid-column: 2; grid-row: 2; border-left: 0; border-top: 1px solid var(--line); }
.workbench-shell.is-left-sidebar-collapsed .workspace-body, .workspace-body.is-route-without-sidebars { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.workbench-shell.is-left-sidebar-collapsed .workspace-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(260px, 36vh); }
.workbench-shell.is-left-sidebar-collapsed.is-right-sidebar-collapsed .workspace-body, .workspace-body.is-route-without-sidebars { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.workbench-shell.is-left-sidebar-collapsed .center-workspace, .workspace-body.is-route-without-sidebars .center-workspace { grid-column: 1; grid-row: 1; }
.workbench-shell.is-left-sidebar-collapsed .right-sidebar { grid-column: 1; grid-row: 2; }
}
@media (max-width: 720px) {