Merge pull request #1608 from pikasTech/codex/1605-workbench-markdown-newlines
fix: 保留 Workbench 消息 Markdown 空白
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]`);
|
||||
|
||||
Reference in New Issue
Block a user