diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 8b2e2de7..eda6bca6 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -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/` 自动或手动回放 +完整 trace 后再标记为“显示全部”。若完整 trace 已过期或后端缺失,也必须显式展示 +缺失状态,而不是把已载入窗口说成全量。 ## Lightweight Checks diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 8bb344d8..468d8f95 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -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= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 +Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:前端首行用中性 `tool gateway.shell status= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。后端 capability 仍可记录 `hardware.invoke.shell` 路由事实,但 Cloud Web 源码不得把它暴露成可点击或可误解的直接硬件写入口。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 ## 短连接 result 轮询 diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index e440dcb1..ca48e064 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -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 }); diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index fa596597..1c642708 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -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; diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index fc5efea2..bc0fc13a 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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 } }; diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 4b4b2905..5e3ada09 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -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 }) { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index dd203112..30133be9 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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/); diff --git a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs index 69b0af60..a93cf2ed 100644 --- a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs +++ b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs @@ -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 ", + 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);