diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index e8e84ff6..34691cf9 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -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, diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 48240477..a8150893 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -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, diff --git a/internal/cloud/workbench-turn-projection.ts b/internal/cloud/workbench-turn-projection.ts index 67b1ac66..9d581738 100644 --- a/internal/cloud/workbench-turn-projection.ts +++ b/internal/cloud/workbench-turn-projection.ts @@ -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); }