fix: converge workbench terminal projection
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user