fix: separate workbench message and trace authorities

This commit is contained in:
lyon
2026-06-20 10:09:47 +08:00
parent 57ba729fca
commit 4290a280d9
6 changed files with 218 additions and 104 deletions
@@ -96,6 +96,8 @@ const terminalAssistantFinalText = [
"| Python | CPython | pass |",
"| Rust | native | pass |"
].join("\n");
const canonicalTraceFinalText = "messageProjection 的 sealed final response 才是主消息正文。";
const traceDetailConflictText = "TRACE_DETAIL_CONFLICT_SHOULD_NOT_REPLACE_MESSAGE";
let state = createScenarioState("baseline");
const sseClients = new Set<ServerResponse>();
@@ -630,6 +632,10 @@ function createScenarioState(scenarioId: string): ScenarioState {
sessions.unshift(sealedFinalResponseDiagnosticSession());
traces.trc_sealed_final_diag = sealedFinalResponseDiagnosticTrace();
}
if (id === "trace-final-response-not-message-authority") {
sessions.unshift(traceFinalResponseConflictSession());
traces.trc_trace_final_conflict = traceFinalResponseConflictTrace();
}
if (id === "scroll-follow-long-trace") {
const session = scrollFollowSession();
sessions.unshift(session);
@@ -673,6 +679,8 @@ function createScenarioState(scenarioId: string): ScenarioState {
? "ses_trace_hydration_timeout"
: id === "sealed-final-response-diagnostics"
? "ses_sealed_final_diag"
: id === "trace-final-response-not-message-authority"
? "ses_trace_final_conflict"
: id === "create-while-route-loading"
? "ses_running"
: base.selectedSessionId;
@@ -993,6 +1001,43 @@ function sealedFinalResponseDiagnosticSession(): SessionRecord {
};
}
function traceFinalResponseConflictTrace(): JsonRecord {
const now = new Date().toISOString();
const event = { seq: 1, sourceSeq: 91, createdAt: now, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", terminal: true, final: true, replyAuthority: true, message: traceDetailConflictText };
return {
traceId: "trc_trace_final_conflict",
status: "completed",
sessionId: "ses_trace_final_conflict",
threadId: "thr_trace_final_conflict",
turnId: "turn_trace_final_conflict",
events: [event],
eventCount: 1,
fullTraceLoaded: true,
hasMore: false,
finalResponse: { text: traceDetailConflictText, status: "completed" },
terminalEvidence: { terminal: true, status: "completed" },
sealedAt: now
};
}
function traceFinalResponseConflictSession(): SessionRecord {
const now = new Date().toISOString();
return {
sessionId: "ses_trace_final_conflict",
threadId: "thr_trace_final_conflict",
status: "completed",
lastTraceId: "trc_trace_final_conflict",
updatedAt: now,
messageCount: 2,
firstUserMessagePreview: "trace final response authority separation",
turnSummary: { traceId: "trc_trace_final_conflict", status: "completed", running: false, terminal: true, sealedAt: now },
messages: [
{ id: "msg_trace_final_conflict_user", messageId: "msg_trace_final_conflict_user", role: "user", title: "用户", text: "trace final response authority separation", status: "sent", createdAt: now, sessionId: "ses_trace_final_conflict", threadId: "thr_trace_final_conflict", turnId: "turn_trace_final_conflict" },
{ id: "msg_trace_final_conflict_agent", messageId: "msg_trace_final_conflict_agent", role: "agent", title: "Code Agent", text: canonicalTraceFinalText, status: "completed", createdAt: now, updatedAt: now, sessionId: "ses_trace_final_conflict", threadId: "thr_trace_final_conflict", traceId: "trc_trace_final_conflict", turnId: "turn_trace_final_conflict", sealedAt: now, finalResponse: { text: canonicalTraceFinalText, status: "completed" }, runnerTrace: { traceId: "trc_trace_final_conflict", status: "completed", sessionId: "ses_trace_final_conflict", threadId: "thr_trace_final_conflict", events: [], eventCount: 1, fullTraceLoaded: false, hasMore: true } }
]
};
}
function noFinalRunningTrace(): JsonRecord {
const createdAt = new Date().toISOString();
return {
@@ -116,6 +116,13 @@ function traceStorageKey(message: ChatMessage): string {
function traceAutoExpanded(message: ChatMessage): boolean | null {
return traceLifecycleExpanded(message, traceTimelinePolicy.value);
}
function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] {
if (!message.runnerTrace) return message.runnerTrace;
const status = firstNonEmptyString(message.status, message.runnerTrace.status);
if (!status || status === message.runnerTrace.status) return message.runnerTrace;
return { ...message.runnerTrace, status };
}
</script>
<template>
@@ -130,7 +137,7 @@ function traceAutoExpanded(message: ChatMessage): boolean | null {
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(message)">!</button>
</div>
</header>
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" />
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="traceForDisplay(message)" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" />
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
<MessageMarkdown v-if="showMessageText(message)" class="message-text" :source="visibleMessageText(message)" />
<p v-if="messageDiagnosticText(message)" class="message-text projection-diagnostic">{{ messageDiagnosticText(message) }}</p>
@@ -22,7 +22,6 @@ export interface TraceSnapshot {
traceStatus?: string;
retention?: unknown;
terminalEvidence?: unknown;
finalResponse?: unknown;
traceSummary?: unknown;
error?: AgentChatResultResponse["error"];
lastEventLabel?: string;
@@ -79,7 +78,6 @@ export function snapshotToRunnerTrace(snapshot: TraceSnapshot): NonNullable<Chat
traceStatus: snapshot.traceStatus,
retention: snapshot.retention,
terminalEvidence: snapshot.terminalEvidence,
finalResponse: snapshot.finalResponse,
traceSummary: snapshot.traceSummary,
agentRun: snapshot.agentRun,
error: snapshot.error,
@@ -149,7 +147,6 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
threadId: trace.threadId ?? terminal.threadId,
agentRun: trace.agentRun ?? terminal.agentRun,
terminalEvidence: trace.terminalEvidence ?? terminal.terminalEvidence,
finalResponse: trace.finalResponse ?? terminal.finalResponse,
traceSummary: trace.traceSummary ?? terminal.traceSummary,
error: trace.error ?? terminal.error,
projection: trace.projection ?? terminal.projection ?? null,
@@ -159,7 +156,6 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
blocker: trace.blocker ?? terminal.blocker ?? null
};
const events = Array.isArray(trace.events) ? trace.events : [];
const assistantText = firstNonEmptyString(finalResponseText(mergedTrace.finalResponse), terminal.assistantText);
return {
...terminal,
traceId: mergedTrace.traceId ?? terminal.traceId,
@@ -167,7 +163,7 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
threadId: mergedTrace.threadId ?? terminal.threadId,
agentRun: mergedTrace.agentRun ?? terminal.agentRun,
error: mergedTrace.error ?? terminal.error,
assistantText: assistantText ?? terminal.assistantText,
assistantText: terminal.assistantText,
runnerTrace: snapshotToRunnerTrace(mergedTrace),
traceEvents: terminal.traceEvents ?? events,
events,
@@ -175,7 +171,7 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
traceStatus: mergedTrace.traceStatus ?? terminal.traceStatus,
retention: mergedTrace.retention ?? terminal.retention,
terminalEvidence: mergedTrace.terminalEvidence ?? terminal.terminalEvidence,
finalResponse: mergedTrace.finalResponse ?? terminal.finalResponse,
finalResponse: terminal.finalResponse,
traceSummary: mergedTrace.traceSummary ?? terminal.traceSummary,
lastEventLabel: mergedTrace.lastEventLabel ?? terminal.lastEventLabel ?? events.at(-1)?.label
};
@@ -266,7 +262,6 @@ function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse,
traceStatus: result.traceStatus,
retention: result.retention,
terminalEvidence: result.terminalEvidence,
finalResponse: result.finalResponse,
traceSummary: result.traceSummary,
error: result.error,
projection: result.projection ?? result.runnerTrace?.projection ?? null,
@@ -305,7 +300,6 @@ function traceSnapshotWithTurnStatus(trace: TraceSnapshot, turn: TraceSnapshot):
agentRun: trace.agentRun ?? turn.agentRun,
traceStatus: trace.traceStatus ?? turn.traceStatus,
terminalEvidence: trace.terminalEvidence ?? turn.terminalEvidence,
finalResponse: trace.finalResponse ?? turn.finalResponse,
traceSummary: trace.traceSummary ?? turn.traceSummary,
error: trace.error ?? turn.error,
projection: trace.projection ?? turn.projection ?? null,
@@ -354,12 +348,3 @@ function traceSnapshotErrorKey(error: TraceSnapshot["error"]): string {
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function finalResponseText(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return firstNonEmptyString(record.content, record.text, record.message);
}
return null;
}
@@ -108,10 +108,27 @@ function reduceSessionDetail(state: WorkbenchServerState, session: WorkbenchSess
function reduceSessionList(state: WorkbenchServerState, sessions: WorkbenchSessionRecord[]): WorkbenchServerState {
const sessionIds = sessions.map((session) => session.sessionId).filter((sessionId): sessionId is string => Boolean(sessionId));
const next = sessions.reduce((current, session) => reduceSessionDetail(current, session), state);
const next = sessions.reduce((current, session) => reduceSessionListItem(current, session), state);
return { ...next, sessionOrder: unique(sessionIds) };
}
function reduceSessionListItem(state: WorkbenchServerState, session: WorkbenchSessionRecord): WorkbenchServerState {
const sessionId = session.sessionId;
if (!sessionId) return state;
const existing = state.sessionsById[sessionId];
const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages;
const messages = Array.isArray(existingMessages) ? existingMessages : Array.isArray(session.messages) ? session.messages : [];
const merged = mergeSessionRecord(existing, { ...session, messages });
const sessionStatus = sessionStatusAuthorityFromDetail(session);
return {
...state,
sessionsById: { ...state.sessionsById, [sessionId]: merged },
sessionStatusById: sessionStatus
? { ...state.sessionStatusById, [sessionId]: sessionStatus }
: state.sessionStatusById
};
}
function reduceSessionMessages(state: WorkbenchServerState, sessionId: string | null | undefined, messages: ChatMessage[]): WorkbenchServerState {
if (!sessionId) return state;
const existing = state.sessionsById[sessionId];
+126 -85
View File
@@ -349,6 +349,19 @@ export const useWorkbenchStore = defineStore("workbench", () => {
replaceActiveMessages(project(messages.value));
}
function updateSessionMessages(sessionId: string | null | undefined, project: (source: ChatMessage[]) => ChatMessage[]): void {
const id = normalizeWorkbenchSessionId(sessionId);
if (!id) return;
const current = serverState.value.messagesBySessionId[id] ?? serverState.value.sessionsById[id]?.messages ?? [];
rememberSessionMessages(id, project(current));
}
function updateTraceMessages(traceId: string, authoritySessionId: string | null | undefined, project: (message: ChatMessage) => ChatMessage): void {
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
if (!ownerSessionId) return;
updateSessionMessages(ownerSessionId, (source) => source.map((message) => messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId) ? project(message) : message));
}
function appendActiveMessages(...items: ChatMessage[]): void {
replaceActiveMessages([...messages.value, ...items]);
}
@@ -402,6 +415,44 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return runWorkbenchReadHydration(() => api.workbench.traceEvents(traceId, codeAgentTimeoutMs.value, () => activityRef.value, { sinceSeq, limit: TRACE_HYDRATION_PAGE_LIMIT }));
}
async function refreshMessageProjectionForTrace(sessionId: string | null | undefined, traceId: string): Promise<void> {
const id = normalizeWorkbenchSessionId(sessionId);
if (!id) return;
const response = await api.workbench.sessionMessages(id, { limit: 100 });
if (!response.ok || !response.data) {
applyProjectionDiagnostic(traceId, projectionDiagnosticFromFailure({ code: "message_projection_refresh_failed", message: response.error ?? "消息投影刷新失败,主消息正文保持上一份 canonical projection。", health: response.status === 0 ? "unavailable" : "degraded" }));
return;
}
const pageMessages = Array.isArray(response.data.messages) ? response.data.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : [];
rememberSessionMessages(id, mergeMessageProjectionPage(id, pageMessages));
}
function mergeMessageProjectionPage(sessionId: string, pageMessages: ChatMessage[]): ChatMessage[] {
const existing = serverState.value.messagesBySessionId[sessionId] ?? [];
return pageMessages.map((message) => mergeMessageProjectionMessage(message, existing));
}
function mergeMessageProjectionMessage(message: ChatMessage, existing: ChatMessage[]): ChatMessage {
const previous = findExistingProjectionMessage(message, existing);
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId, previous?.traceId, previous?.runnerTrace?.traceId);
const authority = traceId ? traceAuthorityById.value[traceId] ?? null : null;
const preservedTrace = authority ?? previous?.runnerTrace ?? null;
if (!preservedTrace) return message;
const runnerTrace = message.runnerTrace ? mergeRunnerTrace(message.runnerTrace, preservedTrace) : preservedTrace;
return { ...message, runnerTrace };
}
function findExistingProjectionMessage(message: ChatMessage, existing: ChatMessage[]): ChatMessage | null {
const messageId = firstNonEmptyString(message.messageId, message.id);
if (messageId) {
const byId = existing.find((item) => firstNonEmptyString(item.messageId, item.id) === messageId);
if (byId) return byId;
}
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return null;
return existing.find((item) => item.role === message.role && firstNonEmptyString(item.traceId, item.runnerTrace?.traceId) === traceId) ?? null;
}
async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise<void> {
await Promise.all(uniqueTraceIds(source).slice(-12).map((traceId) => refreshTurnStatusByTraceId(traceId)));
}
@@ -419,8 +470,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
if (!shouldApplyActiveTraceAuthority(traceId, traceResultSessionId(result))) return;
recordActivity(`turn:${firstNonEmptyString(result.lastEventLabel, result.status, result.waitingFor, "status") ?? "status"}`);
const ownerSessionId = traceOwnerSessionId(traceId, traceResultSessionId(result));
if (!ownerSessionId) return;
if (ownerSessionId === activeSessionId.value) recordActivity(`turn:${firstNonEmptyString(result.lastEventLabel, result.status, result.waitingFor, "status") ?? "status"}`);
rememberTurnStatus(traceId, result);
syncTurnStatusToMessage(traceId, result);
}
@@ -447,27 +499,15 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function syncTurnStatusToMessage(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
const resultStatus = statusFromResult(result.status);
const authoritySessionId = traceResultSessionId(result);
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const status = isTerminalMessageStatus(message.status) ? message.status : resultStatus;
const terminal = isTerminalMessageStatus(status) || (result as AgentChatResultResponse).terminal === true;
updateTraceMessages(traceId, authoritySessionId, (message) => {
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result as AgentChatResultResponse);
rememberTraceAuthority(runnerTrace);
const error = normalizeAgentError((result as AgentChatResultResponse).error ?? runnerTrace?.error ?? message.error);
const errorText = agentErrorDisplayText(error);
const agentRun = agentRunFromResult(result as AgentChatResultResponse, runnerTrace) ?? agentRunFromMessage(message);
const text = terminal
? projectedAgentMessageText({
status,
finalText: finalResponseText((result as AgentChatResultResponse).finalResponse),
errorText,
baseText: message.text
})
: message.text;
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
const projection = projectionFromResult(result as AgentChatResultResponse) ?? runnerTrace.projection ?? message.projection ?? null;
return { ...message, runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
}
async function submitMessage(text: string): Promise<boolean> {
@@ -625,18 +665,20 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function applyTraceHydrationResult(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
if (!ownerSessionId) return;
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
const activityLabel = firstNonEmptyString(result.lastEventLabel, events.at(-1)?.label, events.at(-1)?.type, result.status);
if (events.length > 0 || result.terminal === true || isTerminalMessageStatus(result.status)) recordActivity(`trace:${activityLabel ?? "hydrated"}`);
if (ownerSessionId === activeSessionId.value && (events.length > 0 || result.terminal === true || isTerminalMessageStatus(result.status))) recordActivity(`trace:${activityLabel ?? "hydrated"}`);
markWorkbenchTraceEventsReceived({ traceId, events, transport: "rest_gap" });
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message;
const displayStatus = firstNonEmptyString(message.status, message.runnerTrace?.status, result.status, result.runnerTrace?.status);
const runnerTrace = mergeRunnerTrace(message.runnerTrace, {
...(result.runnerTrace ?? {}),
traceId: result.traceId ?? traceId,
status: result.status ?? result.traceStatus ?? message.runnerTrace?.status,
sessionId: result.sessionId ?? message.runnerTrace?.sessionId,
status: displayStatus ?? undefined,
sessionId: authoritySessionId ?? message.runnerTrace?.sessionId,
threadId: result.threadId ?? message.runnerTrace?.threadId,
events,
eventSource: "trace-api",
@@ -650,7 +692,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
traceStatus: result.traceStatus,
retention: result.retention,
terminalEvidence: result.terminalEvidence,
finalResponse: result.finalResponse,
traceSummary: result.traceSummary,
agentRun: result.agentRun,
projection: projectionFromResult(result),
@@ -661,17 +702,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
lastEventLabel: result.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type,
updatedAt: new Date().toISOString()
});
const explicitStatus = normalizedStatusText(result.status);
const resultStatus = explicitStatus ? statusFromResult(result.status) : message.status;
const status = isTerminalMessageStatus(message.status) ? message.status : resultStatus;
const terminal = result.terminal === true || isTerminalMessageStatus(status);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null;
const errorText = agentErrorDisplayText(error);
const text = terminal ? projectedAgentMessageText({ status, finalText: finalResponseText(result.finalResponse), errorText, baseText: message.text }) : message.text;
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
rememberTraceAuthority(runnerTrace);
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
return { ...message, runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
markWorkbenchTraceProjected(traceId);
}
@@ -776,6 +811,28 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return normalizeWorkbenchSessionId(firstNonEmptyString(message?.sessionId, message?.runnerTrace?.sessionId));
}
function traceOwnerSessionId(traceId: string | null | undefined, authoritySessionId: string | null | undefined): string | null {
const sessionId = normalizeWorkbenchSessionId(authoritySessionId);
if (sessionId) return sessionId;
const id = firstNonEmptyString(traceId);
if (!id) return activeSessionId.value;
for (const [candidateSessionId, candidateMessages] of Object.entries(serverState.value.messagesBySessionId)) {
if (candidateMessages.some((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) === id)) return normalizeWorkbenchSessionId(candidateSessionId);
}
return activeSessionId.value;
}
function messageMatchesTraceAuthority(message: ChatMessage, traceId: string, authoritySessionId: string | null | undefined, ownerSessionId: string | null | undefined): boolean {
if (message.role !== "agent" || firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== traceId) return false;
const sessionId = normalizeWorkbenchSessionId(authoritySessionId);
const ownerId = normalizeWorkbenchSessionId(ownerSessionId);
const messageSessionId = messageSessionAuthority(message);
if (sessionId && ownerId && sessionId !== ownerId) return false;
if (messageSessionId && ownerId && messageSessionId !== ownerId) return false;
if (sessionId && messageSessionId && sessionId !== messageSessionId) return false;
return true;
}
function shouldApplyActiveTraceAuthority(traceId: string | null | undefined, authoritySessionId: string | null | undefined): boolean {
const id = firstNonEmptyString(traceId);
const activeId = activeSessionId.value;
@@ -940,58 +997,51 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void {
const turn = turnStatusAuthority.value[traceId];
const trace = snapshotToRunnerTrace({
...snapshot,
status: firstNonEmptyString(turn?.status, snapshot.status) ?? snapshot.status,
traceStatus: firstNonEmptyString(snapshot.traceStatus, snapshot.status) ?? undefined
});
const authoritySessionId = traceResultSessionId(trace) ?? traceResultSessionId(snapshot);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
if (!ownerSessionId) return;
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message;
const runnerTrace = mergeRunnerTrace(message.runnerTrace, trace);
rememberTraceAuthority(runnerTrace);
const status = message.status;
const terminal = isTerminalMessageStatus(status);
const error = message.role === "agent" ? normalizeAgentError(runnerTrace.error ?? message.error) : normalizeAgentError(message.error);
const errorText = message.role === "agent" ? agentErrorDisplayText(error) : null;
const nextText = message.role === "agent" && terminal
? projectedAgentMessageText({ status, finalText: finalResponseText(runnerTrace.finalResponse), errorText, baseText: message.text })
: message.text;
const projection = trace.projection ?? runnerTrace.projection ?? message.projection ?? null;
return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : message.traceAutoLifecycle, runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, updatedAt: new Date().toISOString() };
return { ...message, runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, updatedAt: new Date().toISOString() };
}));
markWorkbenchTraceProjected(traceId);
void refreshSessions(trace.sessionId ?? selectedSessionId.value);
void refreshSessions(ownerSessionId);
}
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
recordActivity(`trace:terminal:${firstNonEmptyString(result.lastEventLabel, result.status, "completed") ?? "completed"}`);
const terminalStatus = result.status === "completed" ? "completed" : statusFromResult(result.status);
const finalText = finalResponseText(result.finalResponse);
const errorText = agentErrorDisplayText(result.error);
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
if (!ownerSessionId) return;
if (ownerSessionId === activeSessionId.value) recordActivity(`trace:terminal:${firstNonEmptyString(result.lastEventLabel, result.status, "completed") ?? "completed"}`);
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
rememberTraceAuthority(runnerTrace);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null;
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
const text = projectedAgentMessageText({ status: terminalStatus, finalText, errorText, baseText: message.text });
return { ...message, status: terminalStatus, text, traceAutoLifecycle: "terminal", runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
return { ...message, runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
rememberTurnStatus(traceId, result);
markWorkbenchTraceProjected(traceId);
clearActiveTurnGapRefresh(traceId);
void hydrateTraceEvents(messages.value);
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-terminal");
void refreshSessions(result.sessionId ?? activeSessionId.value);
restartRealtime("trace-terminal");
void refreshMessageProjectionForTrace(ownerSessionId, traceId);
void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
if (ownerSessionId === activeSessionId.value) {
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-terminal");
restartRealtime("trace-terminal");
}
void refreshSessions(ownerSessionId);
}
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
@@ -1008,19 +1058,19 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
if (!ownerSessionId) return;
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
rememberTraceAuthority(runnerTrace);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null;
const status = statusFromResult(result.status);
return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
return { ...message, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
void hydrateTraceEvents(messages.value);
void refreshSessions(result.sessionId ?? activeSessionId.value);
void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
void refreshSessions(ownerSessionId);
}
function applyRealtimeProjectionError(event: WorkbenchRealtimeEvent): void {
@@ -1230,7 +1280,7 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage {
const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun);
const status = normalizeChatMessageStatus(message.status);
const baseText = firstNonEmptyString(message.text, messageText((message as Record<string, unknown>).content), messageText((message as Record<string, unknown>).message));
const finalText = firstNonEmptyString(finalResponseText((message as Record<string, unknown>).finalResponse), finalResponseText(runnerTrace?.finalResponse));
const finalText = finalResponseText((message as Record<string, unknown>).finalResponse);
const errorText = agentErrorDisplayText(error);
const text = role === "agent"
? isTerminalMessageStatus(status)
@@ -1271,7 +1321,6 @@ function realtimeSnapshotToTraceSnapshot(traceId: string, snapshot: WorkbenchRea
traceStatus: firstNonEmptyString(source.traceStatus) ?? undefined,
retention: source.retention,
terminalEvidence: source.terminalEvidence,
finalResponse: source.finalResponse,
traceSummary: source.traceSummary,
error: normalizeAgentError(source.error) ?? undefined,
projection: normalizeProjectionDiagnostic(source.projection ?? source) ?? null,
@@ -1294,19 +1343,17 @@ function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result:
const resultTrace = recordValue(result.runnerTrace);
const events = firstArray(result.events, result.traceEvents, resultTrace?.events, previous?.events);
const agentRun = asAgentRun(result.agentRun ?? resultTrace?.agentRun ?? previous?.agentRun);
return mergeRunnerTrace(previous, {
const traceDetailStatus = firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus, resultTrace?.status);
const nextTrace = {
...resultTrace,
traceId: firstNonEmptyString(result.traceId, resultTrace?.traceId, previous?.traceId) ?? undefined,
status: firstNonEmptyString(result.traceStatus, resultTrace?.status, result.status, previous?.status) ?? undefined,
sessionId: firstNonEmptyString(result.sessionId, resultTrace?.sessionId, previous?.sessionId) ?? undefined,
threadId: firstNonEmptyString(result.threadId, resultTrace?.threadId, previous?.threadId) ?? undefined,
events,
eventCount: firstFiniteNumber(result.eventCount, resultTrace?.eventCount, previous?.eventCount, events.length),
eventsCompacted: firstBoolean(resultTrace?.eventsCompacted, previous?.eventsCompacted),
traceStatus: firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus, previous?.traceStatus) ?? undefined,
retention: result.retention ?? resultTrace?.retention ?? previous?.retention,
terminalEvidence: result.terminalEvidence ?? resultTrace?.terminalEvidence ?? previous?.terminalEvidence,
finalResponse: result.finalResponse ?? resultTrace?.finalResponse ?? previous?.finalResponse,
traceSummary: result.traceSummary ?? resultTrace?.traceSummary ?? previous?.traceSummary,
agentRun: agentRun ?? undefined,
error: normalizeAgentError(result.error ?? resultTrace?.error ?? previous?.error) ?? undefined,
@@ -1319,7 +1366,11 @@ function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result:
sessionMode: firstNonEmptyString(agentRun?.backendProfile, resultTrace?.sessionMode, previous?.sessionMode) ?? undefined,
lastEventLabel: firstNonEmptyString(result.lastEventLabel, resultTrace?.lastEventLabel, previous?.lastEventLabel, events.at(-1)?.label, events.at(-1)?.type) ?? undefined,
updatedAt: new Date().toISOString()
} as NonNullable<ChatMessage["runnerTrace"]>);
} as NonNullable<ChatMessage["runnerTrace"]>;
if (traceDetailStatus) nextTrace.status = traceDetailStatus;
const traceStatus = firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus);
if (traceStatus) nextTrace.traceStatus = traceStatus;
return mergeRunnerTrace(previous, nextTrace);
}
function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerTrace"] {
@@ -1442,7 +1493,7 @@ function traceEventHasTerminalEvidence(event: unknown): boolean {
function traceSnapshotHasTerminalEvidence(snapshot: unknown): boolean {
const record = recordValue(snapshot);
if (!record) return false;
if (record.terminal === true || record.terminalEvidence || record.finalResponse) return true;
if (record.terminal === true || record.terminalEvidence) return true;
const events = Array.isArray(record.events) ? record.events : [];
return events.some((event) => traceEventHasTerminalEvidence(event));
}
@@ -1532,16 +1583,6 @@ function makeMessage(role: ChatMessage["role"], text: string, status: ChatMessag
return { id: nextProtocolId("msg"), role, title: extra.title ?? (role === "user" ? "用户" : "Code Agent"), text, status, createdAt: new Date().toISOString(), ...extra };
}
function statusFromResult(status: string | undefined): ChatMessage["status"] {
if (isTraceActiveStatus(status)) return "running";
if (status === "blocked") return "blocked";
if (status === "timeout") return "timeout";
if (status === "canceled" || status === "cancelled") return "canceled";
if (status === "completed") return "completed";
if (!status || status === "unknown") return "pending";
return "failed";
}
function isTraceActiveStatus(status: unknown): boolean {
return ["accepted", "pending", "queued", "dispatching", "streaming", "processing", "running", "retrying", "busy", "creating"].includes(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
@@ -59,3 +59,22 @@ test.describe("sealed final response diagnostics separation", () => {
await saveScreenshot(page, testInfo, "sealed-final-response-diagnostic-separate");
});
});
test.describe("trace detail final response separation", () => {
test.use({ scenarioId: "trace-final-response-not-message-authority" });
test("keeps trace finalResponse out of the main message projection", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/ses_trace_final_conflict");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last();
const messageText = card.locator(".message-markdown.message-text");
await expect(messageText).toContainText("messageProjection 的 sealed final response 才是主消息正文。");
const traceResponse = await page.request.get("/v1/workbench/traces/trc_trace_final_conflict/events?limit=20");
expect(traceResponse.status()).toBe(200);
const tracePayload = await traceResponse.json();
expect(tracePayload.finalResponse.text).toBe("TRACE_DETAIL_CONFLICT_SHOULD_NOT_REPLACE_MESSAGE");
await page.waitForTimeout(500);
await expect(messageText).toContainText("messageProjection 的 sealed final response 才是主消息正文。");
await expect(messageText).not.toContainText("TRACE_DETAIL_CONFLICT_SHOULD_NOT_REPLACE_MESSAGE");
await saveScreenshot(page, testInfo, "trace-final-response-not-message-authority");
});
});