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:
@@ -44,33 +44,6 @@ const codeAgentExternalNetworkPrompt = Object.freeze({
|
||||
text: "访问一下github看看"
|
||||
});
|
||||
|
||||
const codeAgentQuickPromptFixtures = Object.freeze([
|
||||
{
|
||||
id: "pwd",
|
||||
label: "Codex:pwd",
|
||||
prompt: "交给 Codex 执行 pwd,列出当前工作目录。",
|
||||
writable: false
|
||||
},
|
||||
{
|
||||
id: "skills",
|
||||
label: "Codex:列出 skill",
|
||||
prompt: "交给 Codex 列出你能使用的所有 skill。",
|
||||
writable: false
|
||||
},
|
||||
{
|
||||
id: "device-pod-status",
|
||||
label: "Codex:Device 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."
|
||||
|
||||
@@ -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 ?? ""}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" ||
|
||||
|
||||
@@ -230,7 +230,6 @@ initLeftSidebarToggle();
|
||||
initLeftSidebarResize();
|
||||
initRightSidebarResize();
|
||||
initDevicePodPanel();
|
||||
initQuickActions();
|
||||
initCodeAgentTimeoutControl();
|
||||
initConversationScrollMemory();
|
||||
initCommandBar();
|
||||
|
||||
@@ -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,列出当前工作目录。"
|
||||
>Codex:pwd</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 摘要,不执行下载、复位或写入。"
|
||||
>Codex:Device 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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user