fix: read workbench final response from message parts (#2083)

This commit is contained in:
Lyon
2026-06-25 05:17:58 +08:00
committed by GitHub
parent 19eee3b734
commit fe2ed4e308
3 changed files with 66 additions and 47 deletions
+23 -19
View File
@@ -725,6 +725,7 @@ test("workbench read model projects terminal result atomically across session, m
assert.equal(assistant.status, "completed");
assert.equal(assistant.text, finalText);
assert.equal(assistant.parts[0].status, "completed");
assert.equal(assistant.parts[0].type, "final_response");
assert.equal(assistant.parts[0].text, finalText);
const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`);
@@ -1455,7 +1456,7 @@ test("workbench read model keeps non-terminal durable tool completed events out
assert.equal(turn.body.turn.status, "running");
assert.equal(turn.body.turn.running, true);
assert.equal(turn.body.turn.terminal, false);
assert.equal(turn.body.turn.assistantText, "partial assistant text");
assert.equal(turn.body.turn.assistantText, null);
assert.equal(turn.body.turn.finalResponse, null);
assert.equal(turn.body.projectionStatus, "projecting");
@@ -1830,24 +1831,27 @@ function buildDurableFactsForSession({ session, events = [], status = null, fina
valuesRedacted: true
}],
messages: normalizedMessages,
parts: normalizedMessages.filter((message) => message.text).map((message, index) => ({
partId: `prt_${session.id}_${index}`,
messageId: message.messageId,
sessionId: session.id,
turnId: message.turnId,
traceId: message.traceId,
partIndex: 0,
partType: "text",
status: message.status,
text: message.text,
projectedSeq: message.projectedSeq,
sourceSeq: message.sourceSeq,
sourceEventId: message.sourceEventId,
terminal: message.terminal,
sealed: message.sealed,
updatedAt: message.updatedAt,
valuesRedacted: true
})),
parts: normalizedMessages.filter((message) => message.text).map((message, index) => {
const partType = message.role !== "user" && message.terminal ? "final_response" : "text";
return {
partId: `prt_${session.id}_${index}`,
messageId: message.messageId,
sessionId: session.id,
turnId: message.turnId,
traceId: message.traceId,
partIndex: 0,
partType,
status: message.status,
text: message.text,
projectedSeq: message.projectedSeq,
sourceSeq: message.sourceSeq,
sourceEventId: message.sourceEventId,
terminal: message.terminal,
sealed: partType === "final_response" ? true : message.sealed,
updatedAt: message.updatedAt,
valuesRedacted: true
};
}),
turns: traceId ? [{
turnId: traceId,
sessionId: session.id,
+42 -16
View File
@@ -985,10 +985,11 @@ function factMessageDto(message, parts = [], facts = {}) {
const timing = assistantLike
? factCombinedTimingProjection(status, checkpoint, turn, message)
: factTimingProjection(message, status);
const text = projectionText(message?.text, message?.content, message?.message, message?.finalResponse);
const legacyText = projectionText(message?.text, message?.content, message?.message, message?.finalResponse);
const normalizedParts = parts.length > 0
? [...parts].sort(compareFactPartsAsc).map((part) => factPartDto(part, messageId, traceId)).filter(Boolean)
: text ? [partFact({ type: "text", text, status: message?.status }, 0, messageId, traceId)] : [];
: !assistantLike && legacyText ? [partFact({ type: "text", text: legacyText, status: message?.status }, 0, messageId, traceId)] : [];
const text = factMessageAuthorityText({ role, status, parts: normalizedParts, legacyText });
return {
messageId,
role,
@@ -1012,6 +1013,30 @@ function factMessageDto(message, parts = [], facts = {}) {
};
}
function factMessageAuthorityText({ role, status, parts = [], legacyText = null } = {}) {
if (!isAssistantLikeRole(role)) return firstFactPartText(parts) ?? legacyText ?? "";
if (!isTerminalProjectionStatus(status)) return "";
return firstFactPartText(parts, "final_response") ?? "";
}
function isTerminalProjectionStatus(status) {
return TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status);
}
function firstFactPartText(parts = [], type = null) {
for (const part of parts) {
if (type && part?.type !== type) continue;
const text = projectionText(part?.text);
if (text) return text;
}
return null;
}
function factMessageFinalResponseText(message) {
if (!message || !isAssistantLikeRole(message.role) || !isTerminalProjectionStatus(message.status)) return null;
return firstFactPartText(message.parts, "final_response");
}
function factPartDto(part, messageId, traceId) {
const partId = safePartId(part?.partId) || textValue(part?.partId) || `prt_${hash(`${messageId}:${part?.partIndex ?? 0}:${part?.partType ?? part?.type ?? "text"}`).slice(0, 24)}`;
const text = projectionText(part?.text, part?.content, part?.message);
@@ -1049,20 +1074,21 @@ function factTurnSnapshot({ turn = null, session = null, facts = {}, traceId, tu
const checkpointStatus = normalizeTerminalStatus(checkpoint?.status);
const traceStatus = normalizeTerminalStatus(trace.status);
const status = normalizeStatus(checkpointStatus ?? traceStatus ?? turn?.status ?? session?.status);
const assistantText = projectionText(turn?.finalResponse, turn?.assistantText, turn?.text, assistantMessage?.text);
const terminal = isTerminalProjectionStatus(status);
const assistantText = terminal ? factMessageFinalResponseText(assistantMessage) : null;
const timing = factCombinedTimingProjection(status, checkpoint, turn, trace);
return {
turnId: resolvedTurnId,
traceId: safeTrace,
status,
running: RUNNING_STATUSES.has(status),
terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status),
terminal,
sessionId: factSessionId(session) ?? turn?.sessionId ?? checkpoint?.sessionId ?? null,
threadId: safeOpaqueId(session?.threadId) ?? (textValue(session?.threadId) || null),
userMessageId: userMessage?.messageId ?? null,
assistantMessageId: assistantMessage?.messageId ?? turn?.messageId ?? null,
assistantText: assistantText ?? null,
finalResponse: turn?.finalResponse ?? null,
finalResponse: assistantText ? { text: assistantText, sealed: true, source: "message-part" } : null,
timing,
startedAt: timing.startedAt,
lastEventAt: timing.lastEventAt,
@@ -2006,7 +2032,7 @@ function sessionMessages(session, options = {}) {
.map((message) => applyTerminalMessageProjection(message, terminalProjection))
.map((message) => applyProjectionMessageDiagnostic(message, options.projectionDiagnostic, currentTurn));
const hasAssistantLikeFinal = messages.some((message) => isAssistantLikeRole(message.role) && (!finalTraceId || message.traceId === finalTraceId));
const finalText = terminalProjection?.text ?? projectionText(snapshot.finalResponse);
const finalText = terminalProjection?.text ?? null;
if (!hasAssistantLikeFinal && finalText) {
const finalMessageStatus = terminalProjection ? terminalProjection.status : sessionLifecycleProjectionStatus(session);
const finalMessageSource = terminalProjection ? terminalProjection.source : "finalResponse";
@@ -2033,18 +2059,16 @@ function applyProjectionMessageDiagnostic(message, diagnostic, currentTurn) {
function terminalMessageProjection(snapshot, options, currentTurn) {
if (!currentTurn) return null;
const traceId = currentTurn.traceId;
const result = options.result ?? options.codeAgentChatResults?.get?.(traceId) ?? null;
const trace = currentTurn.trace;
const status = currentTurn.status;
if (!TERMINAL_STATUSES.has(status) || RUNNING_STATUSES.has(status)) return null;
const text = projectionText(currentTurn.finalResponse, result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, snapshot.finalResponse);
const text = projectionText(currentTurn.finalResponse);
return { traceId, status, text, source: "turn-projection" };
}
function applyTerminalMessageProjection(message, projection) {
if (!projection || !isAssistantLikeRole(message.role)) return message;
if (projection.traceId && message.traceId && message.traceId !== projection.traceId) return message;
const text = projection.text || message.text || "";
const text = projection.text || "";
const parts = projectTerminalMessageParts(message.parts, message.messageId, projection.traceId ?? message.traceId, projection, text);
return {
...message,
@@ -2060,12 +2084,12 @@ function applyTerminalMessageProjection(message, projection) {
function projectTerminalMessageParts(parts, messageId, traceId, projection, text) {
const sourceParts = Array.isArray(parts) ? parts : [];
const textIndex = sourceParts.findIndex((part) => part?.type === "text" || part?.text);
const textIndex = sourceParts.findIndex((part) => part?.type === "final_response" || part?.partType === "final_response");
if (textIndex >= 0) {
return sourceParts.map((part, index) => index === textIndex ? { ...part, traceId, text: text || part.text || null, status: projection.status } : part);
return sourceParts.map((part, index) => index === textIndex ? { ...part, traceId, type: "final_response", text: text || part.text || null, status: projection.status } : part);
}
if (!text) return sourceParts;
return [partFact({ type: "text", text, status: projection.status }, 0, messageId, traceId), ...sourceParts];
return [partFact({ type: "final_response", text, status: projection.status }, 0, messageId, traceId), ...sourceParts];
}
function projectionText(...values) {
@@ -2085,18 +2109,20 @@ function messageFact(message, index, session, snapshot) {
const role = textValue(message?.role) || (index % 2 === 0 ? "user" : "assistant");
const traceId = safeTraceId(message?.traceId ?? message?.turnTraceId ?? session.lastTraceId ?? snapshot.lastTraceId) ?? null;
const messageId = safeMessageId(message?.messageId ?? message?.id) || `msg_${hash(`${session.id}:${index}:${role}:${traceId ?? "none"}`).slice(0, 24)}`;
const text = projectionText(message?.text, message?.content, message?.message, message?.finalResponse);
const status = normalizeStatus(message?.status ?? (isAssistantLikeRole(role) ? session.status : "completed"));
const legacyText = projectionText(message?.text, message?.content, message?.message, message?.finalResponse);
const rawParts = Array.isArray(message?.parts) ? message.parts : [];
const parts = rawParts.length > 0
? rawParts.map((part, partIndex) => partFact(part, partIndex, messageId, traceId)).filter(Boolean)
: text ? [partFact({ type: "text", text }, 0, messageId, traceId)] : [];
: !isAssistantLikeRole(role) && legacyText ? [partFact({ type: "text", text: legacyText }, 0, messageId, traceId)] : [];
const text = factMessageAuthorityText({ role, status, parts, legacyText });
return {
messageId,
role,
sessionId: session.id,
traceId,
turnId: safeTurnId(message?.turnId) || traceId,
status: normalizeStatus(message?.status ?? (isAssistantLikeRole(role) ? session.status : "completed")),
status,
parts,
text: text || "",
textPreview: text ? text.slice(0, 240) : null,
+1 -12
View File
@@ -15,7 +15,7 @@ export function createWorkbenchTurnProjection({ turnId = null, traceId = null, r
const status = terminalEvidence?.status ?? activeEvidence?.status ?? "unknown";
const running = RUNNING_STATUSES.has(status);
const terminal = Boolean(terminalEvidence && TERMINAL_STATUSES.has(status) && !running);
const finalText = terminal ? projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, terminalEvidence?.finalResponse) : null;
const finalText = terminal ? projectionText(terminalEvidence?.finalResponse) : null;
const agentRun = objectValue(result?.agentRun ?? trace?.agentRun);
const lastEvent = traceLastEvent(trace);
const timing = createWorkbenchTurnTimingProjection({ result, session, trace, status, terminal });
@@ -155,17 +155,6 @@ export function traceTerminalEvidence(trace = null) {
if (status !== "completed" && retryableProviderInterruptionEvidence(direct, trace)) return null;
return { source: "trace-terminal-evidence", status, evidence: direct, valuesRedacted: true };
}
const finalResponse = objectValue(trace?.finalResponse);
if (finalResponse) {
// finalResponse is display text evidence only. AgentRun may surface an
// assistant/finalResponse payload while the runner is still draining tools
// or waiting for its terminal report; using that text as lifecycle authority
// seals the Workbench card too early and makes terminal elapsed jump later.
if (trace?.terminal === true || trace?.sealed === true || firstTimestamp(trace?.finishedAt, trace?.completedAt, trace?.endedAt)) {
const status = terminalStatusFromValue(finalResponse.status ?? trace?.status) ?? "completed";
return { source: "trace-final-response", status, evidence: { textPresent: Boolean(projectionText(finalResponse)), valuesRedacted: true }, valuesRedacted: true };
}
}
const events = Array.isArray(trace?.events) ? trace.events : [];
return terminalTraceEventEvidence(events);
}