Merge pull request #609 from pikasTech/feat/v02-absorb-web-layout-trace

fix: absorb v02 web layout and trace rendering
This commit is contained in:
Lyon
2026-05-31 10:59:23 +08:00
committed by GitHub
8 changed files with 611 additions and 1317 deletions
+2 -476
View File
@@ -44,33 +44,6 @@ const codeAgentExternalNetworkPrompt = Object.freeze({
text: "访问一下github看看"
});
const codeAgentQuickPromptFixtures = Object.freeze([
{
id: "pwd",
label: "Codexpwd",
prompt: "交给 Codex 执行 pwd,列出当前工作目录。",
writable: false
},
{
id: "skills",
label: "Codex:列出 skill",
prompt: "交给 Codex 列出你能使用的所有 skill。",
writable: false
},
{
id: "device-pod-status",
label: "CodexDevice Pod 状态",
prompt: "交给 Codex 读取当前 Device Pod 的 status、profileHash、chip-id 和 UART1 tail 摘要,不执行下载、复位或写入。",
writable: false
},
{
id: "device-pod-events",
label: "Codex:事件流",
prompt: "交给 Codex 查看 Device Pod 最近事件流,并说明是否存在 blocker;只读,不连接旧模拟硬件拓扑。",
writable: false
}
]);
const readOnlyRpcMethods = Object.freeze([
"system.health",
"cloud.adapter.describe"
@@ -336,7 +309,6 @@ export async function runDevCloudWorkbenchSmoke(argv = []) {
if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args);
if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke();
if (args.mode === "session-continuity-fixture") return runDevCloudWorkbenchSessionContinuityFixtureSmoke();
if (args.mode === "quick-prompts-fixture") return runDevCloudWorkbenchQuickPromptsFixtureSmoke();
if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke();
return runStaticSmoke();
}
@@ -399,8 +371,6 @@ export function parseSmokeArgs(argv) {
args.mode = "local-agent-fixture";
} else if (arg === "--session-continuity-fixture") {
args.mode = "session-continuity-fixture";
} else if (arg === "--quick-prompts-fixture") {
args.mode = "quick-prompts-fixture";
} else if (arg === "--auth-fixture") {
args.mode = "auth-fixture";
} else if (arg === "--local-agent-timeout-fixture") {
@@ -532,11 +502,6 @@ function runStaticSmoke() {
evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"]
});
addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Codex examples and Device Pod read-only probes; no quick prompt auto-sends.", {
blocker: "safety_blocker",
evidence: codeAgentQuickPromptFixtures.flatMap((prompt) => [prompt.id, prompt.label, prompt.prompt])
});
addCheck(checks, blockers, "code-agent-long-timeout-contract", hasCodeAgentLongTimeoutContract(files), "Code Agent chat uses a dedicated long timeout and does not reuse the old 4500ms API timeout.", {
blocker: "runtime_blocker",
evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"]
@@ -1540,7 +1505,6 @@ function baseReport({
"node scripts/dev-cloud-workbench-smoke.mjs --mobile",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --session-continuity-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --quick-prompts-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --layout --url http://74.48.78.17:16666/",
`node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ --report ${domOnlyReportPath}`,
@@ -1558,10 +1522,9 @@ function baseReport({
"node scripts/dev-cloud-workbench-smoke.mjs --mobile",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --session-continuity-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --quick-prompts-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture"
],
evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Session continuity fixture verifies same-browser conversation/session/thread reuse and retry request context at SOURCE level.", "Quick prompt fixture verifies fill-only behavior, explicit-send write semantics, copy boundary, and mobile containment.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."],
evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Session continuity fixture verifies same-browser conversation/session/thread reuse and retry request context at SOURCE level.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."],
summary: "Source, layout, mobile, and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey."
},
dryRun: {
@@ -2360,42 +2323,6 @@ function hasCodeAgentChatContract({ html, app }) {
);
}
function hasCodeAgentQuickPromptContract({ html, app, styles }) {
const labels = codeAgentQuickPromptFixtures.every((prompt) =>
html.includes(`data-agent-quick-prompt="${prompt.id}"`) &&
html.includes(prompt.label) &&
html.includes(prompt.prompt)
);
const writeButtonsRequireExplicitSend = codeAgentQuickPromptFixtures
.filter((prompt) => prompt.writable)
.every((prompt) => {
const tag = html.match(new RegExp(`<button\\b(?=[^>]*data-agent-quick-prompt=["']${escapeRegExp(prompt.id)}["'])[\\s\\S]*?<\\/button>`, "u"))?.[0] ?? "";
return (
/data-prompt-action=["']fill["']/u.test(tag) &&
/data-requires-explicit-send=["']true["']/u.test(tag) &&
/只填充输入框;必须再点击发送才确认写入请求/u.test(tag) &&
/我点击发送后才确认执行这次写入请求/u.test(tag)
);
});
const promptCopyUsesCodexRoute = codeAgentQuickPromptFixtures.every((prompt) =>
/交给 Codex/u.test(prompt.prompt) &&
!/PROD/iu.test(prompt.prompt)
);
return (
labels &&
writeButtonsRequireExplicitSend &&
promptCopyUsesCodexRoute &&
/data-agent-quick-prompt/u.test(app) &&
/function\s+fillAgentQuickPrompt\s*\(/u.test(app) &&
/el\.commandInput\.value = prompt/u.test(app) &&
!/data-agent-quick-prompt[\s\S]{0,360}(requestSubmit|submit|click\(\))/u.test(app) &&
/agent-quick-prompts/u.test(styles) &&
/flex-wrap:\s*wrap/u.test(styles) &&
/max-width:\s*100%/u.test(styles) &&
/@media\s*\(max-width:\s*520px\)[\s\S]*?\.agent-quick-prompts button\s*\{[\s\S]*?flex-basis:\s*100%/u.test(styles)
);
}
function hasCodeAgentReadinessVisibility({ html, app, liveStatus = "" }) {
const source = `${html}\n${app}`;
const userFacingBodies = [
@@ -4628,162 +4555,6 @@ export async function runDevCloudWorkbenchSessionContinuityFixtureSmoke(options
}
}
export async function runDevCloudWorkbenchQuickPromptsFixtureSmoke() {
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
const summary = `Code Agent quick prompt fixture smoke skipped because Playwright is unavailable: ${error.message}`;
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode: "quick-prompts-fixture-browser",
evidenceLevel: "SOURCE",
devLive: false,
summary,
checks: [
{
id: "quick-prompts-browser-dependency",
status: "skip",
summary: playwrightDependencySummary,
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
}
],
blockers: [
{
type: "environment_blocker",
scope: "quick-prompts-browser-dependency",
status: "open",
summary
}
],
safety: staticSafety(),
dependency: {
package: "playwright",
declaredIn: "package.json",
installCommand: playwrightDependencyCommand
}
};
}
const server = await startStaticWebServer({ quickPromptsFixture: true });
let browser;
try {
browser = await chromium.launch({ headless: true });
const desktop = await inspectQuickPromptsViewport(browser, server.url, {
width: 1366,
height: 768
});
const mobile = await inspectQuickPromptsViewport(browser, server.url, {
width: 390,
height: 844
});
const promptResults = [...desktop.promptResults, ...mobile.promptResults];
const geometryResults = [desktop.geometry, mobile.geometry];
const checks = [
{
id: "quick-prompts-fill-input",
status: promptResults.every((result) => result.inputMatches && result.focused) ? "pass" : "blocked",
summary: "Each Code Agent quick prompt fills and focuses the input without submitting the form.",
observations: promptResults.map(({ viewport, id, label, inputMatches, focused, inputValue }) => ({
viewport,
id,
label,
inputMatches,
focused,
inputValue
}))
},
{
id: "quick-prompts-write-no-autosend",
status: promptResults
.filter((result) => result.writable)
.every((result) => result.agentPostCountDelta === 0 && result.explicitSendRequired) ? "pass" : "blocked",
summary: "HWLAB API write quick prompts are fill-only and do not POST /v1/agent/chat until the user explicitly sends.",
observations: promptResults
.filter((result) => result.writable)
.map(({ viewport, id, label, agentPostCountDelta, explicitSendRequired }) => ({
viewport,
id,
label,
agentPostCountDelta,
explicitSendRequired
}))
},
{
id: "quick-prompts-copy-boundary",
status: promptResults.every((result) => result.copyBoundaryOk) ? "pass" : "blocked",
summary: "Quick prompt labels and filled text say Codex and avoid PROD claims.",
observations: promptResults.map(({ viewport, id, label, inputValue, copyBoundaryOk, forbiddenHits }) => ({
viewport,
id,
label,
inputValue,
copyBoundaryOk,
forbiddenHits
}))
},
{
id: "quick-prompts-mobile-layout",
status: geometryResults.every((geometry) => geometry.layoutOk) ? "pass" : "blocked",
summary: "Desktop and 390x844 quick prompt strip stays contained, visible, and clear of the input and send controls.",
observations: geometryResults
}
];
const blockers = checks
.filter((check) => check.status !== "pass")
.map((check) => ({
type: check.id === "quick-prompts-write-no-autosend" ? "safety_blocker" : "runtime_blocker",
scope: check.id,
status: "open",
summary: check.summary
}));
return {
status: blockers.length === 0 ? "pass" : "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "quick-prompts-fixture-browser",
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: "Local SOURCE fixture validates Code Agent quick prompt fill behavior, copy boundary, and mobile layout without sending hardware writes.",
checks,
blockers,
safety: {
...staticSafety(),
localFixtureOnly: true,
codeAgentPostSentByQuickPrompt: promptResults.some((result) => result.agentPostCountDelta > 0),
hardwareWriteApis: false,
statement: "Fixture clicks quick prompt buttons only; write examples require a separate user send click before /v1/agent/chat can receive the prompt."
}
};
} catch (error) {
return {
status: "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "quick-prompts-fixture-browser",
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: `Code Agent quick prompt fixture smoke failed: ${error.message}`,
checks: [],
blockers: [
{
type: "runtime_blocker",
scope: "quick-prompts-fixture-browser",
status: "open",
summary: error.message
}
],
safety: staticSafety()
};
} finally {
if (browser) await browser.close();
await server.close();
}
}
async function runLocalAgentFixtureSmoke({
mode,
responseDelayMs = 0,
@@ -5345,190 +5116,6 @@ export async function runDevCloudWorkbenchMobileSmoke() {
}
}
async function inspectQuickPromptsViewport(browser, url, viewport) {
const context = await browser.newContext({
viewport,
deviceScaleFactor: 1,
isMobile: viewport.width <= 520
});
const page = await context.newPage();
const agentPosts = [];
page.on("request", (request) => {
const requestUrl = new URL(request.url());
if (request.method() === "POST" && requestUrl.pathname === "/v1/agent/chat") {
agentPosts.push({
method: request.method(),
urlPath: requestUrl.pathname,
postData: request.postData() ?? ""
});
}
});
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 15000 });
await loginWithDefaultCredentials(page);
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
const promptResults = [];
for (const prompt of codeAgentQuickPromptFixtures) {
const before = agentPosts.length;
await page.locator(`[data-agent-quick-prompt="${prompt.id}"]`).click();
await page.waitForFunction(
({ expected }) => document.querySelector("#command-input")?.value === expected,
{ expected: prompt.prompt },
{ timeout: 4000 }
);
const state = await page.evaluate(({ id, expected }) => {
const button = document.querySelector(`[data-agent-quick-prompt="${id}"]`);
const input = document.querySelector("#command-input");
const text = `${button?.textContent ?? ""}\n${button?.getAttribute("title") ?? ""}\n${button?.dataset.promptText ?? ""}`;
const forbiddenPattern = /\bPROD\b/iu;
const forbiddenHits = [...new Set(text.match(forbiddenPattern) ?? [])];
return {
label: button?.textContent?.replace(/\s+/gu, " ").trim() ?? "",
title: button?.getAttribute("title") ?? "",
inputValue: input?.value ?? "",
inputMatches: input?.value === expected,
focused: document.activeElement === input,
explicitSendRequired: button?.dataset.requiresExplicitSend === "true",
copyBoundaryOk:
/Codex/u.test(text) &&
!forbiddenPattern.test(text) &&
(button?.dataset.requiresExplicitSend !== "true" || /只填充输入框|点击发送后才确认/u.test(text)),
forbiddenHits
};
}, { id: prompt.id, expected: prompt.prompt });
promptResults.push({
viewport,
id: prompt.id,
writable: prompt.writable,
agentPostCountDelta: agentPosts.length - before,
...state
});
}
const geometry = await inspectQuickPromptGeometry(page, viewport);
return {
viewport,
promptResults,
geometry,
agentPosts
};
} finally {
await context.close();
}
}
async function inspectQuickPromptGeometry(page, viewport) {
return page.evaluate((viewport) => {
const boxFor = (selector) => {
const element = document.querySelector(selector);
if (!element) return null;
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
selector,
display: style.display,
visibility: style.visibility,
overflowX: style.overflowX,
overflowY: style.overflowY,
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
width: box.width,
height: box.height,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight
};
};
const boxesOverlap = (a, b, padding = 0) => Boolean(
a && b &&
a.left < b.right - padding &&
a.right > b.left + padding &&
a.top < b.bottom - padding &&
a.bottom > b.top + padding
);
const strip = boxFor(".agent-quick-prompts");
const command = boxFor("#command-form");
const inputShell = boxFor(".input-shell");
const input = boxFor("#command-input");
const send = boxFor("#command-send");
const clear = boxFor("#command-clear");
const buttons = [...document.querySelectorAll("[data-agent-quick-prompt]")].map((button) => {
const box = button.getBoundingClientRect();
return {
id: button.dataset.agentQuickPrompt,
text: button.textContent?.replace(/\s+/gu, " ").trim() ?? "",
left: box.left,
top: box.top,
right: box.right,
bottom: box.bottom,
width: box.width,
height: box.height,
scrollWidth: button.scrollWidth,
clientWidth: button.clientWidth,
visible: box.width > 0 && box.height > 0 && getComputedStyle(button).visibility !== "hidden",
contained: button.scrollWidth <= button.clientWidth + 2,
insideCommand: Boolean(command && box.left >= command.left - 1 && box.right <= command.right + 1),
overlapsInput: boxesOverlap(
{ left: box.left, top: box.top, right: box.right, bottom: box.bottom },
inputShell,
-1
),
overlapsSend: boxesOverlap(
{ left: box.left, top: box.top, right: box.right, bottom: box.bottom },
send,
-1
)
};
});
const rootNoHorizontalOverflow =
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.clientWidth + 2;
const commandContained = Boolean(command) && command.scrollWidth <= command.clientWidth + 2;
const stripContained = Boolean(strip) && strip.scrollWidth <= strip.clientWidth + 2;
const commandControlsTop = Math.min(inputShell?.top ?? Infinity, send?.top ?? Infinity, clear?.top ?? Infinity);
const commandControlsBottom = Math.max(inputShell?.bottom ?? -Infinity, send?.bottom ?? -Infinity, clear?.bottom ?? -Infinity);
const stripAboveControls = Boolean(strip && inputShell && send && clear && strip.bottom <= commandControlsTop + 1);
const stripBelowControls = Boolean(strip && inputShell && send && clear && strip.top >= commandControlsBottom - 1);
const stripClearOfInput =
Boolean(strip && inputShell && send && clear && (stripAboveControls || stripBelowControls)) &&
!boxesOverlap(strip, inputShell, -1) &&
!boxesOverlap(strip, send, -1) &&
!boxesOverlap(strip, clear, -1);
const inputVisible =
Boolean(inputShell && input && send && clear) &&
inputShell.width >= Math.min(160, viewport.width - 160) &&
input.height >= 30 &&
send.width >= 40 &&
clear.width >= 40;
return {
viewport,
strip,
command,
inputShell,
input,
send,
clear,
buttons,
rootNoHorizontalOverflow,
commandContained,
stripContained,
stripPosition: stripAboveControls ? "above-controls" : stripBelowControls ? "below-controls" : "overlapping-controls",
stripClearOfInput,
inputVisible,
layoutOk:
rootNoHorizontalOverflow &&
commandContained &&
stripContained &&
stripClearOfInput &&
inputVisible &&
buttons.length === 4 &&
buttons.every((button) => button.visible && button.contained && button.insideCommand && !button.overlapsInput && !button.overlapsSend)
};
}, viewport);
}
function longSourceFixtureTraceEvents(traceId, count = 18) {
const labels = [
"source-fixture:session",
@@ -5768,9 +5355,6 @@ async function startStaticWebServer(options = {}) {
if (options.authFixture && await handleAuthFixtureApi({ request, response, url, authFixtureSessions })) {
return;
}
if (options.quickPromptsFixture && await handleQuickPromptsFixtureApi({ request, response, url })) {
return;
}
if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options, agentTraceStore })) {
return;
}
@@ -5917,63 +5501,6 @@ function authFixtureCookie(request) {
return "";
}
async function handleQuickPromptsFixtureApi({ request, response, url }) {
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
jsonResponse(response, 200, {
serviceId: "hwlab-cloud-api",
status: "degraded",
ready: false,
sourceKind: "SOURCE",
runtime: {
durable: false,
ready: false
},
agent: {
status: "blocked",
provider: "codex-stdio",
blocker: "fixture-readonly"
},
devicePod: {
status: "ok",
route: "/v1/device-pods",
sourceKind: "SOURCE"
}
});
return true;
}
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
jsonResponse(response, 200, liveBuildsFixturePayload());
return true;
}
if (request.method === "POST" && url.pathname === "/json-rpc") {
const body = await readJsonBody(request);
jsonResponse(response, 200, {
jsonrpc: "2.0",
id: body?.id ?? "req_quick_prompt_fixture",
result: localRpcFixtureResult(body?.method)
});
return true;
}
if (request.method === "POST" && url.pathname === "/v1/agent/chat") {
const body = await readJsonBody(request);
jsonResponse(response, 200, {
status: "completed",
sourceKind: "SOURCE",
provider: "source-fixture",
model: "quick-prompt-fixture",
backend: "local-static-fixture",
traceId: body?.traceId ?? "trc_quick_prompt_fixture",
conversationId: body?.conversationId ?? "cnv_quick_prompt_fixture",
messageId: "msg_quick_prompt_fixture",
reply: {
content: "quick prompt fixture response"
}
});
return true;
}
return false;
}
function handleDevicePodFixtureApi({ request, response, url }) {
if (request.method !== "GET" || !url.pathname.startsWith("/v1/device-pods")) return false;
const payload = buildDevicePodRestPayload(url.pathname, url.searchParams, {
@@ -7671,14 +7198,13 @@ function isBrowserExecutionEnvironmentError(error) {
export function printSmokeHelp() {
return {
status: "usage",
command: `node scripts/dev-cloud-workbench-smoke.mjs --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --session-continuity-fixture | --quick-prompts-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report ${domOnlyReportPath}] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report ${liveReportPath}]`,
command: `node scripts/dev-cloud-workbench-smoke.mjs --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --session-continuity-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report ${domOnlyReportPath}] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report ${liveReportPath}]`,
notes: [
"Default/source mode reads repository files and emits SOURCE-level evidence only; it never calls the live provider and must not claim DEV-LIVE.",
"--layout runs desktop and mobile browser geometry/hit-target checks for sidebar collapse/expand; with --url it remains layout-only live evidence and does not claim M3 acceptance.",
"--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.",
"--local-agent-fixture runs two local browser send/reply paths with a sanitized SOURCE fixture delayed beyond 4500ms and does not claim DEV-LIVE.",
"--session-continuity-fixture verifies same-browser conversation/session/thread reuse, retry context, and degraded missing providerTrace/thread copy without claiming DEV-LIVE.",
"--quick-prompts-fixture clicks Code Agent quick prompts locally and verifies write prompts fill only, without automatic send or hardware write.",
"--local-agent-timeout-fixture runs a local slow SOURCE fixture to verify bounded timeout UI classification and retry preservation without live acceptance.",
"--dom-only preserves runtime/web-asset identity preflight, inspects read-only deployed DOM/help wiring, and never posts /v1/agent/chat.",
"DEV-LIVE mode requires --live --confirm-dev-live, opens the deployed workbench in a browser, sends the simple Chinese prompt and 列出你能使用的所有skill, and retains only non-sensitive response fields."
+23 -6
View File
@@ -1382,8 +1382,10 @@ function patchTraceBodyElement(currentBody, nextBody) {
const position = elementScrollPosition(currentBody);
currentBody.className = nextBody.className;
currentBody.dataset.traceBodyId = nextBody.dataset.traceBodyId ?? "";
if (currentBody.textContent !== nextBody.textContent) {
currentBody.textContent = nextBody.textContent;
currentBody.dataset.traceBodyFormat = nextBody.dataset.traceBodyFormat ?? "text";
if (currentBody.dataset.traceBodyText !== nextBody.dataset.traceBodyText) {
currentBody.dataset.traceBodyText = nextBody.dataset.traceBodyText ?? "";
renderTraceBodyContent(currentBody, currentBody.dataset.traceBodyText, currentBody.dataset.traceBodyFormat);
}
restoreElementScrollPosition(currentBody, position);
rememberTraceBodyScrollPosition(currentBody);
@@ -1400,16 +1402,31 @@ function traceEventRowNode(row, index = 0) {
header.textContent = row.header;
item.append(header);
if (row.body) {
const body = document.createElement("pre");
body.className = "message-trace-body";
const body = document.createElement(row.bodyFormat === "markdown" ? "div" : "pre");
body.className = row.bodyFormat === "markdown" ? "message-trace-body message-trace-markdown message-copy-markdown" : "message-trace-body";
body.dataset.traceBodyId = rowId;
body.dataset.traceBodyFormat = row.bodyFormat === "markdown" ? "markdown" : "text";
body.dataset.traceBodyText = row.body;
installTraceBodyScrollListeners(body);
body.textContent = row.body;
renderTraceBodyContent(body, row.body, body.dataset.traceBodyFormat);
item.append(body);
}
return item;
}
function renderTraceBodyContent(body, text, format = "text") {
if (format === "markdown") {
try {
body.innerHTML = renderMessageMarkdown(text);
hardenRenderedMarkdown(body);
} catch (error) {
body.textContent = text || `Markdown 渲染失败:${error.message}`;
}
return;
}
body.textContent = text;
}
function installTraceBodyScrollListeners(body) {
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
body.addEventListener(eventName, () => markTraceBodyScrollIntent(body), { passive: true });
@@ -1427,5 +1444,5 @@ function traceRowId(row, index = 0) {
}
function traceRowKey(row) {
return `${row.header}\n${row.body ?? ""}\n${row.tone ?? ""}`;
return `${row.header}\n${row.body ?? ""}\n${row.bodyFormat ?? "text"}\n${row.tone ?? ""}`;
}
-16
View File
@@ -40,22 +40,6 @@ function initDevicePodPanel() {
});
}
function initQuickActions() {
for (const button of document.querySelectorAll("[data-agent-quick-prompt]")) {
button.addEventListener("click", () => {
fillAgentQuickPrompt(button);
});
}
}
function fillAgentQuickPrompt(button) {
const prompt = button.dataset.promptText ?? "";
if (!prompt) return;
el.commandInput.value = prompt;
el.commandInput.focus();
el.commandInput.setSelectionRange(prompt.length, prompt.length);
}
function initCommandBar() {
el.commandForm.addEventListener("submit", async (event) => {
event.preventDefault();
+555 -161
View File
@@ -9,6 +9,7 @@ function installWorkbenchTestHooks() {
traceScrollMetrics,
traceDomIdentity,
traceBodyDomIdentity,
traceRowTexts,
setTraceBodyScrollTop,
replaceTraceAssistantStream,
setTraceScrollTop,
@@ -101,7 +102,7 @@ function replaceTraceAssistantStream(stream = {}) {
text: String(stream.text ?? "assistant stream fixture")
}];
patchMessageTracePanel(message);
return traceBodyDomIdentity(".message-trace-events[data-trace-ui-key] li:last-child pre");
return traceBodyDomIdentity();
}
async function reconcileTraceResultFixture(result) {
@@ -182,6 +183,15 @@ function latestAgentMessageText() {
} : null;
}
function traceRowTexts() {
return [...el.conversationList.querySelectorAll(".message-trace-events[data-trace-ui-key] .message-trace-row")]
.map((row, index) => ({
index,
rowId: row.dataset.traceRowId ?? null,
text: row.textContent ?? ""
}));
}
function testTraceEvent(traceId, seq) {
return {
traceId,
@@ -225,7 +235,7 @@ function traceDomIdentity() {
};
}
function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child pre") {
function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child .message-trace-body") {
const body = el.conversationList.querySelector(selector);
const row = body?.closest(".message-trace-row") ?? null;
if (body && !body.__hwlabTraceIdentity) body.__hwlabTraceIdentity = `body-${Date.now()}-${Math.random()}`;
@@ -275,173 +285,565 @@ function setConversationScrollTop(top, { user = true } = {}) {
function traceDisplayRows(trace, events) {
const rows = [];
const assistantRows = traceAssistantStreamRows(trace);
let noisyRun = [];
let toolOutputRun = [];
const flushNoisyRun = () => {
if (noisyRun.length === 0) return;
const row = traceNoiseSummaryRow(trace, noisyRun);
if (row) rows.push(row);
noisyRun = [];
};
const flushToolOutputRun = () => {
if (toolOutputRun.length === 0) return;
const outputRows = traceToolOutputSummaryRows(trace, toolOutputRun);
rows.push(...outputRows);
toolOutputRun = [];
};
const commandGroups = traceCommandExecutionGroups(events);
const requestEvent = tracePrimaryRequestEvent(events);
const hasTerminalAssistant = events.some((event) => isTerminalAssistantTraceEvent(event));
const renderedCommands = new Set();
let requestRendered = false;
let setupRendered = false;
let completionRendered = false;
let assistantIndex = 0;
for (const event of events) {
if (isNoisyTraceEvent(event)) {
flushToolOutputRun();
noisyRun.push(event);
if (!event) continue;
if (isToolOutputChunkTraceEvent(event)) continue;
if (isCommandExecutionTraceEvent(event)) {
const groupKey = traceCommandGroupKey(event);
const group = commandGroups.get(groupKey) ?? { key: groupKey, started: event, completed: event, outputs: [] };
const renderHere = event.label === "item/commandExecution:started" || group.started === event;
if (renderHere && !renderedCommands.has(groupKey)) {
const row = traceCommandSummaryRow(trace, group);
if (row) rows.push(row);
renderedCommands.add(groupKey);
}
continue;
}
if (isToolOutputChunkTraceEvent(event)) {
flushNoisyRun();
toolOutputRun.push(event);
if (isAssistantMessageTraceEvent(event)) {
const row = traceAssistantSummaryRow(trace, event, assistantIndex);
assistantIndex += 1;
if (row) rows.push(row);
continue;
}
flushNoisyRun();
flushToolOutputRun();
if (isRequestTraceEvent(event)) {
if (!requestRendered) {
const row = traceRequestSummaryRow(trace, event, requestEvent ?? event);
if (row) rows.push(row);
requestRendered = true;
}
continue;
}
if (isSetupTraceEvent(event)) {
if (!setupRendered) {
const row = traceSetupSummaryRow(trace, events, event);
if (row) rows.push(row);
setupRendered = true;
}
continue;
}
if (isCompletionTraceEvent(event)) {
if (!completionRendered && !hasTerminalAssistant) {
const row = traceCompletionSummaryRow(trace, event);
if (row) rows.push(row);
completionRendered = true;
}
continue;
}
if (isNoisyTraceEvent(event)) continue;
const row = traceDisplayRow(trace, event);
if (!row) continue;
rows.push(row);
if (row) rows.push(row);
}
flushNoisyRun();
flushToolOutputRun();
rows.push(...assistantRows);
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean);
}
function traceAssistantStreamRows(trace) {
const streams = Array.isArray(trace?.assistantStreams) ? trace.assistantStreams.filter(Boolean) : [];
return streams.map((stream, index) => traceAssistantStreamRow(trace, stream, index)).filter(Boolean);
}
function traceAssistantStreamRow(trace, stream, index = 0) {
const chunkCount = Math.max(0, Number(stream?.chunkCount ?? 0));
if (chunkCount <= 0) return null;
const clock = traceClock(stream.updatedAt ?? stream.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, { createdAt: stream.updatedAt ?? stream.createdAt }));
return {
rowId: `assistant-stream:${stream.itemId ?? stream.messageId ?? index}`,
seq: null,
tone: "source",
header: `${clock} total=${total} stream assistant message x${chunkCount}`,
body: traceAssistantStreamBody(stream, chunkCount)
};
}
function traceAssistantStreamBody(stream, chunkCount) {
const message = cleanTraceText(stream?.text ?? "");
const waitingFor = stream?.waitingFor ? `waiting=${stream.waitingFor}` : null;
return [
`chunks=${chunkCount} assistant message chunks`,
waitingFor,
message ? `message=${message}` : null
].filter(Boolean).join("\n");
}
function traceToolOutputSummaryRows(trace, events) {
const visibleEvents = events.filter(Boolean);
if (visibleEvents.length === 0) return [];
const first = visibleEvents[0];
const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join("");
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 {
rowId: `gateway-jsonrpc:${event.seq ?? first.seq ?? "unknown"}:${payload.id ?? index}`,
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));
return [{
rowId: `tool-output:${first.seq ?? "unknown"}`,
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) {
const visibleEvents = events.filter(Boolean);
if (visibleEvents.length === 0) return null;
const last = visibleEvents.at(-1);
const clock = traceClock(last.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, last));
const assistantChunks = visibleEvents.filter((event) => isAssistantChunkTraceEvent(event));
const label = assistantChunks.length === visibleEvents.length
? `assistant message x${visibleEvents.length}`
: `trace noise x${visibleEvents.length}`;
return {
rowId: `trace-noise:${visibleEvents[0]?.seq ?? last.seq ?? "unknown"}:${assistantChunks.length > 0 ? "assistant" : "events"}`,
seq: last.seq ?? null,
tone: "source",
header: `${clock} total=${total} stream ${label}`,
body: traceNoiseSummaryBody(visibleEvents, assistantChunks)
};
}
function traceNoiseSummaryBody(events, assistantChunks) {
if (assistantChunks.length > 0) {
const text = assistantChunks
.map((event) => String(event.chunk ?? ""))
.join("");
const message = cleanTraceText(text);
const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null;
return [
`chunks=${assistantChunks.length} assistant message chunks`,
waitingFor,
message ? `message=${message}` : null
].filter(Boolean).join("\n");
}
const labels = new Map();
for (const event of events) {
const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`);
labels.set(label, (labels.get(label) ?? 0) + 1);
}
return [...labels.entries()]
.slice(0, 6)
.map(([label, count]) => `${readableTraceLabel({ label })} x${count}`)
.join("\n");
}
function compactTraceTextTail(text, maxLength) {
const clean = cleanTraceText(text);
if (clean.length <= maxLength) return clean;
return `...${clean.slice(-maxLength)}`;
}
function traceCommandExecutionGroups(events) {
const groups = new Map();
let activeKey = null;
const ensureGroup = (key, event) => {
const groupKey = key || traceCommandGroupKey(event);
if (!groups.has(groupKey)) groups.set(groupKey, { key: groupKey, started: null, completed: null, outputs: [] });
return groups.get(groupKey);
};
for (const event of events) {
if (isCommandExecutionTraceEvent(event)) {
const key = traceCommandGroupKey(event, activeKey);
const group = ensureGroup(key, event);
if (event.label === "item/commandExecution:started") {
group.started = group.started ?? event;
activeKey = key;
} else {
group.completed = event;
group.started = group.started ?? event;
if (activeKey === key) activeKey = null;
}
continue;
}
if (isToolOutputChunkTraceEvent(event)) {
const key = traceCommandGroupKey(event, activeKey);
ensureGroup(key, event).outputs.push(event);
}
}
return groups;
}
function traceCommandGroupKey(event, fallback = null) {
return nonEmptyString(event?.itemId ?? event?.callId ?? event?.commandId ?? event?.id) || fallback || `cmd:${event?.seq ?? event?.createdAt ?? Math.random()}`;
}
function traceCommandSummaryRow(trace, group) {
const start = group.started ?? group.completed ?? group.outputs?.[0];
const completed = group.completed ?? null;
if (!start) return null;
const command = displayTraceCommand(completed?.command ?? start.command ?? "");
const clock = traceClock(start.createdAt);
const title = traceCommandTitle(command);
const ok = !completed ? false : traceStatusToken(completed) === "ok" && (!Number.isInteger(completed.exitCode) || completed.exitCode === 0);
return {
rowId: `tool-summary:${group.key}`,
seq: start.seq ?? null,
tone: completed ? (ok ? "ok" : "blocked") : "warn",
header: `${clock} 🔧 工具调用:${title}`,
body: traceCommandSummaryBody(group, command)
};
}
function traceCommandSummaryBody(group, command) {
const completed = group.completed ?? null;
const result = traceCommandResultSummary(group, command);
return [
command ? `命令:${command}` : null,
completed ? `结果:${result}` : "结果:等待命令完成"
].filter(Boolean).join("\n");
}
function traceCommandResultSummary(group, command) {
const completed = group.completed ?? {};
const output = traceCommandCombinedOutput(group);
const fields = traceOutputFields(output);
const success = fields.hostSuccess ?? fields.success ?? commandSuccessFromEvent(completed);
const duration = traceCommandDurationSuffix(group);
if (fields.content && /read-after-launch-flash/u.test(command)) {
return `\n${traceStartupLogSummary(fields.content)}${duration}`;
}
if (fields.hostJobId) {
return [`hostJobId=${fields.hostJobId}`, `success=${success}`].join(", ") + duration;
}
if (fields.logTail && /Flash Load finished/iu.test(fields.logTail)) {
const ticks = fields.content ? traceTickRange(fields.content) : null;
const capture = ticks ? ` 捕获的部分串口:tick ${ticks}` : "";
return `success=${success}, Flash Load finished.${capture}${duration}`;
}
if (fields.logTail) {
return `success=${success}, logTail: "${traceLogTailSummary(fields.logTail)}"${duration}`;
}
if (fields.content) {
const ticks = traceTickRange(fields.content);
const content = ticks ? `捕获的串口:tick ${ticks}` : compactTraceOneLine(fields.content, 180);
return `success=${success}, ${content}${duration}`;
}
const compact = compactTraceOneLine(output || completed.errorMessage || completed.message || "", 220);
return `${success !== null ? `success=${success}` : traceStatusToken(completed)}${compact ? `, ${compact}` : ""}${duration}`;
}
function traceCommandCombinedOutput(group) {
return [
...(Array.isArray(group.outputs) ? group.outputs.map((event) => rawTraceOutputText(event)) : []),
group.completed?.stdoutSummary,
group.completed?.outputSummary,
group.completed?.stderrSummary
].filter(Boolean).join("\n");
}
function commandSuccessFromEvent(event) {
if (!event) return null;
if (Number.isInteger(event.exitCode)) return event.exitCode === 0;
const status = String(event.status ?? "");
if (["completed", "succeeded", "success"].includes(status)) return true;
if (["failed", "error", "timeout", "canceled"].includes(status)) return false;
return null;
}
function traceCommandDurationSuffix(group) {
const completed = group.completed ?? null;
if (!completed) return "";
let seconds = null;
if (typeof completed.durationMs === "number") {
seconds = completed.durationMs / 1000;
} else if (group.started?.createdAt && completed.createdAt) {
const start = Date.parse(group.started.createdAt);
const end = Date.parse(completed.createdAt);
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) seconds = (end - start) / 1000;
}
return seconds === null ? "" : ` (耗时 ${seconds.toFixed(1)}s)`;
}
function traceCommandTitle(command) {
if (/build\s+start/iu.test(command)) return "";
if (/build\s+status/iu.test(command)) return "";
if (/download\s+start/iu.test(command)) return "";
if (/download\s+status/iu.test(command)) return "";
if (/read-after-launch-flash/iu.test(command)) return "";
return "命令执行";
}
function displayTraceCommand(command) {
let text = cleanTraceOutputText(command);
text = text.replace(/^\/?\s*bin\s*\/\s*bash\s+-lc\s+(["'])([\s\S]*)\1$/u, "$2");
text = text.replace(/^bash\s+-lc\s+(["'])([\s\S]*)\1$/u, "$2");
return normalizeTraceCommandSpacing(text);
}
function normalizeTraceCommandSpacing(text) {
return String(text ?? "")
.replace(/:\s+\/\s+/gu, ":/")
.replace(/\s+\/\s+/gu, "/")
.replace(/\s{2,}/gu, " ")
.trim();
}
function traceOutputFields(text) {
return {
hostAction: traceOutputStringField(text, "hostAction"),
hostJobId: traceOutputStringField(text, "hostJobId"),
hostSuccess: traceOutputBooleanField(text, "hostSuccess"),
success: traceOutputBooleanField(text, "success"),
logTail: traceOutputStringField(text, "logTail"),
content: traceOutputStringField(text, "content")
};
}
function traceOutputStringField(text, name) {
const pattern = new RegExp(`"${name}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`, "gu");
let value = "";
for (const match of String(text ?? "").matchAll(pattern)) {
try {
const parsed = JSON.parse(`"${match[1]}"`);
if (parsed) value = parsed;
} catch {
if (match[1]) value = match[1];
}
}
return value;
}
function traceOutputBooleanField(text, name) {
const pattern = new RegExp(`"${name}"\\s*:\\s*(true|false)`, "giu");
let value = null;
for (const match of String(text ?? "").matchAll(pattern)) {
value = match[1].toLowerCase() === "true";
}
return value;
}
function traceLogTailSummary(value) {
const text = cleanSerialText(value);
const build = text.match(/([^\n]*\b\d+ Error\(s\), \d+ Warning\(s\)\.[^\n]*)(?:\n([^\n]*Build Time Elapsed:[^\n]*))?/iu);
if (build) return compactTraceOneLine(`... ${[build[1], build[2]].filter(Boolean).join(" ")}`, 220);
const flash = text.match(/Flash Load finished[^\n]*/iu);
if (flash) return flash[0];
return compactTraceOneLine(text, 220);
}
function traceStartupLogSummary(value) {
const lines = cleanSerialText(value)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const selected = [];
for (const line of lines) {
if (/hello device-pod!/iu.test(line)) selected.push("hello device-pod!");
else if (/STM32F103 USART1 baseline ready/iu.test(line)) selected.push("[01_baseline] STM32F103 USART1 baseline ready");
else if (/baud=/iu.test(line)) selected.push(line.replace(/\s+\/\s+/gu, "/"));
else if (/project=/iu.test(line)) selected.push(line.replace(/\s+\/\s+/gu, "/"));
else if (/dap=.*idcode=/iu.test(line)) selected.push(line);
}
const ticks = traceTickRange(value);
if (ticks) {
const [first, last] = ticks.split("-");
selected.push(last ? `tick=${first}, ..., tick=${last}` : `tick=${first}`);
}
return selected.slice(0, 8).join("\n");
}
function traceTickRange(value) {
const ticks = [...String(value ?? "").matchAll(/tick=(\d+)/giu)].map((match) => Number(match[1])).filter(Number.isFinite);
if (ticks.length === 0) return null;
const first = ticks[0];
const last = ticks.at(-1);
return first === last ? String(first) : `${first}-${last}`;
}
function cleanSerialText(value) {
return String(value ?? "")
.replace(/\u0000/gu, "")
.replace(/\r\n|\r/gu, "\n")
.replace(/\n{3,}/gu, "\n\n")
.trim();
}
function compactTraceOneLine(value, limit = 220) {
const text = cleanSerialText(value).replace(/\s+/gu, " ").trim();
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`;
}
function tracePrimaryRequestEvent(events) {
return events.find((event) => isRequestTraceEvent(event) && tracePromptText(event)) ?? events.find(isRequestTraceEvent) ?? null;
}
function traceRequestSummaryRow(trace, event, promptEvent = event) {
const clock = traceClock(event.createdAt);
const prompt = tracePromptText(promptEvent);
return {
rowId: `trace-request:${event.seq ?? "accepted"}`,
seq: event.seq ?? null,
tone: "warn",
header: `${clock} 请求接受${prompt ? `,提示词:"${compactTraceOneLine(prompt, 120)}"` : ""}`,
body: null
};
}
function traceSetupSummaryRow(trace, events, event) {
const setupEvents = events.filter(isSetupTraceEvent);
const sessionReused = setupEvents.some((item) => /session:reused/iu.test(String(item?.label ?? "")));
const promptSent = setupEvents.some((item) => /prompt:sent|sent prompt/iu.test(String(item?.label ?? "")));
const turnStarted = setupEvents.some((item) => /turn[:/]start|turn:started|turn\/start/iu.test(String(item?.label ?? "")));
const parts = [
sessionReused ? "会话复用" : "会话就绪",
promptSent ? "提示词已发送" : null,
turnStarted ? "轮次开始" : null
].filter(Boolean);
return {
rowId: "trace-setup:session-turn",
seq: event.seq ?? null,
tone: "source",
header: `${traceClock(event.createdAt)} ${parts.join("") || "会话准备完成"}`,
body: null
};
}
function traceCompletionSummaryRow(trace, event) {
return {
rowId: `trace-completion:${event.seq ?? "turn"}`,
seq: event.seq ?? null,
tone: traceEventTone(event),
header: `${traceClock(event.createdAt)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))}`,
body: null
};
}
function traceAssistantSummaryRow(trace, event, index = 0) {
const terminal = isTerminalAssistantTraceEvent(event);
const text = traceAssistantTextForEvent(trace, event, index);
if (!text && !terminal && traceHasLaterTerminalAssistant(trace, event)) return null;
const elapsed = formatTraceDuration(traceRelativeMs(trace, event));
return {
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`,
seq: event.seq ?? null,
tone: traceEventTone(event),
header: terminal
? `${traceClock(event.createdAt)} 🤖 助手最后一条消息,轮次完成(总耗时 ${elapsed}`
: `${traceClock(event.createdAt)} 🤖 助手消息${index === 0 ? "(第一条)" : ""}`,
body: text ? compactTraceAssistantMarkdown(text, terminal ? 5000 : 2200) : null,
bodyFormat: "markdown"
};
}
function traceAssistantTextForEvent(trace, event, index = 0) {
const direct = normalizeTraceAssistantMarkdown(event.message ?? event.outputSummary ?? event.chunk ?? event.text ?? "");
if (direct) return traceAssistantTextDuplicatesTerminal(trace, event, direct) ? "" : direct;
const streams = traceAssistantStreams(trace);
if (streams.length === 0) return "";
const itemId = nonEmptyString(event.itemId ?? event.messageId ?? event.id);
if (itemId) {
const match = streams.find((stream) => stream.itemId === itemId || stream.messageId === itemId || stream.id === itemId);
if (match?.text) {
const text = normalizeTraceAssistantMarkdown(match.text);
return traceAssistantTextDuplicatesTerminal(trace, event, text) ? "" : text;
}
}
if (isTerminalAssistantTraceEvent(event)) return traceAssistantTerminalText(trace, event);
return traceAssistantCumulativeSegment(trace, event, index);
}
function traceAssistantStreams(trace) {
return Array.isArray(trace?.assistantStreams)
? trace.assistantStreams.filter((stream) => stream && typeof stream === "object" && normalizeTraceAssistantMarkdown(stream.text ?? ""))
: [];
}
function traceAssistantEvents(trace) {
return Array.isArray(trace?.events) ? trace.events.filter(isAssistantMessageTraceEvent) : [];
}
function traceAssistantTerminalText(trace, event = null) {
const streams = traceAssistantStreams(trace);
const itemId = nonEmptyString(event?.itemId ?? event?.messageId ?? event?.id);
if (itemId) {
const matched = streams.find((stream) => stream.itemId === itemId || stream.messageId === itemId || stream.id === itemId);
if (matched?.text) return normalizeTraceAssistantMarkdown(matched.text);
}
return normalizeTraceAssistantMarkdown(streams.at(-1)?.text ?? "");
}
function traceAssistantTextDuplicatesTerminal(trace, event, text) {
if (isTerminalAssistantTraceEvent(event) || !traceHasLaterTerminalAssistant(trace, event)) return false;
const terminalText = traceAssistantTerminalText(trace);
return Boolean(terminalText && traceAssistantComparableText(text) === traceAssistantComparableText(terminalText));
}
function traceHasLaterTerminalAssistant(trace, event) {
const events = traceAssistantEvents(trace);
const currentIndex = events.findIndex((item) => item === event || (item?.seq !== undefined && item.seq === event?.seq));
if (currentIndex < 0) return events.some(isTerminalAssistantTraceEvent);
return events.slice(currentIndex + 1).some(isTerminalAssistantTraceEvent);
}
function traceAssistantCumulativeSegment(trace, event, index = 0) {
const assistantEvents = traceAssistantEvents(trace).filter((item) => !isTerminalAssistantTraceEvent(item));
const eventIndex = assistantEvents.findIndex((item) => item === event || (item?.seq !== undefined && item.seq === event?.seq));
const segmentIndex = eventIndex >= 0 ? eventIndex : index;
if (segmentIndex < 0) return "";
const stream = traceAssistantCumulativeStream(trace);
if (!stream?.text) return "";
const cumulative = normalizeTraceAssistantMarkdown(stream.text);
const terminalText = traceAssistantTerminalText(trace);
const nonTerminalText = traceAssistantRemoveTerminalSuffix(cumulative, terminalText);
if (!nonTerminalText || traceAssistantComparableText(nonTerminalText) === traceAssistantComparableText(terminalText)) return "";
const segments = traceAssistantSplitCumulativeText(nonTerminalText, assistantEvents.length);
return normalizeTraceAssistantMarkdown(segments[segmentIndex] ?? "");
}
function traceAssistantCumulativeStream(trace) {
const streams = traceAssistantStreams(trace);
if (streams.length === 0) return null;
const terminalIds = new Set(traceAssistantEvents(trace)
.filter(isTerminalAssistantTraceEvent)
.flatMap((event) => [event.itemId, event.messageId, event.id].map(nonEmptyString).filter(Boolean)));
const candidates = streams.filter((stream) => {
const streamId = nonEmptyString(stream.itemId ?? stream.messageId ?? stream.id);
return !streamId || !terminalIds.has(streamId);
});
return [...(candidates.length > 0 ? candidates : streams)]
.sort((left, right) => String(right.text ?? "").length - String(left.text ?? "").length)[0] ?? null;
}
function traceAssistantRemoveTerminalSuffix(text, terminalText) {
const clean = normalizeTraceAssistantMarkdown(text);
const terminal = normalizeTraceAssistantMarkdown(terminalText);
if (!clean || !terminal) return clean;
if (clean.endsWith(terminal)) return clean.slice(0, -terminal.length).trim();
const index = traceAssistantCompactLastIndex(clean, terminal);
if (index > 0) return clean.slice(0, index).trim();
return clean;
}
function traceAssistantCompactLastIndex(text, needle) {
const haystack = traceAssistantComparableWithMap(text);
const target = traceAssistantComparableText(needle);
if (!target) return -1;
const compactIndex = haystack.text.lastIndexOf(target);
return compactIndex >= 0 ? haystack.map[compactIndex] ?? -1 : -1;
}
function traceAssistantComparableWithMap(value) {
const map = [];
let text = "";
const raw = String(value ?? "");
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index];
if (/\s/u.test(char)) continue;
text += char.toLowerCase();
map.push(index);
}
return { text, map };
}
function traceAssistantComparableText(value) {
return traceAssistantComparableWithMap(normalizeTraceAssistantMarkdown(value)).text;
}
function traceAssistantSplitCumulativeText(text, count) {
const targetCount = Math.max(1, Number(count) || 1);
const clean = normalizeTraceAssistantMarkdown(text);
if (!clean) return [];
if (targetCount === 1) return [clean];
const colonPieces = [];
let remaining = clean;
while (remaining && colonPieces.length < targetCount) {
const match = /^([\s\S]*?[:])(?=\s*[^\s])/u.exec(remaining);
if (!match?.[1] || match[1].trim().length < 8) break;
colonPieces.push(match[1].trim());
remaining = remaining.slice(match[1].length).trim();
}
const pieces = colonPieces.length > 0 ? colonPieces : traceAssistantSentencePieces(clean);
if (colonPieces.length > 0 && remaining) pieces.push(remaining);
if (pieces.length <= targetCount) return [...pieces, ...Array.from({ length: targetCount - pieces.length }, () => "")];
return traceAssistantPackPieces(pieces, targetCount);
}
function traceAssistantSentencePieces(text) {
return String(text ?? "").match(/[^.!?]+[.!?]?/gu)?.map((item) => item.trim()).filter(Boolean) ?? [];
}
function traceAssistantPackPieces(pieces, count) {
const packed = Array.from({ length: count }, () => "");
pieces.forEach((piece, index) => {
const slot = Math.min(count - 1, Math.floor(index * count / pieces.length));
packed[slot] = [packed[slot], piece].filter(Boolean).join("");
});
return packed;
}
function compactTraceAssistantMarkdown(text, maxLength) {
const clean = normalizeTraceAssistantMarkdown(text);
if (clean.length <= maxLength) return clean;
return `${clean.slice(0, Math.max(0, maxLength - 12)).trimEnd()}\n\n...内容已截断`;
}
function normalizeTraceAssistantMarkdown(value) {
let text = String(value ?? "")
.replace(/\u0000/gu, "")
.replace(/\r\n|\r/gu, "\n")
.replace(/[ \t]{2,}/gu, " ")
.replace(/\n{3,}/gu, "\n\n")
.trim();
if (!text) return "";
text = restoreTraceAssistantEnglishSpacing(text);
text = restoreTraceAssistantMarkdownBreaks(text);
return text;
}
function restoreTraceAssistantEnglishSpacing(value) {
return String(value ?? "")
.replace(/\bLetmecheckwhat/gu, "Let me check what ")
.replace(/cloudAPI/giu, "Cloud API")
.replace(/CloudAPI/gu, "Cloud API")
.replace(/gatewaysessions/giu, "gateway sessions")
.replace(/gatewaysession/giu, "gateway session")
.replace(/gatewaysidecar/giu, "gateway sidecar")
.replace(/Windows/gu, "Windows ")
.replace(/gateway/giu, "gateway ")
.replace(/gateway/giu, "gateway ")
.replace(/device-podprofilesareavailable/giu, "device-pod profiles are available")
.replace(/profilesareavailable/giu, "profiles are available")
.replace(/\b([A-Za-z]+)areavailable\b/gu, "$1 are available")
.replace(/(\d+)Error\(s\),\s*(\d+)Warning\(s\)/gu, "$1 Error(s), $2 Warning(s)")
.replace(/(\d+)/gu, " $1 ")
.replace(/(\d+)device-pod/giu, "$1 device-pod")
.replace(/device-pod/giu, " device-pod")
.replace(/(\*\*\d+)/gu, "$1 ")
.replace(/([\u4e00-\u9fff])`/gu, "$1 `")
.replace(/`([^`]+)`([\u4e00-\u9fff])/gu, "`$1` $2")
.replace(/([A-Za-z0-9_-])`/gu, "$1 `")
.replace(/([\u4e00-\u9fff])\*\*/gu, "$1 **")
.replace(/\*\*([\u4e00-\u9fff])/gu, "** $1")
.replace(/([a-z])([\u4e00-\u9fff])/gu, "$1 $2")
.replace(/([\u4e00-\u9fff])([A-Za-z][A-Za-z0-9_-]*)/gu, "$1 $2");
}
function restoreTraceAssistantMarkdownBreaks(value) {
return String(value ?? "")
.replace(/([:.!?])\s*(\|[^\n]+\|)/gu, "$1\n\n$2")
.replace(/\|\s*\|(?=(?:---|:---|[^|\n]+\s*\|))/gu, "|\n|")
.replace(/\s+-\s+(?=(?:\*\*|`|[\u4e00-\u9fffA-Za-z0-9]))/gu, "\n- ")
.replace(/([^\n])\s+(pod)/gu, "$1\n\n$2")
.replace(/([^\n])\s+()/gu, "$1\n\n$2");
}
function traceDisplayRow(trace, event, options = {}) {
if (!options.includeNoise && isNoisyTraceEvent(event)) return null;
if (isCommandExecutionTraceEvent(event)) return traceCommandExecutionRow(trace, event);
@@ -507,19 +909,11 @@ function isCommandExecutionTraceEvent(event) {
function isNoisyTraceEvent(event) {
const label = String(event?.label ?? "");
if (isToolOutputChunkTraceEvent(event)) return false;
if (isAssistantChunkTraceEvent(event)) return true;
if (/token_count|outputDelta:chunk/iu.test(label)) return true;
if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true;
if (event?.label === "request:accepted" && !event.message) return true;
return false;
}
function isAssistantChunkTraceEvent(event) {
const label = String(event?.label ?? "");
return /assistant:chunk/iu.test(label) ||
(event?.type === "assistant_message" && event?.status === "chunk");
}
function isToolOutputChunkTraceEvent(event) {
const label = String(event?.label ?? "");
return label === "item/commandExecution/outputDelta" ||
-1
View File
@@ -230,7 +230,6 @@ initLeftSidebarToggle();
initLeftSidebarResize();
initRightSidebarResize();
initDevicePodPanel();
initQuickActions();
initCodeAgentTimeoutControl();
initConversationScrollMemory();
initCommandBar();
-30
View File
@@ -170,36 +170,6 @@
</section>
<form class="command-bar" id="command-form" aria-label="Agent 输入">
<div class="agent-quick-prompts" aria-label="Code Agent 快捷用例">
<button
type="button"
data-agent-quick-prompt="pwd"
data-prompt-action="fill"
title="只填充输入框,点击发送后交给 Code Agent"
data-prompt-text="交给 Codex 执行 pwd,列出当前工作目录。"
>Codexpwd</button>
<button
type="button"
data-agent-quick-prompt="skills"
data-prompt-action="fill"
title="只填充输入框,点击发送后交给 Code Agent"
data-prompt-text="交给 Codex 列出你能使用的所有 skill。"
>Codex:列出 skill</button>
<button
type="button"
data-agent-quick-prompt="device-pod-status"
data-prompt-action="fill"
title="只填充输入框,点击发送后交给 Code Agent"
data-prompt-text="交给 Codex 读取当前 Device Pod 的 status、profileHash、chip-id 和 UART1 tail 摘要,不执行下载、复位或写入。"
>CodexDevice Pod 状态</button>
<button
type="button"
data-agent-quick-prompt="device-pod-events"
data-prompt-action="fill"
title="只填充输入框,点击发送后交给 Code Agent"
data-prompt-text="交给 Codex 查看 Device Pod 最近事件流,并说明是否存在 blocker;只读,不连接旧模拟硬件拓扑。"
>Codex:事件流</button>
</div>
<label class="agent-timeout-control" for="code-agent-provider-profile">
<span>模型通道</span>
<select id="code-agent-provider-profile" aria-label="Code Agent 模型通道">
@@ -1,555 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultCredentials = Object.freeze({
username: "admin",
password: "hwlab2026"
});
test("final assistant response renders basic markdown without raw html injection", async () => {
const browser = await launchBrowser();
const server = await startStaticServer(webRoot);
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(() => window.__hwlabWorkbenchTestHooks.seedCompletedAgentMessage({
text: [
"# Gateway 控制 Windows CMD 的方法",
"",
"核心工具是 `/app/tools/hwlab-gateway-tran.mjs`。",
"",
"---",
"",
"| 参数 | 作用 |",
"|---|---|",
"| `cmd` | 透传 Windows cmd |",
"",
"```bash",
"node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- pwd",
"```",
"",
"<span class=\"message-copy\">raw html should stay inert</span>",
"[blocked](javascript:alert(1))"
].join("\n")
}));
const markdown = page.locator(".message-card.message-agent .message-copy-markdown");
await markdown.waitFor({ timeout: 12000 });
await expectCount(page, ".message-copy-markdown h1", 1);
await expectCount(page, ".message-copy-markdown table", 1);
await expectCount(page, ".message-copy-markdown pre code", 1);
await expectCount(page, ".message-copy-markdown .message-copy", 0);
await expectCount(page, ".message-copy-markdown a[href^='javascript:']", 0);
const renderedText = await markdown.innerText();
assert.match(renderedText, /Gateway 控制 Windows CMD 的方法/);
assert.match(renderedText, /cmd/);
assert.match(renderedText, /raw html should stay inert/);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
test("trace scroll stays user controlled while trace updates append", async () => {
const browser = await launchBrowser();
const server = await startStaticServer(webRoot);
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(() => window.__hwlabWorkbenchTestHooks.seedTraceMessage({ count: 90 }));
await page.waitForSelector(".message-trace-events[data-trace-ui-key]", { timeout: 12000 });
const initial = await traceMetrics(page);
assert.ok(initial.traceScrollHeight > initial.traceClientHeight + 200, JSON.stringify(initial));
const beforeIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity());
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.resetProgrammaticScrollWriteCount());
const userMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(180, { user: true }));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12));
await page.waitForTimeout(120);
const afterAppend = await traceMetrics(page);
assert.ok(Math.abs(afterAppend.traceTop - userMiddle.traceTop) <= 8, `trace update fought the user's scroll position: before=${JSON.stringify(userMiddle)} after=${JSON.stringify(afterAppend)}`);
const afterIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity());
assert.equal(afterIdentity.panel, beforeIdentity.panel, "trace update must not replace the trace panel DOM node");
assert.equal(afterIdentity.list, beforeIdentity.list, "trace update must not replace the trace list DOM node");
assert.equal(afterIdentity.firstRow, beforeIdentity.firstRow, "trace update must not replace stable existing rows");
assert.equal(afterIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop: ${JSON.stringify(afterIdentity)}`);
const userMoved = await page.evaluate(() => {
const before = window.__hwlabWorkbenchTestHooks.traceScrollMetrics();
return window.__hwlabWorkbenchTestHooks.setTraceScrollTop(before.traceTop + 180, { user: true });
});
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12));
await page.waitForTimeout(120);
const afterUserMove = await traceMetrics(page);
assert.ok(afterUserMove.traceTop >= userMoved.traceTop - 8, `trace update should not snap back after user scrolls down: before=${JSON.stringify(userMoved)} after=${JSON.stringify(afterUserMove)}`);
const afterUserMoveIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity());
assert.equal(afterUserMoveIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop after user move: ${JSON.stringify(afterUserMoveIdentity)}`);
const conversationMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setConversationScrollTop(40, { user: true }));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(4));
await page.waitForTimeout(120);
const afterConversationAppend = await traceMetrics(page);
assert.ok(Math.abs(afterConversationAppend.conversationTop - conversationMiddle.conversationTop) <= 8, `conversation scroll should not snap on trace update: before=${JSON.stringify(conversationMiddle)} after=${JSON.stringify(afterConversationAppend)}`);
const afterConversationIdentity = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceDomIdentity());
assert.equal(afterConversationIdentity.programmaticScrollWrites, 0, `trace update wrote scrollTop after conversation scroll: ${JSON.stringify(afterConversationIdentity)}`);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
test("inner trace message body scroll stays stable when trace rows move or stream text updates", async () => {
const browser = await launchBrowser();
const server = await startStaticServer(webRoot);
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 });
const longStreamText = (chunk) => Array.from({ length: 90 }, (_, index) => `assistant stream chunk ${chunk} line ${index + 1} ${"message ".repeat(8)}`).join("\n");
const longEvents = Array.from({ length: 110 }, (_, index) => ({
seq: index + 1,
label: "trace:inner-scroll-contract",
type: "trace",
status: "observed",
message: Array.from({ length: 70 }, (_, line) => `event ${index + 1} line ${line + 1} ${"payload ".repeat(8)}`).join("\n")
}));
await page.evaluate(({ events, text }) => window.__hwlabWorkbenchTestHooks.seedTraceMessage({
traceId: "trc_inner_scroll_contract",
messageId: "msg_inner_scroll_contract",
events,
assistantStreams: [{ itemId: "assistant-message", chunkCount: 1, waitingFor: "turn/completed", text }]
}), { events: longEvents, text: longStreamText(1) });
await page.waitForSelector(".message-trace-events[data-trace-ui-key] li:nth-child(97) pre", { timeout: 12000 });
const eventBodySelector = ".message-trace-events[data-trace-ui-key] li:nth-child(97) pre";
const streamBodySelector = ".message-trace-events[data-trace-ui-key] li:last-child pre";
const eventBefore = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 90, { user: true }), eventBodySelector);
const streamBefore = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 90, { user: true }), streamBodySelector);
assert.ok(eventBefore.scrollHeight > eventBefore.clientHeight + 80, JSON.stringify(eventBefore));
assert.ok(streamBefore.scrollHeight > streamBefore.clientHeight + 800, JSON.stringify(streamBefore));
await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(5));
await page.waitForTimeout(120);
const eventAfterAppend = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), eventBodySelector);
const streamAfterAppend = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector);
assert.equal(eventAfterAppend.body, eventBefore.body, "stable existing event body node must be reused while appending trace rows");
assert.equal(eventAfterAppend.row, eventBefore.row, "stable existing event row node must be reused while appending trace rows");
assert.ok(Math.abs(eventAfterAppend.top - eventBefore.top) <= 8, `event body scroll changed: before=${JSON.stringify(eventBefore)} after=${JSON.stringify(eventAfterAppend)}`);
assert.equal(streamAfterAppend.body, streamBefore.body, "assistant stream body node must move instead of being replaced when new events append before it");
assert.equal(streamAfterAppend.row, streamBefore.row, "assistant stream row node must move instead of being replaced when new events append before it");
assert.ok(Math.abs(streamAfterAppend.top - streamBefore.top) <= 8, `assistant stream body scroll changed after row move: before=${JSON.stringify(streamBefore)} after=${JSON.stringify(streamAfterAppend)}`);
await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.setTraceBodyScrollTop(selector, 120, { user: true }), streamBodySelector);
const streamBeforeTextUpdate = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector);
await page.evaluate((text) => window.__hwlabWorkbenchTestHooks.replaceTraceAssistantStream({ chunkCount: 2, text }), longStreamText(2));
await page.waitForTimeout(120);
const streamAfterTextUpdate = await page.evaluate((selector) => window.__hwlabWorkbenchTestHooks.traceBodyDomIdentity(selector), streamBodySelector);
assert.equal(streamAfterTextUpdate.body, streamBeforeTextUpdate.body, "assistant stream body node must survive stream text growth");
assert.equal(streamAfterTextUpdate.row, streamBeforeTextUpdate.row, "assistant stream row node must survive stream text growth");
assert.ok(Math.abs(streamAfterTextUpdate.top - streamBeforeTextUpdate.top) <= 8, `assistant stream body scroll changed after text update: before=${JSON.stringify(streamBeforeTextUpdate)} after=${JSON.stringify(streamAfterTextUpdate)}`);
assert.match(streamAfterTextUpdate.text, /chunk 2/u);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
test("trace result-ready fixture is reconciled into final assistant result", async () => {
const browser = await launchBrowser();
const server = await startStaticServer(webRoot);
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(() => {
window.__hwlabWorkbenchTestHooks.seedTraceMessage({
traceId: "trc_fixture_result_ready",
events: [{
label: "result:completed",
type: "result",
status: "completed",
terminal: true,
message: "Code Agent result is ready for short-connection polling."
}]
});
});
const before = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText());
assert.equal(before.status, "running");
assert.match(before.text, /Trace scroll contract fixture/);
const after = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.reconcileTraceResultFixture({
traceId: "trc_fixture_result_ready",
conversationId: "cnv_fixture_result_ready",
sessionId: "ses_fixture_result_ready",
status: "completed",
provider: "codex-stdio",
model: "gpt-test",
backend: "hwlab-cloud-api/codex-app-server-stdio",
capabilityLevel: "long-lived-codex-stdio-session",
runner: { kind: "codex-app-server-stdio-runner" },
session: {
sessionId: "ses_fixture_result_ready",
status: "idle",
idleTimeoutMs: 1800000,
lastTraceId: "trc_fixture_result_ready",
longLivedSession: true,
durableSession: true,
codexStdio: true,
writeCapable: true
},
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
sessionReuse: { reused: true, sessionId: "ses_fixture_result_ready" },
longLivedSessionGate: { status: "pass", pass: true },
reply: { content: "最终回复来自 result endpoint,而不是 result-ready trace 文案。" },
providerTrace: {
protocol: "codex-app-server-jsonrpc-stdio",
command: "codex app-server --listen stdio://",
terminalStatus: "completed",
threadId: "thread_fixture",
turnId: "turn_fixture"
}
}));
assert.equal(after.status, "completed", JSON.stringify(after));
assert.match(after.text, /最终回复来自 result endpoint/);
assert.doesNotMatch(after.text, /Code Agent result is ready for short-connection polling/);
assert.equal(after.providerTrace.terminalStatus, "completed");
const cardText = await page.locator(".message-card.message-agent").last().innerText();
assert.match(cardText, /最终回复来自 result endpoint/);
assert.doesNotMatch(cardText, /Code Agent result is ready for short-connection polling\.$/);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
test("restored pending trace polls result endpoint without token or live provider", async () => {
const traceId = "trc_restore_result_ready";
const browser = await launchBrowser();
const server = await startStaticServer(webRoot, {
[`/v1/agent/chat/result/${traceId}`]: completedResultFixture(traceId, "刷新恢复后来自 result endpoint 的回复。"),
[`/v1/agent/chat/trace/${traceId}`]: {
traceId,
status: "completed",
eventCount: 1,
events: [{
seq: 1,
traceId,
label: "result:completed",
type: "result",
status: "completed",
terminal: true,
message: "Code Agent result is ready for short-connection polling."
}],
lastEvent: {
label: "result:completed",
type: "result",
status: "completed",
terminal: true
}
}
});
try {
const page = await browser.newPage({
viewport: { width: 1366, height: 768 },
deviceScaleFactor: 1
});
await page.addInitScript(({ traceId: injectedTraceId }) => {
window.localStorage.setItem("hwlab.workbench.codeAgentSession.v1", JSON.stringify({
version: 1,
updatedAtMs: Date.now(),
conversationId: "cnv_restore_result_ready",
sessionId: "ses_restore_result_ready",
threadId: "thread_restore_result_ready",
sessionStatus: "busy",
chatMessages: [{
id: "msg_restore_result_ready",
role: "agent",
title: "Code Agent 处理中",
text: "正在处理这次 Code Agent 请求",
status: "running",
traceId: injectedTraceId,
conversationId: "cnv_restore_result_ready",
sessionId: "ses_restore_result_ready",
threadId: "thread_restore_result_ready",
retryInput: "restore fixture"
}]
}));
}, { traceId });
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.waitForFunction(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText()?.status === "completed", null, { timeout: 12000 });
const restored = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText());
assert.equal(restored.status, "completed", JSON.stringify(restored));
assert.match(restored.text, /刷新恢复后来自 result endpoint/);
assert.doesNotMatch(restored.text, /Code Agent result is ready for short-connection polling/);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
test("restored client idle timeout is corrected by completed result polling", async () => {
const traceId = "trc_restore_client_idle_timeout_completed";
const server = await startStaticServer(webRoot, {
[`/v1/agent/chat/result/${traceId}`]: completedResultFixture(traceId, "超时后端补齐的最终回复。"),
[`/v1/agent/chat/trace/${traceId}`]: {
traceId,
status: "completed",
eventCount: 1,
events: [{
seq: 1,
traceId,
label: "result:completed",
type: "result",
status: "completed",
terminal: true,
message: "Code Agent result is ready for short-connection polling."
}],
lastEvent: {
label: "result:completed",
type: "result",
status: "completed",
terminal: true
}
}
});
const browser = await launchBrowser();
try {
const page = await browser.newPage({
viewport: { width: 1366, height: 768 },
deviceScaleFactor: 1
});
await page.addInitScript(({ traceId: injectedTraceId }) => {
window.localStorage.setItem("hwlab.workbench.codeAgentSession.v1", JSON.stringify({
version: 1,
updatedAtMs: Date.now(),
conversationId: "cnv_restore_client_idle_timeout_completed",
sessionId: "ses_restore_client_idle_timeout_completed",
threadId: "thread_restore_client_idle_timeout_completed",
sessionStatus: "timeout",
chatMessages: [{
id: "msg_restore_client_idle_timeout_completed",
role: "agent",
title: "Code Agent 无新事件超时",
text: "Code Agent 超过 600000ms 无新事件;输入已保留,可稍后重试。",
status: "timeout",
traceId: injectedTraceId,
conversationId: "cnv_restore_client_idle_timeout_completed",
sessionId: "ses_restore_client_idle_timeout_completed",
threadId: "thread_restore_client_idle_timeout_completed",
retryInput: "restore timeout fixture",
error: {
code: "client_trace_idle_timeout",
category: "timeout",
timeoutMs: 600000,
message: "Code Agent 超过 600000ms 无新事件"
},
runnerTrace: {
traceId: injectedTraceId,
eventCount: 1,
events: [{
label: "result:completed",
type: "result",
status: "completed",
terminal: true
}],
lastEvent: { label: "result:completed", status: "completed" }
}
}]
}));
}, { traceId });
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.waitForFunction(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText()?.status === "completed", null, { timeout: 12000 });
const restored = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.latestAgentMessageText());
assert.equal(restored.status, "completed", JSON.stringify(restored));
assert.match(restored.text, /超时后端补齐的最终回复/);
assert.equal(restored.providerTrace.terminalStatus, "completed");
assert.equal(restored.error, null);
await page.close();
} finally {
await browser.close();
await server.close();
}
});
async function traceMetrics(page) {
return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics());
}
async function launchBrowser() {
const executablePath = process.env.HWLAB_PLAYWRIGHT_CHROMIUM || process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || systemChromiumExecutablePath();
return chromium.launch({ headless: true, executablePath });
}
function systemChromiumExecutablePath() {
for (const candidate of ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/snap/bin/chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"]) {
if (existsSync(candidate)) return candidate;
}
return undefined;
}
async function expectCount(page, selector, expected) {
assert.equal(await page.locator(selector).count(), expected, selector);
}
async function login(page) {
await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 });
const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false);
if (!loginVisible) {
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
return;
}
await page.locator("#login-username").fill(defaultCredentials.username);
await page.locator("#login-password").fill(defaultCredentials.password);
await page.locator("#login-submit").click();
await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 });
}
function completedResultFixture(traceId, content) {
return {
traceId,
conversationId: "cnv_fixture_result_ready",
sessionId: "ses_fixture_result_ready",
status: "completed",
provider: "codex-stdio",
model: "gpt-test",
backend: "hwlab-cloud-api/codex-app-server-stdio",
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
capabilityLevel: "long-lived-codex-stdio-session",
reply: { content },
runner: {
kind: "codex-app-server-stdio-runner",
codexStdio: true,
writeCapable: true,
durableSession: true,
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
capabilityLevel: "long-lived-codex-stdio-session"
},
session: {
sessionId: "ses_fixture_result_ready",
status: "idle",
idleTimeoutMs: 1800000,
lastTraceId: traceId,
longLivedSession: true,
durableSession: true,
codexStdio: true,
writeCapable: true
},
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
sessionReuse: { reused: true, sessionId: "ses_fixture_result_ready" },
longLivedSessionGate: { status: "pass", pass: true },
toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }],
skills: [],
providerTrace: {
protocol: "codex-app-server-jsonrpc-stdio",
command: "codex app-server --listen stdio://",
terminalStatus: "completed",
threadId: "thread_fixture",
turnId: "turn_fixture"
},
runnerTrace: {
traceId,
events: [],
eventCount: 0,
lastEvent: null
},
updatedAt: new Date().toISOString()
};
}
async function startStaticServer(rootDir, jsonRoutes = {}) {
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (Object.hasOwn(jsonRoutes, url.pathname)) {
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(jsonRoutes[url.pathname]));
return;
}
const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
const resolved = path.resolve(rootDir, `.${pathname}`);
if (!resolved.startsWith(rootDir)) {
response.writeHead(403).end("forbidden");
return;
}
const body = await readFile(resolved);
response.writeHead(200, { "content-type": contentType(resolved) });
response.end(body);
} catch {
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
response.end("not found");
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
url: `http://127.0.0.1:${address.port}/`,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
};
}
function contentType(filePath) {
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
return "application/octet-stream";
}
+31 -72
View File
@@ -1440,64 +1440,25 @@ h3 {
.info-detail,
.command-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px;
padding: 10px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: rgba(20, 22, 20, 0.98);
border-top: 1px solid var(--line);
}
.agent-quick-prompts {
grid-column: 1 / -1;
order: 2;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
overflow: hidden;
}
.agent-quick-prompts button {
flex: 0 1 auto;
max-width: 100%;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--muted);
font-family: var(--mono);
font-size: 10px;
font-weight: 760;
line-height: 1.2;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.agent-quick-prompts button[data-requires-explicit-send="true"] {
border-color: rgba(223, 170, 85, 0.56);
color: var(--text);
background: rgba(52, 47, 29, 0.74);
}
.agent-quick-prompts button:hover,
.agent-quick-prompts button:focus-visible {
border-color: var(--line-strong);
background: var(--surface-3);
color: var(--text);
outline: 0;
}
.agent-timeout-control {
grid-column: 1 / -1;
justify-self: start;
display: inline-grid;
grid-template-columns: auto auto;
order: 1;
flex: 0 1 176px;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-height: 30px;
padding: 4px 8px;
min-width: 142px;
min-height: 28px;
padding: 3px 7px;
border: 1px solid rgba(94, 142, 231, 0.38);
background: rgba(25, 31, 39, 0.78);
color: var(--muted);
@@ -1507,9 +1468,15 @@ h3 {
line-height: 1.2;
}
.agent-timeout-control span {
flex: 0 0 auto;
white-space: nowrap;
}
.agent-timeout-control select {
min-width: 88px;
min-height: 24px;
flex: 1 1 78px;
min-width: 72px;
min-height: 22px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
@@ -1518,6 +1485,8 @@ h3 {
}
.input-shell {
order: 2;
flex: 1 1 320px;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -1548,6 +1517,8 @@ h3 {
}
.command-button {
order: 3;
flex: 0 0 auto;
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--accent);
@@ -1985,26 +1956,17 @@ tbody tr:last-child td {
}
.command-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: stretch;
padding: 8px;
}
.agent-quick-prompts {
gap: 5px;
}
.agent-quick-prompts button {
flex: 1 1 148px;
min-height: 30px;
}
.agent-timeout-control {
width: 100%;
flex: 1 1 100%;
min-width: 0;
justify-content: start;
}
.input-shell {
flex-basis: 100%;
padding: 0 8px;
}
@@ -2109,16 +2071,12 @@ tbody tr:last-child td {
}
.command-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.agent-quick-prompts button {
flex-basis: 100%;
align-items: stretch;
}
.agent-timeout-control {
grid-template-columns: minmax(0, 1fr);
justify-items: start;
flex: 1 1 100%;
min-width: 0;
}
.agent-timeout-control select {
@@ -2130,6 +2088,7 @@ tbody tr:last-child td {
}
.input-shell {
flex-basis: 100%;
grid-template-columns: minmax(0, 1fr);
}