Merge pull request #1608 from pikasTech/codex/1605-workbench-markdown-newlines

fix: 保留 Workbench 消息 Markdown 空白
This commit is contained in:
Lyon
2026-06-19 15:41:18 +08:00
committed by GitHub
10 changed files with 158 additions and 20 deletions
+2 -2
View File
@@ -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) {
+10 -4
View File
@@ -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,
+10 -3
View File
@@ -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()
+23 -2
View File
@@ -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`,
+3 -3
View File
@@ -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,
+9 -3
View File
@@ -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;
+8 -2
View File
@@ -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 {
+1 -1
View File
@@ -195,7 +195,7 @@ export function traceNoiseEventCount(events: TraceEvent[] = []): number {
}
function traceAssistantMessageRow(trace: Record<string, unknown>, 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 {
@@ -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<string, JsonRecord> } };
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<ServerResponse>();
@@ -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();
@@ -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"]`);