fix: show complete readable code agent traces
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user