fix(v02): unify web trace final response
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user