Merge pull request #1545 from pikasTech/fix/pj1538-workbench-server-state

fix: 收敛 Workbench server-state 读写口
This commit is contained in:
Lyon
2026-06-19 01:09:05 +08:00
committed by GitHub
6 changed files with 178 additions and 141 deletions
@@ -73,8 +73,11 @@ test("Workbench session tab status is read from server projection", () => {
]
};
const tab = sessionToSessionTab(session, "ses_tab_projection");
const rawTab = sessionToSessionTab(session, "ses_tab_projection");
const tab = sessionToSessionTab(session, "ses_tab_projection", { ses_tab_projection: { sessionId: "ses_tab_projection", status: "running", lastTraceId: "trc_tab_projection" } });
assert.equal(rawTab.status, "unknown");
assert.equal(rawTab.running, false);
assert.equal(tab.status, "running");
assert.equal(tab.running, true);
});
@@ -23,9 +23,10 @@ const clipboard = useClipboard();
const panelRef = ref<HTMLElement | null>(null);
const following = ref(true);
const detailMessageId = ref<string | null>(null);
const detailMessage = computed(() => workbench.messages.find((message) => message.id === detailMessageId.value) ?? null);
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null);
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
const scrollSignature = computed(() => workbench.messages.map((message) => [
const scrollSignature = computed(() => workbench.activeMessages.map((message) => [
message.id,
message.status,
message.text?.length ?? 0,
@@ -69,7 +70,12 @@ function isAwaitingAgentBody(message: ChatMessage): boolean {
}
function showMessageText(message: ChatMessage): boolean {
return message.role !== "agent" || Boolean(String(message.text ?? "").trim());
return !messageDiagnosticText(message) && (message.role !== "agent" || Boolean(String(message.text ?? "").trim()));
}
function messageDiagnosticText(message: ChatMessage): string | null {
if (message.role !== "user" || String(message.text ?? "").trim()) return null;
return "消息内容缺失:Workbench read model 未返回 user text。";
}
function onPanelScroll(): void {
@@ -85,7 +91,7 @@ function scrollToBottom(): void {
}
function acknowledgeVisibleMessages(): void {
acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.messages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading });
acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.activeMessages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading });
}
function traceStorageKey(message: ChatMessage): string {
@@ -100,7 +106,7 @@ function traceAutoExpanded(message: ChatMessage): boolean | null {
<template>
<section id="conversation-list" ref="panelRef" class="conversation-panel" :data-following="following ? 'true' : 'false'" @scroll="onPanelScroll">
<LoadingState v-if="workbench.sessionDetailLoading" class="conversation-detail-loading" label="加载中" />
<article v-for="message in workbench.sessionDetailLoading ? [] : workbench.messages" :key="message.id" class="message-card" :data-role="message.role" :data-status="message.status">
<article v-for="message in visibleMessages" :key="message.id" class="message-card" :data-role="message.role" :data-status="message.status">
<header v-if="message.role !== 'user'">
<strong>{{ message.title }}</strong>
<div class="message-header-actions">
@@ -111,10 +117,11 @@ function traceAutoExpanded(message: ChatMessage): boolean | null {
</header>
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :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="message.text" />
<p v-if="messageDiagnosticText(message)" class="message-text projection-diagnostic">{{ messageDiagnosticText(message) }}</p>
<MessageMarkdown v-else-if="showMessageText(message)" class="message-text" :source="message.text" />
</article>
<div v-if="!workbench.sessionDetailLoading && workbench.messages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败{{ workbench.error }}</div>
<div v-else-if="!workbench.sessionDetailLoading && workbench.messages.length === 0" class="conversation-empty-hint">发起对话或从左侧选择 session</div>
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败{{ workbench.error }}</div>
<div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话或从左侧选择 session</div>
<div v-if="detailMessage" class="workbench-dialog-backdrop" role="presentation" @click.self="detailMessageId = null">
<section id="message-detail-dialog" class="workbench-dialog wide" role="dialog" aria-modal="true" aria-labelledby="message-detail-title">
<header>
@@ -5,6 +5,7 @@ import type { ChatMessage, WorkbenchSessionRecord } from "@/types";
import type { SessionStatusAuthority, TurnStatusAuthority } from "./workbench-session";
export interface WorkbenchServerState {
sessionOrder: string[];
sessionsById: Record<string, WorkbenchSessionRecord>;
messagesBySessionId: Record<string, ChatMessage[]>;
sessionStatusById: Record<string, SessionStatusAuthority>;
@@ -15,6 +16,7 @@ export interface WorkbenchServerState {
export type WorkbenchServerAction =
| { type: "session.list"; sessions: WorkbenchSessionRecord[] }
| { type: "session.detail"; session: WorkbenchSessionRecord | null | undefined }
| { type: "session.messages"; sessionId: string | null | undefined; messages: ChatMessage[] }
| { type: "session.status"; session: SessionStatusAuthority }
| { type: "session.forget"; sessionId: string }
| { type: "turn.status"; turn: TurnStatusAuthority }
@@ -23,6 +25,7 @@ export type WorkbenchServerAction =
export function createWorkbenchServerState(): WorkbenchServerState {
return {
sessionOrder: [],
sessionsById: {},
messagesBySessionId: {},
sessionStatusById: {},
@@ -34,13 +37,21 @@ export function createWorkbenchServerState(): WorkbenchServerState {
export function reduceWorkbenchServerState(state: WorkbenchServerState, action: WorkbenchServerAction): WorkbenchServerState {
switch (action.type) {
case "session.list":
return action.sessions.reduce((next, session) => reduceWorkbenchServerState(next, { type: "session.detail", session }), state);
return reduceSessionList(state, action.sessions);
case "session.detail":
return reduceSessionDetail(state, action.session);
case "session.messages":
return reduceSessionMessages(state, action.sessionId, action.messages);
case "session.status":
return { ...state, sessionStatusById: { ...state.sessionStatusById, [action.session.sessionId]: action.session } };
case "session.forget":
return { ...state, sessionStatusById: withoutKey(state.sessionStatusById, action.sessionId) };
return {
...state,
sessionOrder: state.sessionOrder.filter((id) => id !== action.sessionId),
sessionsById: withoutKey(state.sessionsById, action.sessionId),
messagesBySessionId: withoutKey(state.messagesBySessionId, action.sessionId),
sessionStatusById: withoutKey(state.sessionStatusById, action.sessionId)
};
case "turn.status":
return { ...state, turnStatusByTraceId: { ...state.turnStatusByTraceId, [action.turn.traceId]: action.turn } };
case "turn.forget":
@@ -62,6 +73,10 @@ export function selectTraceAuthorityById(state: WorkbenchServerState): Readonly<
return state.traceById;
}
export function selectSessionList(state: WorkbenchServerState): WorkbenchSessionRecord[] {
return state.sessionOrder.map((sessionId) => state.sessionsById[sessionId]).filter((session): session is WorkbenchSessionRecord => Boolean(session));
}
export function selectActiveSession(state: WorkbenchServerState, sessionId: string | null): WorkbenchSessionRecord | null {
return sessionId ? state.sessionsById[sessionId] ?? null : null;
}
@@ -73,20 +88,53 @@ export function selectActiveMessages(state: WorkbenchServerState, sessionId: str
function reduceSessionDetail(state: WorkbenchServerState, session: WorkbenchSessionRecord | null | undefined): WorkbenchServerState {
const sessionId = session?.sessionId;
if (!sessionId || !session) return state;
const existing = state.sessionsById[sessionId];
const messages = Array.isArray(session.messages) ? session.messages : state.messagesBySessionId[sessionId] ?? existing?.messages ?? [];
const merged = mergeSessionRecord(existing, { ...session, messages });
const sessionStatus = sessionStatusAuthorityFromDetail(session);
return {
...state,
sessionsById: { ...state.sessionsById, [sessionId]: session },
sessionOrder: state.sessionOrder.includes(sessionId) ? state.sessionOrder : [sessionId, ...state.sessionOrder],
sessionsById: { ...state.sessionsById, [sessionId]: merged },
sessionStatusById: sessionStatus
? { ...state.sessionStatusById, [sessionId]: sessionStatus }
: state.sessionStatusById,
messagesBySessionId: {
...state.messagesBySessionId,
[sessionId]: Array.isArray(session.messages) ? session.messages : state.messagesBySessionId[sessionId] ?? []
[sessionId]: messages
}
};
}
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);
return { ...next, sessionOrder: unique(sessionIds) };
}
function reduceSessionMessages(state: WorkbenchServerState, sessionId: string | null | undefined, messages: ChatMessage[]): WorkbenchServerState {
if (!sessionId) return state;
const existing = state.sessionsById[sessionId];
const session = existing ? { ...existing, messages, messageCount: messages.length } : null;
return {
...state,
sessionsById: session ? { ...state.sessionsById, [sessionId]: session } : state.sessionsById,
messagesBySessionId: { ...state.messagesBySessionId, [sessionId]: messages }
};
}
function mergeSessionRecord(existing: WorkbenchSessionRecord | undefined, incoming: WorkbenchSessionRecord): WorkbenchSessionRecord {
if (!existing) return incoming;
const incomingMessages = Array.isArray(incoming.messages) ? incoming.messages : undefined;
const existingMessages = Array.isArray(existing.messages) ? existing.messages : [];
return {
...existing,
...incoming,
messages: incomingMessages ?? existing.messages,
messageCount: incoming.messageCount ?? existing.messageCount ?? incomingMessages?.length ?? existingMessages.length
};
}
function sessionStatusAuthorityFromDetail(session: WorkbenchSessionRecord): SessionStatusAuthority | null {
const sessionId = session.sessionId;
if (!sessionId || typeof session.status !== "string") return null;
@@ -105,3 +153,7 @@ function withoutKey<T>(source: Record<string, T>, key: string): Record<string, T
delete next[key];
return next;
}
function unique(values: string[]): string[] {
return [...new Set(values)];
}
@@ -251,9 +251,9 @@ function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage |
}
function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): string {
void session;
const authorityStatus = normalizeSessionStatus(authority?.status);
if (authorityStatus) return authorityStatus;
return normalizeSessionStatus(session.status) ?? "unknown";
return authorityStatus ?? "unknown";
}
function messageBelongsToCancelTarget(message: ChatMessage, input: { targetSessionId?: string | null; targetThreadId?: string | null }): boolean {
+97 -124
View File
@@ -1,7 +1,7 @@
// 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.
// Responsibility: Session-first Workbench state orchestration for selection, turn admission, and trace lifecycle rendering.
import { computed, ref } from "vue";
import { computed, nextTick, ref } from "vue";
import { defineStore } from "pinia";
import { api } from "@/api";
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
@@ -11,7 +11,7 @@ import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, norma
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
@@ -19,17 +19,14 @@ const TRACE_HYDRATION_PAGE_LIMIT = 100;
const TRACE_HYDRATION_MAX_PAGES = 60;
const TRACE_HYDRATION_MAX_ATTEMPTS = 3;
const TRACE_HYDRATION_RETRY_DELAY_MS = 700;
const PROJECTION_CATCHUP_DELAY_MS = 1_000;
const PROJECTION_CATCHUP_MAX_ATTEMPTS = 120;
const ACTIVE_TURN_GAP_DELAY_MS = 1_000;
const ACTIVE_TURN_GAP_MAX_ATTEMPTS = 12;
interface HydrateOptions {
sessionId?: string | null;
invalidRouteId?: string | null;
}
export const useWorkbenchStore = defineStore("workbench", () => {
const sessions = ref<WorkbenchSessionRecord[]>([]);
const messages = ref<ChatMessage[]>([]);
const providerProfile = ref<ProviderProfile>(readString("hwlab.workbench.providerProfile.v1", "codex"));
const providerOptions = ref<ProviderProfileOption[]>(defaultProviderProfileOptions(providerProfile.value));
const recentDrafts = ref<DraftEntry[]>(readRecentDrafts());
@@ -47,20 +44,23 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const explicitSessionId = ref<string | null>(initialWorkbenchSessionIdFromLocation());
const selectionEpoch = ref(0);
const serverState = ref(createWorkbenchServerState());
const sessions = computed(() => selectSessionList(serverState.value));
const sessionStatusAuthority = computed(() => selectSessionStatusAuthority(serverState.value));
const turnStatusAuthority = computed(() => selectTurnStatusAuthority(serverState.value));
const traceAuthorityById = computed(() => selectTraceAuthorityById(serverState.value));
const traceHydrationInFlight = new Set<string>();
const terminalRealtimeRefreshInFlight = new Set<string>();
const projectionCatchupAttempts = new Map<string, number>();
const activeTurnGapAttempts = new Map<string, number>();
let realtimeStream: WorkbenchEventStream | null = null;
let realtimeKey = "";
let realtimeGapTimer: number | null = null;
let projectionCatchupTimer: number | null = null;
let activeTurnGapTimer: number | null = null;
const activeSession = computed(() => explicitSessionId.value ? sessions.value.find((item) => item.sessionId === explicitSessionId.value) ?? null : null);
const activeSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value));
const selectedSessionId = computed(() => firstNonEmptyString(activeSession.value?.sessionId, explicitSessionId.value));
const activeSessionId = computed(() => selectedSessionId.value);
const activeMessages = computed(() => selectActiveMessages(serverState.value, activeSessionId.value));
const messages = activeMessages;
const selectedThreadId = computed(() => firstNonEmptyString(activeSession.value?.threadId));
const sessionTabs = computed(() => sortSessionTabs(sessions.value, activeSessionId.value, sessionStatusAuthority.value));
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, sessionsReady: sessionsReady.value }));
@@ -97,14 +97,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
setActiveSessionSelection(targetSessionId);
}
if (targetSessionId) sessionDetailLoadingId.value = targetSessionId;
const previousMessages = messages.value;
const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null;
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected, previousMessages);
if (selected && !isArchivedSession(selected) && routeRequestId && !routeSessionId && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId);
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected);
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) rememberSessionDetail(selected);
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, null, [], "session archived", { restorePrevious: false });
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", null, [], "session URL not found", { restorePrevious: false });
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, "session archived");
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", "session URL not found");
const nextSessions = stableSessionList(sessions.value, listedSessions, selected?.sessionId ?? routeSessionId ?? includeSessionId, selected);
sessions.value = nextSessions;
rememberSessionList(nextSessions);
sessionsReady.value = true;
void refreshSessionStatusAuthority(nextSessions);
@@ -137,8 +136,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
error.value = response.error ?? "session create failed";
return;
}
sessions.value = mergeSessionIntoList(sessions.value, created);
rememberSessionDetail(created);
rememberSessionList(mergeSessionIntoList(sessions.value, created));
sessionsReady.value = true;
setActiveSessionSelection(created.sessionId);
await selectSession(created);
@@ -155,8 +153,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
error.value = "invalid session URL";
return false;
}
const previousSessionId = activeSessionId.value;
const previousMessages = messages.value;
const existing = seed ?? sessions.value.find((session) => session.sessionId === normalized) ?? null;
const requestEpoch = beginSessionSelection();
startWorkbenchSessionSwitch({ sessionId: normalized ?? requestId, source: existing ? "rail" : "deeplink", targetState: existing?.status ?? "unknown", cache: (existing?.messages?.length ?? 0) > 0 ? "warm" : "cold" });
@@ -165,26 +161,31 @@ export const useWorkbenchStore = defineStore("workbench", () => {
loading.value = true;
if (normalized) setActiveSessionSelection(normalized);
if (existing) {
sessions.value = mergeSessionIntoList(sessions.value, existing);
rememberSessionDetail(existing);
rememberSessionList(mergeSessionIntoList(sessions.value, existing));
sessionsReady.value = true;
}
const selected = await loadWorkbenchSession(requestId, existing);
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) return true;
if (selected && !isArchivedSession(selected)) {
applySelectedSessionDetail(selected, previousMessages);
if (selected && !isArchivedSession(selected) && !normalized && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId);
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) {
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
return true;
}
if (selected && !isArchivedSession(selected)) {
applySelectedSessionDetail(selected);
await nextTick();
loading.value = false;
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
error.value = null;
await refreshSessions(selected.sessionId);
finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok");
return activeSessionId.value === selected.sessionId;
}
isolateSessionLoadFailure(requestId, previousSessionId, previousMessages, isArchivedSession(selected) ? "session archived" : "session URL not found", { restorePrevious: Boolean(seed && normalized) });
loading.value = false;
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
loading.value = false;
isolateSessionLoadFailure(requestId, isArchivedSession(selected) ? "session archived" : "session URL not found");
failWorkbenchSessionSwitch(normalized ?? requestId);
return false;
}
@@ -198,7 +199,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
error.value = response.error ?? "session delete failed";
return;
}
sessions.value = sessions.value.filter((session) => session.sessionId !== sessionId);
forgetSession(sessionId);
await refreshSessions(null);
const next = sessions.value.find((session) => !isArchivedSession(session)) ?? null;
replaceActiveSessionSelection(next?.sessionId ?? null);
@@ -207,7 +208,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
await selectSession(next);
return;
}
messages.value = [];
restartRealtime("delete-current-session");
}
@@ -215,7 +215,6 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const response = await api.workbench.sessions({ includeSessionId });
if (response.ok) {
const next = stableSessionList(sessions.value, workbenchSessionsFromPayload(response.data), includeSessionId, activeSession.value);
sessions.value = next;
rememberSessionList(next);
sessionsReady.value = true;
await refreshSessionStatusAuthority(next);
@@ -237,6 +236,26 @@ export const useWorkbenchStore = defineStore("workbench", () => {
reduceServerState({ type: "session.list", sessions: source });
}
function forgetSession(sessionId: string): void {
reduceServerState({ type: "session.forget", sessionId });
}
function rememberSessionMessages(sessionId: string | null | undefined, source: ChatMessage[]): void {
reduceServerState({ type: "session.messages", sessionId, messages: source });
}
function replaceActiveMessages(source: ChatMessage[]): void {
rememberSessionMessages(activeSessionId.value, source);
}
function updateActiveMessages(project: (source: ChatMessage[]) => ChatMessage[]): void {
replaceActiveMessages(project(messages.value));
}
function appendActiveMessages(...items: ChatMessage[]): void {
replaceActiveMessages([...messages.value, ...items]);
}
function setActiveSessionSelection(sessionId: string | null | undefined): void {
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value;
}
@@ -296,7 +315,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const status = statusFromResult(result.status);
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
const authoritySessionId = traceResultSessionId(result);
messages.value = messages.value.map((message) => {
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result as AgentChatResultResponse);
rememberTraceAuthority(runnerTrace);
@@ -309,7 +328,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
? firstNonEmptyString((result as AgentChatResultResponse).assistantText, finalResponseText((result as AgentChatResultResponse).finalResponse), replyText, errorText, (result as AgentChatResultResponse).text, (result as AgentChatResultResponse).summary, traceAssistantText, message.text, "Code Agent 已完成,但没有返回可展示的 final response。") ?? message.text
: message.text;
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
}));
}
async function submitMessage(text: string): Promise<void> {
@@ -317,7 +336,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!value) return;
beginSessionSelection();
if (!composer.value.sessionId) {
messages.value.push(makeMessage("system", "session_required:请先显式新建或选择 Code Agent session。", "blocked", { title: "需要 Session" }));
error.value = "session_required:请先显式新建或选择 Code Agent session。";
return;
}
const steerMode = composer.value.submitMode === "steer";
@@ -329,7 +348,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const providerThreadId = providerThreadIdForRequest(threadId);
const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" });
const pending = makeMessage("agent", "", "running", { traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" });
messages.value.push(user, pending);
appendActiveMessages(user, pending);
startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" });
chatPending.value = true;
currentRequest.value = { traceId, sessionId, threadId, status: "running" };
@@ -440,18 +459,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
await Promise.all(source.filter(messageNeedsTraceHydration).slice(-12).map((message) => hydrateTraceEventsForMessage(message)));
}
function restoreMessagesTraceAuthority(source: ChatMessage[], previous: ChatMessage[] = []): ChatMessage[] {
const previousTraceById = new Map<string, NonNullable<ChatMessage["runnerTrace"]>>();
for (const message of previous) {
const trace = message.runnerTrace;
const traceId = firstNonEmptyString(message.traceId, trace?.traceId);
if (!traceId || !trace || !traceHasEvents(trace)) continue;
previousTraceById.set(traceId, trace);
}
function messagesWithTraceAuthority(source: ChatMessage[]): ChatMessage[] {
return source.map((message) => {
if (message.role !== "agent") return message;
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
const authority = traceId ? traceAuthorityById.value[traceId] ?? previousTraceById.get(traceId) ?? null : null;
const authority = traceId ? traceAuthorityById.value[traceId] ?? null : null;
if (!authority) return message;
return { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, authority) };
});
@@ -471,7 +483,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
markWorkbenchTraceEventsReceived({ traceId, events, transport: "rest_gap" });
messages.value = messages.value.map((message) => {
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeRunnerTrace(message.runnerTrace, {
...(result.runnerTrace ?? {}),
@@ -507,7 +519,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
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() };
});
}));
markWorkbenchTraceProjected(traceId);
}
@@ -537,7 +549,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function clearSessionMessages(): void {
const activeTraceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
messages.value = [];
replaceActiveMessages([]);
currentRequest.value = null;
if (activeTraceId) void clearActiveTrace(activeTraceId, "clear-session-messages");
restartRealtime("clear-session-messages");
@@ -545,7 +557,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function reattachTrace(traceId: string): void {
const initial: AgentChatResponse = { status: "running", traceId, turnUrl: `/v1/workbench/turns/${encodeURIComponent(traceId)}` };
if (!messages.value.some((message) => message.traceId === traceId)) messages.value.push(makeMessage("agent", "", "running", { traceId, sessionId: selectedSessionId.value ?? undefined, threadId: selectedThreadId.value ?? undefined, title: "Code Agent", traceAutoLifecycle: "running" }));
if (!messages.value.some((message) => message.traceId === traceId)) appendActiveMessages(makeMessage("agent", "", "running", { traceId, sessionId: selectedSessionId.value ?? undefined, threadId: selectedThreadId.value ?? undefined, title: "Code Agent", traceAutoLifecycle: "running" }));
currentRequest.value = { traceId, sessionId: selectedSessionId.value ?? null, threadId: selectedThreadId.value ?? null, status: initial.status };
restartRealtime("reattach");
scheduleRealtimeGapHydration("reattach");
@@ -721,27 +733,27 @@ export const useWorkbenchStore = defineStore("workbench", () => {
hydrateTraceEvents(messages.value)
]);
restartRealtime(`gap:${reason}`);
scheduleProjectionCatchup(reason);
scheduleActiveTurnGapRefresh(reason);
}
function scheduleProjectionCatchup(reason: string): void {
const traceId = activeProjectionCatchupTraceId();
function scheduleActiveTurnGapRefresh(reason: string): void {
const traceId = activeTurnGapTraceId();
if (!traceId) {
clearProjectionCatchup();
clearActiveTurnGapRefresh();
return;
}
if (projectionCatchupTimer) return;
const attempts = projectionCatchupAttempts.get(traceId) ?? 0;
if (attempts >= PROJECTION_CATCHUP_MAX_ATTEMPTS) return;
projectionCatchupAttempts.set(traceId, attempts + 1);
if (activeTurnGapTimer) return;
const attempts = activeTurnGapAttempts.get(traceId) ?? 0;
if (attempts >= ACTIVE_TURN_GAP_MAX_ATTEMPTS) return;
activeTurnGapAttempts.set(traceId, attempts + 1);
if (typeof window === "undefined") return;
projectionCatchupTimer = window.setTimeout(() => {
projectionCatchupTimer = null;
void hydrateRealtimeGap(`projection-catchup:${reason}`);
}, PROJECTION_CATCHUP_DELAY_MS);
activeTurnGapTimer = window.setTimeout(() => {
activeTurnGapTimer = null;
void hydrateRealtimeGap(`active-turn-gap:${reason}`);
}, ACTIVE_TURN_GAP_DELAY_MS);
}
function activeProjectionCatchupTraceId(): string | null {
function activeTurnGapTraceId(): string | null {
const traceId = realtimeTraceId();
if (!traceId) return null;
const turn = turnStatusAuthority.value[traceId];
@@ -754,11 +766,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return active ? traceId : null;
}
function clearProjectionCatchup(traceId?: string | null): void {
if (traceId) projectionCatchupAttempts.delete(traceId);
else projectionCatchupAttempts.clear();
if (typeof window !== "undefined" && projectionCatchupTimer) window.clearTimeout(projectionCatchupTimer);
projectionCatchupTimer = null;
function clearActiveTurnGapRefresh(traceId?: string | null): void {
if (traceId) activeTurnGapAttempts.delete(traceId);
else activeTurnGapAttempts.clear();
if (typeof window !== "undefined" && activeTurnGapTimer) window.clearTimeout(activeTurnGapTimer);
activeTurnGapTimer = null;
}
function installRealtimeVisibilityHandler(): void {
@@ -777,7 +789,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
rememberTurnStatus(traceId, snapshot);
const status = statusFromResult(snapshot.status);
const terminal = isTerminalMessageStatus(status);
messages.value = messages.value.map((message) => {
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeRunnerTrace(message.runnerTrace, trace);
rememberTraceAuthority(runnerTrace);
@@ -790,7 +802,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
? firstNonEmptyString(traceAssistantText, finalResponseText(runnerTrace.finalResponse), errorText, message.text) ?? message.text
: message.text;
return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : "running", runnerTrace, error: error ?? message.error ?? null, updatedAt: new Date().toISOString() };
});
}));
markWorkbenchTraceProjected(traceId);
void refreshSessions(trace.sessionId ?? selectedSessionId.value);
}
@@ -798,21 +810,21 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
clearProjectionCatchup(traceId);
const resultTrace = recordValue(result.runnerTrace);
const traceAssistantText = assistantTextFromTraceEvents(firstArray(result.events, result.traceEvents, resultTrace?.events));
const text = firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), typeof result.reply === "string" ? result.reply : result.reply?.content, agentErrorDisplayText(result.error), result.text, result.summary, traceAssistantText) ?? "Code Agent 已完成,但没有返回可展示的 final response。";
const terminalStatus = result.status === "completed" ? "completed" : statusFromResult(result.status);
messages.value = messages.value.map((message) => {
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);
return { ...message, status: terminalStatus, text, traceAutoLifecycle: "terminal", runnerTrace, error: error ?? message.error ?? 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;
@@ -836,7 +848,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
messages.value = messages.value.map((message) => {
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
rememberTraceAuthority(runnerTrace);
@@ -844,7 +856,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
const status = statusFromResult(result.status);
return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
}));
void hydrateTraceEvents(messages.value);
void refreshSessions(result.sessionId ?? activeSessionId.value);
}
@@ -862,7 +874,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
reduceServerState({ type: "turn.status", turn: { traceId: input.traceId, status: "running", running: true, terminal: false, sessionId: input.sessionId, threadId: input.threadId, updatedAt: now, loadedAt: now } });
reduceServerState({ type: "session.status", session: { sessionId: input.sessionId, status: "running", updatedAt: now, lastTraceId: input.traceId, loadedAt: now } });
const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null;
sessions.value = mergeSessionIntoList(sessions.value, {
rememberSessionList(mergeSessionIntoList(sessions.value, {
...(existing ?? {}),
sessionId: input.sessionId,
threadId: firstNonEmptyString(input.threadId, existing?.threadId),
@@ -872,12 +884,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
firstUserMessagePreview: firstNonEmptyString(existing?.firstUserMessagePreview, input.userText),
messageCount: Math.max(existing?.messageCount ?? 0, messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId).length),
messages: messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId)
} as WorkbenchSessionRecord);
rememberSessionList(sessions.value);
} as WorkbenchSessionRecord));
}
function markMessage(traceId: string, patch: Partial<ChatMessage>): void {
messages.value = messages.value.map((message) => message.traceId === traceId && message.role === "agent" ? { ...message, ...patch, updatedAt: new Date().toISOString() } : message);
updateActiveMessages((source) => source.map((message) => message.traceId === traceId && message.role === "agent" ? { ...message, ...patch, updatedAt: new Date().toISOString() } : message));
}
function alignOptimisticTurnMessages(traceId: string, lifecycle: AgentChatResponse): string {
@@ -886,21 +897,21 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const userMessageId = firstNonEmptyString(lifecycle.userMessageId);
const assistantMessageId = firstNonEmptyString(lifecycle.assistantMessageId);
if (!turnId && !userMessageId && !assistantMessageId && canonicalTraceId === traceId) return canonicalTraceId;
messages.value = messages.value.map((message) => {
updateActiveMessages((source) => source.map((message) => {
if (message.traceId !== traceId) return message;
const runnerTrace = message.runnerTrace ? { ...message.runnerTrace, traceId: canonicalTraceId } : message.runnerTrace;
const base = { ...message, traceId: canonicalTraceId, runnerTrace, updatedAt: new Date().toISOString() };
if (message.role === "user" && userMessageId) return { ...base, id: userMessageId, messageId: userMessageId, turnId };
if (message.role === "agent" && assistantMessageId) return { ...base, id: assistantMessageId, messageId: assistantMessageId, turnId };
return turnId ? { ...base, turnId } : base;
});
}));
return canonicalTraceId;
}
async function clearActiveTrace(traceId: string, reason: string): Promise<void> {
void reason;
if (currentRequest.value?.traceId === traceId) currentRequest.value = null;
if (!activeProjectionCatchupTraceId()) clearProjectionCatchup(traceId);
clearActiveTurnGapRefresh(traceId);
}
function beginSessionSelection(): number {
@@ -927,11 +938,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!sessionId || sessionDetailLoadingId.value === sessionId) sessionDetailLoadingId.value = null;
}
function applySelectedSessionDetail(session: WorkbenchSessionRecord, previousMessages: ChatMessage[]): void {
function applySelectedSessionDetail(session: WorkbenchSessionRecord): void {
setActiveSessionSelection(session.sessionId);
rememberSessionDetail(session);
messages.value = restoreMessagesTraceAuthority(messagesFromSession(session), previousMessages);
sessions.value = mergeSessionIntoList(sessions.value, session);
const projectedMessages = messagesWithTraceAuthority(messagesFromSession(session));
rememberSessionDetail({ ...session, messages: projectedMessages, messageCount: session.messageCount ?? projectedMessages.length });
sessionsReady.value = true;
currentRequest.value = null;
void hydrateTurnStatusAuthority(messages.value);
@@ -941,24 +951,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
restartRealtime("apply-selected-session");
}
function isolateSessionLoadFailure(sessionId: string, previousSessionId: string | null, previousMessages: ChatMessage[], message: string, options: { restorePrevious?: boolean } = {}): void {
sessions.value = sessions.value.filter((item) => item.sessionId !== sessionId || (item.messages?.length ?? 0) > 0);
if (options.restorePrevious === true && previousSessionId && previousSessionId !== sessionId) {
const previous = sessions.value.find((item) => item.sessionId === previousSessionId) ?? null;
replaceActiveSessionSelection(previousSessionId);
if (previous) {
rememberSessionDetail(previous);
messages.value = restoreMessagesTraceAuthority(messagesFromSession(previous), previousMessages);
void hydrateTurnStatusAuthority(messages.value);
void hydrateTraceEvents(messages.value);
reattachRestoredActiveTrace();
} else {
messages.value = previousMessages;
}
} else {
replaceActiveSessionSelection(null);
messages.value = [];
}
function isolateSessionLoadFailure(sessionId: string, message: string): void {
forgetSession(sessionId);
replaceActiveSessionSelection(null);
currentRequest.value = null;
error.value = message;
sessionsReady.value = sessions.value.length > 0;
@@ -973,7 +968,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
installRealtimeVisibilityHandler();
return { sessions, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, chatPending, error, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity };
return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, chatPending, error, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity };
});
function workbenchSessionsFromPayload(payload: unknown): WorkbenchSessionRecord[] {
@@ -1012,32 +1007,10 @@ async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRec
const base = detailSession ?? seed;
if (!base) return null;
const page = messages.ok ? messages.data : null;
const pageMessages = Array.isArray(page?.messages) ? repairWorkbenchMessagePage(page.messages.map((message) => normalizeChatMessage(message as ChatMessage)), base) : base.messages;
const pageMessages = Array.isArray(page?.messages) ? page.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : base.messages;
return { ...base, sessionId: id, messages: pageMessages, messageCount: page?.total ?? pageMessages?.length ?? base.messageCount };
}
function repairWorkbenchMessagePage(messages: ChatMessage[], session: WorkbenchSessionRecord): ChatMessage[] {
const userFallback = firstNonEmptyString(session.firstUserMessagePreview, session.snapshot?.firstUserMessagePreview, session.userPreview, session.title, session.name);
return messages.map((message, index) => {
if (message.role !== "user" || firstNonEmptyString(message.text)) return message;
const matched = findMessageTextFallback(message, index, session.messages ?? []);
const text = firstNonEmptyString(matched, index === 0 ? userFallback : null);
return text ? { ...message, text } : message;
});
}
function findMessageTextFallback(message: ChatMessage, index: number, candidates: ChatMessage[]): string | null {
const messageId = firstNonEmptyString(message.messageId, message.id);
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
const turnId = firstNonEmptyString(message.turnId);
const matched = candidates.find((candidate) => candidate.role === message.role && Boolean(
(messageId && firstNonEmptyString(candidate.messageId, candidate.id) === messageId)
|| (traceId && firstNonEmptyString(candidate.traceId, candidate.runnerTrace?.traceId) === traceId)
|| (turnId && firstNonEmptyString(candidate.turnId) === turnId)
));
return firstNonEmptyString(matched?.text, candidates.filter((candidate) => candidate.role === message.role)[index]?.text);
}
function providerThreadIdForRequest(threadId: string | null | undefined): string | null {
const value = firstNonEmptyString(threadId) ?? null;
if (!value) return null;
@@ -22,10 +22,12 @@ test.describe("submit session authority", () => {
expect(legacyGets).toEqual([]);
});
test("replay preserves user prompt when messages page has empty user text but list preview has authority", async ({ page }) => {
test("message page empty user text is diagnostic, not repaired from list preview", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/ses_submit_split");
await expect(page.locator(sessionTab("ses_submit_split"))).toContainText("workbench submit race probe redacted");
await expect(page.locator(`${selectors.messageCard}[data-role="user"]`).first()).toContainText("workbench submit race probe redacted");
const userCard = page.locator(`${selectors.messageCard}[data-role="user"]`).first();
await expect(userCard).not.toContainText("workbench submit race probe redacted");
await expect(userCard).toContainText("Workbench read model 未返回 user text");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
const state = await fakeServerState(page);