1923 lines
114 KiB
TypeScript
1923 lines
114 KiB
TypeScript
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; 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; draft-2026-06-28-p0-d518-session-timeline-consistency; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-010401080313 Workbench实时权威 draft-2026-07-08-p0-workbench-realtime-authority-v2.
|
|
// Responsibility: Session-first Workbench state orchestration for selection, turn admission, and trace lifecycle rendering.
|
|
|
|
import { computed, nextTick, ref } from "vue";
|
|
import { defineStore } from "pinia";
|
|
import { api } from "@/api";
|
|
import { workbenchDebugCapabilities } from "@/config/runtime";
|
|
import { workbenchRuntimePolicy } from "@/config/workbench-runtime-policy";
|
|
import { createKeyedSingleflight } from "@/utils/scheduler/keyed-singleflight";
|
|
import { createWorkbenchHealthProbeCache } from "@/utils/workbench-health";
|
|
import { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagnostic, normalizeProjectionDiagnostic, projectionDiagnosticFromApiFailure, projectionDiagnosticFromFailure } from "@/utils/workbench-error-runtime";
|
|
import { readWorkbenchJson, readWorkbenchNumber, readWorkbenchString, removeWorkbenchStorageKey, writeWorkbenchJson, writeWorkbenchString } from "@/utils/workbench-storage-runtime";
|
|
import { createWorkbenchStreamTransportRuntime, workbenchRealtimeTraceId, type WorkbenchRealtimeEvent, type WorkbenchStreamTransportRecovery } from "@/utils/workbench-realtime-runtime";
|
|
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/workbench-trace-snapshot";
|
|
import type { WorkbenchMessagePageResponse, WorkbenchSessionDetailResponse } from "@/api/workbench";
|
|
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiError, ApiResult, ChatMessage, ErrorDiagnostic, LiveSurface, ProjectionBlocker, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types";
|
|
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
|
|
import { composeWorkbenchScopedKey, workbenchRealtimeScopeKey } from "@/utils/workbench-key";
|
|
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, recordWorkbenchRuntimeDiagnostic, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
|
|
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
|
|
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
|
|
import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
|
|
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache";
|
|
import { reduceWorkbenchRealtimeEvent, type WorkbenchRealtimeAction } from "./workbench-event-reducer";
|
|
import { projectRejectedWorkbenchAdmission } from "./workbench-admission-failure";
|
|
import { messageHasSealedTerminalResult, messageIsSealedTerminal, traceAuthorityIsSealed } from "./workbench-terminal-authority";
|
|
import { boundedProjectionMessageLimit, mergeBoundedProjectionMessages, selectProjectionMessageWindow, traceProjectionIsTerminalSealed } from "./workbench-message-projection-budget";
|
|
import {
|
|
agentErrorDisplayText,
|
|
agentErrorFromApiFailure,
|
|
agentRunFromMessage,
|
|
agentRunFromResult,
|
|
asAgentRun,
|
|
clearRunnerTraceTransientDiagnostics,
|
|
finalResponseText,
|
|
firstFiniteNumber,
|
|
isTerminalMessageStatus,
|
|
isTraceActiveStatus,
|
|
messageHasTerminalResponse,
|
|
messageNeedsTerminalDiagnostics,
|
|
messageNeedsTraceDetailRead,
|
|
messageStatusPatchForTerminalMerge,
|
|
terminalAuthorityMessageFromTurnResult,
|
|
terminalMessagePatchFromTurnResult,
|
|
messageText,
|
|
messageTimingPatch,
|
|
messageTimingPatchForMerge,
|
|
messageTimingPatchFromProjection,
|
|
messageTimingSealPatchForProjectionMerge,
|
|
mergeTerminalResultTrace,
|
|
nonBlockingProjection,
|
|
normalizeAgentError,
|
|
normalizeMessageRunnerTrace,
|
|
normalizedStatusText,
|
|
optionalString,
|
|
positiveDurationBetween,
|
|
projectedAgentMessageText,
|
|
projectionFromMessage,
|
|
projectionFromResult,
|
|
recordValue,
|
|
shouldClearCompletedTurnDiagnostics,
|
|
shouldSuppressTransientWorkbenchReadFailure,
|
|
terminalMessageTimingPatchForNormalize,
|
|
turnResultIsTerminalForMerge,
|
|
turnResultStatusForMerge,
|
|
traceHasTerminalResponse,
|
|
traceHasEvents,
|
|
traceResultHasTerminalEvidence,
|
|
} from "./workbench-message-projection-runtime";
|
|
import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type WorkbenchRealtimeApplyStep, type WorkbenchRealtimeRecoveryStep } from "./workbench-realtime-plan";
|
|
import { useWorkbenchColadaMutations } from "./workbench-colada-mutations";
|
|
import { useWorkbenchColadaQueries } from "./workbench-colada-queries";
|
|
import { useWorkbenchColadaReducer } from "./workbench-colada-reducer";
|
|
import { projectionMergeCommitSummary, traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey, type TraceEventsReadRangeRecord } from "./workbench-session-messages-read-budget";
|
|
import { realtimeSnapshotToTraceSnapshot, terminalSealResultWithoutTraceEvents, traceDetailProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-detail";
|
|
import { appendRawHwlabIngressFrame, createRawHwlabIngressState } from "./workbench-raw-hwlab-ingress";
|
|
|
|
const WORKBENCH_SESSION_PROJECTION_SIGNAL_CHANNEL = "hwlab.workbench.sessionProjection.v1";
|
|
const WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY = "hwlab.workbench.sessionProjectionSignal.v1";
|
|
|
|
interface HydrateOptions {
|
|
sessionId?: string | null;
|
|
invalidRouteId?: string | null;
|
|
}
|
|
|
|
type SessionSelectionSource = "route" | "user" | "system";
|
|
|
|
interface SelectSessionOptions {
|
|
source?: SessionSelectionSource;
|
|
}
|
|
|
|
interface SessionMessagesReadOptions {
|
|
limit: number;
|
|
reason: string;
|
|
force?: boolean;
|
|
}
|
|
|
|
interface SessionDetailReadOptions {
|
|
reason: string;
|
|
force?: boolean;
|
|
}
|
|
|
|
interface RealtimeTurnProjectionItem {
|
|
traceId: string;
|
|
result: AgentChatResultResponse;
|
|
terminalTurn: boolean;
|
|
}
|
|
|
|
function workbenchMessageIdForTrace(traceId: string, role: "user" | "agent"): string {
|
|
const suffix = firstNonEmptyString(traceId)
|
|
?.replace(/^trc_/u, "")
|
|
.replace(/[^A-Za-z0-9_.:-]/gu, "_")
|
|
.slice(0, 48);
|
|
if (!suffix) throw new Error("traceId must produce a stable Workbench message identity");
|
|
return `msg_${suffix}_${role}`;
|
|
}
|
|
|
|
export const useWorkbenchStore = defineStore("workbench", () => {
|
|
const runtimePolicy = workbenchRuntimePolicy();
|
|
const debugCapabilities = workbenchDebugCapabilities();
|
|
const workbenchColadaReducer = useWorkbenchColadaReducer();
|
|
const workbenchColadaQueries = useWorkbenchColadaQueries();
|
|
const workbenchColadaMutations = useWorkbenchColadaMutations();
|
|
const turnStatusReadSingleflight = createKeyedSingleflight<ApiResult<AgentChatResultResponse>>();
|
|
const traceDetailReadSingleflight = createKeyedSingleflight<void>();
|
|
const sessionMessagesReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchMessagePageResponse>>();
|
|
const sessionDetailReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchSessionDetailResponse>>();
|
|
const traceEventsReadRanges = new Map<string, TraceEventsReadRangeRecord>();
|
|
const realtimeTurnProjectionQueue = new Map<string, RealtimeTurnProjectionItem>();
|
|
let realtimeTurnProjectionScheduled = false;
|
|
const providerProfile = ref<ProviderProfile>(readString("hwlab.workbench.providerProfile.v1", "codex"));
|
|
const providerOptions = ref<ProviderProfileOption[]>(defaultProviderProfileOptions(providerProfile.value));
|
|
const recentDrafts = ref<DraftEntry[]>(readRecentDrafts());
|
|
const codeAgentTimeoutMs = ref(readNumber("hwlab.workbench.codeAgentTimeoutMs.v1", runtimePolicy.defaultCodeAgentTimeoutMs));
|
|
const gatewayShellTimeoutMs = ref(readNumber("hwlab.workbench.gatewayShellTimeoutMs.v1", runtimePolicy.defaultGatewayTimeoutMs));
|
|
const live = ref<LiveSurface | null>(null);
|
|
const loading = ref(false);
|
|
const sessionsReady = ref(false);
|
|
const switchingSessionId = ref<string | null>(null);
|
|
const sessionDetailLoadingId = ref<string | null>(null);
|
|
const sessionListHasMore = ref(false);
|
|
const sessionListNextCursor = ref<string | null>(null);
|
|
const sessionListLoadingMore = ref(false);
|
|
const sessionListLoadMoreError = ref<string | null>(null);
|
|
const chatPending = ref(false);
|
|
const rawHwlabIngress = ref(createRawHwlabIngressState());
|
|
const error = ref<string | null>(null);
|
|
const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null });
|
|
const currentRequest = ref<{ traceId: string; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
|
|
const explicitSessionId = ref<string | null>(initialWorkbenchSessionIdFromLocation());
|
|
const activeSelectionSource = ref<SessionSelectionSource>(explicitSessionId.value ? "route" : "system");
|
|
const selectionEpoch = ref(0);
|
|
const serverState = workbenchColadaReducer.serverState;
|
|
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 realtimeTransport = createWorkbenchStreamTransportRuntime();
|
|
const workbenchProjectionSignalSourceId = nextProtocolId("wbtab");
|
|
let workbenchProjectionSignalChannel: BroadcastChannel | null = null;
|
|
const workbenchHealthProbeCache = createWorkbenchHealthProbeCache({ cacheMs: runtimePolicy.workbenchReadFailureCooldownMs });
|
|
|
|
const projectedActiveSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value));
|
|
const routeActiveSession = computed(() => routeSelectedSessionRecord(explicitSessionId.value, sessionDetailLoadingId.value, error.value, projectedActiveSession.value));
|
|
const activeSession = computed(() => projectedActiveSession.value ?? routeActiveSession.value);
|
|
const activeSessionSelectionSource = computed(() => activeSelectionSource.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(routeActiveSession.value ? mergeSessionIntoList(sessions.value, routeActiveSession.value) : sessions.value, activeSessionId.value, sessionStatusAuthority.value));
|
|
const sessionListLoadedCount = computed(() => sessionTabs.value.length);
|
|
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, sessionsReady: sessionsReady.value }));
|
|
const sessionDetailLoading = computed(() => Boolean(sessionDetailLoadingId.value || switchingSessionId.value || (loading.value && messages.value.length === 0)));
|
|
const composer = computed(() => resolveComposerState({ messages: messages.value, sessions: sessions.value, activeSessionId: activeSessionId.value, chatPending: chatPending.value, realtimeReady: true, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value }));
|
|
|
|
function recordActivity(label = "user-activity"): void {
|
|
const now = Date.now();
|
|
activityRef.value = { lastActivityAt: now, lastActivityIso: new Date(now).toISOString(), waitingFor: "code-agent", lastEventLabel: label };
|
|
}
|
|
|
|
async function hydrate(options: HydrateOptions = {}): Promise<void> {
|
|
const routeRequestId = normalizeWorkbenchSessionRouteId(options.sessionId);
|
|
const routeSessionId = normalizeWorkbenchSessionId(options.sessionId);
|
|
const requestEpoch = beginSessionSelection();
|
|
sessionsReady.value = sessions.value.length > 0;
|
|
const includeSessionId = routeSessionId ?? activeSessionId.value;
|
|
loading.value = true;
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: true, reason: "hydrate", sessionId: includeSessionId });
|
|
recordWorkbenchLoadingState({ scope: "session_list", active: true, reason: "hydrate", sessionId: includeSessionId });
|
|
error.value = null;
|
|
void refreshProviderOptions();
|
|
if (routeSessionId) {
|
|
sessionDetailLoadingId.value = routeSessionId;
|
|
recordWorkbenchLoadingState({ scope: "session_detail", active: true, reason: "hydrate", sessionId: routeSessionId });
|
|
setActiveSessionSelection(routeSessionId, "route");
|
|
}
|
|
const sessionsResult = await workbenchColadaQueries.fetchSessions({ includeSessionId, limit: runtimePolicy.sessionListPageLimit, minIntervalMs: runtimePolicy.sessionListMinRefreshIntervalMs });
|
|
const listedSessions = sessionsResult.ok ? workbenchSessionsFromPayload(sessionsResult.data) : [];
|
|
if (sessionsResult.ok) {
|
|
applySessionPagination(sessionsResult.data);
|
|
} else {
|
|
error.value = sessionsResult.error ?? "session list unavailable";
|
|
}
|
|
const targetSessionId = routeRequestId ?? includeSessionId ?? activeSessionId.value ?? listedSessions[0]?.sessionId ?? null;
|
|
if (!sessionsResult.ok && !targetSessionId) {
|
|
clearSessionDetailLoading(includeSessionId);
|
|
loading.value = false;
|
|
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "hydrate", sessionId: includeSessionId });
|
|
recordWorkbenchLoadingState({ scope: "session_list", active: false, reason: "hydrate", sessionId: includeSessionId });
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "hydrate", sessionId: includeSessionId });
|
|
return;
|
|
}
|
|
if (targetSessionId && (routeSessionId || !routeRequestId)) {
|
|
setActiveSessionSelection(targetSessionId, routeSessionId ? "route" : "system");
|
|
}
|
|
if (targetSessionId) {
|
|
sessionDetailLoadingId.value = targetSessionId;
|
|
recordWorkbenchLoadingState({ scope: "session_detail", active: true, reason: "hydrate", sessionId: targetSessionId });
|
|
}
|
|
const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null;
|
|
if (selected && !isArchivedSession(selected) && routeRequestId && !routeSessionId && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId, "route");
|
|
const selectedIsCurrent = Boolean(selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId));
|
|
if (sessionsResult.ok || (selectedIsCurrent && selected)) error.value = null;
|
|
if (selectedIsCurrent && selected) applySelectedSessionDetail(selected, "route");
|
|
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId) && selected.sessionId !== activeSessionId.value) rememberSessionDetail(selected);
|
|
if (selected && isArchivedSession(selected)) applyCanonicalSessionArchive(selected.sessionId, "session archived");
|
|
if (options.invalidRouteId) markSessionReadUnavailable(options.invalidRouteId, "invalid session URL");
|
|
if (targetSessionId && !selected) markSessionReadUnavailable(targetSessionId, "session detail unavailable");
|
|
const stableSelected = selectedIsCurrent && selected ? selected : activeSessionListSeed();
|
|
const nextSessions = stableSessionList(sessions.value, listedSessions, stableSelected?.sessionId ?? selected?.sessionId ?? routeSessionId ?? includeSessionId, stableSelected);
|
|
rememberSessionList(nextSessions);
|
|
sessionsReady.value = sessionsResult.ok || nextSessions.length > 0;
|
|
void refreshSessionStatusAuthority(nextSessions);
|
|
clearSessionDetailLoading(targetSessionId);
|
|
loading.value = false;
|
|
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "hydrate", sessionId: targetSessionId });
|
|
recordWorkbenchLoadingState({ scope: "session_list", active: false, reason: "hydrate", sessionId: includeSessionId });
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "hydrate", sessionId: targetSessionId ?? includeSessionId });
|
|
await refreshProviderOptions();
|
|
restartRealtime("hydrate");
|
|
}
|
|
|
|
async function refreshLive(): Promise<void> {
|
|
const [healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds] = await Promise.all([
|
|
liveHealthCall("health/live", api.healthLive),
|
|
liveHealthCall("health", api.health),
|
|
liveHealthCall("REST index", api.restIndex),
|
|
liveHealthCall("hwpod specs", api.hwpod.specs),
|
|
liveHealthCall("hwpod node ops", api.hwpod.nodeOpsHealth),
|
|
liveHealthCall("live builds", api.liveBuilds)
|
|
]);
|
|
live.value = { healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds, loadedAt: new Date().toISOString() };
|
|
}
|
|
|
|
async function liveHealthCall<T>(label: string, call: () => Promise<ApiResult<T>>): Promise<ApiResult<T>> {
|
|
const snapshot = await workbenchHealthProbeCache.probe<ApiResult<T>>({
|
|
key: label,
|
|
fetcher: () => liveCall(label, call),
|
|
classify: (value) => value.ok ? "ok" : "degraded"
|
|
});
|
|
if (snapshot.value) return snapshot.value;
|
|
const diagnostic: ErrorDiagnostic = { contractVersion: "hwlab-error-diagnostic-v1", code: "workbench_health_probe_unavailable", category: "health", layer: "workbench-health-runtime", source: "workbench-web", retryable: true, valuesPrinted: false };
|
|
return { ok: false, status: 0, data: null, error: `${label}: health probe unavailable`, diagnostic } as ApiResult<T>;
|
|
}
|
|
|
|
async function createSession(): Promise<void> {
|
|
beginSessionSelection();
|
|
loading.value = true;
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: true, reason: "create_session", sessionId: activeSessionId.value });
|
|
error.value = null;
|
|
void refreshProviderOptions();
|
|
const response = await workbenchColadaMutations.createAgentSession({ providerProfile: providerProfile.value });
|
|
loading.value = false;
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "create_session", sessionId: activeSessionId.value });
|
|
const created = response.ok ? sessionFromWorkbenchSession(response.data?.session) : null;
|
|
if (!response.ok || !created) {
|
|
error.value = response.error ?? "session create failed";
|
|
return;
|
|
}
|
|
rememberSessionList(mergeSessionIntoList(sessions.value, created));
|
|
sessionsReady.value = true;
|
|
setActiveSessionSelection(created.sessionId, "user");
|
|
await selectSession(created);
|
|
}
|
|
|
|
async function selectSession(session: WorkbenchSessionRecord, options: SelectSessionOptions = {}): Promise<void> {
|
|
await selectSessionById(session.sessionId, session, options);
|
|
}
|
|
|
|
async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null, options: SelectSessionOptions = {}): Promise<boolean> {
|
|
const source = options.source ?? "user";
|
|
const requestId = normalizeWorkbenchSessionRouteId(sessionId);
|
|
const normalized = normalizeWorkbenchSessionId(sessionId);
|
|
if (!requestId) {
|
|
error.value = "invalid session URL";
|
|
return false;
|
|
}
|
|
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" });
|
|
switchingSessionId.value = requestId;
|
|
sessionDetailLoadingId.value = requestId;
|
|
loading.value = true;
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: true, reason: "select_session", sessionId: normalized ?? requestId });
|
|
recordWorkbenchLoadingState({ scope: "session_detail", active: true, reason: "select_session", sessionId: normalized ?? requestId });
|
|
if (normalized) setActiveSessionSelection(normalized, source);
|
|
if (existing) {
|
|
rememberSessionList(mergeSessionIntoList(sessions.value, existing));
|
|
sessionsReady.value = true;
|
|
}
|
|
const selected = await loadWorkbenchSession(requestId, existing);
|
|
if (selected && !isArchivedSession(selected) && !normalized && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId, source);
|
|
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) {
|
|
clearSessionDetailLoading(requestId);
|
|
clearSwitchingSession(requestId);
|
|
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "select_session", sessionId: normalized ?? requestId });
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "select_session", sessionId: normalized ?? requestId });
|
|
return true;
|
|
}
|
|
if (selected && !isArchivedSession(selected)) {
|
|
applySelectedSessionDetail(selected, source);
|
|
await nextTick();
|
|
loading.value = false;
|
|
clearSessionDetailLoading(requestId);
|
|
clearSwitchingSession(requestId);
|
|
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "select_session", sessionId: selected.sessionId });
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "select_session", sessionId: selected.sessionId });
|
|
error.value = null;
|
|
await refreshSessions(selected.sessionId, { force: true });
|
|
finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok");
|
|
return activeSessionId.value === selected.sessionId;
|
|
}
|
|
loading.value = false;
|
|
clearSessionDetailLoading(requestId);
|
|
clearSwitchingSession(requestId);
|
|
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "select_session", sessionId: normalized ?? requestId });
|
|
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "select_session", sessionId: normalized ?? requestId });
|
|
if (selected && isArchivedSession(selected)) applyCanonicalSessionArchive(selected.sessionId, "session archived");
|
|
else markSessionReadUnavailable(requestId, "session detail unavailable");
|
|
failWorkbenchSessionSwitch(normalized ?? requestId);
|
|
return false;
|
|
}
|
|
|
|
async function deleteCurrentSession(): Promise<void> {
|
|
const sessionId = activeSessionId.value;
|
|
if (!sessionId) return;
|
|
error.value = null;
|
|
const response = await workbenchColadaMutations.deleteAgentSession({ sessionId });
|
|
if (!response.ok) {
|
|
error.value = response.error ?? "session delete failed";
|
|
return;
|
|
}
|
|
forgetSession(sessionId);
|
|
await refreshSessions(null, { force: true });
|
|
const next = sessions.value.find((session) => !isArchivedSession(session)) ?? null;
|
|
replaceActiveSessionSelection(next?.sessionId ?? null);
|
|
currentRequest.value = null;
|
|
if (next) {
|
|
await selectSession(next, { source: "system" });
|
|
return;
|
|
}
|
|
restartRealtime("delete-current-session");
|
|
}
|
|
|
|
async function refreshSessions(includeSessionId: string | null = activeSessionId.value, options: { force?: boolean } = {}): Promise<void> {
|
|
const requestIncludeSessionId = firstNonEmptyString(includeSessionId);
|
|
const requestLimit = currentSessionListLimit();
|
|
await refreshSessionsNow(requestIncludeSessionId, requestLimit, { force: options.force });
|
|
}
|
|
|
|
async function refreshSessionsNow(includeSessionId: string | null, limit: number, options: { force?: boolean } = {}): Promise<void> {
|
|
const response = await workbenchColadaQueries.fetchSessions({ includeSessionId, limit, force: options.force, minIntervalMs: runtimePolicy.sessionListMinRefreshIntervalMs });
|
|
if (response.ok) {
|
|
const listed = workbenchSessionsFromPayload(response.data);
|
|
const activeSeed = activeSessionListSeed();
|
|
const refreshed = stableSessionList(sessions.value, listed, activeSeed?.sessionId ?? includeSessionId, activeSeed);
|
|
const next = sessions.value.length > refreshed.length ? appendSessionPage(sessions.value, refreshed) : refreshed;
|
|
rememberSessionList(next);
|
|
applySessionPagination(response.data);
|
|
sessionsReady.value = true;
|
|
return;
|
|
}
|
|
sessionsReady.value = sessions.value.length > 0;
|
|
if (sessions.value.length === 0) error.value = response.error ?? "session list unavailable";
|
|
}
|
|
|
|
function scheduleSessionListRefresh(includeSessionId: string | null | undefined = activeSessionId.value, delayMs = runtimePolicy.sessionListRealtimeRefreshDelayMs): void {
|
|
void includeSessionId;
|
|
if (typeof window === "undefined") {
|
|
void workbenchColadaQueries.invalidateSessionList();
|
|
return;
|
|
}
|
|
window.setTimeout(() => { void workbenchColadaQueries.invalidateSessionList(); }, delayMs);
|
|
}
|
|
|
|
async function loadMoreSessions(): Promise<void> {
|
|
if (sessionListLoadingMore.value || !sessionListHasMore.value) return;
|
|
const cursor = sessionListNextCursor.value;
|
|
if (!cursor) {
|
|
sessionListHasMore.value = false;
|
|
return;
|
|
}
|
|
sessionListLoadingMore.value = true;
|
|
sessionListLoadMoreError.value = null;
|
|
const response = await workbenchColadaQueries.fetchSessions({ includeSessionId: activeSessionId.value, limit: runtimePolicy.sessionListPageLimit, cursor, force: true });
|
|
sessionListLoadingMore.value = false;
|
|
if (!response.ok) {
|
|
sessionListLoadMoreError.value = response.error ?? "session list load more failed";
|
|
return;
|
|
}
|
|
const next = appendSessionPage(sessions.value, workbenchSessionsFromPayload(response.data));
|
|
rememberSessionList(next);
|
|
applySessionPagination(response.data);
|
|
sessionsReady.value = true;
|
|
}
|
|
|
|
function currentSessionListLimit(): number {
|
|
return Math.max(runtimePolicy.sessionListPageLimit, sessionListLoadedCount.value || 0);
|
|
}
|
|
|
|
function applySessionPagination(payload: unknown): void {
|
|
const record = recordValue(payload);
|
|
sessionListHasMore.value = record?.hasMore === true;
|
|
sessionListNextCursor.value = sessionListHasMore.value ? firstNonEmptyString(record?.nextCursor) : null;
|
|
if (!sessionListHasMore.value) sessionListLoadMoreError.value = null;
|
|
}
|
|
|
|
function reduceServerState(action: WorkbenchServerAction): void {
|
|
workbenchColadaReducer.reduceServerState(action);
|
|
}
|
|
|
|
function rememberSessionDetail(session: WorkbenchSessionRecord | null | undefined): void {
|
|
reduceServerState({ type: "session.detail", session });
|
|
}
|
|
|
|
function rememberSessionList(source: WorkbenchSessionRecord[]): void {
|
|
reduceServerState({ type: "session.list", sessions: source });
|
|
const beforeTrim = serverState.value;
|
|
const trimmed = trimWorkbenchSessionCache(beforeTrim, { retainSessionIds: [activeSessionId.value, explicitSessionId.value], maxSessions: currentSessionListLimit() });
|
|
const cleaned = cleanupDroppedWorkbenchSessionCaches(trimmed.state, trimmed.state.sessionOrder);
|
|
const authorityCleaned = cleanupWorkbenchServerStateSessions(beforeTrim, [...trimmed.evictedSessionIds, ...cleaned.droppedSessionIds]);
|
|
workbenchColadaReducer.replaceServerState(() => ({
|
|
...authorityCleaned,
|
|
sessionOrder: cleaned.state.sessionOrder,
|
|
sessionsById: cleaned.state.sessionsById,
|
|
messagesBySessionId: cleaned.state.messagesBySessionId
|
|
}));
|
|
}
|
|
|
|
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 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 latestMessageForTrace(traceId: string | null | undefined, source: ChatMessage[] = messages.value): ChatMessage | null {
|
|
const id = firstNonEmptyString(traceId);
|
|
if (!id) return null;
|
|
return [...source].reverse().find((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) === id) ?? null;
|
|
}
|
|
|
|
function traceTerminalBodyIsVisible(traceId: string | null | undefined, sessionId: string | null | undefined = null): boolean {
|
|
const id = firstNonEmptyString(traceId);
|
|
if (!id) return false;
|
|
const ownerSessionId = normalizeWorkbenchSessionId(sessionId) ?? traceOwnerSessionId(id, null);
|
|
const source = ownerSessionId ? serverState.value.messagesBySessionId[ownerSessionId] ?? [] : messages.value;
|
|
return !shouldReadTerminalTraceDetail({ traceId: id, messages: source, turnStatusAuthority: turnStatusAuthority.value });
|
|
}
|
|
|
|
function appendActiveMessages(...items: ChatMessage[]): void {
|
|
replaceActiveMessages([...messages.value, ...items]);
|
|
}
|
|
|
|
function setActiveSessionSelection(sessionId: string | null | undefined, source: SessionSelectionSource = "system"): void {
|
|
const normalized = normalizeWorkbenchSessionId(sessionId);
|
|
if (!normalized) return;
|
|
explicitSessionId.value = normalized;
|
|
activeSelectionSource.value = source;
|
|
}
|
|
|
|
function replaceActiveSessionSelection(sessionId: string | null | undefined, source: SessionSelectionSource = "system"): void {
|
|
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId);
|
|
activeSelectionSource.value = source;
|
|
}
|
|
|
|
function activeSessionListSeed(): WorkbenchSessionRecord | null {
|
|
return projectedActiveSession.value ?? null;
|
|
}
|
|
|
|
async function refreshSessionStatusAuthority(source: WorkbenchSessionRecord[] = sessions.value): Promise<void> {
|
|
void source;
|
|
}
|
|
|
|
function fetchWorkbenchTurnStatus(traceId: string, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
|
|
const activitySource = useActivityTimeout ? () => activityRef.value : null;
|
|
return turnStatusReadSingleflight.run(traceId, () => workbenchColadaQueries.fetchTurn(traceId, { timeoutMs: 8000, activityRef: activitySource, minIntervalMs: runtimePolicy.workbenchTurnStatusMinRefreshMs, force: options.force }), { reason: options.force ? "force-turn-status" : "turn-status" });
|
|
}
|
|
|
|
function fetchWorkbenchTraceEvents(traceId: string, afterProjectedSeq: number, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
|
|
const activitySource = useActivityTimeout ? () => activityRef.value : null;
|
|
return workbenchColadaQueries.fetchTraceEvents(traceId, { timeoutMs: runtimePolicy.workbenchTraceEventsTimeoutMs, activityRef: activitySource, afterProjectedSeq, limit: runtimePolicy.traceDetailPageLimit, minIntervalMs: runtimePolicy.workbenchTraceEventsMinRefreshMs, force: options.force });
|
|
}
|
|
|
|
function shouldUseActivityTimeoutForTrace(traceId: string | null | undefined): boolean {
|
|
const id = firstNonEmptyString(traceId);
|
|
if (!id) return false;
|
|
if (currentRequest.value?.traceId === id) return true;
|
|
const turn = turnStatusAuthority.value[id];
|
|
const message = [...messages.value].reverse().find((item) => firstNonEmptyString(item.traceId, item.runnerTrace?.traceId) === id) ?? null;
|
|
if (traceAuthorityIsSealed(turn, message)) return false;
|
|
if (messageIsSealedTerminal(message)) return false;
|
|
return isTraceActiveStatus(turn?.status) || isTraceActiveStatus(message?.status);
|
|
}
|
|
|
|
async function refreshMessageProjectionForTrace(sessionId: string | null | undefined, traceId: string, options: { force?: boolean } = {}): Promise<void> {
|
|
const id = normalizeWorkbenchSessionId(sessionId);
|
|
if (!id) return;
|
|
const existing = serverState.value.messagesBySessionId[id] ?? [];
|
|
if (traceProjectionIsTerminalSealed(traceId, existing)) return;
|
|
const response = await fetchSessionMessagesPage(id, { limit: traceMessageProjectionWindowLimit(), reason: `trace-message-page:${traceId}`, force: options.force });
|
|
if (!response.ok || !response.data) {
|
|
if (shouldSuppressTransientWorkbenchReadFailure(response)) return;
|
|
if (traceHasTerminalResponse(traceId, messages.value)) return;
|
|
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(response, { 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)) : [];
|
|
const merged = mergeMessageProjectionPage(id, pageMessages, { traceId, limit: traceMessageProjectionWindowLimit() });
|
|
commitMergedSessionMessages(id, pageMessages, merged, { traceId, limit: traceMessageProjectionWindowLimit(), reason: "trace-message-page" });
|
|
await hydrateTurnStatusAuthority(merged, { traceId, limit: 1, reason: "trace-message-page" });
|
|
if (!traceProjectionIsTerminalSealed(traceId, merged)) readTerminalTraceDetailGaps(merged, `trace-message-page:${traceId}`);
|
|
}
|
|
|
|
function fetchSessionMessagesPage(sessionId: string, options: SessionMessagesReadOptions): Promise<ApiResult<WorkbenchMessagePageResponse>> {
|
|
const key = workbenchSessionMessagesReadKey({ sessionId, limit: options.limit, force: options.force });
|
|
return sessionMessagesReadSingleflight.run(key, () => workbenchColadaQueries.fetchSessionMessages(sessionId, { limit: options.limit, minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs, force: options.force }), { reason: options.reason });
|
|
}
|
|
|
|
function fetchSessionDetailPage(sessionId: string, options: SessionDetailReadOptions): Promise<ApiResult<WorkbenchSessionDetailResponse>> {
|
|
const key = workbenchSessionDetailReadKey({ sessionId, force: options.force });
|
|
return sessionDetailReadSingleflight.run(key, () => workbenchColadaQueries.fetchSession(sessionId, { includeMessages: false, minIntervalMs: runtimePolicy.workbenchSessionDetailMinRefreshMs, force: options.force }), { reason: options.reason });
|
|
}
|
|
|
|
function sessionMessageProjectionWindowLimit(): number {
|
|
return boundedProjectionMessageLimit(runtimePolicy.workbenchSessionMessagesWindowLimit, runtimePolicy.sessionListPageLimit);
|
|
}
|
|
|
|
function traceMessageProjectionWindowLimit(): number {
|
|
return boundedProjectionMessageLimit(runtimePolicy.workbenchTraceMessagesWindowLimit, runtimePolicy.traceDetailAutoQueueLimit);
|
|
}
|
|
|
|
function mergeMessageProjectionPage(sessionId: string, pageMessages: ChatMessage[], options: { traceId?: string | null; limit: number }): ChatMessage[] {
|
|
const existing = serverState.value.messagesBySessionId[sessionId] ?? [];
|
|
const windowMessages = selectProjectionMessageWindow(pageMessages, options);
|
|
const incoming = windowMessages.map((message) => mergeMessageProjectionMessage(message, existing));
|
|
return mergeBoundedProjectionMessages(existing, incoming);
|
|
}
|
|
|
|
function commitMergedSessionMessages(sessionId: string, incoming: ChatMessage[], merged: ChatMessage[], options: { traceId?: string | null; limit: number; reason: string }): void {
|
|
const existing = serverState.value.messagesBySessionId[sessionId] ?? [];
|
|
const summary = projectionMergeCommitSummary(existing, merged);
|
|
recordWorkbenchRuntimeDiagnostic({
|
|
module: "workbench-message-projection",
|
|
sessionId,
|
|
traceId: firstNonEmptyString(options.traceId) ?? null,
|
|
outcome: "ok",
|
|
diagnostic: {
|
|
code: "workbench_message_projection_merge",
|
|
reason: options.reason,
|
|
source: "session-messages-page",
|
|
inputCount: incoming.length,
|
|
existingCount: existing.length,
|
|
mergedCount: merged.length,
|
|
changedCount: summary.changedCount,
|
|
limit: options.limit,
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
if (summary.changed) rememberSessionMessages(sessionId, merged);
|
|
}
|
|
|
|
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;
|
|
const timingSealPatch = messageTimingSealPatchForProjectionMerge(message, previous);
|
|
if (!preservedTrace) return { ...message, ...timingSealPatch };
|
|
const runnerTrace = message.runnerTrace ? mergeRunnerTrace(message.runnerTrace, preservedTrace) : preservedTrace;
|
|
return { ...message, ...timingSealPatch, 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, options: { traceId?: string | null; limit?: number; reason?: string; force?: boolean } = {}): Promise<void> {
|
|
const traceIds = activeTurnStatusRefreshTraceIds(source, options);
|
|
if (options.force !== true) {
|
|
if (traceIds.length > 0) {
|
|
recordWorkbenchRuntimeDiagnostic({
|
|
module: "workbench-turn-status",
|
|
traceId: options.traceId ?? traceIds[0] ?? null,
|
|
outcome: "ok",
|
|
diagnostic: { code: "workbench_turn_status_auto_read_disabled", reason: options.reason ?? "auto-turn-status-hydrate", source: "projection-sse-authority", requestedCount: traceIds.length, valuesRedacted: true }
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
for (const traceId of traceIds) {
|
|
await refreshTurnStatusByTraceId(traceId);
|
|
}
|
|
}
|
|
|
|
function activeTurnStatusRefreshTraceIds(source: ChatMessage[] = messages.value, options: { traceId?: string | null; limit?: number; reason?: string; force?: boolean } = {}): string[] {
|
|
const requestedTraceId = firstNonEmptyString(options.traceId, currentRequest.value?.traceId);
|
|
const traceIds = selectActiveTurnStatusRefreshTraceIds({ messages: source, currentRequestTraceId: requestedTraceId, turnStatusAuthority: turnStatusAuthority.value, limit: options.limit ?? 1 });
|
|
recordWorkbenchRuntimeDiagnostic({
|
|
module: "workbench-turn-status",
|
|
traceId: requestedTraceId ?? traceIds[0] ?? null,
|
|
outcome: "ok",
|
|
diagnostic: {
|
|
code: "workbench_turn_status_hydrate_budget",
|
|
reason: options.reason ?? "auto-turn-status-hydrate",
|
|
source: "session-message-projection",
|
|
requestedCount: traceIds.length,
|
|
limit: options.limit ?? 1,
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
return traceIds;
|
|
}
|
|
|
|
async function refreshTurnStatusByTraceId(traceId: string | null | undefined, options: { force?: boolean } = {}): Promise<void> {
|
|
const id = firstNonEmptyString(traceId);
|
|
if (!id) return;
|
|
if (options.force !== true) {
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-turn-status", traceId: id, outcome: "ok", diagnostic: { code: "workbench_turn_status_auto_read_disabled", source: "projection-sse-authority", valuesRedacted: true } });
|
|
return;
|
|
}
|
|
if (traceTerminalBodyIsVisible(id)) {
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", traceId: id, outcome: "ok", diagnostic: { code: "terminal_low_priority_turn_skip", source: "turn-status", valuesRedacted: true } });
|
|
return;
|
|
}
|
|
const response = await fetchWorkbenchTurnStatus(id, shouldUseActivityTimeoutForTrace(id), { force: options.force });
|
|
if (response.ok && response.data) {
|
|
applyTurnStatusSnapshot(id, response.data);
|
|
if (turnResultIsTerminalForMerge(response.data as AgentChatResultResponse)) completeTrace(id, response.data, { forceRead: options.force });
|
|
return;
|
|
}
|
|
if (shouldSuppressTransientWorkbenchReadFailure(response)) return;
|
|
if (traceHasTerminalResponse(id, messages.value)) return;
|
|
applyProjectionDiagnostic(id, projectionDiagnosticFromApiFailure(response, { code: "turn_status_poll_failed", message: response.error ?? "状态更新失败,当前 turn 状态暂不可见。", health: response.status === 0 ? "unavailable" : "degraded" }));
|
|
}
|
|
|
|
function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
|
|
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);
|
|
}
|
|
|
|
function rememberTurnStatus(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
|
|
const id = firstNonEmptyString(result.traceId, traceId);
|
|
if (!id) return;
|
|
const status = turnResultStatusForMerge(result as AgentChatResultResponse);
|
|
const running = (result as AgentChatResultResponse).running === true || isTraceActiveStatus(status);
|
|
const terminal = turnResultIsTerminalForMerge(result as AgentChatResultResponse);
|
|
reduceServerState({
|
|
type: "turn.status",
|
|
turn: {
|
|
traceId: id,
|
|
status,
|
|
running,
|
|
terminal,
|
|
sessionId: firstNonEmptyString(result.sessionId) ?? null,
|
|
threadId: firstNonEmptyString(result.threadId) ?? null,
|
|
updatedAt: firstNonEmptyString((result as AgentChatResultResponse).updatedAt) ?? null,
|
|
loadedAt: new Date().toISOString()
|
|
}
|
|
});
|
|
}
|
|
|
|
function syncTurnStatusToMessage(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
|
|
projectTurnAuthorityToMessages(traceId, result as AgentChatResultResponse, "turn-status");
|
|
}
|
|
|
|
function projectTurnAuthorityToMessages(traceId: string, result: AgentChatResultResponse, reason: string): boolean {
|
|
const authoritySessionId = traceResultSessionId(result);
|
|
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
|
|
if (!ownerSessionId) return false;
|
|
const source = serverState.value.messagesBySessionId[ownerSessionId] ?? serverState.value.sessionsById[ownerSessionId]?.messages ?? [];
|
|
const existing = [...source].reverse().find((message) => messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) ?? null;
|
|
const next = existing ? projectTurnAuthorityMessage(existing, result) : terminalAuthorityMessageFromTurnResult(result);
|
|
if (!next) return false;
|
|
const message = next.sessionId ? next : { ...next, sessionId: ownerSessionId };
|
|
reduceServerState({ type: "message.upsert", sessionId: ownerSessionId, message });
|
|
const sealed = !messageHasSealedTerminalResult(existing) && messageHasSealedTerminalResult(message);
|
|
if (sealed) {
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-authority", sessionId: ownerSessionId, traceId, outcome: "ok", diagnostic: { code: "terminal_direct_seal", reason, source: "turn-authority", valuesRedacted: true } });
|
|
}
|
|
return sealed;
|
|
}
|
|
|
|
function projectTurnAuthorityMessage(message: ChatMessage, result: AgentChatResultResponse): ChatMessage {
|
|
const resultStatus = turnResultStatusForMerge(result);
|
|
const terminal = turnResultIsTerminalForMerge(result);
|
|
const resultError = normalizeAgentError(result.error ?? null);
|
|
const resultProjection = projectionFromResult(result);
|
|
const mergedRunnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
|
|
const clearCompletedDiagnostics = shouldClearCompletedTurnDiagnostics(resultStatus, resultError);
|
|
const runnerTrace = clearCompletedDiagnostics ? clearRunnerTraceTransientDiagnostics(mergedRunnerTrace) : mergedRunnerTrace;
|
|
rememberTraceAuthority(runnerTrace);
|
|
const error = resultError ?? (terminal || clearCompletedDiagnostics ? null : normalizeAgentError(runnerTrace?.error ?? message.error));
|
|
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
|
|
const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null);
|
|
const terminalPatch = terminalMessagePatchFromTurnResult(message, result) ?? {};
|
|
const updatedAt = firstNonEmptyString(result.updatedAt, runnerTrace?.updatedAt, message.updatedAt) ?? new Date().toISOString();
|
|
return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, ...terminalPatch, updatedAt };
|
|
}
|
|
|
|
async function submitMessage(text: string): Promise<boolean> {
|
|
const value = text.trim();
|
|
if (!value) return false;
|
|
beginSessionSelection();
|
|
if (!composer.value.sessionId) {
|
|
error.value = "session_required:请先显式新建或选择 Code Agent session。";
|
|
return false;
|
|
}
|
|
const steerMode = composer.value.submitMode === "steer";
|
|
const submitEntry = steerMode ? "steer" : "existing";
|
|
const traceId = steerMode && composer.value.targetTraceId ? composer.value.targetTraceId : nextProtocolId("trc");
|
|
const steerTraceId = steerMode ? nextProtocolId("trc_steer") : null;
|
|
const userMessageId = workbenchMessageIdForTrace(steerTraceId ?? traceId, "user");
|
|
const agentMessageId = workbenchMessageIdForTrace(traceId, "agent");
|
|
const sessionId = composer.value.sessionId;
|
|
const threadId = composer.value.threadId;
|
|
const providerThreadId = providerThreadIdForRequest(threadId);
|
|
const submittedAt = new Date().toISOString();
|
|
const previousChatPending = chatPending.value;
|
|
if (!steerMode) {
|
|
const user = makeMessage("user", value, "sent", { id: userMessageId, messageId: userMessageId, traceId, sessionId, threadId, title: "用户", createdAt: submittedAt, updatedAt: submittedAt });
|
|
const pending = makeMessage("agent", "", "running", { id: agentMessageId, messageId: agentMessageId, traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running", createdAt: submittedAt, updatedAt: submittedAt, ...optimisticRunningTimingPatch(submittedAt) });
|
|
appendActiveMessages(user, pending);
|
|
currentRequest.value = { traceId, sessionId, threadId, status: "running" };
|
|
}
|
|
startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" });
|
|
chatPending.value = true;
|
|
rememberRecentDraft(value);
|
|
recordActivity("submit");
|
|
const payload = { message: value, prompt: value, sessionId, threadId: providerThreadId, traceId, userMessageId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, targetTraceId: composer.value.targetTraceId, steerTraceId };
|
|
const response = steerMode
|
|
? await workbenchColadaMutations.steerAgentMessage({ payload, timeoutMs: codeAgentTimeoutMs.value, activityRef: () => activityRef.value, sessionId, traceId })
|
|
: await workbenchColadaMutations.submitAgentMessage({ payload, timeoutMs: codeAgentTimeoutMs.value, activityRef: () => activityRef.value, sessionId, traceId });
|
|
if (!response.ok || !response.data) {
|
|
const projection = projectionDiagnosticFromApiFailure(response, { code: "code_agent_admission_failed", message: response.error ?? "Code Agent 请求失败", health: response.status === 0 ? "unavailable" : "degraded" });
|
|
const failureError = agentErrorFromApiFailure(response, projection, "Code Agent 请求失败");
|
|
const failureMessage = agentErrorDisplayText(failureError) ?? "Code Agent 请求失败";
|
|
if (steerMode) {
|
|
error.value = failureMessage;
|
|
applyProjectionDiagnostic(traceId, projection);
|
|
failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error");
|
|
chatPending.value = previousChatPending;
|
|
restartRealtime("steer-not-admitted");
|
|
return false;
|
|
}
|
|
projectRejectedAdmissionFailure({ traceId, message: failureMessage, error: failureError, projection, submittedAt });
|
|
failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error");
|
|
await clearActiveTrace(traceId, steerMode && response.status === 404 ? "steer-trace-not-found" : "submit-not-admitted");
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
restartRealtime("submit-not-admitted");
|
|
return false;
|
|
}
|
|
markWorkbenchSubmitApiAccepted(traceId);
|
|
if (steerMode) {
|
|
recordActivity("steer:accepted");
|
|
chatPending.value = previousChatPending;
|
|
currentRequest.value = {
|
|
traceId,
|
|
sessionId,
|
|
threadId,
|
|
status: "running"
|
|
};
|
|
void readTraceEventsForMessages(messages.value);
|
|
scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
|
|
restartRealtime("steer");
|
|
return true;
|
|
}
|
|
projectOptimisticRunningTurn({ sessionId, threadId, traceId, userText: value });
|
|
const canonicalTraceId = alignOptimisticTurnMessages(traceId, response.data);
|
|
if (canonicalTraceId !== traceId) {
|
|
reduceServerState({ type: "turn.forget", traceId });
|
|
}
|
|
if (traceTerminalBodyIsVisible(canonicalTraceId, sessionId)) {
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
return true;
|
|
}
|
|
currentRequest.value = {
|
|
traceId: canonicalTraceId,
|
|
sessionId: firstNonEmptyString(response.data.sessionId, sessionId),
|
|
threadId: firstNonEmptyString(response.data.threadId, threadId),
|
|
status: response.data.status ?? "running"
|
|
};
|
|
applyTurnStatusSnapshot(canonicalTraceId, response.data);
|
|
publishWorkbenchProjectionSignal(sessionId, canonicalTraceId, "submit-admitted");
|
|
scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
|
|
if (turnResultIsTerminalForMerge(response.data as AgentChatResultResponse)) {
|
|
completeTrace(canonicalTraceId, response.data as AgentChatResultResponse);
|
|
return true;
|
|
}
|
|
restartRealtime("submit");
|
|
return true;
|
|
}
|
|
|
|
async function cancelAgentMessage(message: ChatMessage): Promise<void> {
|
|
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
|
if (!traceId) return;
|
|
const sessionId = message.sessionId ?? selectedSessionId.value;
|
|
const response = await workbenchColadaMutations.cancelAgentMessage({ traceId, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value });
|
|
if (!response.ok) {
|
|
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(response, { code: "code_agent_cancel_failed", message: response.error ?? "Code Agent 取消请求失败,当前 turn 状态保持 canonical projection。", health: response.status === 0 ? "unavailable" : "degraded" }));
|
|
return;
|
|
}
|
|
recordActivity("cancel:accepted");
|
|
if (message.status === "running") chatPending.value = false;
|
|
currentRequest.value = null;
|
|
void clearActiveTrace(traceId, "cancel-agent-message");
|
|
scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
|
|
}
|
|
|
|
async function cancelRunningTrace(): Promise<void> {
|
|
const target = composer.value;
|
|
const message = resolveCancelableAgentMessage({
|
|
messages: messages.value,
|
|
targetTraceId: target.targetTraceId,
|
|
targetSessionId: target.sessionId,
|
|
targetThreadId: target.threadId,
|
|
turnStatusAuthority: turnStatusAuthority.value
|
|
});
|
|
if (message) await cancelAgentMessage(message);
|
|
}
|
|
|
|
async function retryAgentMessage(message: ChatMessage): Promise<void> {
|
|
const retryInput = firstNonEmptyString(message.retryInput, messages.value.find((item) => item.role === "user" && item.traceId === message.traceId)?.text);
|
|
if (!retryInput) return;
|
|
await submitMessage(retryInput);
|
|
}
|
|
|
|
async function readTraceEventsForMessage(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
|
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
|
if (!traceId) return;
|
|
if (options.force !== true) {
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "projection-sse-authority", source: "trace-detail-read", valuesRedacted: true } });
|
|
return;
|
|
}
|
|
await readTraceEventsForMessageNow(message, options);
|
|
}
|
|
|
|
async function readTraceEventsForMessageNow(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
|
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
|
if (!traceId) return;
|
|
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) {
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: message.sessionId ?? null, traceId, outcome: "ok", diagnostic: { code: "terminal_low_priority_trace_skip", source: "trace-detail-read", valuesRedacted: true } });
|
|
return;
|
|
}
|
|
await traceDetailReadSingleflight.run(traceId, async () => {
|
|
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) return;
|
|
await readTraceEventsForExplicitDetailPages(message, options);
|
|
}, { reason: options.force ? "force-trace-detail-read" : "trace-detail-read" });
|
|
}
|
|
|
|
async function readTraceEventsForExplicitDetailPages(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
|
|
const traceId = message.traceId ?? message.runnerTrace?.traceId;
|
|
if (!traceId) return;
|
|
let afterProjectedSeq = traceDetailProjectedSeq(traceAuthorityById.value[traceId] ?? message.runnerTrace);
|
|
for (let page = 0; page < runtimePolicy.traceDetailMaxPages; page += 1) {
|
|
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) return;
|
|
const decision = traceEventsDetailReadDecision(traceId, afterProjectedSeq, message, options);
|
|
if (!decision.read) {
|
|
recordTraceEventsDetailReadSkip(traceId, message, decision.reason, afterProjectedSeq);
|
|
return;
|
|
}
|
|
const result = await fetchTraceDetailEventsPage(traceId, afterProjectedSeq, { force: options.force });
|
|
if (!result.ok || !result.data) {
|
|
if (shouldSuppressTransientWorkbenchReadFailure(result)) return;
|
|
if (messageHasTerminalResponse(message)) return;
|
|
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_detail_read_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
|
|
return;
|
|
}
|
|
applyTraceDetailEventsResult(traceId, result.data);
|
|
const nextProjectedSeq = traceNextProjectedSeq(result.data, afterProjectedSeq);
|
|
rememberTraceEventsDetailRead(traceId, afterProjectedSeq, nextProjectedSeq, result.data);
|
|
if (result.data.hasMore !== true || nextProjectedSeq <= afterProjectedSeq) return;
|
|
afterProjectedSeq = nextProjectedSeq;
|
|
}
|
|
}
|
|
|
|
function traceEventsDetailReadDecision(traceId: string, afterProjectedSeq: number, message: ChatMessage, options: { force?: boolean }): { read: boolean; reason: string } {
|
|
const limit = runtimePolicy.traceDetailPageLimit;
|
|
const key = workbenchTraceEventsReadKey({ traceId, afterProjectedSeq, limit });
|
|
return traceEventsAutoReadDecision({
|
|
traceId,
|
|
afterProjectedSeq,
|
|
limit,
|
|
force: options.force,
|
|
terminalBodyVisible: traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId),
|
|
cachedRange: traceEventsReadRanges.get(key) ?? null
|
|
});
|
|
}
|
|
|
|
function rememberTraceEventsDetailRead(traceId: string, afterProjectedSeq: number, nextProjectedSeq: number, result: AgentChatResultResponse): void {
|
|
const limit = runtimePolicy.traceDetailPageLimit;
|
|
traceEventsReadRanges.set(workbenchTraceEventsReadKey({ traceId, afterProjectedSeq, limit }), { traceId, afterProjectedSeq, limit, nextProjectedSeq, hasMore: result.hasMore === true });
|
|
}
|
|
|
|
function recordTraceEventsDetailReadSkip(traceId: string, message: ChatMessage, reason: string, afterProjectedSeq: number): void {
|
|
recordWorkbenchRuntimeDiagnostic({
|
|
module: "workbench-trace-events-read",
|
|
sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null,
|
|
traceId,
|
|
outcome: "ok",
|
|
diagnostic: { code: "trace_events_auto_read_skip", reason, source: "trace-detail-read", afterProjectedSeq, limit: runtimePolicy.traceDetailPageLimit, valuesRedacted: true }
|
|
});
|
|
}
|
|
|
|
async function fetchTraceDetailEventsPage(traceId: string, afterProjectedSeq: number, options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
|
|
return fetchWorkbenchTraceEvents(traceId, afterProjectedSeq, shouldUseActivityTimeoutForTrace(traceId), { force: options.force });
|
|
}
|
|
|
|
function readTerminalTraceDetailGaps(source: ChatMessage[], reason: string): void {
|
|
// Terminal trace rows are loaded on explicit force/detail paths. Auto-filling
|
|
// historical terminal traces makes multi-turn Workbench sessions main-thread bound.
|
|
void source;
|
|
void reason;
|
|
}
|
|
|
|
async function readTraceEventsForMessages(source: ChatMessage[] = messages.value, options: { force?: boolean } = {}): Promise<void> {
|
|
const candidates = traceDetailReadCandidates(source);
|
|
if (options.force !== true) {
|
|
if (candidates.length > 0) {
|
|
const traceId = firstNonEmptyString(candidates[0]?.traceId, candidates[0]?.runnerTrace?.traceId);
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-trace-events-read", sessionId: candidates[0]?.sessionId ?? candidates[0]?.runnerTrace?.sessionId ?? null, traceId: traceId ?? null, outcome: "ok", diagnostic: { code: "trace_events_auto_read_disabled", reason: "projection-sse-authority", source: "trace-detail-read", candidateCount: candidates.length, valuesRedacted: true } });
|
|
}
|
|
return;
|
|
}
|
|
for (const message of candidates) void readTraceEventsForMessage(message, { force: true });
|
|
}
|
|
|
|
function traceDetailReadCandidates(source: ChatMessage[]): ChatMessage[] {
|
|
return source.filter(messageNeedsTraceDetailRead).slice(-runtimePolicy.traceDetailAutoQueueLimit).reverse();
|
|
}
|
|
|
|
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] ?? null : null;
|
|
if (!authority) return message;
|
|
const runnerTrace = mergeRunnerTrace(message.runnerTrace, authority);
|
|
return { ...message, ...messageTimingPatchForMerge(message, runnerTrace), runnerTrace };
|
|
});
|
|
}
|
|
|
|
function rememberTraceAuthority(trace: ChatMessage["runnerTrace"]): void {
|
|
const traceId = firstNonEmptyString(trace?.traceId);
|
|
if (!traceId || !trace) return;
|
|
const existing = traceAuthorityById.value[traceId] ?? null;
|
|
if (trace.eventSource !== "trace-api" && !traceHasEvents(trace) && !existing) return;
|
|
const nextTrace = mergeRunnerTrace(existing, trace as NonNullable<ChatMessage["runnerTrace"]>);
|
|
reduceServerState({ type: "trace.snapshot", traceId, trace: nextTrace });
|
|
}
|
|
|
|
function applyTraceDetailEventsResult(traceId: string, result: AgentChatResultResponse): void {
|
|
const authoritySessionId = traceResultSessionId(result);
|
|
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, result.status);
|
|
if (ownerSessionId === activeSessionId.value && (events.length > 0 || turnResultIsTerminalForMerge(result))) recordActivity(`trace:${activityLabel ?? "hydrated"}`);
|
|
markWorkbenchTraceEventsReceived({ traceId, events, transport: "detail_history" });
|
|
if (turnResultIsTerminalForMerge(result)) {
|
|
const terminalSeal = terminalSealResultWithoutTraceEvents(result);
|
|
rememberTurnStatus(traceId, terminalSeal);
|
|
projectTurnAuthorityToMessages(traceId, terminalSeal, "trace-detail-read-terminal-seal");
|
|
}
|
|
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
|
|
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message;
|
|
const traceDetailStatus = firstNonEmptyString(result.traceStatus, result.runnerTrace?.traceStatus, result.runnerTrace?.status);
|
|
const { traceId: _resultTraceId, sessionId: _resultSessionId, threadId: _resultThreadId, ...resultTraceRest } = result.runnerTrace ?? {};
|
|
const nextTrace = {
|
|
...resultTraceRest,
|
|
traceId: optionalString(result.traceId, traceId),
|
|
status: traceDetailStatus ?? undefined,
|
|
sessionId: optionalString(authoritySessionId, message.runnerTrace?.sessionId),
|
|
threadId: optionalString(result.threadId, message.runnerTrace?.threadId),
|
|
events,
|
|
eventSource: "trace-api",
|
|
eventCount: result.eventCount ?? events.length,
|
|
eventsCompacted: result.runnerTrace?.eventsCompacted ?? false,
|
|
fullTraceLoaded: result.fullTraceLoaded === true || result.hasMore === false,
|
|
hasMore: result.hasMore,
|
|
truncated: result.truncated,
|
|
nextProjectedSeq: typeof result.nextProjectedSeq === "number" ? result.nextProjectedSeq : null,
|
|
range: result.range,
|
|
traceStatus: result.traceStatus,
|
|
retention: result.retention,
|
|
terminalEvidence: result.terminalEvidence,
|
|
traceSummary: result.traceSummary,
|
|
agentRun: result.agentRun,
|
|
projection: projectionFromResult(result),
|
|
projectionStatus: result.projectionStatus ?? result.projection?.projectionStatus,
|
|
projectionHealth: result.projectionHealth ?? result.projection?.projectionHealth,
|
|
staleMs: result.staleMs ?? result.projection?.staleMs,
|
|
blocker: result.blocker ?? result.projection?.blocker,
|
|
...messageTimingPatch(result),
|
|
lastEventLabel: result.lastEventLabel ?? undefined,
|
|
updatedAt: new Date().toISOString()
|
|
} as NonNullable<ChatMessage["runnerTrace"]>;
|
|
const resultStatus = turnResultStatusForMerge(result);
|
|
const terminal = turnResultIsTerminalForMerge(result);
|
|
const resultError = normalizeAgentError(result.error ?? null);
|
|
const resultProjection = projectionFromResult(result);
|
|
const mergedRunnerTrace = mergeRunnerTrace(message.runnerTrace, nextTrace);
|
|
const clearCompletedDiagnostics = shouldClearCompletedTurnDiagnostics(resultStatus, resultError);
|
|
const runnerTrace = clearCompletedDiagnostics ? clearRunnerTraceTransientDiagnostics(mergedRunnerTrace) : mergedRunnerTrace;
|
|
const error = resultError ?? (terminal || clearCompletedDiagnostics ? null : normalizeAgentError(runnerTrace?.error ?? message.error));
|
|
const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null);
|
|
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
|
|
rememberTraceAuthority(runnerTrace);
|
|
return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
|
}));
|
|
markWorkbenchTraceProjected(traceId);
|
|
if (traceResultHasTerminalEvidence(result) && !turnResultIsTerminalForMerge(result)) {
|
|
}
|
|
if (turnResultIsTerminalForMerge(result)) {
|
|
rememberTurnStatus(traceId, result);
|
|
if (ownerSessionId === activeSessionId.value) {
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
void clearActiveTrace(traceId, "trace-detail-read-terminal");
|
|
restartRealtime("trace-detail-read-terminal");
|
|
}
|
|
}
|
|
}
|
|
|
|
function setProviderProfile(value: ProviderProfile): void {
|
|
providerProfile.value = value;
|
|
writeWorkbenchString("hwlab.workbench.providerProfile.v1", value);
|
|
}
|
|
|
|
async function refreshProviderOptions(): Promise<boolean> {
|
|
const response = await workbenchColadaQueries.fetchProviderProfiles({ minIntervalMs: runtimePolicy.workbenchReadFailureCooldownMs });
|
|
providerOptions.value = response.ok ? providerProfileOptionsFromPayload(response.data, providerProfile.value) : defaultProviderProfileOptions(providerProfile.value);
|
|
return response.ok === true;
|
|
}
|
|
|
|
function rememberRecentDraft(text: string): void {
|
|
recentDrafts.value = recordRecentDraft(recentDrafts.value, text);
|
|
writeRecentDrafts(recentDrafts.value);
|
|
}
|
|
|
|
function pickDraft(draft: DraftEntry | string): string {
|
|
return typeof draft === "string" ? draft : draft.text;
|
|
}
|
|
|
|
function clearRecentDrafts(): void {
|
|
recentDrafts.value = [];
|
|
removeWorkbenchStorageKey(RECENT_DRAFTS_STORAGE_KEY);
|
|
}
|
|
|
|
function clearSessionMessages(): void {
|
|
const activeTraceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
|
|
replaceActiveMessages([]);
|
|
currentRequest.value = null;
|
|
if (activeTraceId) void clearActiveTrace(activeTraceId, "clear-session-messages");
|
|
restartRealtime("clear-session-messages");
|
|
}
|
|
|
|
function reattachTrace(traceId: string): void {
|
|
const initial: AgentChatResponse = { status: "running", traceId };
|
|
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");
|
|
}
|
|
|
|
function restartRealtime(reason: string, forceReconnect = false): void {
|
|
const traceId = realtimeTraceId();
|
|
const sessionId = selectedSessionId.value ?? null;
|
|
void reason;
|
|
const realtimeScopeKey = workbenchRealtimeScopeKey(sessionId, traceId);
|
|
const scopeChanged = realtimeTransport.currentKey() !== realtimeScopeKey;
|
|
if (debugCapabilities.rawHwlabEventWindow.enabled && scopeChanged) rawHwlabIngress.value = createRawHwlabIngressState(realtimeScopeKey);
|
|
realtimeTransport.restart({
|
|
sessionId,
|
|
traceId,
|
|
forceReconnect,
|
|
errorRecoveryMinMs: runtimePolicy.workbenchRealtimeErrorSyncReplayMinMs,
|
|
flushMaxItemsPerChunk: runtimePolicy.workbenchRealtimeFlushMaxItemsPerChunk,
|
|
flushMaxChunkMs: runtimePolicy.workbenchRealtimeFlushMaxChunkMs,
|
|
flushYieldMs: runtimePolicy.workbenchRealtimeFlushYieldMs,
|
|
onIngress: debugCapabilities.rawHwlabEventWindow.enabled
|
|
? (frame) => { rawHwlabIngress.value = appendRawHwlabIngressFrame(rawHwlabIngress.value, frame, debugCapabilities.rawHwlabEventWindow); }
|
|
: undefined,
|
|
onRecovery: (recovery) => handleRealtimeRecovery(recovery),
|
|
onEvent: (event, eventName) => applyRealtimeEvent(event, eventName)
|
|
});
|
|
}
|
|
|
|
function handleRealtimeRecovery(recovery: WorkbenchStreamTransportRecovery): void {
|
|
const sessionId = normalizeWorkbenchSessionId(recovery.sessionId ?? selectedSessionId.value);
|
|
const traceId = firstNonEmptyString(recovery.traceId, realtimeTraceId());
|
|
const plan = planWorkbenchRealtimeRecovery(recovery, {
|
|
selectedSessionId: selectedSessionId.value,
|
|
activeSessionId: activeSessionId.value,
|
|
fallbackTraceId: traceId,
|
|
activeTraceAuthorized: Boolean(traceId && shouldApplyActiveTraceAuthority(traceId, sessionId)),
|
|
terminalTraceSealed: Boolean(traceId && traceTerminalBodyIsVisible(traceId, sessionId))
|
|
});
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-stream-transport", diagnostic: recovery.diagnostic, sessionId: plan.sessionId, traceId: plan.traceId, outcome: "network" });
|
|
for (const step of plan.steps) executeRealtimeRecoveryStep(step);
|
|
}
|
|
|
|
function executeRealtimeRecoveryStep(step: WorkbenchRealtimeRecoveryStep): void {
|
|
switch (step.type) {
|
|
case "events-reconnect":
|
|
restartRealtime(step.reason, true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function stopRealtime(): void {
|
|
realtimeTransport.stop();
|
|
}
|
|
|
|
function realtimeTraceId(): string | null {
|
|
return workbenchRealtimeTraceId(
|
|
currentRequest.value?.traceId,
|
|
activeTraceIdFromMessages(messages.value, turnStatusAuthority.value)
|
|
);
|
|
}
|
|
|
|
function normalizeTraceAuthoritySessionId(value: unknown): string | null {
|
|
const sessionId = normalizeWorkbenchSessionId(value);
|
|
const match = sessionId?.match(/^ses_agentrun_([0-9a-f]{8})_([0-9a-f]{4})_([0-9a-f]{4})_([0-9a-f]{4})_([0-9a-f]{12})$/iu);
|
|
if (!match) return sessionId;
|
|
return `ses_${match.slice(1).join("-").toLowerCase()}`;
|
|
}
|
|
|
|
function realtimeEventSessionId(event: WorkbenchRealtimeEvent): string | null {
|
|
const turn = recordValue(event.turn);
|
|
const snapshot = recordValue(event.snapshot);
|
|
const traceEvent = recordValue(event.event);
|
|
const sessionId = firstNonEmptyString(event.hwlabSessionId, event.sessionId, turn?.sessionId, snapshot?.sessionId, traceEvent?.sessionId);
|
|
return normalizeTraceAuthoritySessionId(sessionId);
|
|
}
|
|
|
|
function traceResultSessionId(result: AgentChatResultResponse | TraceSnapshot | Record<string, unknown> | null | undefined): string | null {
|
|
const value = recordValue(result);
|
|
const runnerTrace = recordValue(value?.runnerTrace);
|
|
const session = recordValue(value?.session);
|
|
return normalizeTraceAuthoritySessionId(firstNonEmptyString(value?.sessionId, runnerTrace?.sessionId, session?.sessionId));
|
|
}
|
|
|
|
function messageSessionAuthority(message: ChatMessage | null | undefined): string | null {
|
|
return normalizeTraceAuthoritySessionId(firstNonEmptyString(message?.sessionId, message?.runnerTrace?.sessionId));
|
|
}
|
|
|
|
function traceOwnerSessionId(traceId: string | null | undefined, authoritySessionId: string | null | undefined, canonicalSession = false): string | null {
|
|
const sessionId = canonicalSession ? normalizeWorkbenchSessionId(authoritySessionId) : normalizeTraceAuthoritySessionId(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 normalizeTraceAuthoritySessionId(candidateSessionId);
|
|
}
|
|
return activeSessionId.value;
|
|
}
|
|
|
|
function messageMatchesTraceAuthority(message: ChatMessage, traceId: string, authoritySessionId: string | null | undefined, ownerSessionId: string | null | undefined, canonicalSession = false): boolean {
|
|
if (message.role !== "agent" || firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== traceId) return false;
|
|
const normalizeSession = canonicalSession ? normalizeWorkbenchSessionId : normalizeTraceAuthoritySessionId;
|
|
const sessionId = normalizeSession(authoritySessionId);
|
|
const ownerId = normalizeSession(ownerSessionId);
|
|
const messageSessionId = canonicalSession ? normalizeWorkbenchSessionId(firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId)) : 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, canonicalSession = false): boolean {
|
|
const id = firstNonEmptyString(traceId);
|
|
const activeId = activeSessionId.value;
|
|
const sessionId = canonicalSession ? normalizeWorkbenchSessionId(authoritySessionId) : normalizeTraceAuthoritySessionId(authoritySessionId);
|
|
if (!activeId) return false;
|
|
if (sessionId && sessionId !== activeId) return false;
|
|
if (!id) return Boolean(sessionId && sessionId === activeId);
|
|
const message = messages.value.find((item) => firstNonEmptyString(item.traceId, item.runnerTrace?.traceId) === id) ?? null;
|
|
const messageSessionId = messageSessionAuthority(message);
|
|
if (messageSessionId && messageSessionId !== activeId) return false;
|
|
if (sessionId && messageSessionId && sessionId !== messageSessionId) return false;
|
|
const knownSessionId = normalizeTraceAuthoritySessionId(firstNonEmptyString(turnStatusAuthority.value[id]?.sessionId, traceAuthorityById.value[id]?.sessionId));
|
|
if (knownSessionId && knownSessionId !== activeId) return false;
|
|
if (sessionId && knownSessionId && sessionId !== knownSessionId) return false;
|
|
return Boolean(sessionId || messageSessionId || knownSessionId || message);
|
|
}
|
|
|
|
function shouldApplyTraceToMessage(message: ChatMessage, traceId: string, authoritySessionId: string | null | undefined): boolean {
|
|
if (message.role !== "agent" || firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== traceId) return false;
|
|
const activeId = activeSessionId.value;
|
|
const sessionId = normalizeTraceAuthoritySessionId(authoritySessionId);
|
|
const messageSessionId = messageSessionAuthority(message);
|
|
if (activeId && messageSessionId && messageSessionId !== activeId) return false;
|
|
if (sessionId && activeId && sessionId !== activeId) return false;
|
|
if (sessionId && messageSessionId && sessionId !== messageSessionId) return false;
|
|
return true;
|
|
}
|
|
|
|
function applyRealtimeEvent(event: WorkbenchRealtimeEvent, eventName: string): void {
|
|
const reduced = reduceWorkbenchRealtimeEvent(event, eventName);
|
|
recordActivity(reduced.activityLabel);
|
|
applyWorkbenchRealtimeAction(reduced.action);
|
|
}
|
|
|
|
function applyWorkbenchRealtimeAction(action: WorkbenchRealtimeAction): void {
|
|
const plan = planWorkbenchRealtimeApply(action);
|
|
for (const step of plan.steps) applyWorkbenchRealtimePlanStep(step);
|
|
}
|
|
|
|
function applyWorkbenchRealtimePlanStep(step: WorkbenchRealtimeApplyStep): void {
|
|
switch (step.type) {
|
|
case "apply-trace-snapshot":
|
|
applyRealtimeTraceSnapshot(step.traceId, step.snapshot);
|
|
return;
|
|
case "apply-trace-event":
|
|
applyRealtimeTraceEvent(step.traceId, step.event, step.snapshot, step.realtimeEvent);
|
|
return;
|
|
case "apply-message-snapshot":
|
|
applyRealtimeMessageSnapshot(step.realtimeEvent);
|
|
return;
|
|
case "apply-turn-snapshot":
|
|
applyRealtimeTurnSnapshot(step.turn);
|
|
return;
|
|
case "apply-projection-error":
|
|
applyRealtimeProjectionError(step.realtimeEvent);
|
|
return;
|
|
case "clear-active-trace":
|
|
void clearActiveTrace(step.traceId, step.reason);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function applyRealtimeMessageSnapshot(event: WorkbenchRealtimeEvent): void {
|
|
const message = event.message ? normalizeChatMessage(event.message) : null;
|
|
const sessionId = normalizeWorkbenchSessionId(firstNonEmptyString(event.sessionId, message?.sessionId));
|
|
if (!message || !sessionId) return;
|
|
const activeId = activeSessionId.value;
|
|
if (activeId && sessionId !== activeId) return;
|
|
reduceServerState({ type: "message.snapshot", sessionId, message });
|
|
}
|
|
|
|
function applyRealtimeTraceSnapshot(traceId: string | null | undefined, snapshot: WorkbenchRealtimeEvent["snapshot"]): void {
|
|
const id = firstNonEmptyString(traceId, snapshot?.traceId);
|
|
if (!id || !snapshot) return;
|
|
const sessionId = traceResultSessionId(snapshot);
|
|
if (!shouldApplyActiveTraceAuthority(id, sessionId)) return;
|
|
if (traceTerminalBodyIsVisible(id, sessionId)) {
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId, traceId: id, outcome: "ok", diagnostic: { code: "terminal_low_priority_sse_trace_skip", source: "realtime-trace-snapshot", valuesRedacted: true } });
|
|
return;
|
|
}
|
|
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot));
|
|
}
|
|
|
|
function applyRealtimeTraceEvent(traceId: string | null | undefined, event: WorkbenchRealtimeEvent["event"], snapshot: WorkbenchRealtimeEvent["snapshot"], realtimeEvent?: WorkbenchRealtimeEvent | null): void {
|
|
const id = firstNonEmptyString(traceId, event?.traceId, snapshot?.traceId);
|
|
if (!id) return;
|
|
const sessionId = realtimeEvent ? realtimeEventSessionId(realtimeEvent) : traceResultSessionId(snapshot ?? event ?? null);
|
|
if (!shouldApplyActiveTraceAuthority(id, sessionId)) return;
|
|
if (traceTerminalBodyIsVisible(id, sessionId)) {
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId, traceId: id, outcome: "ok", diagnostic: { code: "terminal_low_priority_sse_trace_skip", source: "realtime-trace-event", valuesRedacted: true } });
|
|
return;
|
|
}
|
|
const events = event ? [event] : Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
markWorkbenchTraceEventsReceived({ traceId: id, events, transport: "sse", serverSentAt: realtimeEvent?.serverSentAt, eventCreatedAt: realtimeEvent?.eventCreatedAt, traceSeq: realtimeEvent?.traceSeq ?? realtimeEvent?.cursor?.traceSeq });
|
|
const eventSnapshot = snapshot ?? { traceId: id, status: event?.status, events };
|
|
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, eventSnapshot, events));
|
|
}
|
|
|
|
function applyRealtimeTurnSnapshot(turn: Record<string, unknown>): void {
|
|
const traceId = firstNonEmptyString(turn.traceId);
|
|
if (!traceId) return;
|
|
if (!shouldApplyActiveTraceAuthority(traceId, traceResultSessionId(turn))) return;
|
|
const activeId = activeSessionId.value;
|
|
const status = firstNonEmptyString(turn.status) ?? undefined;
|
|
const terminalTurn = turn.terminal === true || isTerminalMessageStatus(status);
|
|
if (activeId && !terminalTurn && !messages.value.some((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) === traceId)) {
|
|
recordWorkbenchRuntimeDiagnostic({ module: "workbench-realtime-authority", sessionId: activeId, traceId, outcome: "ok", diagnostic: { code: "workbench_realtime_turn_gap_no_legacy_repair", reason: "realtime-turn-gap", source: "turn-snapshot", valuesRedacted: true } });
|
|
}
|
|
const result = { ...turn, traceId, status, running: turn.running === true, terminal: turn.terminal === true, sessionId: firstNonEmptyString(turn.sessionId) ?? undefined, threadId: firstNonEmptyString(turn.threadId) ?? undefined, agentRun: turn.agentRun as AgentRunProvenance | undefined } as AgentChatResultResponse;
|
|
rememberTurnStatus(traceId, result);
|
|
scheduleRealtimeTurnProjection({ traceId, result, terminalTurn });
|
|
}
|
|
|
|
function realtimeEventName(event: WorkbenchRealtimeEvent): string {
|
|
switch (event.type) {
|
|
case "trace.snapshot":
|
|
return "workbench.trace.snapshot";
|
|
case "trace.event":
|
|
return "workbench.trace.event";
|
|
case "message.snapshot":
|
|
return "workbench.message.snapshot";
|
|
case "turn.snapshot":
|
|
return "workbench.turn.snapshot";
|
|
case "trace.unavailable":
|
|
return "workbench.trace.unavailable";
|
|
case "error":
|
|
return "workbench.error";
|
|
default:
|
|
return "message";
|
|
}
|
|
}
|
|
|
|
function scheduleRealtimeTurnProjection(item: RealtimeTurnProjectionItem): void {
|
|
realtimeTurnProjectionQueue.set(item.traceId, item);
|
|
scheduleRealtimeTurnProjectionFlush();
|
|
}
|
|
|
|
function scheduleRealtimeTurnProjectionFlush(): void {
|
|
if (realtimeTurnProjectionScheduled) return;
|
|
realtimeTurnProjectionScheduled = true;
|
|
const flush = () => flushRealtimeTurnProjection();
|
|
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
|
window.requestAnimationFrame(flush);
|
|
return;
|
|
}
|
|
setTimeout(flush, Math.max(0, runtimePolicy.workbenchRealtimeFlushYieldMs));
|
|
}
|
|
|
|
function flushRealtimeTurnProjection(): void {
|
|
realtimeTurnProjectionScheduled = false;
|
|
const next = realtimeTurnProjectionQueue.values().next().value as RealtimeTurnProjectionItem | undefined;
|
|
if (!next) return;
|
|
realtimeTurnProjectionQueue.delete(next.traceId);
|
|
const startedAt = performanceNowMs();
|
|
if (shouldApplyActiveTraceAuthority(next.traceId, traceResultSessionId(next.result))) {
|
|
syncTurnStatusToMessage(next.traceId, next.result);
|
|
}
|
|
recordWorkbenchRuntimeDiagnostic({
|
|
module: "workbench-turn-status",
|
|
sessionId: traceResultSessionId(next.result),
|
|
traceId: next.traceId,
|
|
outcome: "ok",
|
|
diagnostic: {
|
|
code: "workbench_realtime_turn_projection_budget",
|
|
reason: "realtime-turn-snapshot",
|
|
source: "turn-snapshot-frame-queue",
|
|
remainingCount: realtimeTurnProjectionQueue.size,
|
|
terminal: next.terminalTurn,
|
|
flushDurationMs: Math.round(performanceNowMs() - startedAt),
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
if (realtimeTurnProjectionQueue.size > 0) scheduleRealtimeTurnProjectionFlush();
|
|
}
|
|
|
|
function installRealtimeVisibilityHandler(): void {
|
|
if (typeof document === "undefined") return;
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState !== "visible") return;
|
|
restartRealtime("visibility");
|
|
});
|
|
}
|
|
|
|
function installWorkbenchProjectionSignalHandler(): void {
|
|
if (typeof window === "undefined") return;
|
|
if (typeof BroadcastChannel !== "undefined") {
|
|
workbenchProjectionSignalChannel = new BroadcastChannel(WORKBENCH_SESSION_PROJECTION_SIGNAL_CHANNEL);
|
|
workbenchProjectionSignalChannel.addEventListener("message", (event: MessageEvent<unknown>) => handleWorkbenchProjectionSignal(event.data));
|
|
}
|
|
window.addEventListener("storage", (event: StorageEvent) => {
|
|
if (event.key !== WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY || !event.newValue) return;
|
|
try {
|
|
handleWorkbenchProjectionSignal(JSON.parse(event.newValue));
|
|
} catch {
|
|
// Ignore malformed cross-tab payloads from older pages.
|
|
}
|
|
});
|
|
}
|
|
|
|
function publishWorkbenchProjectionSignal(sessionId: string | null | undefined, traceId: string | null | undefined, reason: string): void {
|
|
if (typeof window === "undefined") return;
|
|
const id = normalizeWorkbenchSessionId(sessionId);
|
|
if (!id) return;
|
|
const payload = { type: "session-projection", sourceId: workbenchProjectionSignalSourceId, sessionId: id, traceId: firstNonEmptyString(traceId) ?? null, reason, at: new Date().toISOString(), valuesRedacted: true };
|
|
try { workbenchProjectionSignalChannel?.postMessage(payload); } catch { /* ignore */ }
|
|
writeWorkbenchString(WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY, JSON.stringify(payload));
|
|
}
|
|
|
|
function handleWorkbenchProjectionSignal(value: unknown): void {
|
|
const record = recordValue(value);
|
|
if (!record || record.sourceId === workbenchProjectionSignalSourceId) return;
|
|
if (firstNonEmptyString(record.type) !== "session-projection") return;
|
|
const sessionId = normalizeWorkbenchSessionId(record.sessionId);
|
|
if (!sessionId || sessionId !== activeSessionId.value) return;
|
|
const traceId = firstNonEmptyString(record.traceId);
|
|
recordActivity(`cross-tab-session-projection:${firstNonEmptyString(record.reason, traceId, "session") ?? "session"}`);
|
|
restartRealtime("cross-tab-session-projection", true);
|
|
}
|
|
|
|
function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot, canonicalSession = false): void {
|
|
const trace = snapshotToRunnerTrace({
|
|
...snapshot,
|
|
traceStatus: firstNonEmptyString(snapshot.traceStatus, snapshot.status) ?? undefined
|
|
});
|
|
const authoritySessionId = canonicalSession
|
|
? normalizeWorkbenchSessionId(firstNonEmptyString(trace.sessionId, snapshot.sessionId))
|
|
: traceResultSessionId(trace) ?? traceResultSessionId(snapshot);
|
|
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId, canonicalSession);
|
|
if (!ownerSessionId) return;
|
|
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
|
|
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId, canonicalSession)) return message;
|
|
const mergedRunnerTrace = mergeRunnerTrace(message.runnerTrace, trace);
|
|
const traceStatus = normalizedStatusText(trace.status ?? snapshot.status) ?? null;
|
|
const clearCompletedDiagnostics = shouldClearCompletedTurnDiagnostics(traceStatus, null);
|
|
const runnerTrace = clearCompletedDiagnostics ? clearRunnerTraceTransientDiagnostics(mergedRunnerTrace) : mergedRunnerTrace;
|
|
const terminal = turnResultIsTerminalForMerge({ ...(snapshot as AgentChatResultResponse), status: traceStatus } as AgentChatResultResponse);
|
|
rememberTraceAuthority(runnerTrace);
|
|
const error = clearCompletedDiagnostics ? null : message.role === "agent" ? normalizeAgentError(runnerTrace.error ?? message.error) : normalizeAgentError(message.error);
|
|
const projection = clearCompletedDiagnostics ? nonBlockingProjection(trace.projection ?? null) : trace.projection ?? runnerTrace.projection ?? message.projection ?? null;
|
|
return { ...message, ...messageTimingPatchForMerge(message, trace), ...messageStatusPatchForTerminalMerge(message, traceStatus, terminal), runnerTrace, error: clearCompletedDiagnostics ? null : error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, updatedAt: new Date().toISOString() };
|
|
}));
|
|
markWorkbenchTraceProjected(traceId);
|
|
if (!traceProjectionIsTerminalSealed(traceId, serverState.value.messagesBySessionId[ownerSessionId] ?? [])) scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListRealtimeRefreshDelayMs);
|
|
}
|
|
|
|
function completeTrace(traceId: string, result: AgentChatResultResponse, options: { forceRead?: boolean } = {}): void {
|
|
const authoritySessionId = traceResultSessionId(result);
|
|
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
|
|
if (!ownerSessionId) return;
|
|
if (ownerSessionId === activeSessionId.value) recordActivity(`trace:terminal:${firstNonEmptyString(result.lastEventLabel, result.status, "completed") ?? "completed"}`);
|
|
projectTurnAuthorityToMessages(traceId, result, "complete-trace");
|
|
rememberTurnStatus(traceId, result);
|
|
markWorkbenchTraceProjected(traceId);
|
|
const ownerMessages = serverState.value.messagesBySessionId[ownerSessionId] ?? [];
|
|
const terminalMessage = latestMessageForTrace(traceId, ownerMessages);
|
|
if (!traceProjectionIsTerminalSealed(traceId, ownerMessages)) {
|
|
if (options.forceRead) void refreshMessageProjectionForTrace(ownerSessionId, traceId, { force: true });
|
|
}
|
|
if (options.forceRead && terminalMessage && !traceProjectionIsTerminalSealed(traceId, ownerMessages)) void readTraceEventsForMessage(terminalMessage, { force: true });
|
|
if (ownerSessionId === activeSessionId.value) {
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
void clearActiveTrace(traceId, "trace-terminal");
|
|
restartRealtime("trace-terminal");
|
|
}
|
|
if (!traceProjectionIsTerminalSealed(traceId, serverState.value.messagesBySessionId[ownerSessionId] ?? [])) scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
|
|
}
|
|
|
|
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
|
|
const targets = messages.value.filter(messageNeedsTerminalDiagnostics).slice(-6);
|
|
void targets;
|
|
void readTraceEventsForMessages(messages.value);
|
|
}
|
|
|
|
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
|
|
const authoritySessionId = traceResultSessionId(result);
|
|
const ownerSessionId = traceOwnerSessionId(traceId, authoritySessionId);
|
|
if (!ownerSessionId) return;
|
|
updateSessionMessages(ownerSessionId, (source) => source.map((message) => {
|
|
if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message;
|
|
const resultStatus = turnResultStatusForMerge(result);
|
|
const terminal = turnResultIsTerminalForMerge(result);
|
|
const resultError = normalizeAgentError(result.error ?? null);
|
|
const resultProjection = projectionFromResult(result);
|
|
const mergedRunnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
|
|
const clearCompletedDiagnostics = shouldClearCompletedTurnDiagnostics(resultStatus, resultError);
|
|
const runnerTrace = clearCompletedDiagnostics ? clearRunnerTraceTransientDiagnostics(mergedRunnerTrace) : mergedRunnerTrace;
|
|
rememberTraceAuthority(runnerTrace);
|
|
const error = resultError ?? (terminal || clearCompletedDiagnostics ? null : normalizeAgentError(runnerTrace?.error ?? message.error));
|
|
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
|
|
const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null);
|
|
return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
|
|
}));
|
|
void readTraceEventsForMessages(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
|
|
scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs);
|
|
}
|
|
|
|
function applyRealtimeProjectionError(event: WorkbenchRealtimeEvent): void {
|
|
const traceId = firstNonEmptyString(event.traceId, realtimeTraceId());
|
|
const errorRecord = recordValue(event.error) ?? event;
|
|
if (!traceId) {
|
|
const code = firstNonEmptyString(errorRecord.code, "workbench_realtime_error") ?? "workbench_realtime_error";
|
|
const phase = firstNonEmptyString(event.phase, errorRecord.phase, "realtime") ?? "realtime";
|
|
const message = firstNonEmptyString(errorRecord.message, errorRecord.summary, "Workbench 实时状态更新异常,最新运行记录暂不可见。") ?? "Workbench 实时状态更新异常,最新运行记录暂不可见。";
|
|
error.value = `${phase} · ${code} · ${message}`;
|
|
return;
|
|
}
|
|
const projection = normalizeProjectionDiagnostic(event) ?? projectionDiagnosticFromFailure({ code: firstNonEmptyString(errorRecord.code, "workbench_realtime_error") ?? "workbench_realtime_error", message: firstNonEmptyString(errorRecord.message, errorRecord.summary, "Workbench 实时状态更新异常,最新运行记录暂不可见。") ?? "Workbench 实时状态更新异常,最新运行记录暂不可见。", health: "degraded", diagnostic: normalizeErrorDiagnostic(errorRecord.diagnostic, recordValue(event.projection)?.diagnostic, recordValue(event)?.diagnostic), apiError: normalizeApiErrorRecord(errorRecord, "Workbench 实时状态更新异常,最新运行记录暂不可见。") });
|
|
applyProjectionDiagnostic(traceId, projection.projectionStatus ? projection : { ...projection, projectionStatus: "blocked", projectionHealth: projection.projectionHealth ?? "degraded" });
|
|
}
|
|
|
|
function applyProjectionDiagnostic(traceId: string, projection: ProjectionDiagnostic): void {
|
|
const message = [...messages.value].reverse().find((item) => shouldApplyTraceToMessage(item, traceId, activeSessionId.value)) ?? null;
|
|
const sessionId = normalizeWorkbenchSessionId(firstNonEmptyString(message?.sessionId, message?.runnerTrace?.sessionId, activeSessionId.value));
|
|
const existing = turnStatusAuthority.value[traceId];
|
|
if (existing?.terminal === true || messageHasSealedTerminalResult(message)) return;
|
|
const status = existing?.status ?? normalizedStatusText(message?.status) ?? null;
|
|
const running = existing?.running === true || (!isTerminalMessageStatus(message?.status) && isTraceActiveStatus(message?.status));
|
|
const now = new Date().toISOString();
|
|
reduceServerState({ type: "turn.status", turn: { traceId, status, running, terminal: false, sessionId, threadId: firstNonEmptyString(message?.threadId, message?.runnerTrace?.threadId) ?? existing?.threadId ?? null, updatedAt: now, projection, loadedAt: now } });
|
|
const projectionError = agentErrorFromProjection(projection);
|
|
updateActiveMessages((source) => source.map((item) => {
|
|
if (!shouldApplyTraceToMessage(item, traceId, sessionId)) return item;
|
|
if (messageHasSealedTerminalResult(item)) return item;
|
|
const runnerTrace = mergeRunnerTrace(item.runnerTrace, { ...(item.runnerTrace ?? {}), traceId, sessionId: sessionId ?? item.runnerTrace?.sessionId, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, staleMs: projection.staleMs ?? null, blocker: projection.blocker ?? null, events: item.runnerTrace?.events ?? [], eventCount: item.runnerTrace?.eventCount ?? item.runnerTrace?.events?.length ?? 0, eventSource: item.runnerTrace?.eventSource ?? "projection-diagnostic", updatedAt: now });
|
|
rememberTraceAuthority(runnerTrace);
|
|
return { ...item, runnerTrace, error: projectionError ? { ...(item.error ?? {}), ...projectionError } : item.error ?? null, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null, updatedAt: now };
|
|
}));
|
|
}
|
|
|
|
function failTrace(traceId: string, message: string): void {
|
|
markMessage(traceId, { status: "failed", text: message });
|
|
chatPending.value = false;
|
|
currentRequest.value = null;
|
|
void clearActiveTrace(traceId, "trace-infrastructure-error");
|
|
scheduleSessionListRefresh(selectedSessionId.value, runtimePolicy.sessionListTerminalRefreshDelayMs);
|
|
}
|
|
|
|
function projectRejectedAdmissionFailure(input: { traceId: string; message: string; error: ChatMessage["error"]; projection: ProjectionDiagnostic; submittedAt: string }): void {
|
|
const finishedAt = new Date().toISOString();
|
|
updateActiveMessages((source) => source.map((message) => message.traceId === input.traceId && message.role === "agent"
|
|
? projectRejectedWorkbenchAdmission(message, { ...input, finishedAt })
|
|
: message));
|
|
reduceServerState({ type: "turn.forget", traceId: input.traceId });
|
|
}
|
|
|
|
function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void {
|
|
const now = new Date().toISOString();
|
|
reduceServerState({ type: "turn.status", turn: { traceId: input.traceId, status: "running", running: true, terminal: false, sessionId: input.sessionId, threadId: input.threadId, updatedAt: now, loadedAt: now } });
|
|
const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null;
|
|
rememberSessionList(mergeSessionIntoList(sessions.value, {
|
|
...(existing ?? {}),
|
|
sessionId: input.sessionId,
|
|
threadId: firstNonEmptyString(input.threadId, existing?.threadId),
|
|
status: "running",
|
|
updatedAt: now,
|
|
lastTraceId: input.traceId,
|
|
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));
|
|
}
|
|
|
|
function markMessage(traceId: string, patch: Partial<ChatMessage>): void {
|
|
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 {
|
|
const canonicalTraceId = firstNonEmptyString(lifecycle.traceId, traceId) ?? traceId;
|
|
const turnId = firstNonEmptyString(lifecycle.turnId, canonicalTraceId);
|
|
const userMessageId = firstNonEmptyString(lifecycle.userMessageId);
|
|
const assistantMessageId = firstNonEmptyString(lifecycle.assistantMessageId);
|
|
const admissionTiming = optimisticAdmissionTimingPatch(lifecycle);
|
|
const hasAdmissionTiming = Boolean(admissionTiming.timing?.startedAt || admissionTiming.timing?.lastEventAt);
|
|
if (!turnId && !userMessageId && !assistantMessageId && !hasAdmissionTiming && canonicalTraceId === traceId) return canonicalTraceId;
|
|
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") {
|
|
const next = { ...base, ...stableAdmissionTimingPatchForRunningMessage(message, admissionTiming) };
|
|
return {
|
|
...next,
|
|
...(assistantMessageId ? { id: assistantMessageId, messageId: assistantMessageId } : {}),
|
|
...(turnId ? { turnId } : {})
|
|
};
|
|
}
|
|
return turnId ? { ...base, turnId } : base;
|
|
}));
|
|
return canonicalTraceId;
|
|
}
|
|
|
|
function stableAdmissionTimingPatchForRunningMessage(message: ChatMessage, patch: Partial<ChatMessage>): Partial<ChatMessage> {
|
|
const incoming = patch.timing;
|
|
const existing = message.timing;
|
|
if (!incoming || !existing?.startedAt || !isRunningAgentMessageForTiming(message)) return patch;
|
|
const timing = {
|
|
...incoming,
|
|
startedAt: existing.startedAt,
|
|
lastEventAt: existing.lastEventAt ?? incoming.lastEventAt ?? existing.startedAt,
|
|
finishedAt: null,
|
|
durationMs: null,
|
|
valuesRedacted: incoming.valuesRedacted !== false,
|
|
} as WorkbenchTurnTimingProjection;
|
|
return { ...patch, timing, startedAt: timing.startedAt ?? null, lastEventAt: timing.lastEventAt ?? null, finishedAt: null, durationMs: null };
|
|
}
|
|
|
|
function isRunningAgentMessageForTiming(message: ChatMessage): boolean {
|
|
if (message.role !== "agent") return false;
|
|
const status = normalizedStatusText(message.status);
|
|
if (status && isTerminalMessageStatus(status)) return false;
|
|
return status === null || isTraceActiveStatus(status) || message.traceAutoLifecycle === "running";
|
|
}
|
|
|
|
function optimisticAdmissionTimingPatch(lifecycle: AgentChatResponse): Partial<ChatMessage> {
|
|
const patch = messageTimingPatch(lifecycle);
|
|
if (patch.timing?.startedAt && patch.timing?.lastEventAt) return patch;
|
|
const record = lifecycle as Record<string, unknown>;
|
|
const admittedAt = firstNonEmptyString(record.startedAt, record.createdAt, record.updatedAt);
|
|
if (!admittedAt) return patch;
|
|
const timing = {
|
|
...(patch.timing ?? {}),
|
|
startedAt: patch.timing?.startedAt ?? admittedAt,
|
|
lastEventAt: patch.timing?.lastEventAt ?? admittedAt,
|
|
finishedAt: null,
|
|
durationMs: null,
|
|
valuesRedacted: true,
|
|
} as WorkbenchTurnTimingProjection;
|
|
return { ...patch, timing, startedAt: timing.startedAt ?? null, lastEventAt: timing.lastEventAt ?? null, finishedAt: null, durationMs: null };
|
|
}
|
|
|
|
function optimisticRunningTimingPatch(startedAt: string): Partial<ChatMessage> {
|
|
const timing = {
|
|
startedAt,
|
|
lastEventAt: startedAt,
|
|
finishedAt: null,
|
|
durationMs: null,
|
|
valuesRedacted: true,
|
|
} as WorkbenchTurnTimingProjection;
|
|
return { timing, startedAt, lastEventAt: startedAt, finishedAt: null, durationMs: null };
|
|
}
|
|
|
|
async function clearActiveTrace(traceId: string, reason: string): Promise<void> {
|
|
if (currentRequest.value?.traceId === traceId) currentRequest.value = null;
|
|
}
|
|
|
|
function beginSessionSelection(): number {
|
|
selectionEpoch.value += 1;
|
|
return selectionEpoch.value;
|
|
}
|
|
|
|
function isCurrentSessionSelection(epoch: number, sessionId?: string | null): boolean {
|
|
if (epoch !== selectionEpoch.value) return false;
|
|
if (!sessionId) return true;
|
|
return activeSessionId.value === sessionId || switchingSessionId.value === sessionId;
|
|
}
|
|
|
|
function isCurrentSessionRequest(epoch: number, requestId: string, selectedSessionId?: string | null): boolean {
|
|
if (epoch !== selectionEpoch.value) return false;
|
|
return activeSessionId.value === requestId || switchingSessionId.value === requestId || Boolean(selectedSessionId && (activeSessionId.value === selectedSessionId || switchingSessionId.value === selectedSessionId));
|
|
}
|
|
|
|
function clearSwitchingSession(sessionId: string): void {
|
|
if (switchingSessionId.value === sessionId) switchingSessionId.value = null;
|
|
}
|
|
|
|
function clearSessionDetailLoading(sessionId: string | null | undefined): void {
|
|
if (!sessionId || sessionDetailLoadingId.value === sessionId) sessionDetailLoadingId.value = null;
|
|
}
|
|
|
|
function applySelectedSessionDetail(session: WorkbenchSessionRecord, source: SessionSelectionSource = "system"): void {
|
|
setActiveSessionSelection(session.sessionId, source);
|
|
const projectedMessages = messagesWithTraceAuthority(messagesFromSession(session));
|
|
rememberSessionDetail({ ...session, messages: projectedMessages, messageCount: session.messageCount ?? projectedMessages.length });
|
|
sessionsReady.value = true;
|
|
currentRequest.value = null;
|
|
void hydrateTurnStatusAuthority(messages.value);
|
|
void hydrateTerminalMessageDiagnostics();
|
|
void readTraceEventsForMessages(messages.value);
|
|
reattachRestoredActiveTrace();
|
|
restartRealtime("apply-selected-session");
|
|
}
|
|
|
|
function applyCanonicalSessionArchive(sessionId: string, message: string): void {
|
|
forgetSession(sessionId);
|
|
if (activeSessionId.value === sessionId) replaceActiveSessionSelection(null);
|
|
if (currentRequest.value?.sessionId === sessionId) currentRequest.value = null;
|
|
error.value = message;
|
|
sessionsReady.value = sessions.value.length > 0;
|
|
restartRealtime("session-archived");
|
|
}
|
|
|
|
function markSessionReadUnavailable(sessionId: string, message: string): void {
|
|
void sessionId;
|
|
error.value = message;
|
|
sessionsReady.value = sessions.value.length > 0;
|
|
restartRealtime("session-read-unavailable");
|
|
}
|
|
|
|
async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise<WorkbenchSessionRecord | null> {
|
|
const requestId = normalizeWorkbenchSessionRouteId(sessionId);
|
|
if (!requestId) return null;
|
|
const normalizedRequestId = normalizeWorkbenchSessionId(requestId);
|
|
const detail = await fetchSessionDetailPage(requestId, { reason: "load-session:detail", force: true });
|
|
if (!detail.ok) return null;
|
|
const detailSession = sessionFromWorkbenchSession(detail.data?.session, { includeMessages: false });
|
|
const id = detailSession?.sessionId ?? normalizedRequestId ?? seed?.sessionId;
|
|
if (!id) return null;
|
|
const fallbackMessages = seed?.sessionId === id ? seed.messages ?? [] : [];
|
|
const base = detailSession ? { ...detailSession, messages: fallbackMessages } : seed;
|
|
if (!base) return null;
|
|
return { ...base, sessionId: id, messages: [], messageCount: base.messageCount ?? 0 };
|
|
}
|
|
|
|
async function sealRestoredActiveTurnMessages(source: ChatMessage[]): Promise<ChatMessage[]> {
|
|
const targets = source.filter(messageNeedsRestoredTurnSeal).slice(-1);
|
|
if (targets.length === 0) return source;
|
|
for (const message of targets) {
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
if (!traceId || traceProjectionIsTerminalSealed(traceId, source)) continue;
|
|
recordWorkbenchRuntimeDiagnostic({
|
|
module: "workbench-turn-status",
|
|
sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null,
|
|
traceId,
|
|
outcome: "ok",
|
|
diagnostic: { code: "workbench_restored_turn_auto_read_disabled", reason: "load-session:restored-turn", source: "projection-sse-authority", valuesRedacted: true }
|
|
});
|
|
}
|
|
return source;
|
|
}
|
|
|
|
function reattachRestoredActiveTrace(): void {
|
|
void readTraceEventsForMessages(messages.value);
|
|
const traceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
|
|
if (traceId) reattachTrace(traceId);
|
|
}
|
|
|
|
function performanceNowMs(): number {
|
|
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
}
|
|
|
|
function clearRawHwlabIngress(): void {
|
|
rawHwlabIngress.value = createRawHwlabIngressState(rawHwlabIngress.value.scopeKey);
|
|
}
|
|
|
|
installRealtimeVisibilityHandler();
|
|
installWorkbenchProjectionSignalHandler();
|
|
|
|
return { sessions, messages, activeMessages, activeSession, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, rawHwlabIngress, activeSessionId, activeSessionSelectionSource, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, clearRawHwlabIngress, recordActivity };
|
|
});
|
|
|
|
function workbenchSessionsFromPayload(payload: unknown): WorkbenchSessionRecord[] {
|
|
const record = recordValue(payload);
|
|
const sessions = Array.isArray(record?.sessions) ? record.sessions : [];
|
|
return sessions.map((item) => sessionFromWorkbenchSession(item)).filter((item): item is WorkbenchSessionRecord => Boolean(item));
|
|
}
|
|
|
|
function sessionFromWorkbenchSession(value: unknown, options: { includeMessages?: boolean } = {}): WorkbenchSessionRecord | null {
|
|
const record = recordValue(value);
|
|
const sessionId = normalizeWorkbenchSessionId(record?.sessionId ?? record?.id);
|
|
if (!sessionId) return null;
|
|
return {
|
|
...record,
|
|
sessionId,
|
|
threadId: firstNonEmptyString(record?.threadId),
|
|
status: firstNonEmptyString(record?.status),
|
|
updatedAt: firstNonEmptyString(record?.updatedAt),
|
|
startedAt: firstNonEmptyString(record?.startedAt),
|
|
lastTraceId: firstNonEmptyString(record?.lastTraceId),
|
|
messageCount: firstFiniteNumber(record?.messageCount) ?? undefined,
|
|
firstUserMessagePreview: firstNonEmptyString(record?.firstUserMessagePreview),
|
|
messages: options.includeMessages === false ? undefined : Array.isArray(record?.messages) ? record.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : undefined
|
|
};
|
|
}
|
|
|
|
function traceIdFromRealtimeRefreshReason(reason: string): string | null {
|
|
const match = /(?:^|[:\s])((?:trc|trace)[_-][A-Za-z0-9_-]+)/u.exec(reason);
|
|
return firstNonEmptyString(match?.[1]);
|
|
}
|
|
|
|
function routeSelectedSessionRecord(sessionId: string | null | undefined, loadingSessionId: string | null | undefined, errorText: string | null | undefined, projected: WorkbenchSessionRecord | null): WorkbenchSessionRecord | null {
|
|
if (projected) return null;
|
|
const id = normalizeWorkbenchSessionId(sessionId);
|
|
if (!id) return null;
|
|
const loadingId = normalizeWorkbenchSessionId(loadingSessionId);
|
|
const loading = loadingId === id;
|
|
const message = loading ? "session 投影加载中" : firstNonEmptyString(errorText, "session 投影暂不可见");
|
|
const projection: ProjectionDiagnostic = {
|
|
projectionStatus: loading ? "pending" : "blocked",
|
|
projectionHealth: loading ? "degraded" : "unavailable",
|
|
blocker: {
|
|
code: loading ? "session_detail_loading" : "session_detail_unavailable",
|
|
message,
|
|
userMessage: message,
|
|
retryable: true
|
|
}
|
|
};
|
|
return {
|
|
sessionId: id,
|
|
status: "unknown",
|
|
messageCount: 0,
|
|
messages: [],
|
|
firstUserMessagePreview: loading ? "正在加载 session 投影" : "session 投影暂不可见",
|
|
projection,
|
|
projectionStatus: projection.projectionStatus ?? undefined,
|
|
projectionHealth: projection.projectionHealth ?? undefined
|
|
} as WorkbenchSessionRecord;
|
|
}
|
|
|
|
function messageNeedsRestoredTurnSeal(message: ChatMessage): boolean {
|
|
if (message.role !== "agent") return false;
|
|
if (messageHasTerminalResponse(message)) return false;
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
if (!traceId) return false;
|
|
return isTraceActiveStatus(message.status) || isTerminalMessageStatus(message.status) || message.traceAutoLifecycle === "running";
|
|
}
|
|
|
|
function providerThreadIdForRequest(threadId: string | null | undefined): string | null {
|
|
const value = firstNonEmptyString(threadId) ?? null;
|
|
if (!value) return null;
|
|
return /^thr_[A-Za-z0-9]{12,32}$/u.test(value) ? null : value;
|
|
}
|
|
|
|
function uniqueSessionIds(sessions: WorkbenchSessionRecord[]): string[] {
|
|
return [...new Set(sessions.map((session) => firstNonEmptyString(session.sessionId)).filter((sessionId): sessionId is string => Boolean(sessionId)))];
|
|
}
|
|
|
|
function uniqueTraceIds(messages: ChatMessage[]): string[] {
|
|
return [...new Set(messages.map((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId)).filter((traceId): traceId is string => Boolean(traceId)))];
|
|
}
|
|
|
|
function messagesFromSession(session: WorkbenchSessionRecord): ChatMessage[] {
|
|
return (session.messages ?? []).map((message) => normalizeChatMessage(message));
|
|
}
|
|
|
|
function normalizeChatMessage(message: ChatMessage): ChatMessage {
|
|
const role = (message.role as string) === "assistant" ? "agent" : message.role;
|
|
const runnerTrace = normalizeMessageRunnerTrace(message);
|
|
const error = normalizeAgentError(message.error ?? runnerTrace?.error);
|
|
const projection = projectionFromMessage(message, runnerTrace);
|
|
const agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun);
|
|
const rawStatus = normalizeChatMessageStatus(message.status);
|
|
const baseText = firstNonEmptyString(message.text, messageText((message as Record<string, unknown>).content), messageText((message as Record<string, unknown>).message));
|
|
const finalText = finalResponseText((message as Record<string, unknown>).finalResponse);
|
|
const errorText = agentErrorDisplayText(error);
|
|
const status = rawStatus;
|
|
const text = role === "agent"
|
|
? isTerminalMessageStatus(status)
|
|
? projectedAgentMessageText({ status, finalText, errorText, baseText })
|
|
: firstNonEmptyString(baseText, finalText) ?? ""
|
|
: firstNonEmptyString(baseText, finalText, errorText) ?? "";
|
|
const messageId = firstNonEmptyString((message as Record<string, unknown>).messageId, message.id) ?? nextProtocolId("msg");
|
|
const timingPatch = isTerminalMessageStatus(status) ? terminalMessageTimingPatchForNormalize(message) : messageTimingPatch(message);
|
|
return { ...message, ...timingPatch, role, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined };
|
|
}
|
|
|
|
function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record<string, TurnStatusAuthority>): string | null {
|
|
for (const message of [...messages].reverse()) {
|
|
if (message.role !== "agent") continue;
|
|
if (messageHasTerminalResponse(message)) continue;
|
|
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
|
|
if (!traceId) continue;
|
|
const turn = turnStatusAuthority[traceId];
|
|
if (turn?.running !== true && !isTraceActiveStatus(turn?.status)) continue;
|
|
return traceId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function liveCall<T>(label: string, call: () => Promise<ApiResult<T>>): Promise<ApiResult<T>> {
|
|
try {
|
|
return await call();
|
|
} catch (error) {
|
|
return { ok: false, status: 0, data: null, error: `${label}: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
}
|
|
|
|
function makeMessage(role: ChatMessage["role"], text: string, status: ChatMessage["status"], extra: Partial<ChatMessage> = {}): ChatMessage {
|
|
return { id: nextProtocolId("msg"), role, title: extra.title ?? (role === "user" ? "用户" : "Code Agent"), text, status, createdAt: new Date().toISOString(), ...extra };
|
|
}
|
|
|
|
function readString(key: string, fallback: string): string {
|
|
return readWorkbenchString(key, fallback);
|
|
}
|
|
|
|
function readRecentDrafts(): DraftEntry[] {
|
|
return normalizeRecentDrafts(readWorkbenchJson<unknown[]>(RECENT_DRAFTS_STORAGE_KEY, []).value);
|
|
}
|
|
|
|
function writeRecentDrafts(drafts: DraftEntry[]): void {
|
|
writeWorkbenchJson(RECENT_DRAFTS_STORAGE_KEY, drafts);
|
|
}
|
|
|
|
function readNumber(key: string, fallback: number): number {
|
|
return readWorkbenchNumber(key, fallback);
|
|
}
|