|
|
|
@@ -11,6 +11,7 @@ import { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagno
|
|
|
|
|
import { readWorkbenchJson, readWorkbenchNumber, readWorkbenchString, removeWorkbenchStorageKey, writeWorkbenchJson, writeWorkbenchString } from "@/utils/workbench-storage-runtime";
|
|
|
|
|
import { createWorkbenchStreamTransportRuntime, type WorkbenchRealtimeEvent, type WorkbenchStreamTransportRecovery } from "@/utils/workbench-realtime-runtime";
|
|
|
|
|
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
|
|
|
|
|
import type { WorkbenchMessagePageResponse } 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 } from "@/utils/workbench-key";
|
|
|
|
@@ -84,6 +85,12 @@ interface SelectSessionOptions {
|
|
|
|
|
source?: SessionSelectionSource;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SessionMessagesReadOptions {
|
|
|
|
|
limit: number;
|
|
|
|
|
reason: string;
|
|
|
|
|
force?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const runtimePolicy = workbenchRuntimePolicy();
|
|
|
|
|
const workbenchColadaReducer = useWorkbenchColadaReducer();
|
|
|
|
@@ -91,6 +98,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
const workbenchColadaMutations = useWorkbenchColadaMutations();
|
|
|
|
|
const turnStatusReadSingleflight = createKeyedSingleflight<ApiResult<AgentChatResultResponse>>();
|
|
|
|
|
const traceHydrationSingleflight = createKeyedSingleflight<void>();
|
|
|
|
|
const sessionMessagesReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchMessagePageResponse>>();
|
|
|
|
|
const providerProfile = ref<ProviderProfile>(readString("hwlab.workbench.providerProfile.v1", "codex"));
|
|
|
|
|
const providerOptions = ref<ProviderProfileOption[]>(defaultProviderProfileOptions(providerProfile.value));
|
|
|
|
|
const recentDrafts = ref<DraftEntry[]>(readRecentDrafts());
|
|
|
|
@@ -504,12 +512,12 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
async function refreshSessionMessageProjectionPage(sessionId: string | null | undefined, options: { force?: boolean } = {}): Promise<void> {
|
|
|
|
|
const id = normalizeWorkbenchSessionId(sessionId);
|
|
|
|
|
if (!id) return;
|
|
|
|
|
const response = await workbenchColadaQueries.fetchSessionMessages(id, { limit: sessionMessageProjectionWindowLimit(), minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs, force: options.force });
|
|
|
|
|
const response = await fetchSessionMessagesPage(id, { limit: sessionMessageProjectionWindowLimit(), reason: "session-message-page", force: options.force });
|
|
|
|
|
if (!response.ok || !response.data) return;
|
|
|
|
|
const pageMessages = Array.isArray(response.data.messages) ? response.data.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : [];
|
|
|
|
|
const merged = mergeMessageProjectionPage(id, pageMessages, { limit: sessionMessageProjectionWindowLimit() });
|
|
|
|
|
rememberSessionMessages(id, merged);
|
|
|
|
|
await hydrateTurnStatusAuthority(merged);
|
|
|
|
|
if (!commitMergedSessionMessages(id, pageMessages, merged, { limit: sessionMessageProjectionWindowLimit(), reason: "session-message-page" })) return;
|
|
|
|
|
await hydrateTurnStatusAuthority(merged, { limit: 1, reason: "session-message-page" });
|
|
|
|
|
hydrateTerminalTraceGaps(merged, "session-message-page");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -525,7 +533,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
if (!id) return;
|
|
|
|
|
const existing = serverState.value.messagesBySessionId[id] ?? [];
|
|
|
|
|
if (traceProjectionIsTerminalSealed(traceId, existing)) return;
|
|
|
|
|
const response = await workbenchColadaQueries.fetchSessionMessages(id, { limit: traceMessageProjectionWindowLimit(), minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs, force: options.force });
|
|
|
|
|
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;
|
|
|
|
@@ -534,11 +542,16 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
}
|
|
|
|
|
const pageMessages = Array.isArray(response.data.messages) ? response.data.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : [];
|
|
|
|
|
const merged = mergeMessageProjectionPage(id, pageMessages, { traceId, limit: traceMessageProjectionWindowLimit() });
|
|
|
|
|
rememberSessionMessages(id, merged);
|
|
|
|
|
await hydrateTurnStatusAuthority(merged);
|
|
|
|
|
if (!commitMergedSessionMessages(id, pageMessages, merged, { traceId, limit: traceMessageProjectionWindowLimit(), reason: "trace-message-page" })) return;
|
|
|
|
|
await hydrateTurnStatusAuthority(merged, { traceId, limit: 1, reason: "trace-message-page" });
|
|
|
|
|
if (!traceProjectionIsTerminalSealed(traceId, merged)) hydrateTerminalTraceGaps(merged, `trace-message-page:${traceId}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fetchSessionMessagesPage(sessionId: string, options: SessionMessagesReadOptions): Promise<ApiResult<WorkbenchMessagePageResponse>> {
|
|
|
|
|
const key = composeWorkbenchScopedKey("workbench.session-messages.read", sessionId);
|
|
|
|
|
return sessionMessagesReadSingleflight.run(key, () => workbenchColadaQueries.fetchSessionMessages(sessionId, { limit: options.limit, minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs, force: options.force }), { reason: options.reason });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sessionMessageProjectionWindowLimit(): number {
|
|
|
|
|
return boundedProjectionMessageLimit(runtimePolicy.workbenchSessionMessagesWindowLimit, runtimePolicy.sessionListPageLimit);
|
|
|
|
|
}
|
|
|
|
@@ -554,6 +567,40 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
return mergeBoundedProjectionMessages(existing, incoming);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commitMergedSessionMessages(sessionId: string, incoming: ChatMessage[], merged: ChatMessage[], options: { traceId?: string | null; limit: number; reason: string }): boolean {
|
|
|
|
|
const existing = serverState.value.messagesBySessionId[sessionId] ?? [];
|
|
|
|
|
const changedCount = projectionChangedMessageCount(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,
|
|
|
|
|
limit: options.limit,
|
|
|
|
|
valuesRedacted: true
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (changedCount === 0) return false;
|
|
|
|
|
rememberSessionMessages(sessionId, merged);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function projectionChangedMessageCount(existing: ChatMessage[], merged: ChatMessage[]): number {
|
|
|
|
|
let changed = Math.max(0, merged.length - existing.length);
|
|
|
|
|
const sharedLength = Math.min(existing.length, merged.length);
|
|
|
|
|
for (let index = 0; index < sharedLength; index += 1) {
|
|
|
|
|
if (existing[index] !== merged[index]) changed += 1;
|
|
|
|
|
}
|
|
|
|
|
return changed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
@@ -576,12 +623,29 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
return existing.find((item) => item.role === message.role && firstNonEmptyString(item.traceId, item.runnerTrace?.traceId) === traceId) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise<void> {
|
|
|
|
|
await Promise.all(activeTurnStatusRefreshTraceIds(source).map((traceId) => refreshTurnStatusByTraceId(traceId)));
|
|
|
|
|
async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value, options: { traceId?: string | null; limit?: number; reason?: string } = {}): Promise<void> {
|
|
|
|
|
for (const traceId of activeTurnStatusRefreshTraceIds(source, options)) {
|
|
|
|
|
await refreshTurnStatusByTraceId(traceId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function activeTurnStatusRefreshTraceIds(source: ChatMessage[] = messages.value): string[] {
|
|
|
|
|
return selectActiveTurnStatusRefreshTraceIds({ messages: source, currentRequestTraceId: currentRequest.value?.traceId, turnStatusAuthority: turnStatusAuthority.value, limit: 3 });
|
|
|
|
|
function activeTurnStatusRefreshTraceIds(source: ChatMessage[] = messages.value, options: { traceId?: string | null; limit?: number; reason?: string } = {}): 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> {
|
|
|
|
@@ -1703,13 +1767,13 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
|
|
|
|
if (!requestId) return null;
|
|
|
|
|
const normalizedRequestId = normalizeWorkbenchSessionId(requestId);
|
|
|
|
|
const messageLimit = sessionMessageProjectionWindowLimit();
|
|
|
|
|
const eagerMessages = normalizedRequestId ? workbenchColadaQueries.fetchSessionMessages(normalizedRequestId, { limit: messageLimit, force: true, minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs }) : null;
|
|
|
|
|
const eagerMessages = normalizedRequestId ? fetchSessionMessagesPage(normalizedRequestId, { limit: messageLimit, reason: "load-session:eager", force: true }) : null;
|
|
|
|
|
const detail = await workbenchColadaQueries.fetchSession(requestId, { includeMessages: false, force: true, minIntervalMs: runtimePolicy.workbenchSessionDetailMinRefreshMs });
|
|
|
|
|
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 messages = eagerMessages && id === normalizedRequestId ? await eagerMessages : await workbenchColadaQueries.fetchSessionMessages(id, { limit: messageLimit, force: true, minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs });
|
|
|
|
|
const messages = eagerMessages && id === normalizedRequestId ? await eagerMessages : await fetchSessionMessagesPage(id, { limit: messageLimit, reason: "load-session", force: true });
|
|
|
|
|
const fallbackMessages = seed?.sessionId === id ? seed.messages ?? [] : [];
|
|
|
|
|
const base = detailSession ? { ...detailSession, messages: fallbackMessages } : seed;
|
|
|
|
|
if (!base) return null;
|
|
|
|
|