Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/stores/workbench.ts
T

1428 lines
75 KiB
TypeScript

// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Session-first Workbench state orchestration for selection, turn admission, and trace lifecycle rendering.
import { computed, nextTick, ref } from "vue";
import { defineStore } from "pinia";
import { api } from "@/api";
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { assistantTextFromTraceEvents, mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, LiveSurface, ProviderProfile, TraceEvent, WorkbenchSessionRecord } from "@/types";
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
const TRACE_HYDRATION_PAGE_LIMIT = 100;
const TRACE_HYDRATION_MAX_PAGES = 60;
const TRACE_HYDRATION_MAX_ATTEMPTS = 3;
const TRACE_HYDRATION_RETRY_DELAY_MS = 700;
const ACTIVE_TURN_GAP_DELAY_MS = 1_000;
const ACTIVE_TURN_GAP_MAX_ATTEMPTS = 12;
const SESSION_LIST_PAGE_LIMIT = 20;
interface HydrateOptions {
sessionId?: string | null;
invalidRouteId?: string | null;
}
export const useWorkbenchStore = defineStore("workbench", () => {
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", DEFAULT_CODE_AGENT_TIMEOUT_MS));
const gatewayShellTimeoutMs = ref(readNumber("hwlab.workbench.gatewayShellTimeoutMs.v1", DEFAULT_GATEWAY_TIMEOUT_MS));
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 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 selectionEpoch = ref(0);
const serverState = ref(createWorkbenchServerState());
const sessions = computed(() => selectSessionList(serverState.value));
const sessionStatusAuthority = computed(() => selectSessionStatusAuthority(serverState.value));
const turnStatusAuthority = computed(() => selectTurnStatusAuthority(serverState.value));
const traceAuthorityById = computed(() => selectTraceAuthorityById(serverState.value));
const traceHydrationInFlight = new Set<string>();
const terminalRealtimeRefreshInFlight = new Set<string>();
const activeTurnGapAttempts = new Map<string, number>();
let realtimeStream: WorkbenchEventStream | null = null;
let realtimeKey = "";
let realtimeGapTimer: number | null = null;
let activeTurnGapTimer: number | null = null;
const activeSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value));
const selectedSessionId = computed(() => firstNonEmptyString(activeSession.value?.sessionId, explicitSessionId.value));
const activeSessionId = computed(() => selectedSessionId.value);
const activeMessages = computed(() => selectActiveMessages(serverState.value, activeSessionId.value));
const messages = activeMessages;
const selectedThreadId = computed(() => firstNonEmptyString(activeSession.value?.threadId));
const sessionTabs = computed(() => sortSessionTabs(sessions.value, activeSessionId.value, sessionStatusAuthority.value));
const 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, 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;
loading.value = true;
error.value = null;
const includeSessionId = routeSessionId ?? activeSessionId.value;
if (routeSessionId) {
sessionDetailLoadingId.value = routeSessionId;
setActiveSessionSelection(routeSessionId);
}
const sessionsResult = await api.workbench.sessions({ includeSessionId, limit: SESSION_LIST_PAGE_LIMIT });
if (!sessionsResult.ok) {
clearSessionDetailLoading(includeSessionId);
loading.value = false;
error.value = sessionsResult.error ?? "session list unavailable";
return;
}
applySessionPagination(sessionsResult.data);
const listedSessions = workbenchSessionsFromPayload(sessionsResult.data);
const targetSessionId = routeRequestId ?? includeSessionId ?? activeSessionId.value ?? listedSessions[0]?.sessionId ?? null;
if (targetSessionId && (routeSessionId || !routeRequestId)) {
setActiveSessionSelection(targetSessionId);
}
if (targetSessionId) sessionDetailLoadingId.value = 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);
if (selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)) applySelectedSessionDetail(selected);
if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId)) rememberSessionDetail(selected);
if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, "session archived");
if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", "session URL not found");
const nextSessions = stableSessionList(sessions.value, listedSessions, selected?.sessionId ?? routeSessionId ?? includeSessionId, selected);
rememberSessionList(nextSessions);
sessionsReady.value = true;
void refreshSessionStatusAuthority(nextSessions);
clearSessionDetailLoading(targetSessionId);
loading.value = false;
await refreshProviderOptions();
restartRealtime("hydrate");
}
async function refreshLive(): Promise<void> {
const [healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds] = await Promise.all([
liveCall("health/live", api.healthLive),
liveCall("health", api.health),
liveCall("REST index", api.restIndex),
liveCall("hwpod specs", api.hwpod.specs),
liveCall("hwpod node ops", api.hwpod.nodeOpsHealth),
liveCall("live builds", api.liveBuilds)
]);
live.value = { healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds, loadedAt: new Date().toISOString() };
}
async function createSession(): Promise<void> {
beginSessionSelection();
loading.value = true;
error.value = null;
const response = await api.agent.createAgentSession({ providerProfile: providerProfile.value });
loading.value = false;
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);
await selectSession(created);
}
async function selectSession(session: WorkbenchSessionRecord): Promise<void> {
await selectSessionById(session.sessionId, session);
}
async function selectSessionById(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise<boolean> {
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;
if (normalized) setActiveSessionSelection(normalized);
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);
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) {
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
return true;
}
if (selected && !isArchivedSession(selected)) {
applySelectedSessionDetail(selected);
await nextTick();
loading.value = false;
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
error.value = null;
await refreshSessions(selected.sessionId);
finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok");
return activeSessionId.value === selected.sessionId;
}
loading.value = false;
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
isolateSessionLoadFailure(requestId, isArchivedSession(selected) ? "session archived" : "session URL not found");
failWorkbenchSessionSwitch(normalized ?? requestId);
return false;
}
async function deleteCurrentSession(): Promise<void> {
const sessionId = activeSessionId.value;
if (!sessionId) return;
error.value = null;
const response = await api.agent.deleteAgentSession(sessionId);
if (!response.ok) {
error.value = response.error ?? "session delete failed";
return;
}
forgetSession(sessionId);
await refreshSessions(null);
const next = sessions.value.find((session) => !isArchivedSession(session)) ?? null;
replaceActiveSessionSelection(next?.sessionId ?? null);
currentRequest.value = null;
if (next) {
await selectSession(next);
return;
}
restartRealtime("delete-current-session");
}
async function refreshSessions(includeSessionId: string | null = activeSessionId.value): Promise<void> {
const response = await api.workbench.sessions({ includeSessionId, limit: currentSessionListLimit() });
if (response.ok) {
const listed = workbenchSessionsFromPayload(response.data);
const refreshed = stableSessionList(sessions.value, listed, includeSessionId, activeSession.value);
const next = sessions.value.length > refreshed.length ? appendSessionPage(sessions.value, refreshed) : refreshed;
rememberSessionList(next);
applySessionPagination(response.data);
sessionsReady.value = true;
await refreshSessionStatusAuthority(next);
return;
}
sessionsReady.value = sessions.value.length > 0;
if (sessions.value.length === 0) error.value = response.error ?? "session list unavailable";
}
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 api.workbench.sessions({ includeSessionId: activeSessionId.value, limit: SESSION_LIST_PAGE_LIMIT, cursor });
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;
await refreshSessionStatusAuthority(next);
}
function currentSessionListLimit(): number {
return Math.max(SESSION_LIST_PAGE_LIMIT, 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 {
serverState.value = reduceWorkbenchServerState(serverState.value, action);
}
function rememberSessionDetail(session: WorkbenchSessionRecord | null | undefined): void {
reduceServerState({ type: "session.detail", session });
}
function rememberSessionList(source: WorkbenchSessionRecord[]): void {
reduceServerState({ type: "session.list", sessions: source });
}
function forgetSession(sessionId: string): void {
reduceServerState({ type: "session.forget", sessionId });
}
function rememberSessionMessages(sessionId: string | null | undefined, source: ChatMessage[]): void {
reduceServerState({ type: "session.messages", sessionId, messages: source });
}
function replaceActiveMessages(source: ChatMessage[]): void {
rememberSessionMessages(activeSessionId.value, source);
}
function updateActiveMessages(project: (source: ChatMessage[]) => ChatMessage[]): void {
replaceActiveMessages(project(messages.value));
}
function appendActiveMessages(...items: ChatMessage[]): void {
replaceActiveMessages([...messages.value, ...items]);
}
function setActiveSessionSelection(sessionId: string | null | undefined): void {
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId) ?? explicitSessionId.value;
}
function replaceActiveSessionSelection(sessionId: string | null | undefined): void {
explicitSessionId.value = normalizeWorkbenchSessionId(sessionId);
}
async function refreshSessionStatusAuthority(source: WorkbenchSessionRecord[] = sessions.value): Promise<void> {
void source;
}
async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise<void> {
await Promise.all(uniqueTraceIds(source).slice(-12).map((traceId) => refreshTurnStatusByTraceId(traceId)));
}
async function refreshTurnStatusByTraceId(traceId: string | null | undefined): Promise<void> {
const id = firstNonEmptyString(traceId);
if (!id) return;
const response = await api.workbench.turn(id, 8000, () => activityRef.value);
if (response.ok && response.data) {
applyTurnStatusSnapshot(id, response.data);
if (response.data.terminal === true || isTerminalMessageStatus(response.data.status)) completeTrace(id, response.data);
return;
}
reduceServerState({ type: "turn.forget", traceId: id });
}
function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void {
if (!shouldApplyActiveTraceAuthority(traceId, traceResultSessionId(result))) return;
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 = normalizedStatusText(result.status) ?? null;
const running = (result as AgentChatResultResponse).running === true || isTraceActiveStatus(status);
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
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 {
const status = statusFromResult(result.status);
const terminal = (result as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(status);
const authoritySessionId = traceResultSessionId(result);
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result as AgentChatResultResponse);
rememberTraceAuthority(runnerTrace);
const error = normalizeAgentError((result as AgentChatResultResponse).error ?? runnerTrace?.error ?? message.error);
const errorText = agentErrorDisplayText(error);
const agentRun = agentRunFromResult(result as AgentChatResultResponse, runnerTrace) ?? agentRunFromMessage(message);
const replyText = agentReplyText((result as AgentChatResultResponse).reply);
const traceAssistantText = assistantTextFromTraceEvents(firstArray((result as AgentChatResultResponse).events, (result as AgentChatResultResponse).traceEvents, runnerTrace?.events));
const text = terminal
? terminalAgentMessageText({
status,
assistantText: (result as AgentChatResultResponse).assistantText,
finalText: finalResponseText((result as AgentChatResultResponse).finalResponse),
replyText,
errorText,
resultText: (result as AgentChatResultResponse).text,
summaryText: (result as AgentChatResultResponse).summary,
traceAssistantText
})
: message.text;
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
}
async function submitMessage(text: string): Promise<void> {
const value = text.trim();
if (!value) return;
beginSessionSelection();
if (!composer.value.sessionId) {
error.value = "session_required:请先显式新建或选择 Code Agent session。";
return;
}
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 sessionId = composer.value.sessionId;
const threadId = composer.value.threadId;
const providerThreadId = providerThreadIdForRequest(threadId);
const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" });
const pending = makeMessage("agent", "", "running", { traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" });
appendActiveMessages(user, pending);
startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" });
chatPending.value = true;
currentRequest.value = { traceId, sessionId, threadId, status: "running" };
projectOptimisticRunningTurn({ sessionId, threadId, traceId, userText: value });
rememberRecentDraft(value);
recordActivity("submit");
const payload = { message: value, prompt: value, sessionId, threadId: providerThreadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, targetTraceId: composer.value.targetTraceId, steerTraceId };
const route = steerMode ? api.agent.steerAgentMessage : api.agent.sendAgentMessage;
const response = await route(payload, codeAgentTimeoutMs.value, () => activityRef.value);
if (!response.ok || !response.data) {
markMessage(traceId, { status: "failed", text: response.error ?? "Code Agent 请求失败" });
failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error");
if (steerMode && response.status === 404) void clearActiveTrace(traceId, "steer-trace-not-found");
chatPending.value = false;
currentRequest.value = null;
return;
}
markWorkbenchSubmitApiAccepted(traceId);
const canonicalTraceId = alignOptimisticTurnMessages(traceId, response.data);
if (canonicalTraceId !== traceId) {
reduceServerState({ type: "turn.forget", traceId });
if (currentRequest.value?.traceId === traceId) {
currentRequest.value = {
...currentRequest.value,
traceId: canonicalTraceId,
sessionId: firstNonEmptyString(response.data.sessionId, currentRequest.value.sessionId),
threadId: firstNonEmptyString(response.data.threadId, currentRequest.value.threadId),
status: response.data.status ?? currentRequest.value.status
};
}
}
applyTurnStatusSnapshot(canonicalTraceId, response.data);
void refreshSessions(sessionId);
if ((response.data as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(response.data.status)) {
completeTrace(canonicalTraceId, response.data as AgentChatResultResponse);
return;
}
restartRealtime("submit");
scheduleRealtimeGapHydration("submit");
}
async function cancelAgentMessage(message: ChatMessage): Promise<void> {
const traceId = message.traceId ?? message.runnerTrace?.traceId;
if (!traceId) return;
const response = await api.agent.cancelAgentMessage({ traceId, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value });
applyTurnStatusSnapshot(traceId, { traceId, status: firstNonEmptyString((response.data as Record<string, unknown> | null)?.status, "canceled"), running: false, terminal: true, sessionId: message.sessionId ?? selectedSessionId.value ?? undefined, threadId: message.threadId ?? selectedThreadId.value ?? undefined } as AgentChatResultResponse);
markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" });
void clearActiveTrace(traceId, "cancel-agent-message");
if (message.status === "running") chatPending.value = false;
currentRequest.value = null;
void refreshSessions(message.sessionId ?? selectedSessionId.value);
}
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 hydrateTraceEventsForMessage(message: ChatMessage): Promise<void> {
const traceId = message.traceId ?? message.runnerTrace?.traceId;
if (!traceId) return;
if (traceHydrationInFlight.has(traceId)) return;
traceHydrationInFlight.add(traceId);
try {
let sinceSeq = traceHydrationSinceSeq(message.runnerTrace);
for (let page = 0; page < TRACE_HYDRATION_MAX_PAGES; page += 1) {
const result = await fetchTraceHydrationPage(traceId, sinceSeq);
if (!result.ok || !result.data) return;
applyTraceHydrationResult(traceId, result.data);
const nextSinceSeq = traceNextSinceSeq(result.data, sinceSeq);
if (result.data.hasMore !== true || nextSinceSeq <= sinceSeq) return;
sinceSeq = nextSinceSeq;
}
} finally {
traceHydrationInFlight.delete(traceId);
}
}
async function fetchTraceHydrationPage(traceId: string, sinceSeq: number): Promise<ApiResult<AgentChatResultResponse>> {
let lastResult: ApiResult<AgentChatResultResponse> | null = null;
for (let attempt = 0; attempt < TRACE_HYDRATION_MAX_ATTEMPTS; attempt += 1) {
const result = await api.workbench.traceEvents(traceId, codeAgentTimeoutMs.value, () => activityRef.value, { sinceSeq, limit: TRACE_HYDRATION_PAGE_LIMIT });
if (result.ok && result.data) return result;
lastResult = result;
if (attempt < TRACE_HYDRATION_MAX_ATTEMPTS - 1) await delayTraceHydrationRetry(TRACE_HYDRATION_RETRY_DELAY_MS * (attempt + 1));
}
return lastResult ?? { ok: false, status: 0, data: null, error: "trace_hydration_failed" };
}
function delayTraceHydrationRetry(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function hydrateTraceEvents(source: ChatMessage[] = messages.value): Promise<void> {
await Promise.all(source.filter(messageNeedsTraceHydration).slice(-12).map((message) => hydrateTraceEventsForMessage(message)));
}
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;
return { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, authority) };
});
}
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 applyTraceHydrationResult(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
markWorkbenchTraceEventsReceived({ traceId, events, transport: "rest_gap" });
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeRunnerTrace(message.runnerTrace, {
...(result.runnerTrace ?? {}),
traceId: result.traceId ?? traceId,
status: result.status ?? result.traceStatus ?? message.runnerTrace?.status,
sessionId: result.sessionId ?? message.runnerTrace?.sessionId,
threadId: 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,
nextSinceSeq: typeof result.nextSinceSeq === "number" ? result.nextSinceSeq : null,
range: result.range,
traceStatus: result.traceStatus,
retention: result.retention,
terminalEvidence: result.terminalEvidence,
finalResponse: result.finalResponse,
traceSummary: result.traceSummary,
agentRun: result.agentRun,
lastEventLabel: result.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type,
updatedAt: new Date().toISOString()
});
const explicitStatus = normalizedStatusText(result.status);
const status = explicitStatus ? statusFromResult(result.status) : message.status;
const terminal = result.terminal === true || isTerminalMessageStatus(status);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const errorText = agentErrorDisplayText(error);
const traceAssistantText = assistantTextFromTraceEvents(events);
const text = terminal ? firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), agentReplyText(result.reply), errorText, result.text, result.summary, traceAssistantText, message.text) ?? message.text : message.text;
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
rememberTraceAuthority(runnerTrace);
return { ...message, status, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
markWorkbenchTraceProjected(traceId);
}
function setProviderProfile(value: ProviderProfile): void {
providerProfile.value = value;
localStorage.setItem("hwlab.workbench.providerProfile.v1", value);
}
async function refreshProviderOptions(): Promise<void> {
const response = await api.providerProfiles.catalog();
providerOptions.value = response.ok ? providerProfileOptionsFromPayload(response.data, providerProfile.value) : defaultProviderProfileOptions(providerProfile.value);
}
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 = [];
try { localStorage.removeItem(RECENT_DRAFTS_STORAGE_KEY); } catch { /* ignore */ }
}
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, turnUrl: `/v1/workbench/turns/${encodeURIComponent(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");
scheduleRealtimeGapHydration("reattach");
}
async function validateAndReattachTrace(traceId: string): Promise<void> {
const result = await api.workbench.turn(traceId, 8000, () => activityRef.value);
if (!result.ok || !result.data) {
await clearActiveTrace(traceId, result.status === 404 ? "reattach-result-not-found" : "reattach-result-unavailable");
return;
}
applyTurnStatusSnapshot(traceId, result.data);
if (result.data.running === true || isTraceActiveStatus(result.data.status)) {
reattachTrace(traceId);
return;
}
await clearActiveTrace(traceId, "reattach-terminal-result");
}
function restartRealtime(reason: string): void {
const traceId = realtimeTraceId();
const sessionId = selectedSessionId.value ?? null;
const key = [sessionId ?? "", traceId ?? ""].join("|");
if (key === realtimeKey && realtimeStream) return;
stopRealtime();
realtimeKey = key;
if (!sessionId && !traceId) return;
realtimeStream = connectWorkbenchEvents({
sessionId,
traceId,
onOpen: () => scheduleRealtimeGapHydration(`${reason}:open`),
onError: () => scheduleRealtimeGapHydration(`${reason}:error`),
onEvent: (event, eventName) => applyRealtimeEvent(event, eventName)
});
if (!realtimeStream) scheduleRealtimeGapHydration(`${reason}:eventsource-unavailable`);
}
function stopRealtime(): void {
realtimeStream?.close();
realtimeStream = null;
realtimeKey = "";
}
function realtimeTraceId(): string | null {
return firstNonEmptyString(currentRequest.value?.traceId, activeTraceIdFromMessages(messages.value, turnStatusAuthority.value));
}
function realtimeEventSessionId(event: WorkbenchRealtimeEvent): string | null {
const turn = recordValue(event.turn);
const snapshot = recordValue(event.snapshot);
const traceEvent = recordValue(event.event);
return normalizeWorkbenchSessionId(firstNonEmptyString(event.sessionId, turn?.sessionId, snapshot?.sessionId, traceEvent?.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 normalizeWorkbenchSessionId(firstNonEmptyString(value?.sessionId, runnerTrace?.sessionId, session?.sessionId));
}
function messageSessionAuthority(message: ChatMessage | null | undefined): string | null {
return normalizeWorkbenchSessionId(firstNonEmptyString(message?.sessionId, message?.runnerTrace?.sessionId));
}
function shouldApplyActiveTraceAuthority(traceId: string | null | undefined, authoritySessionId: string | null | undefined): boolean {
const id = firstNonEmptyString(traceId);
const activeId = activeSessionId.value;
const sessionId = normalizeWorkbenchSessionId(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 = normalizeWorkbenchSessionId(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 = normalizeWorkbenchSessionId(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 {
recordActivity(event.type ? `realtime:${event.type}` : `realtime:${eventName}`);
if (event.type === "trace.snapshot") {
applyRealtimeTraceSnapshot(event.traceId, event.snapshot);
return;
}
if (event.type === "trace.event") {
applyRealtimeTraceEvent(event.traceId, event.event, event.snapshot, event);
return;
}
if (event.type === "turn.snapshot" && event.turn) {
applyRealtimeTurnSnapshot(event.turn);
return;
}
if (event.type === "trace.unavailable") {
const traceId = firstNonEmptyString(event.traceId);
if (traceId) void clearActiveTrace(traceId, "realtime-trace-unavailable");
}
}
function applyRealtimeTraceSnapshot(traceId: string | null | undefined, snapshot: WorkbenchRealtimeEvent["snapshot"]): void {
const id = firstNonEmptyString(traceId, snapshot?.traceId);
if (!id || !snapshot) return;
if (!shouldApplyActiveTraceAuthority(id, traceResultSessionId(snapshot))) return;
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot));
if (traceSnapshotHasTerminalEvidence(snapshot)) void refreshTerminalTraceFromRest(id, "realtime-trace-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;
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 });
applyTraceSnapshot(id, realtimeSnapshotToTraceSnapshot(id, snapshot ?? { traceId: id, status: event?.status, events }, events));
if (traceEventHasTerminalEvidence(event) || traceSnapshotHasTerminalEvidence(snapshot)) void refreshTerminalTraceFromRest(id, "realtime-trace-event");
}
function applyRealtimeTurnSnapshot(turn: Record<string, unknown>): void {
const traceId = firstNonEmptyString(turn.traceId);
if (!traceId) return;
if (!shouldApplyActiveTraceAuthority(traceId, traceResultSessionId(turn))) return;
const status = firstNonEmptyString(turn.status) ?? undefined;
applyTurnStatusSnapshot(traceId, { 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);
if (turn.terminal === true || isTerminalMessageStatus(status)) void refreshTerminalTraceFromRest(traceId, "realtime-turn-snapshot");
}
async function refreshTerminalTraceFromRest(traceId: string, reason: string): Promise<void> {
if (terminalRealtimeRefreshInFlight.has(traceId)) return;
terminalRealtimeRefreshInFlight.add(traceId);
try {
const result = await api.workbench.turn(traceId, 8000, () => activityRef.value);
if (!result.ok || !result.data) return;
applyTurnStatusSnapshot(traceId, result.data);
if (result.data.terminal === true || isTerminalMessageStatus(result.data.status)) completeTrace(traceId, result.data);
} finally {
terminalRealtimeRefreshInFlight.delete(traceId);
restartRealtime(reason);
}
}
function scheduleRealtimeGapHydration(reason: string): void {
if (typeof window === "undefined") {
void hydrateRealtimeGap(reason);
return;
}
if (realtimeGapTimer) window.clearTimeout(realtimeGapTimer);
realtimeGapTimer = window.setTimeout(() => {
realtimeGapTimer = null;
void hydrateRealtimeGap(reason);
}, 200);
}
async function hydrateRealtimeGap(reason: string): Promise<void> {
recordActivity(`realtime-gap:${reason}`);
await Promise.all([
refreshSessions(),
hydrateTurnStatusAuthority(messages.value),
hydrateTraceEvents(messages.value)
]);
restartRealtime(`gap:${reason}`);
scheduleActiveTurnGapRefresh(reason);
}
function scheduleActiveTurnGapRefresh(reason: string): void {
const traceId = activeTurnGapTraceId();
if (!traceId) {
clearActiveTurnGapRefresh();
return;
}
if (activeTurnGapTimer) return;
const attempts = activeTurnGapAttempts.get(traceId) ?? 0;
if (attempts >= ACTIVE_TURN_GAP_MAX_ATTEMPTS) return;
activeTurnGapAttempts.set(traceId, attempts + 1);
if (typeof window === "undefined") return;
activeTurnGapTimer = window.setTimeout(() => {
activeTurnGapTimer = null;
void hydrateRealtimeGap(`active-turn-gap:${reason}`);
}, ACTIVE_TURN_GAP_DELAY_MS);
}
function activeTurnGapTraceId(): string | null {
const traceId = realtimeTraceId();
if (!traceId) return null;
const turn = turnStatusAuthority.value[traceId];
const message = [...messages.value].reverse().find((item) => item.role === "agent" && firstNonEmptyString(item.traceId, item.runnerTrace?.traceId) === traceId) ?? null;
const active = currentRequest.value?.traceId === traceId
|| turn?.running === true
|| isTraceActiveStatus(turn?.status)
|| isTraceActiveStatus(message?.status)
|| isTraceActiveStatus(message?.runnerTrace?.status);
return active ? traceId : null;
}
function clearActiveTurnGapRefresh(traceId?: string | null): void {
if (traceId) activeTurnGapAttempts.delete(traceId);
else activeTurnGapAttempts.clear();
if (typeof window !== "undefined" && activeTurnGapTimer) window.clearTimeout(activeTurnGapTimer);
activeTurnGapTimer = null;
}
function installRealtimeVisibilityHandler(): void {
if (typeof document === "undefined") return;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
restartRealtime("visibility");
scheduleRealtimeGapHydration("visibility");
});
}
function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void {
const turn = turnStatusAuthority.value[traceId];
const trace = snapshotToRunnerTrace({
...snapshot,
status: firstNonEmptyString(turn?.status, snapshot.status) ?? snapshot.status,
traceStatus: firstNonEmptyString(snapshot.traceStatus, snapshot.status) ?? undefined
});
const authoritySessionId = traceResultSessionId(trace) ?? traceResultSessionId(snapshot);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeRunnerTrace(message.runnerTrace, trace);
rememberTraceAuthority(runnerTrace);
const status = statusFromResult(firstNonEmptyString(turn?.status, message.status) ?? message.status);
const terminal = turn?.terminal === true || isTerminalMessageStatus(turn?.status);
const traceAssistantText = message.role === "agent" ? assistantTextFromTraceEvents(Array.isArray(runnerTrace.events) ? runnerTrace.events : []) : null;
const error = message.role === "agent" ? normalizeAgentError(runnerTrace.error ?? message.error) : normalizeAgentError(message.error);
const errorText = message.role === "agent" ? agentErrorDisplayText(error) : null;
const nextText = message.role === "agent" && terminal
? firstNonEmptyString(finalResponseText(runnerTrace.finalResponse), errorText, traceAssistantText, message.text) ?? message.text
: message.text;
return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : "running", runnerTrace, error: error ?? message.error ?? null, updatedAt: new Date().toISOString() };
}));
markWorkbenchTraceProjected(traceId);
void refreshSessions(trace.sessionId ?? selectedSessionId.value);
}
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
const resultTrace = recordValue(result.runnerTrace);
const traceAssistantText = assistantTextFromTraceEvents(firstArray(result.events, result.traceEvents, resultTrace?.events));
const terminalStatus = result.status === "completed" ? "completed" : statusFromResult(result.status);
const text = terminalAgentMessageText({
status: terminalStatus,
assistantText: result.assistantText,
finalText: finalResponseText(result.finalResponse),
replyText: typeof result.reply === "string" ? result.reply : result.reply?.content,
errorText: agentErrorDisplayText(result.error),
resultText: result.text,
summaryText: result.summary,
traceAssistantText
});
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
rememberTraceAuthority(runnerTrace);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
return { ...message, status: terminalStatus, text, traceAutoLifecycle: "terminal", runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
rememberTurnStatus(traceId, result);
markWorkbenchTraceProjected(traceId);
clearActiveTurnGapRefresh(traceId);
void hydrateTraceEvents(messages.value);
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-terminal");
void refreshSessions(result.sessionId ?? activeSessionId.value);
restartRealtime("trace-terminal");
}
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
const targets = messages.value.filter(messageNeedsTerminalDiagnostics).slice(-6);
await Promise.all(targets.map(async (message) => {
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return;
const result = await api.workbench.turn(traceId, 8000, () => activityRef.value);
if (!result.ok || !result.data) return;
applyTurnStatusSnapshot(traceId, result.data);
}));
void hydrateTraceEvents(messages.value);
}
function applyTerminalResultDiagnostics(traceId: string, result: AgentChatResultResponse): void {
const authoritySessionId = traceResultSessionId(result);
if (!shouldApplyActiveTraceAuthority(traceId, authoritySessionId)) return;
updateActiveMessages((source) => source.map((message) => {
if (!shouldApplyTraceToMessage(message, traceId, authoritySessionId)) return message;
const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result);
rememberTraceAuthority(runnerTrace);
const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error);
const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message);
const status = statusFromResult(result.status);
return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
}));
void hydrateTraceEvents(messages.value);
void refreshSessions(result.sessionId ?? activeSessionId.value);
}
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");
void refreshSessions(selectedSessionId.value);
}
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 } });
reduceServerState({ type: "session.status", session: { sessionId: input.sessionId, status: "running", updatedAt: now, lastTraceId: input.traceId, loadedAt: now } });
const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null;
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);
if (!turnId && !userMessageId && !assistantMessageId && 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" && assistantMessageId) return { ...base, id: assistantMessageId, messageId: assistantMessageId, turnId };
return turnId ? { ...base, turnId } : base;
}));
return canonicalTraceId;
}
async function clearActiveTrace(traceId: string, reason: string): Promise<void> {
void reason;
if (currentRequest.value?.traceId === traceId) currentRequest.value = null;
clearActiveTurnGapRefresh(traceId);
}
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): void {
setActiveSessionSelection(session.sessionId);
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 hydrateTraceEvents(messages.value);
reattachRestoredActiveTrace();
restartRealtime("apply-selected-session");
}
function isolateSessionLoadFailure(sessionId: string, message: string): void {
forgetSession(sessionId);
replaceActiveSessionSelection(null);
currentRequest.value = null;
error.value = message;
sessionsReady.value = sessions.value.length > 0;
restartRealtime("session-load-failed");
}
function reattachRestoredActiveTrace(): void {
void hydrateTraceEvents(messages.value);
const traceId = activeTraceIdFromMessages(messages.value, turnStatusAuthority.value);
if (traceId) void validateAndReattachTrace(traceId);
}
installRealtimeVisibilityHandler();
return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, activeSessionId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, 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): 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: Array.isArray(record?.messages) ? record.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : undefined
};
}
async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise<WorkbenchSessionRecord | null> {
const requestId = normalizeWorkbenchSessionRouteId(sessionId);
if (!requestId) return null;
const detail = await api.workbench.session(requestId);
if (!detail.ok) return null;
const detailSession = sessionFromWorkbenchSession(detail.data?.session);
const id = detailSession?.sessionId ?? normalizeWorkbenchSessionId(requestId) ?? seed?.sessionId;
if (!id) return null;
const messages = await api.workbench.sessionMessages(id, { limit: 100 });
const base = detailSession ?? seed;
if (!base) return null;
const page = messages.ok ? messages.data : null;
const pageMessages = Array.isArray(page?.messages) ? page.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : base.messages;
return { ...base, sessionId: id, messages: pageMessages, messageCount: page?.total ?? pageMessages?.length ?? base.messageCount };
}
function 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 agentRun = agentRunFromMessage(message) ?? asAgentRun(runnerTrace?.agentRun);
const status = normalizeChatMessageStatus(message.status);
const baseText = firstNonEmptyString(message.text, messageText((message as Record<string, unknown>).content), messageText((message as Record<string, unknown>).message));
const finalText = firstNonEmptyString(finalResponseText((message as Record<string, unknown>).finalResponse), finalResponseText(runnerTrace?.finalResponse));
const traceAssistantText = assistantTextFromTraceEvents(Array.isArray(runnerTrace?.events) ? runnerTrace.events : []);
const errorText = agentErrorDisplayText(error);
const text = role === "agent"
? isTerminalMessageStatus(status)
? terminalAgentMessageText({ status, finalText, errorText, traceAssistantText, baseText })
: ""
: firstNonEmptyString(baseText, finalText, traceAssistantText, errorText) ?? "";
const messageId = firstNonEmptyString((message as Record<string, unknown>).messageId, message.id) ?? nextProtocolId("msg");
return { ...message, role, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined };
}
function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record<string, TurnStatusAuthority>): string | null {
for (const message of [...messages].reverse()) {
if (message.role !== "agent") 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;
}
function realtimeSnapshotToTraceSnapshot(traceId: string, snapshot: WorkbenchRealtimeEvent["snapshot"], eventPageEvents?: TraceEvent[]): TraceSnapshot {
const source = snapshot ?? { traceId };
const events = Array.isArray(eventPageEvents) ? eventPageEvents : Array.isArray(source.events) ? source.events : [];
return {
...source,
traceId: firstNonEmptyString(source.traceId, traceId) ?? traceId,
status: firstNonEmptyString(source.status) ?? undefined,
events,
eventCount: firstFiniteNumber(source.eventCount, events.length),
fullTraceLoaded: source.fullTraceLoaded === true,
hasMore: source.hasMore === true,
truncated: source.truncated === true,
nextSinceSeq: firstFiniteNumber(source.nextSinceSeq) ?? null,
range: recordValue(source.range) as TraceSnapshot["range"],
agentRun: asAgentRun(source.agentRun) ?? undefined,
traceStatus: firstNonEmptyString(source.traceStatus) ?? undefined,
retention: source.retention,
terminalEvidence: source.terminalEvidence,
finalResponse: source.finalResponse,
traceSummary: source.traceSummary,
error: normalizeAgentError(source.error) ?? undefined,
lastEventLabel: firstNonEmptyString(source.lastEventLabel, source.lastEvent?.label, source.lastEvent?.type, events.at(-1)?.label, events.at(-1)?.type) ?? undefined,
eventSource: "trace-api",
updatedAt: firstNonEmptyString(source.updatedAt) ?? new Date().toISOString()
};
}
function normalizedStatusText(value: unknown): string | null {
const text = firstNonEmptyString(value);
return text ? text.trim().toLowerCase().replace(/_/gu, "-") : null;
}
function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: AgentChatResultResponse): NonNullable<ChatMessage["runnerTrace"]> {
const resultTrace = recordValue(result.runnerTrace);
const events = firstArray(result.events, result.traceEvents, resultTrace?.events, previous?.events);
const agentRun = asAgentRun(result.agentRun ?? resultTrace?.agentRun ?? previous?.agentRun);
return mergeRunnerTrace(previous, {
...resultTrace,
traceId: firstNonEmptyString(result.traceId, resultTrace?.traceId, previous?.traceId) ?? undefined,
status: firstNonEmptyString(result.traceStatus, resultTrace?.status, result.status, previous?.status) ?? undefined,
sessionId: firstNonEmptyString(result.sessionId, resultTrace?.sessionId, previous?.sessionId) ?? undefined,
threadId: firstNonEmptyString(result.threadId, resultTrace?.threadId, previous?.threadId) ?? undefined,
events,
eventCount: firstFiniteNumber(result.eventCount, resultTrace?.eventCount, previous?.eventCount, events.length),
eventsCompacted: firstBoolean(resultTrace?.eventsCompacted, previous?.eventsCompacted),
traceStatus: firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus, previous?.traceStatus) ?? undefined,
retention: result.retention ?? resultTrace?.retention ?? previous?.retention,
terminalEvidence: result.terminalEvidence ?? resultTrace?.terminalEvidence ?? previous?.terminalEvidence,
finalResponse: result.finalResponse ?? resultTrace?.finalResponse ?? previous?.finalResponse,
traceSummary: result.traceSummary ?? resultTrace?.traceSummary ?? previous?.traceSummary,
agentRun: agentRun ?? undefined,
error: normalizeAgentError(result.error ?? resultTrace?.error ?? previous?.error) ?? undefined,
runnerKind: firstNonEmptyString(agentRun?.adapter, resultTrace?.runnerKind, previous?.runnerKind) ?? undefined,
sessionMode: firstNonEmptyString(agentRun?.backendProfile, resultTrace?.sessionMode, previous?.sessionMode) ?? undefined,
lastEventLabel: firstNonEmptyString(result.lastEventLabel, resultTrace?.lastEventLabel, previous?.lastEventLabel, events.at(-1)?.label, events.at(-1)?.type) ?? undefined,
updatedAt: new Date().toISOString()
} as NonNullable<ChatMessage["runnerTrace"]>);
}
function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerTrace"] {
const trace = recordValue(message.runnerTrace);
const agentRun = agentRunFromMessage(message);
const error = normalizeAgentError(message.error ?? trace?.error);
if (!trace && !agentRun && !error) return message.runnerTrace ?? null;
return {
...(trace ?? {}),
traceId: message.traceId ?? trace?.traceId,
sessionId: message.sessionId ?? trace?.sessionId,
threadId: message.threadId ?? trace?.threadId,
agentRun: agentRun ?? trace?.agentRun,
error: error ?? trace?.error,
runnerKind: agentRun?.adapter ?? trace?.runnerKind,
sessionMode: agentRun?.backendProfile ?? trace?.sessionMode
} as ChatMessage["runnerTrace"];
}
function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
if (!isTerminalMessageStatus(message.status)) return false;
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return false;
const agentRun = agentRunFromMessage(message);
const error = normalizeAgentError(message.error ?? message.runnerTrace?.error);
return !agentRun || (message.status !== "completed" && !error);
}
function messageNeedsTraceHydration(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId);
if (!traceId) return false;
const trace = message.runnerTrace;
const events = Array.isArray(trace?.events) ? trace.events : [];
const eventCount = firstFiniteNumber(trace?.eventCount) ?? events.length;
if (trace?.fullTraceLoaded === true && trace.eventsCompacted !== true) return events.length === 0 && (eventCount > 0 || isTraceActiveStatus(trace?.status) || isTraceActiveStatus(message.status));
return events.length === 0 || trace?.eventsCompacted === true || trace?.fullTraceLoaded !== true;
}
function traceHasEvents(trace: ChatMessage["runnerTrace"]): boolean {
return Array.isArray(trace?.events) && trace.events.length > 0;
}
function agentRunFromResult(result: AgentChatResultResponse, runnerTrace: ChatMessage["runnerTrace"]): AgentRunProvenance | null {
return asAgentRun(result.agentRun ?? runnerTrace?.agentRun);
}
function agentRunFromMessage(message: ChatMessage): AgentRunProvenance | null {
return asAgentRun((message as Record<string, unknown>).agentRun ?? message.runnerTrace?.agentRun);
}
function asAgentRun(value: unknown): AgentRunProvenance | null {
return value && typeof value === "object" ? value as AgentRunProvenance : null;
}
function normalizeAgentError(value: unknown): ChatMessage["error"] | null {
if (!value) return null;
if (typeof value === "string") return value.trim() ? { message: value.trim() } : null;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
const message = firstNonEmptyString(record.message, record.userMessage, record.reason, record.detail, record.error);
const code = firstNonEmptyString(record.code, record.failureKind, record.name);
const category = firstNonEmptyString(record.category, record.layer, record.type);
const providerStatus = typeof record.providerStatus === "number" ? record.providerStatus : Number.isFinite(Number(record.providerStatus)) ? Number(record.providerStatus) : undefined;
return { ...record, code: code ?? undefined, message: message ?? undefined, category: category ?? undefined, providerStatus };
}
return { message: String(value) };
}
function agentErrorDisplayText(value: unknown): string | null {
const error = normalizeAgentError(value);
if (!error) return null;
return firstNonEmptyString(error.message, typeof error.userMessage === "string" ? error.userMessage : null, error.code ? `Code Agent 请求失败:${error.code}` : null);
}
function isTerminalMessageStatus(value: unknown): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function traceEventHasTerminalEvidence(event: unknown): boolean {
const record = recordValue(event);
return record?.terminal === true || record?.final === true || record?.replyAuthority === true;
}
function traceSnapshotHasTerminalEvidence(snapshot: unknown): boolean {
const record = recordValue(snapshot);
if (!record) return false;
if (record.terminal === true || record.terminalEvidence || record.finalResponse) return true;
const events = Array.isArray(record.events) ? record.events : [];
return events.some((event) => traceEventHasTerminalEvidence(event));
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? value as Record<string, unknown> : null;
}
function firstArray(...values: unknown[]): TraceEvent[] {
for (const value of values) {
if (Array.isArray(value)) return value as TraceEvent[];
}
return [];
}
function firstFiniteNumber(...values: unknown[]): number | undefined {
for (const value of values) {
const number = typeof value === "number" ? value : Number(value);
if (Number.isFinite(number)) return number;
}
return undefined;
}
function firstBoolean(...values: unknown[]): boolean | undefined {
for (const value of values) {
if (typeof value === "boolean") return value;
}
return undefined;
}
function finalResponseText(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message));
}
function agentReplyText(value: AgentChatResultResponse["reply"]): string | null {
if (typeof value === "string") return value;
return value && typeof value === "object" ? firstNonEmptyString(value.content) : null;
}
function terminalAgentMessageText(input: {
status: unknown;
assistantText?: unknown;
finalText?: string | null;
replyText?: string | null;
errorText?: string | null;
resultText?: unknown;
summaryText?: unknown;
traceAssistantText?: string | null;
baseText?: string | null;
}): string {
const finalText = firstNonEmptyString(
input.assistantText,
input.finalText,
input.replyText,
input.resultText,
input.summaryText,
input.traceAssistantText,
input.baseText
);
if (!isFailureTerminalStatus(input.status)) return finalText ?? "";
return firstNonEmptyString(input.errorText, finalText) ?? "";
}
function isFailureTerminalStatus(value: unknown): boolean {
return ["failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function messageText(value: unknown): string | null {
if (typeof value === "string") return value.trim() || null;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.map(messageText).filter((item): item is string => Boolean(item)).join("\n") || null;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message), messageText(record.summary), messageText(record.preview), messageText(record.title));
}
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 traceHydrationSinceSeq(trace: ChatMessage["runnerTrace"]): number {
const rangeNext = Number(trace?.nextSinceSeq ?? trace?.range?.toSeq);
if (Number.isFinite(rangeNext) && rangeNext > 0 && trace?.hasMore === true) return Math.trunc(rangeNext);
const events = Array.isArray(trace?.events) ? trace.events : [];
return events.reduce((max, event) => {
const seq = Number(event.seq);
return Number.isFinite(seq) && seq > max ? Math.trunc(seq) : max;
}, 0);
}
function traceNextSinceSeq(result: AgentChatResultResponse, fallback: number): number {
const direct = Number(result.nextSinceSeq ?? result.range?.toSeq);
if (Number.isFinite(direct) && direct >= 0) return Math.trunc(direct);
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
return events.reduce((max, event) => {
const seq = Number(event.seq);
return Number.isFinite(seq) && seq > max ? Math.trunc(seq) : max;
}, fallback);
}
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 statusFromResult(status: string | undefined): ChatMessage["status"] {
if (isTraceActiveStatus(status)) return "running";
if (status === "blocked") return "blocked";
if (status === "timeout") return "timeout";
if (status === "canceled" || status === "cancelled") return "canceled";
if (status === "completed") return "completed";
if (!status || status === "unknown") return "pending";
return "failed";
}
function isTraceActiveStatus(status: unknown): boolean {
return ["accepted", "pending", "processing", "running", "busy", "creating"].includes(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function readString(key: string, fallback: string): string {
try {
return localStorage.getItem(key) ?? fallback;
} catch {
return fallback;
}
}
function readRecentDrafts(): DraftEntry[] {
try {
const raw = localStorage.getItem(RECENT_DRAFTS_STORAGE_KEY);
return raw ? normalizeRecentDrafts(JSON.parse(raw)) : [];
} catch {
return [];
}
}
function writeRecentDrafts(drafts: DraftEntry[]): void {
try { localStorage.setItem(RECENT_DRAFTS_STORAGE_KEY, JSON.stringify(drafts)); } catch { /* ignore */ }
}
function readNumber(key: string, fallback: number): number {
try {
const value = Number(localStorage.getItem(key));
return Number.isFinite(value) && value > 0 ? value : fallback;
} catch {
return fallback;
}
}