Merge pull request #1581 from pikasTech/fix/1567-read-side-projection

fix: Workbench 读侧 final text 推理清零
This commit is contained in:
Lyon
2026-06-19 05:01:12 +08:00
committed by GitHub
5 changed files with 40 additions and 144 deletions
@@ -3,7 +3,7 @@ import test from "node:test";
import type { AgentRunProvenance, ChatMessage, TraceEvent } from "../src/types/index.ts";
import { canCancelMessage, canRetryMessage, messageTraceId, renderSafeMarkdown, traceEventBody, traceEventLabel, traceIdentityText, visibleTraceEvents } from "../src/components/workbench/message-rendering.ts";
import { assistantTextFromTraceEvents, mergeRunnerTrace } from "../src/composables/useTraceSubscription.ts";
import { mergeRunnerTrace } from "../src/composables/useTraceSubscription.ts";
import { traceDisplayRows, traceNoiseEventCount } from "../../../tools/src/hwlab-cli/trace-renderer.ts";
test("R1 markdown rendering keeps structure and strips unsafe HTML", () => {
@@ -103,16 +103,6 @@ test("R1 trace API snapshots stay authoritative over compact result traces", ()
assert.equal(merged.events?.[1]?.message, "OK");
});
test("R1 trace event assistant text prefers authoritative final assistant message", () => {
const events: TraceEvent[] = [
{ seq: 1, label: "agentrun:backend:resource-bundle-materialized", message: "bundle ready" },
{ seq: 2, label: "agentrun:assistant:message", type: "assistant", status: "running", replyAuthority: false, final: false, message: "我先检查当前状态。" },
{ seq: 3, label: "agentrun:assistant:message", type: "assistant", status: "completed", terminal: true, message: "OK" }
];
assert.equal(assistantTextFromTraceEvents(events), "OK");
});
test("R1 status summary keeps the restored 23-row floor", () => {
const message = agentMessage({
status: "failed",
+12 -20
View File
@@ -91,9 +91,7 @@ function normalizeWorkbenchTurnResult(result: ApiResult<Record<string, unknown>>
if (!result.ok || !result.data) return result as ApiResult<AgentChatResultResponse>;
const turn = recordValue(result.data.turn) ?? result.data;
const trace = recordValue(turn.trace);
const envelopeStatus = textValue(result.data.status);
const status = normalizeStatus(textValue(turn.status) ?? (envelopeStatus && !["ok", "succeeded", "found"].includes(envelopeStatus) ? envelopeStatus : null)) ?? "unknown";
const terminal = turn.terminal === true || isTerminalStatus(status);
const status = normalizeStatus(textValue(turn.status)) ?? "unknown";
return {
...result,
data: {
@@ -103,8 +101,8 @@ function normalizeWorkbenchTurnResult(result: ApiResult<Record<string, unknown>>
lastProjectedSeq: Number.isFinite(Number(result.data.lastProjectedSeq)) ? Number(result.data.lastProjectedSeq) : undefined,
traceId: textValue(turn.traceId) ?? traceId,
status,
running: !terminal && (turn.running === true || isActiveStatus(status)),
terminal,
running: typeof turn.running === "boolean" ? turn.running : undefined,
terminal: typeof turn.terminal === "boolean" ? turn.terminal : undefined,
sessionId: textValue(turn.sessionId) ?? undefined,
threadId: textValue(turn.threadId) ?? undefined,
agentRun: recordValue(turn.agentRun) ?? undefined,
@@ -117,30 +115,32 @@ function normalizeWorkbenchTraceResult(result: ApiResult<Record<string, unknown>
if (!result.ok || !result.data) return result as ApiResult<AgentChatResultResponse>;
const events = Array.isArray(result.data.events) ? result.data.events : [];
const nextSeq = Number(result.data.nextSeq ?? result.data.nextSinceSeq);
const traceStatus = textValue(result.data.traceStatus, result.data.status) ?? "unknown";
const traceStatus = textValue(result.data.traceStatus) ?? undefined;
const eventCount = Number.isFinite(Number(result.data.eventCount)) ? Number(result.data.eventCount) : events.length;
const normalizedNextSeq = Number.isFinite(nextSeq) ? Math.trunc(nextSeq) : null;
const hasMore = result.data.hasMore === true;
const fullTraceLoaded = typeof result.data.fullTraceLoaded === "boolean" ? result.data.fullTraceLoaded : undefined;
return {
...result,
data: {
...result.data,
traceId: textValue(result.data.traceId) ?? traceId,
status: traceStatus,
status: undefined,
traceStatus,
events,
traceEvents: events,
eventCount,
hasMore: result.data.hasMore === true,
fullTraceLoaded: result.data.hasMore !== true,
hasMore,
fullTraceLoaded,
nextSinceSeq: normalizedNextSeq,
range: recordValue(result.data.range) ?? undefined,
runnerTrace: {
traceId: textValue(result.data.traceId) ?? traceId,
status: traceStatus,
status: undefined,
events,
eventCount,
hasMore: result.data.hasMore === true,
fullTraceLoaded: result.data.hasMore !== true,
hasMore,
fullTraceLoaded,
nextSinceSeq: normalizedNextSeq,
range: recordValue(result.data.range) ?? undefined,
eventSource: "trace-api"
@@ -156,14 +156,6 @@ function normalizeStatus(value: unknown): string | null {
return normalized === "cancelled" ? "canceled" : normalized;
}
function isActiveStatus(value: unknown): boolean {
return ["accepted", "active", "busy", "creating", "pending", "processing", "running"].includes(normalizeStatus(value) ?? "");
}
function isTerminalStatus(value: unknown): boolean {
return ["blocked", "canceled", "completed", "failed", "stale", "thread-resume-failed", "timeout"].includes(normalizeStatus(value) ?? "");
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
@@ -144,12 +144,7 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
error: trace.error ?? terminal.error
};
const events = Array.isArray(trace.events) ? trace.events : [];
const assistantText = bestAssistantTextCandidate([
finalResponseText(mergedTrace.finalResponse),
terminalAssistantTextFromTraceEvents(events),
firstNonEmptyResultText(terminal),
assistantTextFromTraceEvents(events)
]);
const assistantText = firstNonEmptyString(finalResponseText(mergedTrace.finalResponse), terminal.assistantText);
return {
...terminal,
traceId: mergedTrace.traceId ?? terminal.traceId,
@@ -333,12 +328,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function firstNonEmptyResultText(result: AgentChatResultResponse): string | null {
const reply = result.reply;
const replyText = typeof reply === "string" ? reply : typeof reply?.content === "string" ? reply.content : null;
return firstNonEmptyString(result.assistantText, replyText);
}
function finalResponseText(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
@@ -347,41 +336,3 @@ function finalResponseText(value: unknown): string | null {
}
return null;
}
export function assistantTextFromTraceEvents(events: TraceEvent[]): string | null {
const terminal = terminalAssistantTextFromTraceEvents(events);
if (terminal) return terminal;
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (!event || !isAssistantTraceEvent(event)) continue;
const text = firstNonEmptyString(event.message, event.text, event.outputSummary);
if (text) return text;
}
return null;
}
function terminalAssistantTextFromTraceEvents(events: TraceEvent[]): string | null {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (!event || !isTerminalAssistantTraceEvent(event)) continue;
const text = firstNonEmptyString(event.message, event.text, event.outputSummary);
if (text) return text;
}
return null;
}
function bestAssistantTextCandidate(candidates: Array<string | null | undefined>): string | null {
return candidates.find((candidate) => typeof candidate === "string" && candidate.trim())?.trim() ?? null;
}
function isAssistantTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
return label === "agentrun:assistant:message" || label === "assistant:message" || label === "assistant:completed" || event.type === "assistant" || event.type === "assistant_message";
}
function isTerminalAssistantTraceEvent(event: TraceEvent): boolean {
const label = String(event.label ?? "");
if (label === "assistant:completed") return true;
if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true || event.terminal === true || String(event.status ?? "") === "completed";
return event.type === "assistant_message" && (event.status === "completed" || event.final === true || event.terminal === true);
}
@@ -78,8 +78,9 @@ export function resolveComposerState(input: { messages: ChatMessage[]; sessions?
const turn = activeTraceId ? input.turnStatusAuthority?.[activeTraceId] : null;
const activeByRequest = Boolean(currentRequest && isActiveStatus(currentRequest.status ?? "running"));
const activeByMessage = latestMessage?.role === "agent" && isActiveStatus(latestMessage.status);
const activeByStatus = turn?.running === true || isActiveStatus(turn?.status) || activeByRequest || activeByMessage;
const terminal = turn?.terminal === true || isTerminalStatus(turn?.status);
const terminalByMessage = latestMessage?.role === "agent" && isTerminalStatus(latestMessage.status);
const activeByStatus = !terminalByMessage && (turn?.running === true || isActiveStatus(turn?.status) || activeByRequest || activeByMessage);
const terminal = terminalByMessage || turn?.terminal === true || isTerminalStatus(turn?.status);
const active = activeSession(input.sessions ?? [], sessionId);
const effectiveSessionId = firstNonEmptyString(turn?.sessionId, currentRequest?.sessionId, active?.sessionId, sessionId);
const threadId = firstNonEmptyString(turn?.threadId, currentRequest?.threadId, active?.threadId);
@@ -98,6 +99,7 @@ export function resolveCancelableAgentMessage(input: { messages: ChatMessage[];
if (message.role !== "agent") continue;
if (firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== targetTraceId) continue;
if (!messageBelongsToCancelTarget(message, input)) continue;
if (isTerminalStatus(message.status)) continue;
return message;
}
return null;
+22 -61
View File
@@ -5,7 +5,7 @@ import { computed, nextTick, ref } from "vue";
import { defineStore } from "pinia";
import { api } from "@/api";
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { assistantTextFromTraceEvents, mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, LiveSurface, ProviderProfile, TraceEvent, WorkbenchSessionRecord } from "@/types";
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
@@ -356,28 +356,23 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
function syncTurnStatusToMessage(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
const status = statusFromResult(result.status);
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
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;
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 replyText = agentReplyText((result as AgentChatResultResponse).reply);
const traceAssistantText = assistantTextFromTraceEvents(firstArray((result as AgentChatResultResponse).events, (result as AgentChatResultResponse).traceEvents, runnerTrace?.events));
const text = terminal
? terminalAgentMessageText({
? projectedAgentMessageText({
status,
assistantText: (result as AgentChatResultResponse).assistantText,
finalText: finalResponseText((result as AgentChatResultResponse).finalResponse),
replyText,
errorText,
resultText: (result as AgentChatResultResponse).text,
summaryText: (result as AgentChatResultResponse).summary,
traceAssistantText
baseText: message.text
})
: message.text;
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
@@ -563,12 +558,12 @@ export const useWorkbenchStore = defineStore("workbench", () => {
updatedAt: new Date().toISOString()
});
const explicitStatus = normalizedStatusText(result.status);
const status = explicitStatus ? statusFromResult(result.status) : message.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 errorText = agentErrorDisplayText(error);
const traceAssistantText = assistantTextFromTraceEvents(events);
const text = terminal ? firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), agentReplyText(result.reply), errorText, result.text, result.summary, traceAssistantText, message.text) ?? message.text : message.text;
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, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
@@ -848,15 +843,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeRunnerTrace(message.runnerTrace, trace);
rememberTraceAuthority(runnerTrace);
const status = statusFromResult(firstNonEmptyString(turn?.status, message.status) ?? message.status);
const terminal = turn?.terminal === true || isTerminalMessageStatus(turn?.status);
const traceAssistantText = message.role === "agent" ? assistantTextFromTraceEvents(Array.isArray(runnerTrace.events) ? runnerTrace.events : []) : null;
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
? firstNonEmptyString(finalResponseText(runnerTrace.finalResponse), errorText, traceAssistantText, message.text) ?? message.text
? projectedAgentMessageText({ status, finalText: finalResponseText(runnerTrace.finalResponse), errorText, baseText: message.text })
: message.text;
return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : "running", runnerTrace, error: error ?? message.error ?? null, updatedAt: new Date().toISOString() };
return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : message.traceAutoLifecycle, runnerTrace, error: error ?? message.error ?? null, updatedAt: new Date().toISOString() };
}));
markWorkbenchTraceProjected(traceId);
void refreshSessions(trace.sessionId ?? selectedSessionId.value);
@@ -865,25 +859,16 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
const resultTrace = recordValue(result.runnerTrace);
const traceAssistantText = assistantTextFromTraceEvents(firstArray(result.events, result.traceEvents, resultTrace?.events));
const terminalStatus = result.status === "completed" ? "completed" : statusFromResult(result.status);
const text = terminalAgentMessageText({
status: terminalStatus,
assistantText: result.assistantText,
finalText: finalResponseText(result.finalResponse),
replyText: typeof result.reply === "string" ? result.reply : result.reply?.content,
errorText: agentErrorDisplayText(result.error),
resultText: result.text,
summaryText: result.summary,
traceAssistantText
});
const finalText = finalResponseText(result.finalResponse);
const errorText = agentErrorDisplayText(result.error);
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) 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 text = projectedAgentMessageText({ status: terminalStatus, finalText, errorText, baseText: message.text });
return { ...message, status: terminalStatus, text, traceAutoLifecycle: "terminal", runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
rememberTurnStatus(traceId, result);
@@ -1101,13 +1086,12 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage {
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 traceAssistantText = assistantTextFromTraceEvents(Array.isArray(runnerTrace?.events) ? runnerTrace.events : []);
const errorText = agentErrorDisplayText(error);
const text = role === "agent"
? isTerminalMessageStatus(status)
? terminalAgentMessageText({ status, finalText, errorText, traceAssistantText, baseText })
? projectedAgentMessageText({ status, finalText, errorText, baseText })
: ""
: firstNonEmptyString(baseText, finalText, traceAssistantText, errorText) ?? "";
: firstNonEmptyString(baseText, finalText, errorText) ?? "";
const messageId = firstNonEmptyString((message as Record<string, unknown>).messageId, message.id) ?? nextProtocolId("msg");
return { ...message, role, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined };
}
@@ -1306,33 +1290,10 @@ function finalResponseText(value: unknown): string | null {
return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message));
}
function agentReplyText(value: AgentChatResultResponse["reply"]): string | null {
if (typeof value === "string") return value;
return value && typeof value === "object" ? firstNonEmptyString(value.content) : null;
}
function terminalAgentMessageText(input: {
status: unknown;
assistantText?: unknown;
finalText?: string | null;
replyText?: string | null;
errorText?: string | null;
resultText?: unknown;
summaryText?: unknown;
traceAssistantText?: string | null;
baseText?: string | null;
}): string {
const finalText = firstNonEmptyString(
input.assistantText,
input.finalText,
input.replyText,
input.resultText,
input.summaryText,
input.traceAssistantText,
input.baseText
);
if (!isFailureTerminalStatus(input.status)) return finalText ?? "";
return firstNonEmptyString(input.errorText, finalText) ?? "";
function projectedAgentMessageText(input: { status: unknown; finalText?: string | null; errorText?: string | null; baseText?: string | null }): string {
const text = firstNonEmptyString(input.finalText, input.baseText);
if (!isFailureTerminalStatus(input.status)) return text ?? "";
return firstNonEmptyString(input.errorText, text) ?? "";
}
function isFailureTerminalStatus(value: unknown): boolean {