fix: show complete readable code agent traces

This commit is contained in:
lyon
2026-05-25 17:40:07 +08:00
parent c25e380afc
commit c3b00408d9
8 changed files with 341 additions and 76 deletions
+14 -2
View File
@@ -114,8 +114,20 @@ Code Agent trace 在消息卡片内使用独立滚动容器展示。SSE、轮询
conversation list 和按 `messageId/traceId` 记录的 trace list
`scrollTop/scrollLeft``#conversation-list` 需要绑定 scroll listener 持续记录用户
位置,并在 DOM 替换后立即恢复一次、下一帧再恢复一次,避免布局重算或 scroll
anchoring 把列表拉回顶部。完整 trace 可通过复制/下载获取,页面内默认保持“显示全部 +
内部滚动”的单一模式。
anchoring 把列表拉回顶部。
Trace 展示遵循 UniDesk commander loop 的低噪声完整可读事件线原则:原始 trace
JSON 仍是复制/下载和追责的完整来源;页面内默认展示每个有意义的 agent message、
command/tool completed、stderr/error、turn/status 和 gateway JSON-RPC 结果。token
delta、assistant chunk、command output chunk、reasoning delta 等噪声可以聚合或隐藏,
但聚合不得吞掉中间的可读 message/tool call。命令和工具行必须至少保留状态、exit
code、耗时、输出体量、关键 operation/evidence id 和可读摘要。
“显示全部”只能用于已经覆盖完整 trace 的可读事件线,不能把 result polling 返回的
head-tail 压缩窗口或 9 行摘要冒充全量。若当前只拿到压缩窗口,界面必须明确显示
“压缩窗口/待回放完整 trace”,并通过 `/v1/agent/chat/trace/<traceId>` 自动或手动回放
完整 trace 后再标记为“显示全部”。若完整 trace 已过期或后端缺失,也必须显式展示
缺失状态,而不是把已载入窗口说成全量。
## Lightweight Checks
+1 -1
View File
@@ -102,7 +102,7 @@ Workbench 会把“Gateway 命令超时”控件的毫秒值随 `/v1/agent/chat`
调大 timeout 不能替代正确的长任务控制语义。Gateway poll loop 必须支持后台 in-flight 执行,长 Keil/UV4 命令运行期间仍能处理短 `job-status`、state/log 读取和健康探测;如果 trace 出现 `shellExecuted=false` 的 dispatch timeout,优先检查 gateway 是否队头阻塞或离线,而不是把所有 wrapper 调用改成长等待。
Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:首行展示 `tool hardware.invoke.shell status=<status> op=<operationId> exit=<exitCode> s=<duration>`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。
Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:前端首行用中性 `tool gateway.shell status=<status> op=<operationId> exit=<exitCode> s=<duration>`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。后端 capability 仍可记录 `hardware.invoke.shell` 路由事实,但 Cloud Web 源码不得把它暴露成可点击或可误解的直接硬件写入口。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。
## 短连接 result 轮询
@@ -1887,7 +1887,7 @@ test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardw
assert.match(turn.args.prompt, /-EncodedCommand/u);
assert.match(turn.args.prompt, /Windows filesystem inventory/u);
assert.match(turn.args.prompt, /Select-Object -First/u);
assert.match(turn.args.prompt, /ConvertTo-Json -Compress/u);
assert.match(turn.args.prompt, /ConvertTo-HwlabJson/u);
} finally {
await rm(fakeCodex.root, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
+6 -6
View File
@@ -91,7 +91,7 @@ test("source/default workbench report cannot claim DEV-LIVE and documents the co
assert.equal(report.checks.find((check) => check.id === "code-agent-long-timeout-contract")?.status, "pass");
const traceDisclosure = report.checks.find((check) => check.id === "code-agent-trace-replay-disclosure");
assert.equal(traceDisclosure?.status, "pass");
assert.deepEqual(traceDisclosure?.evidence, ["显示最近 14 / 总计 N", "展开全部", "复制 JSON", "下载 trace", "internal scroll for full trace"]);
assert.deepEqual(traceDisclosure?.evidence, ["显示全部可读事件 / 压缩窗口", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"]);
});
test("Code Agent browser failure classifier emits Chinese timeout/provider/browser categories with traceId", () => {
@@ -742,7 +742,7 @@ test("local external network fixture renders GitHub blocker with trace and witho
assert.equal(blockerCheck.observations.ui.failedMessageHasTrace, true);
assert.equal(blockerCheck.observations.ui.retryInputPreserved, true);
assert.equal(blockerCheck.observations.ui.retryInputValue, "访问一下github看看");
assert.match(blockerCheck.observations.ui.traceText, /network:started/u);
assert.match(blockerCheck.observations.ui.traceText, /network started/u);
assert.match(blockerCheck.observations.ui.traceText, /tool:external\.network\.check:blocked/u);
assert.equal(blockerCheck.observations.ui.noMainAttributionNoise, true);
assert.equal(blockerCheck.observations.prompt.response.error.code, "external_network_blocked");
@@ -876,8 +876,8 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.equal(desktopDefault.wiring.horizontalScroll.panelScrollWidth <= desktopDefault.wiring.horizontalScroll.panelClientWidth + 2, true);
assert.equal(desktopDefault.noHorizontalOverflow.right, true);
assert.equal(desktopDefault.liveBuildLayout.overlayPositioned, true);
assert.equal(desktopDefault.liveBuildLayout.popoverVisible, true);
assert.equal(desktopDefault.liveBuildLayout.popoverViewportContained, true);
assert.equal(desktopDefault.liveBuildLayout.dialogVisible, true);
assert.equal(desktopDefault.liveBuildLayout.dialogViewportContained, true);
assert.equal(desktopDefault.liveBuildLayout.stableGeometry, true);
assert.equal(desktopDefault.liveBuildLayout.closedByButton, true);
assert.equal(Object.hasOwn(desktopDefault.boxes.shell, "text"), false);
@@ -901,8 +901,8 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou
assert.equal(mobileDefault.wiring.metadataInDetails, true);
assert.equal(mobileDefault.noHorizontalOverflow.right, true);
assert.equal(mobileDefault.liveBuildLayout.overlayPositioned, true);
assert.equal(mobileDefault.liveBuildLayout.popoverVisible, true);
assert.equal(mobileDefault.liveBuildLayout.popoverViewportContained, true);
assert.equal(mobileDefault.liveBuildLayout.dialogVisible, true);
assert.equal(mobileDefault.liveBuildLayout.dialogViewportContained, true);
assert.equal(mobileDefault.liveBuildLayout.stableGeometry, true);
const leftCollapse = report.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.observations;
+12 -3
View File
@@ -628,7 +628,7 @@ function runStaticSmoke() {
addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels disclose full trace access and preserve open/scroll state while live trace updates.", {
blocker: "observability_blocker",
evidence: ["显示全部 N / 原始 N", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"]
evidence: ["显示全部可读事件 / 压缩窗口", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"]
});
addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider/stdio blockers without exposing credential internals and only long-lived Codex stdio replies can become full Code Agent completion.", {
@@ -2651,7 +2651,10 @@ function hasCodeAgentTraceReplayDisclosure({ app, styles }) {
const renderConversationBody = functionBody(app, "renderConversation");
return (
/function\s+messageTraceCountText\s*\(/u.test(app) &&
/显示全部\s+\$\{displayTotal\}\s+\/\s+原始\s+\$\{rawTotal\}/u.test(app) &&
/显示全部可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) &&
/压缩窗口\s+\$\{readableTotal\}\s+条可读事件\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) &&
/function\s+maybeReplayFullTraceForMessage\s*\(/u.test(app) &&
/function\s+replayFullTrace\s*\(/u.test(app) &&
/function\s+renderTraceEventList\s*\(/u.test(app) &&
/messageTraceToolbar\(message,\s*trace,\s*events,\s*rows\)/u.test(tracePanelBody) &&
/list\.dataset\.traceMode\s*=\s*"all"/u.test(tracePanelBody) &&
@@ -4407,7 +4410,7 @@ async function inspectJourneyUi(page) {
actions,
eventCount: events.length,
traceMode: panel.querySelector(".message-trace-events")?.dataset.traceMode ?? "",
allTraceVisible: /显示全部\s+\d+\s*\/\s*原始\s+\d+/u.test(countText),
allTraceVisible: /显示全部可读事件\s+\d+\s*\/\s*已载入原始\s+\d+\s*\/\s*后端原始\s+\d+/u.test(countText),
copyJsonVisible: actions.includes("复制 JSON"),
downloadTraceVisible: actions.includes("下载 trace"),
fixedAllMode: panel.querySelector(".message-trace-events")?.dataset.traceMode === "all"
@@ -5014,6 +5017,9 @@ async function runLocalAgentFixtureSmoke({
...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}),
timeouts: {
...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}),
codeAgentSubmitTimeoutMsMinMs: 1,
codeAgentSubmitTimeoutMs: timeoutMs,
codeAgentTimeoutMsMinMs: 1,
codeAgentTimeoutMs: timeoutMs
}
};
@@ -5323,6 +5329,9 @@ async function inspectLocalAgentPendingViewport(browser, url, { width, height, r
...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}),
timeouts: {
...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}),
codeAgentSubmitTimeoutMsMinMs: 1,
codeAgentSubmitTimeoutMs: timeoutMs,
codeAgentTimeoutMsMinMs: 1,
codeAgentTimeoutMs: timeoutMs
}
};
+188 -60
View File
@@ -26,10 +26,11 @@ const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1";
const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1";
const TRACE_STREAM_FALLBACK_MS = 2500;
const TRACE_POLL_INTERVAL_MS = 1000;
const FULL_TRACE_REPLAY_TIMEOUT_MS = 15000;
const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 });
let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs();
let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs();
const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: 5000, max: 120000 });
const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: configuredTimeoutMinMs("codeAgentSubmitTimeoutMs", 5000), max: 120000 });
const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 });
const rpcReadMethods = Object.freeze([
"system.health",
@@ -187,6 +188,7 @@ const state = {
conversationScrollPosition: { top: 0, left: 0 },
traceScrollPositions: new Map(),
traceScrollUserActiveUntil: new Map(),
fullTraceReplayInFlight: new Set(),
canceledTraces: new Set(),
currentRequest: null,
sessionStatus: null,
@@ -260,6 +262,13 @@ function resolveTimeoutMs(name, fallback, { min, max }) {
return Math.min(Math.max(Math.trunc(configured), min), max);
}
function configuredTimeoutMinMs(name, fallback) {
const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {};
const configured = Number(config?.[`${name}MinMs`]);
if (!Number.isFinite(configured)) return fallback;
return Math.max(1, Math.min(Math.trunc(configured), fallback));
}
function clampTimeoutMs(value, { min, max }) {
if (value === null || value === undefined) return null;
if (typeof value === "string" && value.trim() === "") return null;
@@ -269,14 +278,15 @@ function clampTimeoutMs(value, { min, max }) {
}
function resolveUserCodeAgentTimeoutMs() {
const min = configuredTimeoutMinMs("codeAgentTimeoutMs", 30000);
let stored = null;
try {
stored = window.localStorage?.getItem(CODE_AGENT_TIMEOUT_STORAGE_KEY) ?? null;
} catch {
stored = null;
}
return clampTimeoutMs(stored, { min: 30000, max: 1200000 })
?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 30000, max: 1200000 });
return clampTimeoutMs(stored, { min, max: 1200000 })
?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min, max: 1200000 });
}
function resolveUserGatewayShellTimeoutMs() {
@@ -805,6 +815,7 @@ function initCommandBar() {
state.conversationScrollPosition = { top: 0, left: 0 };
state.traceScrollPositions.clear();
state.traceScrollUserActiveUntil.clear();
state.fullTraceReplayInFlight.clear();
state.chatMessages = [];
state.conversationId = null;
state.sessionId = null;
@@ -961,6 +972,7 @@ async function submitAgentMessage(value, options = {}) {
: undefined),
availability: result.availability
};
maybeReplayFullTraceForMessage(state.chatMessages[index]);
if (result.availability) {
state.codeAgentAvailability = result.availability;
}
@@ -1418,6 +1430,7 @@ function runnerTraceFromSnapshot(snapshot, previous = null) {
eventCount: Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length,
eventsCompacted: snapshot.eventsCompacted === true,
eventWindow: snapshot.eventWindow ?? previous?.eventWindow,
fullTraceLoaded: snapshot.fullTraceLoaded === true || (snapshot.eventsCompacted !== true && Number.isInteger(snapshot.eventCount) && snapshot.eventCount === snapshot.events.length),
elapsedMs: snapshot.elapsedMs ?? previous?.elapsedMs,
waitingFor: snapshot.waitingFor ?? previous?.waitingFor,
events: snapshot.events,
@@ -3948,16 +3961,16 @@ async function replayAgentTrace(messageId) {
const message = state.chatMessages[index];
if (!message.traceId) return;
const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, {
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS),
timeoutName: "Code Agent trace replay"
});
if (response.ok) {
const runnerTrace = runnerTraceFromSnapshot(response.data, message.runnerTrace);
const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, message.runnerTrace);
state.chatMessages[index] = {
...message,
runnerTrace,
threadId: runnerTrace.threadId ?? message.threadId,
traceReplayStatus: `已回放 ${runnerTrace.events?.length ?? 0}真实 trace event${lastTraceEventLabel(runnerTrace)}`,
traceReplayStatus: `完整 trace 已回放${runnerTrace.events?.length ?? 0}原始 event${lastTraceEventLabel(runnerTrace)}`,
updatedAt: response.data?.updatedAt ?? new Date().toISOString()
};
} else {
@@ -3972,6 +3985,49 @@ async function replayAgentTrace(messageId) {
renderRecords(state.liveSurface);
}
function maybeReplayFullTraceForMessage(message) {
if (!message || !message.traceId || message.runnerTrace?.eventsCompacted !== true) return;
if (state.fullTraceReplayInFlight.has(message.traceId)) return;
replayFullTrace(message.id, { quiet: true });
}
async function replayFullTrace(messageId, options = {}) {
const index = state.chatMessages.findIndex((message) => message.id === messageId);
if (index < 0) return false;
const message = state.chatMessages[index];
if (!message.traceId) return false;
if (state.fullTraceReplayInFlight.has(message.traceId)) return false;
state.fullTraceReplayInFlight.add(message.traceId);
try {
const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, {
timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS),
timeoutName: "Code Agent full trace replay"
});
if (!response.ok) return false;
const currentIndex = state.chatMessages.findIndex((item) => item.id === messageId);
if (currentIndex < 0) return false;
const current = state.chatMessages[currentIndex];
const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, current.runnerTrace);
state.chatMessages[currentIndex] = {
...current,
runnerTrace,
threadId: runnerTrace.threadId ?? current.threadId,
traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event${lastTraceEventLabel(runnerTrace)}`,
updatedAt: response.data?.updatedAt ?? new Date().toISOString()
};
if (options.quiet !== true) {
renderCodeAgentSummary();
renderConversation();
renderRecords(state.liveSurface);
} else {
renderConversation();
}
return true;
} finally {
state.fullTraceReplayInFlight.delete(message.traceId);
}
}
function lastTraceEventLabel(runnerTrace) {
const event = runnerTrace?.lastEvent ?? (Array.isArray(runnerTrace?.events) ? runnerTrace.events.at(-1) : null);
if (!event) return "lastEvent=none";
@@ -4247,15 +4303,18 @@ function messageTraceToolbar(message, trace, events, rows) {
const toolbar = document.createElement("div");
toolbar.className = "message-trace-toolbar";
const rawTotal = Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length;
const count = textSpan(messageTraceCountText(rawTotal, rows.length), "message-trace-count");
const count = textSpan(messageTraceCountText(trace, rawTotal, events.length, rows.length), "message-trace-count");
toolbar.append(count);
toolbar.append(traceActionButton("复制 JSON", () => copyTextToClipboard(messageTraceJson(message, trace, events)), "复制完整 trace JSON"));
toolbar.append(traceActionButton("下载 trace", () => downloadTraceJson(message, trace, events), "下载完整 trace JSON 文件"));
return toolbar;
}
function messageTraceCountText(rawTotal, displayTotal) {
return `显示全部 ${displayTotal} / 原始 ${rawTotal}`;
function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) {
if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) {
return `压缩窗口 ${readableTotal} 条可读事件 / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`;
}
return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`;
}
function renderTraceEventList(list, rows) {
@@ -4283,6 +4342,7 @@ function installWorkbenchTestHooks() {
seedTraceMessage,
appendTraceEvents,
traceScrollMetrics,
tracePanelText,
setTraceScrollTop,
setConversationScrollTop
};
@@ -4296,7 +4356,9 @@ function seedTraceMessage(options = {}) {
const traceId = options.traceId ?? "trc_scroll_contract";
const messageId = options.messageId ?? "msg_scroll_contract";
const count = Math.max(1, Number(options.count ?? 80));
const events = Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1));
const events = Array.isArray(options.events)
? options.events.map((event, index) => normalizeTestTraceEvent(traceId, event, index + 1))
: Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1));
state.chatMessages = [{
id: messageId,
role: "agent",
@@ -4307,14 +4369,29 @@ function seedTraceMessage(options = {}) {
runnerTrace: {
traceId,
events,
eventCount: events.length,
lastEvent: events.at(-1),
eventCount: Number.isInteger(options.eventCount) ? options.eventCount : events.length,
eventsCompacted: options.eventsCompacted === true,
eventWindow: options.eventWindow ?? null,
fullTraceLoaded: options.fullTraceLoaded === true,
lastEvent: options.lastEvent ?? events.at(-1),
waitingFor: "turn/completed"
}
}];
renderConversation();
}
function normalizeTestTraceEvent(traceId, event, seq) {
return {
traceId,
seq,
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
...event,
traceId: event?.traceId ?? traceId,
seq: event?.seq ?? seq,
createdAt: event?.createdAt ?? new Date(1779690000000 + seq * 1000).toISOString()
};
}
function appendTraceEvents(count = 1) {
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
if (!message) return;
@@ -4353,6 +4430,15 @@ function traceScrollMetrics() {
};
}
function tracePanelText() {
const conversation = el.conversationList;
return {
count: conversation.querySelector(".message-trace-count")?.textContent ?? "",
rows: [...conversation.querySelectorAll(".message-trace-row")].map((row) => row.textContent ?? ""),
bodies: [...conversation.querySelectorAll(".message-trace-body")].map((body) => body.textContent ?? "")
};
}
function setTraceScrollTop(top, { user = true } = {}) {
const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]");
if (!list) return traceScrollMetrics();
@@ -4381,8 +4467,8 @@ function traceDisplayRows(trace, events) {
};
const flushToolOutputRun = () => {
if (toolOutputRun.length === 0) return;
const row = traceToolOutputSummaryRow(trace, toolOutputRun);
if (row) rows.push(row);
const outputRows = traceToolOutputSummaryRows(trace, toolOutputRun);
rows.push(...outputRows);
toolOutputRun = [];
};
for (const event of events) {
@@ -4407,43 +4493,53 @@ function traceDisplayRows(trace, events) {
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean);
}
function traceToolOutputSummaryRow(trace, events) {
function traceToolOutputSummaryRows(trace, events) {
const visibleEvents = events.filter(Boolean);
if (visibleEvents.length === 0) return null;
if (visibleEvents.length === 0) return [];
const first = visibleEvents[0];
const last = visibleEvents.at(-1);
const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join("");
const parsed = parseGatewayJsonRpcOutput(combined);
const parsedItems = parseGatewayJsonRpcOutputs(combined);
if (parsedItems.length > 0) {
return parsedItems.map((parsed, index) => {
const { payload, result, dispatch } = parsed;
const event = visibleEvents[Math.min(index, visibleEvents.length - 1)] ?? first;
const clock = traceClock(event.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, event));
const chunkCount = index === 0 ? visibleEvents.length : 0;
const suffix = chunkCount > 0 ? ` chunks=${chunkCount}` : "";
const status = result.status ?? dispatch.dispatchStatus ?? "unknown";
const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null;
const ok = status === "succeeded" || status === "completed" || exitCode === 0;
const header = [
`${clock} total=${total}`,
ok ? "ok" : status === "timed_out" ? "timeout" : "fail",
"tool gateway.shell",
`status=${status}`,
result.operationId ? `op=${result.operationId}` : "op=null",
exitCode !== null ? `exit=${exitCode}` : null,
typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null,
suffix.trim() || null
].filter(Boolean).join(" ");
return {
seq: event.seq ?? null,
tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked",
header,
body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined })
};
});
}
const clock = traceClock(first.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, first));
if (parsed) {
const { payload, result, dispatch } = parsed;
const status = result.status ?? dispatch.dispatchStatus ?? "unknown";
const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null;
const ok = status === "succeeded" || status === "completed" || exitCode === 0;
const header = [
`${clock} total=${total}`,
ok ? "ok" : status === "timed_out" ? "timeout" : "fail",
"tool hardware.invoke.shell",
`status=${status}`,
result.operationId ? `op=${result.operationId}` : "op=null",
exitCode !== null ? `exit=${exitCode}` : null,
typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null,
`chunks=${visibleEvents.length}`
].filter(Boolean).join(" ");
return {
seq: first.seq ?? null,
tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked",
header,
body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined })
};
}
return {
return [{
seq: first.seq ?? null,
tone: "source",
header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`,
body: compactTraceTextTail(cleanTraceOutputText(combined), 1600)
};
}];
}
function traceToolOutputSummaryRow(trace, events) {
return traceToolOutputSummaryRows(trace, events)[0] ?? null;
}
function traceNoiseSummaryRow(trace, events) {
@@ -4454,7 +4550,7 @@ function traceNoiseSummaryRow(trace, events) {
const total = formatTraceDuration(traceRelativeMs(trace, last));
const assistantChunks = visibleEvents.filter((event) => isAssistantChunkTraceEvent(event));
const label = assistantChunks.length === visibleEvents.length
? `assistant stream x${visibleEvents.length}`
? `assistant message x${visibleEvents.length}`
: `trace noise x${visibleEvents.length}`;
return {
seq: last.seq ?? null,
@@ -4467,15 +4563,14 @@ function traceNoiseSummaryRow(trace, events) {
function traceNoiseSummaryBody(events, assistantChunks) {
if (assistantChunks.length > 0) {
const text = assistantChunks
.map((event) => cleanTraceText(event.chunk))
.join("")
.trim();
.map((event) => String(event.chunk ?? ""))
.join("");
const message = cleanTraceText(text);
const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null;
const recent = text ? compactTraceTextTail(text, 900) : null;
return [
`compressed=${assistantChunks.length} assistant chunks`,
waitingFor,
recent ? `recent=${recent}` : null
message ? `message=${message}` : null
].filter(Boolean).join("\n");
}
const labels = new Map();
@@ -4575,19 +4670,56 @@ function rawTraceOutputText(event) {
return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? "");
}
function parseGatewayJsonRpcOutputs(text) {
const payloads = parseJsonObjects(text);
return payloads
.filter((payload) => payload && payload.jsonrpc === "2.0" && payload.result && typeof payload.result === "object")
.map((payload) => {
const result = payload.result;
const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {};
if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null;
return { payload, result, dispatch };
})
.filter(Boolean);
}
function parseGatewayJsonRpcOutput(text) {
const payload = parseFirstJsonObject(text);
if (!payload || payload.jsonrpc !== "2.0" || !payload.result || typeof payload.result !== "object") return null;
const result = payload.result;
const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {};
if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null;
return { payload, result, dispatch };
return parseGatewayJsonRpcOutputs(text)[0] ?? null;
}
function parseJsonObjects(text) {
const source = String(text ?? "");
const values = [];
let searchFrom = 0;
while (searchFrom < source.length) {
const start = source.indexOf("{", searchFrom);
if (start < 0) break;
const end = jsonObjectEnd(source, start);
if (end < 0) break;
try {
values.push(JSON.parse(source.slice(start, end + 1)));
searchFrom = end + 1;
} catch {
searchFrom = start + 1;
}
}
return values;
}
function parseFirstJsonObject(text) {
const source = String(text ?? "");
const start = source.indexOf("{");
if (start < 0) return null;
const end = jsonObjectEnd(source, start);
if (end < 0) return null;
try {
return JSON.parse(source.slice(start, end + 1));
} catch {
return null;
}
}
function jsonObjectEnd(source, start) {
let depth = 0;
let inString = false;
let escaped = false;
@@ -4610,14 +4742,10 @@ function parseFirstJsonObject(text) {
if (char === "{") depth += 1;
if (char === "}") depth -= 1;
if (depth === 0) {
try {
return JSON.parse(source.slice(start, index + 1));
} catch {
return null;
}
return index;
}
}
return null;
return -1;
}
function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) {
+13 -3
View File
@@ -881,9 +881,15 @@ assert.match(app, /function refreshTraceAfterResultPollError/);
assert.match(app, /function messageTracePanel/);
assert.match(app, /function messageTraceToolbar/);
assert.match(app, /function messageTraceCountText/);
assert.match(app, /显示全部\s+\$\{displayTotal\}\s+\/\s+原始\s+\$\{rawTotal\}/);
assert.match(app, /显示全部可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/);
assert.match(app, /压缩窗口\s+\$\{readableTotal\}\s+条可读事件\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/);
assert.match(app, /FULL_TRACE_REPLAY_TIMEOUT_MS\s*=\s*15000/);
assert.match(app, /fullTraceReplayInFlight:\s*new Set\(\)/);
assert.match(app, /function maybeReplayFullTraceForMessage/);
assert.match(app, /function replayFullTrace/);
assert.match(app, /eventsCompacted:\s*snapshot\.eventsCompacted === true/);
assert.match(app, /eventWindow:\s*snapshot\.eventWindow/);
assert.match(app, /fullTraceLoaded:/);
assert.match(app, /eventCount:\s*Number\.isInteger\(trace\?\.eventCount\)/);
assert.match(app, /traceDetailsOpen:\s*new Map\(\)/);
assert.match(app, /conversationRenderVersion:\s*0/);
@@ -896,6 +902,7 @@ assert.match(app, /state\.conversationRenderVersion \+= 1/);
assert.match(app, /state\.conversationScrollPosition\s*=\s*\{\s*top:\s*0,\s*left:\s*0\s*\}/);
assert.match(app, /state\.traceScrollPositions\.clear\(\)/);
assert.match(app, /state\.traceScrollUserActiveUntil\.clear\(\)/);
assert.match(app, /state\.fullTraceReplayInFlight\.clear\(\)/);
assert.match(app, /function messageTraceUiKey/);
assert.match(app, /function defaultTraceDetailsOpen/);
assert.match(app, /initConversationScrollMemory\(\)/);
@@ -933,13 +940,16 @@ assert.match(app, /function installWorkbenchTestHooks/);
assert.match(app, /hwlab-test-hooks/);
assert.match(app, /function traceDisplayRows/);
assert.match(app, /function traceDisplayRow/);
assert.match(app, /function traceToolOutputSummaryRows/);
assert.match(app, /function traceToolOutputSummaryRow/);
assert.match(app, /function parseGatewayJsonRpcOutputs/);
assert.match(app, /function parseGatewayJsonRpcOutput/);
assert.match(app, /tool hardware\.invoke\.shell/);
assert.match(app, /tool gateway\.shell/);
assert.match(app, /function traceNoiseSummaryRow/);
assert.match(styles, /max-height:\s*min\(520px,\s*54dvh\)/);
assert.match(app, /assistant stream x\$\{visibleEvents\.length\}/);
assert.match(app, /assistant message x\$\{visibleEvents\.length\}/);
assert.match(app, /compressed=\$\{assistantChunks\.length\} assistant chunks/);
assert.match(app, /message=\$\{text\}/);
assert.match(app, /waiting first assistant token/);
assert.match(app, /function compactTraceTextTail/);
assert.match(app, /function isAssistantChunkTraceEvent/);
@@ -64,10 +64,116 @@ test("trace scroll stays user controlled while trace updates append", async () =
}
});
test("trace display full means complete readable timeline, not compacted result window", async () => {
const server = await startStaticServer(webRoot);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({
viewport: { width: 1366, height: 768 },
deviceScaleFactor: 1
});
await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 });
await login(page);
await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 });
await page.evaluate(() => {
const gatewayPayload = (id, operationId, status = "succeeded") => JSON.stringify({
jsonrpc: "2.0",
id,
result: {
accepted: true,
status,
operationId,
gatewaySessionId: "gws_test",
resourceId: "res_windows_host",
capabilityId: "cap_windows_cmd_exec",
dispatch: {
shellExecuted: true,
dispatchStatus: status,
exitCode: status === "succeeded" ? 0 : 1,
durationMs: 1200,
command: "powershell -EncodedCommand <redacted>",
stdout: `stdout for ${operationId}`,
stderr: ""
}
}
});
window.__hwlabWorkbenchTestHooks.seedTraceMessage({
eventsCompacted: true,
eventCount: 600,
events: [
{
label: "request:accepted-short-connection",
status: "started",
message: "accepted"
},
{
label: "assistant:chunk",
type: "assistant_message",
status: "chunk",
chunk: "first readable sentence "
},
{
label: "assistant:chunk",
type: "assistant_message",
status: "chunk",
chunk: "middle detail "
},
{
label: "assistant:chunk",
type: "assistant_message",
status: "chunk",
chunk: "final conclusion"
},
{
label: "item/commandExecution/outputDelta",
type: "tool_call",
status: "output_chunk",
outputSummary: `${gatewayPayload("req_1", "op_one")}${gatewayPayload("req_2", "op_two")}`
}
]
});
});
await page.waitForSelector(".message-trace-count", { timeout: 12000 });
const compacted = await tracePanelText(page);
assert.match(compacted.count, /压缩窗口/);
assert.doesNotMatch(compacted.count, /显示全部/);
assert.equal(compacted.rows.filter((row) => row.includes("tool gateway.shell")).length, 2, compacted.rows.join("\n---\n"));
assert.ok(compacted.rows.some((row) => row.includes("op=op_one")), compacted.rows.join("\n"));
assert.ok(compacted.rows.some((row) => row.includes("op=op_two")), compacted.rows.join("\n"));
assert.match(compacted.bodies.join("\n"), /first readable sentence middle detail final conclusion/);
await page.evaluate(() => {
const message = window.__hwlabWorkbenchTestHooks.tracePanelText();
const rows = message.rows.length;
window.__hwlabWorkbenchTestHooks.seedTraceMessage({
fullTraceLoaded: true,
events: Array.from({ length: rows }, (_, index) => ({
label: `full:readable:${index + 1}`,
status: "completed",
message: `full readable event ${index + 1}`
}))
});
});
const full = await tracePanelText(page);
assert.match(full.count, /显示全部可读事件/);
assert.doesNotMatch(full.count, /压缩窗口/);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
async function traceMetrics(page) {
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics());
}
async function tracePanelText(page) {
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.tracePanelText());
}
async function login(page) {
await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 });
const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false);