From 02989a8237b66c4b359b367887161ef4a22a7284 Mon Sep 17 00:00:00 2001 From: lyon Date: Fri, 19 Jun 2026 15:38:54 +0800 Subject: [PATCH] fix: preserve workbench message whitespace --- internal/cloud/access-control.ts | 4 +- internal/cloud/code-agent-agentrun-adapter.ts | 14 +++-- internal/cloud/code-agent-trace-store.ts | 13 +++- internal/cloud/server-agent-chat.test.ts | 25 +++++++- internal/cloud/server-code-agent-http.ts | 6 +- internal/cloud/server-workbench-http.ts | 12 +++- internal/cloud/workbench-turn-projection.ts | 10 +++- tools/src/hwlab-cli/trace-renderer.ts | 2 +- .../scripts/workbench-e2e-server.ts | 60 +++++++++++++++++++ .../specs/trace-rendering.spec.ts | 32 ++++++++++ 10 files changed, 158 insertions(+), 20 deletions(-) diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index c5460670..fb22f7b2 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1973,8 +1973,8 @@ function firstConversationText(values = [], maxBytes = 12000) { return ""; } function conversationText(value, maxBytes = 12000) { - const extracted = conversationTextRaw(value).replace(/\r\n?/gu, "\n").trim(); - if (!extracted || extracted === "[object Object]") return ""; + const extracted = conversationTextRaw(value).replace(/\r\n?/gu, "\n"); + if (!extracted.trim() || extracted.trim() === "[object Object]") return ""; return boundedText(extracted, maxBytes); } function conversationTextRaw(value) { diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index f93c16ab..a33ecd19 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1109,6 +1109,12 @@ function promptTextValue(value) { return extracted; } +function messageAuthorityTextValue(value) { + const extracted = promptTextRaw(value).replace(/\r\n?/gu, "\n"); + if (!extracted.trim() || extracted.trim() === "[object Object]") return ""; + return extracted; +} + function promptTextRaw(value) { if (value === undefined || value === null) return ""; if (typeof value === "string") return value; @@ -1136,7 +1142,7 @@ function adapterError(code, message, details = {}) { } function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId }) { - const prompt = promptTextValue(params.message ?? params.prompt ?? ""); + const prompt = messageAuthorityTextValue(params.message ?? params.prompt ?? ""); const promptEvidence = agentRunPromptMetadataFromText(prompt, traceId, "agentrun-command-payload", { fullText: true }); const threadId = safeOpaqueId(params.threadId); return { @@ -1163,7 +1169,7 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile, sessionId } function buildAgentRunSteerCommandInput({ params, traceId, steerTraceId, mapped }) { - const prompt = promptTextValue(params.message ?? params.prompt ?? params.text ?? ""); + const prompt = messageAuthorityTextValue(params.message ?? params.prompt ?? params.text ?? ""); const promptEvidence = agentRunPromptMetadataFromText(prompt, steerTraceId, "agentrun-steer-command-payload", { fullText: true }); return { type: "steer", @@ -1602,7 +1608,7 @@ function agentRunToolCalls(result = {}, status = "completed") { function agentRunReplyText(result = {}) { for (const value of [result?.reply, result?.assistantMessage, result?.finalResponse, result?.content, result?.text, result?.message]) { - const text = promptTextValue(value); + const text = messageAuthorityTextValue(value); if (text) return text; } return ""; @@ -1735,7 +1741,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, a } function agentRunCompletedFinalResponse({ base, result, traceId, now, replyText = null }) { - const textValue = promptTextValue(replyText) || agentRunReplyText(result); + const textValue = messageAuthorityTextValue(replyText) || agentRunReplyText(result); return { text: textValue, textChars: textValue.length, diff --git a/internal/cloud/code-agent-trace-store.ts b/internal/cloud/code-agent-trace-store.ts index 02ddd87c..713d757a 100644 --- a/internal/cloud/code-agent-trace-store.ts +++ b/internal/cloud/code-agent-trace-store.ts @@ -407,8 +407,8 @@ function normalizeTraceEvent(event, { traceId, seq, now, fallbackRunnerKind } = outputSummary: safeText(event.outputSummary, 400), stdoutSummary: safeText(event.stdoutSummary, 600), stderrSummary: safeText(event.stderrSummary, 600), - chunk: safeText(event.chunk, 400), - message: safeText(event.message, 1200), + chunk: safeMessageText(event.chunk, 400), + message: safeMessageText(event.message, 1200), errorCode: safeText(event.errorCode, 120), diagnosisCode: safeText(event.diagnosisCode ?? event.diagnosis?.code, 160), diagnosis: normalizeDiagnosis(event.diagnosis), @@ -445,7 +445,7 @@ function normalizeDetails(value, depth = 0) { } function normalizeAssistantDelta(delta, { traceId, now, fallbackRunnerKind } = {}) { - const chunk = safeText(delta.chunk ?? delta.delta, 400); + const chunk = safeMessageText(delta.chunk ?? delta.delta, 400); if (!chunk) return null; return dropUndefined({ traceId, @@ -618,6 +618,13 @@ function safeText(value, limit = TEXT_LIMIT) { return redacted.length > limit ? `${redacted.slice(0, limit - 3)}...` : redacted; } +function safeMessageText(value, limit = TEXT_LIMIT) { + if (value === undefined || value === null || value === "") return undefined; + const redacted = String(value).replace(SECRET_PATTERN, "[redacted]").replace(/\r\n?/gu, "\n"); + if (!redacted.trim()) return undefined; + return redacted.length > limit ? `${redacted.slice(0, limit - 3)}...` : redacted; +} + function safeToken(value) { return String(value ?? "") .trim() diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 49454210..490395c6 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -837,6 +837,21 @@ test("AgentRun sync converts terminal command result even when run remains claim const traceId = "trc_issue1555_terminal_command"; const runId = "run_issue1555_claimed"; const commandId = "cmd_issue1555_completed"; + const finalText = [ + "已新增并实际运行:", + "", + "- Go:`go test ./...` 保留两个空格", + "- Rust:`cargo test --release`", + "", + "```sh", + "go test ./...", + "cargo test --release", + "```", + "", + "| benchmark | Go | Rust |", + "|---|---:|---:|", + "| fib | 1.2ms | 1.0ms |" + ].join("\n"); const agentRunServer = createHttpServer(async (request, response) => { const url = new URL(request.url || "/", "http://127.0.0.1"); calls.push({ method: request.method, path: url.pathname, search: url.search }); @@ -846,6 +861,7 @@ test("AgentRun sync converts terminal command result even when run remains claim }; if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) { return send({ items: [ + { id: "evt_issue1555_assistant", runId, seq: 138, type: "assistant_message", payload: { commandId, text: finalText, final: true, replyAuthority: true }, createdAt: "2026-06-18T00:00:00.000Z" }, { id: "evt_issue1555_terminal", runId, seq: 139, type: "terminal_status", payload: { commandId, terminalStatus: "completed" }, createdAt: "2026-06-18T00:00:00.000Z" } ] }); } @@ -867,7 +883,7 @@ test("AgentRun sync converts terminal command result even when run remains claim commandState: "completed", terminalStatus: "completed", completed: true, - reply: "issue 1555 command result is terminal even though the reusable run is still claimed.", + reply: finalText, lastSeq: 139, eventCount: 139, sessionRef: { sessionId: "ses_issue1555", conversationId: "cnv_issue1555", threadId: "thread_issue1555" } @@ -919,7 +935,12 @@ test("AgentRun sync converts terminal command result even when run remains claim assert.equal(synced.result.agentRun.runStatus, "claimed"); assert.equal(synced.result.agentRun.commandState, "completed"); assert.equal(synced.result.agentRun.terminalStatus, "completed"); - assert.match(synced.result.reply.content, /issue 1555 command result is terminal/u); + assert.equal(synced.result.finalResponse.text, finalText); + assert.equal(synced.result.reply.content, finalText); + assert.ok((synced.result.finalResponse.text.match(/\n/gu) ?? []).length > 8); + assert.ok(synced.result.finalResponse.text.includes("`go test ./...` 保留两个空格")); + const assistantEvent = traceStore.snapshot(traceId).events.find((event) => event.label === "agentrun:assistant:message"); + assert.equal(assistantEvent?.message, finalText); assert.deepEqual(calls.map((call) => call.path), [ `/api/v1/runs/${runId}/commands`, `/api/v1/runs/${runId}/events`, diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index fdb8e6f3..8aef0fb3 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -2561,8 +2561,8 @@ function boundedConversationMessageText(value, limit = 12000) { } function conversationText(value) { - const extracted = conversationTextRaw(value).replace(/\s+/gu, " ").trim(); - if (!extracted || extracted === "[object Object]") return ""; + const extracted = conversationTextRaw(value).replace(/\r\n?/gu, "\n"); + if (!extracted.trim() || extracted.trim() === "[object Object]") return ""; return extracted; } @@ -2645,7 +2645,7 @@ function codeAgentConversationRunnerTraceEvidence(runnerTrace = {}, traceId = nu } function codeAgentFinalResponseEvidence(payload = {}, traceId = null) { - const text = textValue(payload.reply?.content ?? payload.message?.content ?? payload.assistantText); + const text = conversationText(payload.reply?.content ?? payload.message?.content ?? payload.assistantText); if (!text) return null; return { text, diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 5e6cb9ae..fbb55011 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -643,11 +643,11 @@ function projectTerminalMessageParts(parts, messageId, traceId, projection, text function projectionText(...values) { for (const value of values) { if (value && typeof value === "object") { - const nested = textValue(value.text ?? value.content ?? value.message ?? value.summary ?? value.preview ?? value.title); + const nested = messageAuthorityTextValue(value.text ?? value.content ?? value.message ?? value.summary ?? value.preview ?? value.title); if (nested) return nested; continue; } - const text = textValue(value); + const text = messageAuthorityTextValue(value); if (text) return text; } return null; @@ -680,7 +680,7 @@ function messageFact(message, index, session, snapshot) { function partFact(part, index, messageId, traceId) { const type = textValue(part?.type) || "text"; - const text = textValue(part?.text ?? part?.content ?? part?.message); + const text = messageAuthorityTextValue(part?.text ?? part?.content ?? part?.message); return { partId: safePartId(part?.partId ?? part?.id) || `prt_${hash(`${messageId}:${index}:${type}`).slice(0, 24)}`, messageId, @@ -967,6 +967,12 @@ function textValue(value) { return String(value ?? "").trim(); } +function messageAuthorityTextValue(value) { + const text = String(value ?? "").replace(/\r\n?/gu, "\n"); + if (!text.trim() || text.trim() === "[object Object]") return ""; + return text; +} + function safeTurnId(value) { const text = textValue(value); return /^turn_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; diff --git a/internal/cloud/workbench-turn-projection.ts b/internal/cloud/workbench-turn-projection.ts index 7c9e62cd..b8014395 100644 --- a/internal/cloud/workbench-turn-projection.ts +++ b/internal/cloud/workbench-turn-projection.ts @@ -216,11 +216,11 @@ function eventSeq(event, index) { function projectionText(...values) { for (const value of values) { if (value && typeof value === "object") { - const nested = textValue(value.text ?? value.content ?? value.message ?? value.summary ?? value.preview ?? value.title); + const nested = messageAuthorityTextValue(value.text ?? value.content ?? value.message ?? value.summary ?? value.preview ?? value.title); if (nested) return nested; continue; } - const text = textValue(value); + const text = messageAuthorityTextValue(value); if (text) return text; } return null; @@ -234,6 +234,12 @@ function textValue(value) { return String(value ?? "").trim(); } +function messageAuthorityTextValue(value) { + const text = String(value ?? "").replace(/\r\n?/gu, "\n"); + if (!text.trim() || text.trim() === "[object Object]") return ""; + return text; +} + function diagnosticBlocker(value = {}) { if (!value || typeof value !== "object") return { code: "projection_blocked", summary: String(value), valuesRedacted: true }; return { diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index b4ffeece..c19e8966 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -195,7 +195,7 @@ export function traceNoiseEventCount(events: TraceEvent[] = []): number { } function traceAssistantMessageRow(trace: Record, event: TraceEvent, { terminal }: { terminal: boolean }): TraceEventRow { - const text = cleanTraceText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? ""); + const text = cleanTraceDetailText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? ""); const index = Number.isInteger(event.messageIndex) ? Number(event.messageIndex) : null; const count = Number.isInteger(event.messageCount) ? Number(event.messageCount) : null; return { diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 50d3e908..51554d77 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -59,6 +59,25 @@ const capturePath = resolve(cwd, "tests/workbench-e2e/fixtures/real-captures/d60 const capture = JSON.parse(await readFile(capturePath, "utf8")) as { scenario: { selectedSessionId: string; providerProfile: string; sessions: SessionRecord[]; traces: Record } }; const progressOnlyText = "PROGRESS_ONLY_SHOULD_NOT_RENDER_AS_FINAL"; const finalOnlyText = "FINAL_ONLY_SHOULD_RENDER"; +const markdownFinalText = [ + "已新增并实际运行:", + "", + "- Go:`go test ./...` 保留两个空格", + "- Rust:`cargo test --release`", + "", + "```sh", + "go test ./...", + "cargo test --release", + "```", + "", + "| benchmark | Go | Rust |", + "|---|---:|---:|", + "| fib | 1.2ms | 1.0ms |", + "", + "| runtime | status |", + "|---|---|", + "| local | pass |" +].join("\n"); let state = createScenarioState("baseline"); const sseClients = new Set(); @@ -282,6 +301,10 @@ function createScenarioState(scenarioId: string): ScenarioState { sessions.push(terminalEmptyTraceSession()); traces.trc_terminal_empty = terminalEmptyTrace(); } + if (id === "markdown-final-response") { + sessions.unshift(markdownFinalSession()); + traces.trc_markdown_final = markdownFinalTrace(); + } if (id === "progress-only-final-response") markRunningProgressOnly(sessions, traces); if (id === "terminal-completed-no-final-response") markRunningNoFinalResponse(sessions, traces); if (id === "tool-completed-projection-running") markToolCompletedProjectionRunning(sessions, traces); @@ -324,6 +347,8 @@ function createScenarioState(scenarioId: string): ScenarioState { ? "ses_completed" : id === "terminal-empty-trace" ? "ses_terminal_empty" + : id === "markdown-final-response" + ? "ses_markdown_final" : id === "progress-only-final-response" || id === "terminal-completed-no-final-response" || id === "tool-completed-projection-running" ? "ses_running" : id === "projector-resume-from-checkpoint" @@ -789,6 +814,41 @@ function legacyCanonicalTrace(): JsonRecord { }; } +function markdownFinalSession(): SessionRecord { + const now = new Date().toISOString(); + return { + sessionId: "ses_markdown_final", + conversationId: "cnv_markdown_final", + threadId: "thr_markdown_final", + status: "completed", + lastTraceId: "trc_markdown_final", + startedAt: now, + updatedAt: now, + messageCount: 2, + firstUserMessagePreview: "render markdown final response", + messages: [ + { id: "msg_markdown_user", messageId: "msg_markdown_user", role: "user", title: "用户", text: "render markdown final response", status: "sent", createdAt: now, sessionId: "ses_markdown_final", threadId: "thr_markdown_final", traceId: "trc_markdown_final", turnId: "trc_markdown_final" }, + { id: "msg_markdown_agent", messageId: "msg_markdown_agent", role: "agent", title: "Code Agent", text: markdownFinalText, parts: [{ type: "text", text: markdownFinalText }], status: "completed", createdAt: now, updatedAt: now, sessionId: "ses_markdown_final", threadId: "thr_markdown_final", traceId: "trc_markdown_final", turnId: "trc_markdown_final", runnerTrace: markdownFinalTrace() } + ] + }; +} + +function markdownFinalTrace(): JsonRecord { + const createdAt = new Date().toISOString(); + const event = { seq: 1, sourceSeq: 88, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", terminal: true, final: true, replyAuthority: true, message: markdownFinalText }; + return { + traceId: "trc_markdown_final", + status: "completed", + sessionId: "ses_markdown_final", + threadId: "thr_markdown_final", + events: [event], + eventCount: 1, + fullTraceLoaded: true, + hasMore: false, + finalResponse: { text: markdownFinalText, status: "completed" } + }; +} + function manyRailSession(index: number): SessionRecord { const id = String(index).padStart(2, "0"); const now = new Date(Date.now() - (index + 1) * 1000).toISOString(); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts index ab25de74..011415f6 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/trace-rendering.spec.ts @@ -56,6 +56,38 @@ test.describe("non-terminal assistant progress", () => { }); }); +test.describe("markdown final response", () => { + test.use({ scenarioId: "markdown-final-response" }); + + test("preserves message whitespace and renders GFM tables in final response and trace", async ({ page }, testInfo) => { + const sessionId = "ses_markdown_final"; + const traceId = "trc_markdown_final"; + const messagesResponse = await page.request.get(`/v1/workbench/sessions/${sessionId}/messages?limit=100`); + expect(messagesResponse.status()).toBe(200); + const messagesPayload = await messagesResponse.json(); + const agentMessage = messagesPayload.messages.find((message: { role?: string }) => message.role === "agent"); + const agentText = String(agentMessage?.text ?? ""); + expect((agentText.match(/\n/gu) ?? []).length).toBeGreaterThan(8); + expect(agentText).toContain("`go test ./...` 保留两个空格"); + expect(agentMessage?.parts?.[0]?.text).toBe(agentText); + + const traceResponse = await page.request.get(`/v1/workbench/traces/${traceId}/events?limit=20`); + expect(traceResponse.status()).toBe(200); + const tracePayload = await traceResponse.json(); + const assistantEvent = tracePayload.events.find((event: { label?: string }) => event.label === "agentrun:assistant:message"); + const traceText = String(assistantEvent?.message ?? ""); + expect((traceText.match(/\n/gu) ?? []).length).toBeGreaterThan(8); + expect(traceText).toContain("`go test ./...` 保留两个空格"); + + await gotoWorkbench(page, `/workbench/sessions/${sessionId}`); + const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last(); + await expect(card.locator(".message-markdown.message-text table")).toHaveCount(2); + await card.locator("summary.trace-disclosure-summary").click(); + await expect(card.locator(".trace-row-markdown table")).toHaveCount(2); + await saveScreenshot(page, testInfo, "markdown-final-response-tables"); + }); +}); + test("failed trace exposes readable failure row", async ({ page }, testInfo) => { await gotoWorkbench(page, "/workbench/sessions/ses_failed"); const trace = page.locator(`${selectors.traceTimeline}[data-status="failed"]`);