fix: preserve natural final response markdown

This commit is contained in:
Codex Agent
2026-06-06 00:58:31 +08:00
parent 02c55ede3e
commit faad89fc6f
3 changed files with 116 additions and 3 deletions
+50 -2
View File
@@ -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 <jobId>"
@@ -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",
+20 -1
View File
@@ -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;