fix: read workbench final response from message parts (#2083)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user