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 7611f26c..2e3e294a 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts @@ -94,6 +94,36 @@ test("mergeTraceResults prefers terminal result markdown over non-final flattene assert.doesNotMatch(merged.assistantText ?? "", /^I'll read context first/u); }); +test("mergeTraceResults prefers structured result markdown over flattened trace finalResponse", () => { + const resultMarkdown = "ISSUE959_MARKDOWN_OK\n\n## hwpod-spec 机制说明\n\n- 第一条\n- 第二条\n\n```bash\nhwpod-cli run --spec .hwlab/hwpod-spec.yaml\n```"; + const flattenedFinalResponse = "Let me read context first.ISSUE959_MARKDOWN_OK ## hwpod-spec 机制说明 - 第一条 - 第二条 ```bash hwpod-cli run --spec .hwlab/hwpod-spec.yaml ```"; + const terminal: AgentChatResultResponse = { + status: "completed", + traceId: "trc_issue959_finalresponse_flattened", + assistantText: flattenedFinalResponse, + reply: { role: "assistant", content: resultMarkdown } + }; + const events: TraceEvent[] = [ + { seq: 31, label: "agentrun:assistant:message", type: "assistant", status: "running", message: flattenedFinalResponse }, + { seq: 36, label: "agentrun:assistant:message", type: "assistant", status: "running", message: flattenedFinalResponse }, + { seq: 42, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, message: "AgentRun result is ready for HWLAB short-connection polling." } + ]; + const trace: TraceSnapshot = { + traceId: "trc_issue959_finalresponse_flattened", + status: "completed", + events, + eventCount: events.length, + finalResponse: { text: flattenedFinalResponse }, + lastEventLabel: "agentrun:result:completed" + }; + + const merged = mergeTraceResults(terminal, trace); + + assert.equal(merged.assistantText, resultMarkdown); + assert.match(merged.assistantText ?? "", /ISSUE959_MARKDOWN_OK\n\n## hwpod-spec/u); + assert.doesNotMatch(merged.assistantText ?? "", /^Let me read context first/u); +}); + test("mergeTraceResults prefers persisted finalResponse text for expired or compact natural traces", () => { const finalMarkdown = "**结论**\n\n- 自然结束和刷新后都应该显示这一段。\n- Markdown 换行必须保留。"; const terminal: AgentChatResultResponse = { diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index ea5e5d4b..3dca1854 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -97,10 +97,12 @@ 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 = finalResponseText(trace.finalResponse) - ?? terminalAssistantTextFromTraceEvents(events) - ?? firstNonEmptyResultText(terminal) - ?? assistantTextFromTraceEvents(events); + const assistantText = bestAssistantTextCandidate([ + { text: finalResponseText(trace.finalResponse), priority: 40 }, + { text: terminalAssistantTextFromTraceEvents(events), priority: 30 }, + { text: firstNonEmptyResultText(terminal), priority: 20 }, + { text: assistantTextFromTraceEvents(events), priority: 10 } + ]); return { ...terminal, traceId: trace.traceId ?? terminal.traceId, @@ -144,7 +146,53 @@ function mergeTerminalRunnerTrace(terminalTrace: AgentChatResultResponse["runner function firstNonEmptyResultText(result: AgentChatResultResponse): string | null { const reply = result.reply; const replyText = typeof reply === "string" ? reply : typeof reply?.content === "string" ? reply.content : null; - return firstNonEmptyString(result.assistantText, replyText); + return bestAssistantTextCandidate([ + { text: result.assistantText, priority: 20 }, + { text: replyText, priority: 10 } + ]); +} + +interface AssistantTextCandidate { + text: string | null | undefined; + priority: number; +} + +function bestAssistantTextCandidate(candidates: AssistantTextCandidate[]): string | null { + const scored: Array<{ text: string; fidelity: number; priority: number; index: number }> = []; + candidates.forEach((candidate, index) => { + const text = firstNonEmptyString(candidate.text); + if (!text) return; + scored.push({ text, fidelity: markdownBlockFidelityScore(text), priority: candidate.priority, index }); + }); + const best = scored.reduce((currentBest, current) => { + if (!currentBest || compareAssistantTextCandidate(current, currentBest) > 0) return current; + return currentBest; + }, null); + return best?.text ?? null; +} + +function compareAssistantTextCandidate( + left: { fidelity: number; priority: number; index: number }, + right: { fidelity: number; priority: number; index: number } +): number { + if (left.fidelity !== right.fidelity) return left.fidelity - right.fidelity; + if (left.priority !== right.priority) return left.priority - right.priority; + return right.index - left.index; +} + +function markdownBlockFidelityScore(text: string): number { + const newlineCount = (text.match(/\n/gu) ?? []).length; + const headingCount = (text.match(/^#{1,6}\s+/gmu) ?? []).length; + const listItemCount = (text.match(/^\s*(?:[-*+] |\d+\.\s+)/gmu) ?? []).length; + const codeFenceCount = (text.match(/^```/gmu) ?? []).length; + const tableRowCount = (text.match(/^\s*\|.*\|\s*$/gmu) ?? []).length; + const paragraphBreakCount = (text.match(/\n\s*\n/gu) ?? []).length; + return Math.min(newlineCount, 24) + + headingCount * 8 + + listItemCount * 4 + + codeFenceCount * 8 + + tableRowCount * 4 + + paragraphBreakCount * 3; } function assistantTextFromTraceEvents(events: TraceEvent[]): string | null {