From faad89fc6fa32d255f4c44f5caa98f163cab8e3b Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 6 Jun 2026 00:58:31 +0800 Subject: [PATCH] fix: preserve natural final response markdown --- scripts/web-live-dom-probe.mjs | 52 ++++++++++++++++++- .../src/state/runner-trace.test.ts | 46 ++++++++++++++++ web/hwlab-cloud-web/src/state/runner-trace.ts | 21 +++++++- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/scripts/web-live-dom-probe.mjs b/scripts/web-live-dom-probe.mjs index 53406c0f..d9239d5f 100644 --- a/scripts/web-live-dom-probe.mjs +++ b/scripts/web-live-dom-probe.mjs @@ -48,6 +48,7 @@ export function parseProbeArgs(argv) { projectId: process.env.HWLAB_WEB_PROBE_PROJECT_ID ?? "prj_hwpod_workbench", conversationId: process.env.HWLAB_WEB_PROBE_CONVERSATION_ID ?? "", waitMessagesMs: 2500, + waitAgentTerminalMs: Number(process.env.HWLAB_WEB_PROBE_WAIT_AGENT_TERMINAL_MS ?? "0"), reportPath: tempReportPath("web-live-dom-probe.json"), screenshotPath: tempReportPath("web-live-dom-probe.png"), jobId: "" @@ -75,6 +76,7 @@ export function parseProbeArgs(argv) { else if (arg === "--job-id" || arg.startsWith("--job-id=")) args.jobId = readValue("--job-id"); else if (arg === "--wait-after-submit-ms" || arg.startsWith("--wait-after-submit-ms=")) args.waitAfterSubmitMs = positiveInteger(readValue("--wait-after-submit-ms"), "--wait-after-submit-ms"); else if (arg === "--wait-messages-ms" || arg.startsWith("--wait-messages-ms=")) args.waitMessagesMs = positiveInteger(readValue("--wait-messages-ms"), "--wait-messages-ms"); + else if (arg === "--wait-agent-terminal-ms" || arg.startsWith("--wait-agent-terminal-ms=")) args.waitAgentTerminalMs = positiveInteger(readValue("--wait-agent-terminal-ms"), "--wait-agent-terminal-ms"); else if (arg === "--timeout-ms" || arg.startsWith("--timeout-ms=")) args.timeoutMs = positiveInteger(readValue("--timeout-ms"), "--timeout-ms"); else if (arg === "--fresh-session") args.freshSession = true; else if (arg === "--no-cancel-running") args.cancelRunning = false; @@ -105,6 +107,7 @@ export async function runProbe(args) { if (args.conversationId) await selectConversationInBrowserSession(page, args, actions); if (args.freshSession) await createFreshSession(page, args, actions); if (args.message) await submitPrompt(page, args, actions); + if (args.waitAgentTerminalMs) await waitForAgentTerminal(page, args, actions); await waitForMessageCards(page, args, actions); await page.screenshot({ path: screenshotPath, fullPage: true }); const dom = await collectDomEvidence(page); @@ -171,7 +174,8 @@ async function startProbe(args) { "--report", reportPath, "--screenshot", screenshotPath, "--timeout-ms", String(args.timeoutMs), - "--wait-after-submit-ms", String(args.waitAfterSubmitMs) + "--wait-after-submit-ms", String(args.waitAfterSubmitMs), + "--wait-agent-terminal-ms", String(args.waitAgentTerminalMs) ]; if (args.freshSession) childArgs.push("--fresh-session"); if (!args.cancelRunning) childArgs.push("--no-cancel-running"); @@ -451,6 +455,37 @@ async function waitForMessageCards(page, args, actions) { actions.push({ action: "wait-message-cards", before, after, found: Boolean(found), timeoutMs: args.waitMessagesMs }); } +async function waitForAgentTerminal(page, args, actions) { + const timeoutMs = Math.min(args.waitAgentTerminalMs, args.timeoutMs); + const before = await collectLatestAgentMessageState(page); + const terminal = await page.waitForFunction(() => { + const cards = [...document.querySelectorAll(".message-card")]; + const latestAgent = [...cards].reverse().find((card) => card.getAttribute("data-message-role") === "agent" || card.classList.contains("message-agent")); + if (!latestAgent) return false; + const status = latestAgent.getAttribute("data-message-status") ?? ""; + const finalResponse = latestAgent.querySelector(".message-final-response"); + const text = finalResponse?.textContent?.trim() ?? ""; + return status && status !== "running" && text.length > 0; + }, null, { timeout: timeoutMs }).catch(() => null); + const after = await collectLatestAgentMessageState(page); + actions.push({ action: "wait-agent-terminal", terminal: Boolean(terminal), timeoutMs, before, after }); + if (!terminal) throw new Error(`agent final response did not reach terminal DOM state: ${JSON.stringify({ before, after, timeoutMs })}`); +} + +async function collectLatestAgentMessageState(page) { + return page.evaluate(() => { + const cards = [...document.querySelectorAll(".message-card")]; + const latestAgent = [...cards].reverse().find((card) => card.getAttribute("data-message-role") === "agent" || card.classList.contains("message-agent")); + const finalResponse = latestAgent?.querySelector(".message-final-response"); + return { + messageCount: cards.length, + status: latestAgent?.getAttribute("data-message-status") ?? null, + textChars: finalResponse?.textContent?.trim().length ?? 0, + textPreview: (finalResponse?.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 240) + }; + }); +} + async function collectDomEvidence(page) { return page.evaluate(() => { const workspace = document.querySelector("#workspace"); @@ -483,6 +518,7 @@ async function collectDomEvidence(page) { const finalStyle = finalResponse ? getComputedStyle(finalResponse) : null; const traceRect = traceEvents?.getBoundingClientRect(); const finalRect = finalResponse?.getBoundingClientRect(); + const finalBody = finalResponse?.querySelector(".message-body"); return { status: card.getAttribute("data-message-status") ?? null, role: card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system")?.replace(/^message-/, "") ?? null, @@ -502,7 +538,18 @@ async function collectDomEvidence(page) { maxHeight: finalStyle?.maxHeight ?? null, overflowY: finalStyle?.overflowY ?? null, height: finalRect ? Math.round(finalRect.height) : null, - scrollHeight: Math.round(finalResponse.scrollHeight) + scrollHeight: Math.round(finalResponse.scrollHeight), + textChars: finalResponse.textContent?.trim().length ?? 0, + textPreview: (finalResponse.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 500), + markdown: finalBody ? { + paragraphs: finalBody.querySelectorAll("p").length, + headings: finalBody.querySelectorAll("h1,h2,h3,h4,h5,h6").length, + lists: finalBody.querySelectorAll("ul,ol").length, + listItems: finalBody.querySelectorAll("li").length, + codeBlocks: finalBody.querySelectorAll("pre code").length, + inlineCode: finalBody.querySelectorAll("p code,li code").length, + tables: finalBody.querySelectorAll("table").length + } : null } : { present: false } }; }), @@ -546,6 +593,7 @@ function helpPayload() { command: "web-live-dom-probe", usage: [ "node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/ --fresh-session --report /tmp/hwlab-dev-gate/web-live-dom-probe.json", + "node scripts/web-live-dom-probe.mjs start --url http://74.48.78.17:19666/ --fresh-session --message '...' --no-cancel-running --wait-agent-terminal-ms 180000", "node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/#/workspace --conversation-id cnv_...", "node scripts/web-live-dom-probe.mjs start --url http://74.48.78.17:19666/ --fresh-session", "node scripts/web-live-dom-probe.mjs status " diff --git a/web/hwlab-cloud-web/src/state/runner-trace.test.ts b/web/hwlab-cloud-web/src/state/runner-trace.test.ts index 5685c683..4843337f 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts @@ -48,6 +48,52 @@ test("mergeTraceResults keeps trace assistant markdown ahead of flattened termin assert.match(merged.assistantText ?? "", /\n\n\| hwpodId \| node \|/u); }); +test("mergeTraceResults uses final assistant authority instead of later progress snapshots", () => { + const finalMarkdown = "以下是 hwpod-spec 的机制说明:\n\n## 调用链\n\n1. `hwpod-cli` 读取 `.hwlab/hwpod-spec.yaml`。\n2. `hwpod-compiler-cli` 编译 intent。\n\n```bash\nhwpod inspect --dry-run\n```"; + const flattenedProgress = "让我先重新加载当前工作区的上下文。再快速看看源码的实现:以下是对你问题的回答: --- ## hwpod-spec 机制说明 ### 整体架构 hwpod-cli / hwpod-ctl -> hwpod-compiler-cli"; + const terminal: AgentChatResultResponse = { + status: "completed", + traceId: "trc_issue959_natural", + assistantText: flattenedProgress + }; + const events: TraceEvent[] = [ + { seq: 10, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "让我先重新加载当前工作区的上下文。" }, + { seq: 20, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "再快速看看源码的实现:" }, + { seq: 30, label: "agentrun:assistant:message", type: "assistant", status: "completed", final: true, replyAuthority: true, terminal: true, message: finalMarkdown }, + { seq: 31, label: "agentrun:assistant:message", type: "assistant", status: "running", message: flattenedProgress }, + { seq: 32, label: "agentrun:result:completed", type: "result", status: "completed", message: "AgentRun result is ready for HWLAB short-connection polling." } + ]; + const trace: TraceSnapshot = { traceId: "trc_issue959_natural", status: "completed", events, eventCount: events.length, lastEventLabel: "agentrun:result:completed" }; + + const merged = mergeTraceResults(terminal, trace); + + assert.equal(merged.assistantText, finalMarkdown); + assert.match(merged.assistantText ?? "", /\n\n## 调用链\n\n1\. `hwpod-cli`/u); + assert.doesNotMatch(merged.assistantText ?? "", / --- ## hwpod-spec 机制说明 ###/u); +}); + +test("mergeTraceResults prefers persisted finalResponse text for expired or compact natural traces", () => { + const finalMarkdown = "**结论**\n\n- 自然结束和刷新后都应该显示这一段。\n- Markdown 换行必须保留。"; + const terminal: AgentChatResultResponse = { + status: "completed", + traceId: "trc_issue959_fallback", + assistantText: "自然运行时的压平文本: **结论** - 自然结束和刷新后都应该显示这一段。" + }; + const trace: TraceSnapshot = { + traceId: "trc_issue959_fallback", + status: "completed", + events: [], + eventCount: 63, + finalResponse: { text: finalMarkdown }, + traceSummary: { sourceEventCount: 63 } + }; + + const merged = mergeTraceResults(terminal, trace); + + assert.equal(merged.assistantText, finalMarkdown); + assert.match(merged.assistantText ?? "", /\*\*结论\*\*\n\n- 自然结束/u); +}); + test("mergeTraceResults keeps full polled trace events when terminal runnerTrace is compact", () => { const terminal: AgentChatResultResponse = { status: "completed", diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index 48c6bda4..e45f1e2e 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -97,7 +97,7 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac const terminalRunnerEvents = Array.isArray(terminalRunnerTrace?.events) ? terminalRunnerTrace.events : []; const events = longestTraceEvents(traceEvents, terminalEvents, terminalRunnerEvents); const mergedTrace = { ...trace, events, eventCount: trace.eventCount ?? events.length }; - const assistantText = assistantTextFromTraceEvents(events) ?? firstNonEmptyResultText(terminal); + const assistantText = finalResponseText(trace.finalResponse) ?? assistantTextFromTraceEvents(events) ?? firstNonEmptyResultText(terminal); return { ...terminal, traceId: trace.traceId ?? terminal.traceId, @@ -145,6 +145,8 @@ function firstNonEmptyResultText(result: AgentChatResultResponse): string | null } function assistantTextFromTraceEvents(events: TraceEvent[]): string | null { + const terminal = terminalAssistantTextFromTraceEvents(events); + if (terminal) return terminal; for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]; if (!event || !isAssistantTraceEvent(event)) continue; @@ -154,11 +156,28 @@ function assistantTextFromTraceEvents(events: TraceEvent[]): string | null { return null; } +function terminalAssistantTextFromTraceEvents(events: TraceEvent[]): string | null { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (!event || !isTerminalAssistantTraceEvent(event)) continue; + const text = firstNonEmptyString(event.message, event.text, event.outputSummary); + if (text) return text; + } + return null; +} + function isAssistantTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); return label === "agentrun:assistant:message" || label === "assistant:message" || label === "assistant:completed" || event.type === "assistant" || event.type === "assistant_message"; } +function isTerminalAssistantTraceEvent(event: TraceEvent): boolean { + const label = String(event.label ?? ""); + if (label === "assistant:completed") return true; + if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true; + return event.type === "assistant_message" && (event.status === "completed" || event.final === true || event.terminal === true); +} + function firstNonEmptyString(...values: unknown[]): string | null { for (const value of values) { if (typeof value !== "string") continue;