fix: converge workbench terminal projection

This commit is contained in:
lyon
2026-06-18 19:34:30 +08:00
parent b24584f07c
commit 7b5261e407
7 changed files with 309 additions and 25 deletions
+70 -9
View File
@@ -1,5 +1,5 @@
/*
* SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* 职责: Workbench REST read model。只读投影 session/message/turn/trace facts,不执行 AgentRun sync、billing finalize 或 workspace repair。
*/
import { createHash } from "node:crypto";
@@ -244,7 +244,7 @@ async function handleWorkbenchSessionDetail(response, options, actor, sessionId)
async function handleWorkbenchMessagePage(response, url, options, actor, sessionId) {
const session = await visibleSessionByRouteId(options.accessController?.store, sessionId, actor);
if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId }));
const messages = sessionMessages(session);
const messages = sessionMessages(session, options);
const limit = boundedLimit(url.searchParams.get("limit"));
const offset = cursorOffset(url.searchParams.get("cursor") ?? url.searchParams.get("after"));
const page = messages.slice(offset, offset + limit);
@@ -347,7 +347,7 @@ function sessionSummary(session, options) {
const snapshot = objectValue(session.session);
const traceId = safeTraceId(session.lastTraceId ?? snapshot.lastTraceId ?? snapshot.currentTraceId) ?? null;
const trace = traceId ? traceSnapshotSync(options, traceId) : null;
const messages = sessionMessages(session);
const messages = sessionMessages(session, options);
const status = canonicalWorkbenchStatus(snapshot.sessionStatus ?? session.status, trace?.status);
return {
sessionId: session.id,
@@ -388,23 +388,81 @@ function sessionDetail(session, options) {
};
}
function sessionMessages(session) {
function sessionMessages(session, options = {}) {
const snapshot = objectValue(session.session);
const raw = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : [];
const messages = raw.map((message, index) => messageFact(message, index, session, snapshot)).filter(Boolean);
const finalTraceId = safeTraceId(session.lastTraceId ?? snapshot.lastTraceId ?? snapshot.currentTraceId) ?? null;
const terminalProjection = terminalMessageProjection(session, snapshot, options, finalTraceId);
const messages = raw
.map((message, index) => messageFact(message, index, session, snapshot))
.filter(Boolean)
.map((message) => applyTerminalMessageProjection(message, terminalProjection));
const hasAssistantLikeFinal = messages.some((message) => isAssistantLikeRole(message.role) && (!finalTraceId || message.traceId === finalTraceId));
if (!hasAssistantLikeFinal && textValue(snapshot.finalResponse)) {
messages.push(messageFact({ role: "assistant", text: snapshot.finalResponse, traceId: session.lastTraceId ?? snapshot.lastTraceId, source: "finalResponse" }, messages.length, session, snapshot));
const finalText = terminalProjection?.text ?? projectionText(snapshot.finalResponse);
if (!hasAssistantLikeFinal && finalText) {
messages.push(messageFact({ role: "assistant", text: finalText, status: terminalProjection?.status ?? session.status, traceId: finalTraceId, source: terminalProjection?.source ?? "finalResponse" }, messages.length, session, snapshot));
}
return messages;
}
function terminalMessageProjection(session, snapshot, options, traceId) {
if (!traceId) return null;
const result = options.result ?? options.codeAgentChatResults?.get?.(traceId) ?? null;
const trace = options.trace ?? traceSnapshotSync(options, traceId);
const status = canonicalWorkbenchStatus(
result?.status ?? result?.agentRun?.terminalStatus ?? result?.agentRun?.commandState ?? result?.agentRun?.status ?? session?.status ?? snapshot.sessionStatus,
trace?.status
);
if (!TERMINAL_STATUSES.has(status) || RUNNING_STATUSES.has(status)) return null;
const text = projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, snapshot.finalResponse);
return { traceId, status, text, source: result ? "turn-result" : trace?.status && trace.status !== "missing" ? "trace-projection" : "session-snapshot" };
}
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 parts = projectTerminalMessageParts(message.parts, message.messageId, projection.traceId ?? message.traceId, projection, text);
return {
...message,
traceId: projection.traceId ?? message.traceId,
turnId: message.turnId ?? projection.traceId ?? null,
status: projection.status,
parts,
text,
textPreview: text ? text.slice(0, 240) : message.textPreview ?? null,
updatedAt: message.updatedAt ?? null
};
}
function projectTerminalMessageParts(parts, messageId, traceId, projection, text) {
const sourceParts = Array.isArray(parts) ? parts : [];
const textIndex = sourceParts.findIndex((part) => part?.type === "text" || part?.text);
if (textIndex >= 0) {
return sourceParts.map((part, index) => index === textIndex ? { ...part, traceId, 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];
}
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);
if (nested) return nested;
continue;
}
const text = textValue(value);
if (text) return text;
}
return null;
}
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 = textValue(message?.text ?? message?.content ?? message?.message ?? message?.finalResponse);
const text = 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)
@@ -442,9 +500,10 @@ function partFact(part, index, messageId, traceId) {
}
function turnSnapshot({ turnId, traceId, status, result, session, trace }) {
const messages = session ? sessionMessages(session) : [];
const messages = session ? sessionMessages(session, { result, trace }) : [];
const userMessage = messages.find((message) => message.role === "user") ?? null;
const assistantMessage = [...messages].reverse().find((message) => isAssistantLikeRole(message.role)) ?? null;
const finalText = projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, assistantMessage?.text);
return {
turnId,
traceId,
@@ -455,6 +514,8 @@ function turnSnapshot({ turnId, traceId, status, result, session, trace }) {
threadId: safeOpaqueId(result?.threadId ?? result?.session?.threadId ?? session?.threadId) ?? (textValue(session?.threadId) || null),
userMessageId: userMessage?.messageId ?? null,
assistantMessageId: assistantMessage?.messageId ?? null,
assistantText: finalText ?? null,
finalResponse: finalText ? { text: finalText, status, traceId, valuesPrinted: false } : null,
agentRun: result?.agentRun ?? null,
trace: {
traceId,