fix: persist workbench code agent sessions

This commit is contained in:
lyon
2026-05-26 00:37:29 +08:00
parent 8f80a6f9c3
commit c6adf34f27
7 changed files with 209 additions and 30 deletions
+16 -5
View File
@@ -123,11 +123,22 @@ delta、assistant chunk、command output chunk、reasoning delta 等噪声可以
但聚合不得吞掉中间的可读 message/tool call。命令和工具行必须至少保留状态、exit
code、耗时、输出体量、关键 operation/evidence id 和可读摘要。
“显示全部”只能用于已经覆盖完整 trace 的可读事件线,不能把 result polling 返回的
head-tail 压缩窗口或 9 行摘要冒充全量。若当前只拿到压缩窗口,界面必须明确显示
“压缩窗口/待回放完整 trace”,并通过 `/v1/agent/chat/trace/<traceId>` 自动或手动回放
完整 trace 后再标记为“显示全部”。若完整 trace 已过期或后端缺失,也必须显式展
缺失状态,而不是把已载入窗口说成全量
Workbench 面向用户的 trace 视图不得展示“压缩窗口”作为可读事件列表,也不得把
result polling 返回的 head-tail 窗口或少量摘要冒充完整 trace。只要发现
`eventsCompacted=true`,前端必须自动请求 `/v1/agent/chat/trace/<traceId>` 回放完整
trace;回放期间只能显示“完整 trace 回放中/当前已载入”状态,回放完成后才显
“显示全部可读事件”。如果完整 trace 已过期或后端缺失,必须显式展示缺失状态。
assistant chunk、token delta、command output chunk 可以在同一 message/tool output 内
合并成一条高可读行,但不能隐藏任意 assistant message、tool call、tool completed、
stderr/error 或 turn/status 边界。
Code Agent 工作台状态必须跨浏览器刷新持久化。前端至少保存
`conversationId/sessionId/threadId/sessionStatus`、最近消息、traceId 和必要的 runtime
摘要,刷新或重新打开页面后继续使用同一 repo-owned Codex session/thread 和固定
workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对话或登出,不得把下一轮
请求降级成“首轮新会话”。登录态采用 server cookie 与本地 session 双轨:server session
可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期,
刷新页面不得要求用户重新登录。
## Lightweight Checks
+5 -2
View File
@@ -73,7 +73,10 @@ blocker,不能降级到 M3 Skill CLI、受控硬件 shortcut、外网专用检
持久 session 是默认合同:同一个 `conversationId/sessionId` 必须映射到 repo-owned Codex
thread 和固定 workspace,刷新前端、重新打开页面或短连接 result 轮询不得创建新的短期 runner。
除非 Pod 重建或 Codex supervisor 明确重启,workspace、thread/session 绑定和可见 trace 应持续
存在。
存在。Workbench 前端必须把这些会话标识和最近消息持久化到浏览器本地状态;用户显式清空
对话或登出时才清除该本地状态。刷新后下一轮自然语言请求必须携带已保存的
`conversationId/sessionId/threadId`,不能只因为 JS 内存重建就显示“首轮请求”或重新分配
Codex workspace。
## PC Gateway Windows Skill 调用
@@ -99,7 +102,7 @@ Workbench 与 Code Agent 的用户请求必须是短连接 submit + 短连接 re
cloud-web 同源代理必须把短连接语义原样转发给 cloud-api,至少包括 `Prefer: respond-async``X-HWLAB-Short-Connection``X-Trace-Id`。如果这些 header 在 cloud-web 层被过滤,cloud-api 会把同一个请求当成长同步请求处理,用户入口会表现为 16666 卡住或代理超时,而 16667 直连 cloud-api 正常。此类问题应先比对同一 trace 在 16666 与 16667 的 submit 行为,再修代理 header 透传,而不是调大前端等待时间。
`/v1/agent/chat/result/<traceId>` 是终态摘要接口,不是完整 trace 下载接口。它可以携带压缩后的 `runnerTrace` 窗口用于当前 UI 刷新,但必须保留 `eventCount``lastEvent``providerTrace``threadId/sessionId` 和终态 reply/blocker;完整 trace 只能从 `/v1/agent/chat/trace/<traceId>`、复制 JSON 或下载 trace 入口取得。默认 result trace 窗口上限由 `HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT` 控制;不要把数百个大 chunk 原样塞进 result 响应,避免 cloud-web 代理层或浏览器 fetch 把“正常执行中的大响应”表现成 503、非 JSON 或空响应。
`/v1/agent/chat/result/<traceId>` 是终态摘要接口,不是完整 trace 下载接口。它可以携带压缩后的 `runnerTrace` 窗口用于传输保活,但 Workbench 用户界面不得把该窗口显示为“压缩窗口”或“显示全部”。只要结果或轮询快照声明 `eventsCompacted=true`,前端必须自动请求 `/v1/agent/chat/trace/<traceId>` 并用完整 trace 替换可视事件线;回放完成前只能显示“完整 trace 回放中/当前已载入”状态。result 响应仍必须保留 `eventCount``lastEvent``providerTrace``threadId/sessionId` 和终态 reply/blocker;完整 trace 只能从 `/v1/agent/chat/trace/<traceId>`、复制 JSON 或下载 trace 入口取得。默认 result trace 窗口上限由 `HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT` 控制;不要把数百个大 chunk 原样塞进 result 响应,避免 cloud-web 代理层或浏览器 fetch 把“正常执行中的大响应”表现成 503、非 JSON 或空响应。
result 轮询的 408/425/429/5xx、浏览器 timeout、非 JSON 或空响应应按“可恢复传输抖动”处理:前端先拉取一次 trace 刷新活性,再带退避继续轮询,只有后端返回结构化 terminal blocker、真实终态失败,或 trace 按无新事件 idle timeout 超时,才向用户显示失败。只要 `/trace` 仍显示新事件或 `waitingFor` 仍在推进,就不能把一次 result poll 失败标成“Code Agent API 错误”并停止。
+12 -3
View File
@@ -628,9 +628,9 @@ function runStaticSmoke() {
evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"]
});
addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels disclose full trace access and preserve open/scroll state while live trace updates.", {
addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels auto-replay full trace, avoid compressed-window UI, and preserve open/scroll state while live trace updates.", {
blocker: "observability_blocker",
evidence: ["显示全部可读事件 / 压缩窗口", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"]
evidence: ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"]
});
addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider/stdio blockers without exposing credential internals and only long-lived Codex stdio replies can become full Code Agent completion.", {
@@ -1978,6 +1978,8 @@ function hasDefaultLoginEntry({ html, app, auth, styles }, artifactPublisher = "
/DEFAULT_AUTH_PASSWORD\s*=\s*["']hwlab2026["']/u.test(auth) &&
/HWLAB_CLOUD_WEB_CONFIG\?\.auth/u.test(auth) &&
/AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) &&
/const localSession = readLocalSession\(config\)/u.test(auth) &&
/writeLocalSession\(config\)/u.test(functionBody(auth, "attemptLogin")) &&
/账号或密码不正确,请重新输入。/u.test(auth) &&
/ensureWorkbenchAuth/u.test(app) &&
/await\s+ensureWorkbenchAuth/u.test(app) &&
@@ -2672,7 +2674,14 @@ function hasCodeAgentTraceReplayDisclosure({ app, styles }) {
return (
/function\s+messageTraceCountText\s*\(/u.test(app) &&
/显示全部可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) &&
/压缩窗口\s+\$\{readableTotal\}\s+条可读事件\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) &&
/完整 trace 回放中\s+\/\s+当前可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) &&
!/压缩窗口\s+\$\{readableTotal\}/u.test(app) &&
/CODE_AGENT_SESSION_STORAGE_KEY\s*=\s*"hwlab\.workbench\.codeAgentSession\.v1"/u.test(app) &&
/function\s+restoreCodeAgentSessionState\s*\(/u.test(app) &&
/function\s+persistCodeAgentSessionState\s*\(/u.test(app) &&
/window\.localStorage\?\.setItem\(CODE_AGENT_SESSION_STORAGE_KEY/u.test(app) &&
/window\.localStorage\?\.removeItem\(CODE_AGENT_SESSION_STORAGE_KEY/u.test(app) &&
/refreshRestoredCodeAgentTraces/u.test(app) &&
/function\s+maybeReplayFullTraceForMessage\s*\(/u.test(app) &&
/function\s+replayFullTrace\s*\(/u.test(app) &&
/function\s+renderTraceEventList\s*\(/u.test(app) &&
+144 -2
View File
@@ -25,6 +25,12 @@ const DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS = 30000;
const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000;
const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1";
const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1";
const CODE_AGENT_SESSION_STORAGE_KEY = "hwlab.workbench.codeAgentSession.v1";
const CODE_AGENT_SESSION_STORAGE_VERSION = 1;
const CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT = 30;
const CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT = 80;
const CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS = 250;
const TRACE_STREAM_FALLBACK_MS = 2500;
const TRACE_POLL_INTERVAL_MS = 1000;
const FULL_TRACE_REPLAY_TIMEOUT_MS = 15000;
@@ -219,6 +225,10 @@ const state = {
}
};
let codeAgentSessionPersistTimer = null;
restoreCodeAgentSessionState();
initRoutes();
initLayoutSizing();
initLeftSidebarToggle();
@@ -232,6 +242,7 @@ initConversationScrollMemory();
initCommandBar();
initLiveBuildOverlay();
initWorkbenchLogout(el.logoutButton);
el.logoutButton.addEventListener("click", clearCodeAgentSessionState);
initM3Control();
initGateControls();
installWorkbenchTestHooks();
@@ -240,6 +251,7 @@ renderProbePending();
renderCodeAgentSummary();
loadHelpSurface();
loadLiveSurface().then(renderLiveSurface);
window.setTimeout(refreshRestoredCodeAgentTraces, 0);
function byId(id) {
const element = document.getElementById(id);
@@ -336,6 +348,131 @@ function syncCodeAgentTimeoutControl() {
el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`;
}
function restoreCodeAgentSessionState() {
let payload = null;
try {
const raw = window.localStorage?.getItem(CODE_AGENT_SESSION_STORAGE_KEY);
payload = raw ? JSON.parse(raw) : null;
} catch {
payload = null;
}
if (!payload || payload.version !== CODE_AGENT_SESSION_STORAGE_VERSION) return;
const updatedAtMs = Number(payload.updatedAtMs);
if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS) {
clearCodeAgentSessionState();
return;
}
state.conversationId = nonEmptyString(payload.conversationId);
state.sessionId = nonEmptyString(payload.sessionId);
state.threadId = nonEmptyString(payload.threadId);
state.sessionStatus = nonEmptyString(payload.sessionStatus);
state.chatMessages = Array.isArray(payload.chatMessages)
? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean)
: [];
}
function restoreStoredChatMessage(message) {
if (!message || typeof message !== "object") return null;
const restored = { ...message };
restored.id = nonEmptyString(restored.id) ?? nextProtocolId("msg");
restored.role = restored.role === "user" ? "user" : restored.role === "system" ? "system" : "agent";
restored.title = nonEmptyString(restored.title) ?? (restored.role === "user" ? "用户" : "Agent");
restored.text = typeof restored.text === "string" ? restored.text : "";
restored.status = nonEmptyString(restored.status) ?? "source";
if (restored.status === "running") restored.status = "source";
restored.conversationId = nonEmptyString(restored.conversationId) ?? state.conversationId;
restored.sessionId = nonEmptyString(restored.sessionId) ?? state.sessionId;
restored.threadId = nonEmptyString(restored.threadId) ?? state.threadId;
restored.traceId = nonEmptyString(restored.traceId);
if (restored.runnerTrace && typeof restored.runnerTrace === "object") {
restored.runnerTrace = restoreStoredRunnerTrace(restored.runnerTrace);
}
return restored;
}
function restoreStoredRunnerTrace(trace) {
return {
...trace,
events: Array.isArray(trace.events) ? trace.events : [],
eventCount: Number.isInteger(trace.eventCount) ? trace.eventCount : Array.isArray(trace.events) ? trace.events.length : 0,
eventsCompacted: trace.eventsCompacted === true,
fullTraceLoaded: trace.fullTraceLoaded === true
};
}
function scheduleCodeAgentSessionPersist() {
if (codeAgentSessionPersistTimer !== null) window.clearTimeout(codeAgentSessionPersistTimer);
codeAgentSessionPersistTimer = window.setTimeout(() => {
codeAgentSessionPersistTimer = null;
persistCodeAgentSessionState();
}, CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS);
}
function persistCodeAgentSessionState() {
if (!state.conversationId && !state.sessionId && !state.threadId && state.chatMessages.length === 0) {
clearCodeAgentSessionState();
return;
}
const payload = {
version: CODE_AGENT_SESSION_STORAGE_VERSION,
updatedAtMs: Date.now(),
conversationId: state.conversationId,
sessionId: state.sessionId,
threadId: state.threadId,
sessionStatus: state.sessionStatus,
chatMessages: state.chatMessages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT).map(storedChatMessage)
};
try {
window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload));
} catch {
// Keep the in-memory conversation when localStorage is unavailable or full.
}
}
function clearCodeAgentSessionState() {
if (codeAgentSessionPersistTimer !== null) {
window.clearTimeout(codeAgentSessionPersistTimer);
codeAgentSessionPersistTimer = null;
}
try {
window.localStorage?.removeItem(CODE_AGENT_SESSION_STORAGE_KEY);
} catch {
// Ignore storage availability errors.
}
}
function storedChatMessage(message) {
const stored = { ...message };
if (stored.runnerTrace && typeof stored.runnerTrace === "object") {
stored.runnerTrace = storedRunnerTrace(stored.runnerTrace);
}
delete stored.m3Evidence;
delete stored.availability;
return stored;
}
function storedRunnerTrace(trace) {
const events = Array.isArray(trace.events) ? trace.events : [];
const storedEvents = trace.fullTraceLoaded === true && events.length <= CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT
? events
: [];
const fullTraceLoaded = storedEvents.length > 0 && trace.fullTraceLoaded === true;
return {
...trace,
events: storedEvents,
eventsCompacted: !fullTraceLoaded,
fullTraceLoaded
};
}
function refreshRestoredCodeAgentTraces() {
for (const message of state.chatMessages) {
if (!message.traceId) continue;
if (message.runnerTrace?.fullTraceLoaded === true) continue;
replayFullTrace(message.id, { quiet: true });
}
}
function initLiveBuildOverlay() {
el.liveBuildSummary.open = false;
el.liveBuildToggle.addEventListener("click", (event) => {
@@ -826,6 +963,7 @@ function initCommandBar() {
state.currentRequest = null;
state.canceledTraces.clear();
state.chatPending = false;
clearCodeAgentSessionState();
el.commandInput.value = "";
renderAgentChatStatus("idle");
renderCodeAgentSummary();
@@ -1383,6 +1521,9 @@ function updateMessageTrace(messageId, snapshot, options = {}) {
if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && threadId) {
state.currentRequest.threadId = threadId;
}
if (runnerTrace.eventsCompacted === true && runnerTrace.fullTraceLoaded !== true) {
maybeReplayFullTraceForMessage(state.chatMessages[index]);
}
if (options.quiet !== true) {
renderCodeAgentSummary();
renderConversation();
@@ -2103,6 +2244,7 @@ function renderConversation() {
replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard));
restoreConversationScrollPosition();
restoreTraceScrollPositions();
scheduleCodeAgentSessionPersist();
window.requestAnimationFrame(() => {
if (renderVersion !== state.conversationRenderVersion) return;
restoreConversationScrollPosition({ deferred: true });
@@ -4332,7 +4474,7 @@ function messageTraceToolbar(message, trace, events, rows) {
function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) {
if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) {
return `压缩窗口 ${readableTotal} 条可读事件 / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`;
return `完整 trace 回放中 / 当前可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`;
}
return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`;
}
@@ -4588,7 +4730,7 @@ function traceNoiseSummaryBody(events, assistantChunks) {
const message = cleanTraceText(text);
const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null;
return [
`compressed=${assistantChunks.length} assistant chunks`,
`chunks=${assistantChunks.length} assistant message chunks`,
waitingFor,
message ? `message=${message}` : null
].filter(Boolean).join("\n");
+14 -8
View File
@@ -71,6 +71,10 @@ async function readActiveSession(config) {
if (config.mode !== "local") {
const serverSession = await fetchServerSession();
if (serverSession.available) {
if (!serverSession.authenticated && config.mode !== "server") {
const localSession = readLocalSession(config);
if (localSession.authenticated) return localSession;
}
return {
authenticated: serverSession.authenticated,
mode: "server",
@@ -210,14 +214,16 @@ async function attemptLogin({ username, password, config }) {
if (config.mode !== "local") {
const serverResult = await attemptServerLogin(username, password);
if (serverResult.available) {
return serverResult.authenticated
? {
authenticated: true,
mode: "server",
user: serverResult.user,
expiresAt: serverResult.expiresAt
}
: { authenticated: false, mode: "server" };
if (serverResult.authenticated) {
writeLocalSession(config);
return {
authenticated: true,
mode: "server",
user: serverResult.user,
expiresAt: serverResult.expiresAt
};
}
return { authenticated: false, mode: "server" };
}
if (config.mode === "server") {
return { authenticated: false, mode: "server" };
+10 -2
View File
@@ -801,7 +801,14 @@ assert.match(app, /function messageTracePanel/);
assert.match(app, /function messageTraceToolbar/);
assert.match(app, /function messageTraceCountText/);
assert.match(app, /显示全部可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/);
assert.match(app, /压缩窗口\s+\$\{readableTotal\}\s+条可读事件\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/);
assert.match(app, /完整 trace 回放中\s+\/\s+当前可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/);
assert.doesNotMatch(app, /压缩窗口\s+\$\{readableTotal\}/);
assert.match(app, /CODE_AGENT_SESSION_STORAGE_KEY\s*=\s*"hwlab\.workbench\.codeAgentSession\.v1"/);
assert.match(app, /function restoreCodeAgentSessionState/);
assert.match(app, /function persistCodeAgentSessionState/);
assert.match(app, /window\.localStorage\?\.setItem\(CODE_AGENT_SESSION_STORAGE_KEY/);
assert.match(app, /window\.localStorage\?\.removeItem\(CODE_AGENT_SESSION_STORAGE_KEY/);
assert.match(app, /refreshRestoredCodeAgentTraces/);
assert.match(app, /FULL_TRACE_REPLAY_TIMEOUT_MS\s*=\s*15000/);
assert.match(app, /fullTraceReplayInFlight:\s*new Set\(\)/);
assert.match(app, /function maybeReplayFullTraceForMessage/);
@@ -867,7 +874,8 @@ assert.match(app, /tool gateway\.shell/);
assert.match(app, /function traceNoiseSummaryRow/);
assert.match(styles, /max-height:\s*min\(520px,\s*54dvh\)/);
assert.match(app, /assistant message x\$\{visibleEvents\.length\}/);
assert.match(app, /compressed=\$\{assistantChunks\.length\} assistant chunks/);
assert.match(app, /chunks=\$\{assistantChunks\.length\} assistant message chunks/);
assert.doesNotMatch(app, /compressed=\$\{assistantChunks\.length\} assistant chunks/);
assert.match(app, /message=\$\{text\}/);
assert.match(app, /waiting first assistant token/);
assert.match(app, /function compactTraceTextTail/);
@@ -135,13 +135,13 @@ test("trace display full means complete readable timeline, not compacted result
});
});
await page.waitForSelector(".message-trace-count", { timeout: 12000 });
const compacted = await tracePanelText(page);
assert.match(compacted.count, /压缩窗口/);
assert.doesNotMatch(compacted.count, /显示全部/);
assert.equal(compacted.rows.filter((row) => row.includes("tool gateway.shell")).length, 2, compacted.rows.join("\n---\n"));
assert.ok(compacted.rows.some((row) => row.includes("op=op_one")), compacted.rows.join("\n"));
assert.ok(compacted.rows.some((row) => row.includes("op=op_two")), compacted.rows.join("\n"));
assert.match(compacted.bodies.join("\n"), /first readable sentence middle detail final conclusion/);
const replaying = await tracePanelText(page);
assert.match(replaying.count, /完整 trace 回放中/);
assert.doesNotMatch(replaying.count, /压缩窗口|显示全部/);
assert.equal(replaying.rows.filter((row) => row.includes("tool gateway.shell")).length, 2, replaying.rows.join("\n---\n"));
assert.ok(replaying.rows.some((row) => row.includes("op=op_one")), replaying.rows.join("\n"));
assert.ok(replaying.rows.some((row) => row.includes("op=op_two")), replaying.rows.join("\n"));
assert.match(replaying.bodies.join("\n"), /first readable sentence middle detail final conclusion/);
await page.evaluate(() => {
const message = window.__hwlabWorkbenchTestHooks.tracePanelText();
@@ -157,7 +157,7 @@ test("trace display full means complete readable timeline, not compacted result
});
const full = await tracePanelText(page);
assert.match(full.count, /显示全部可读事件/);
assert.doesNotMatch(full.count, /压缩窗口/);
assert.doesNotMatch(full.count, /完整 trace 回放中|压缩窗口/);
await page.close();
} finally {