feat(web): surface Workbench diagnostics

This commit is contained in:
lyon
2026-06-20 16:03:05 +08:00
parent cf219821f0
commit d947b9e4e8
5 changed files with 315 additions and 26 deletions
@@ -158,8 +158,8 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (traceMatch && method === "GET") {
const traceId = decodeURIComponent(traceMatch[1] ?? "");
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } });
if (state.scenarioId === "trace-hydration-timeout-diagnostic" && traceId === "trc_trace_hydration_timeout") return json(response, 504, { ok: false, status: 504, error: { code: "trace_hydration_timeout", message: "Trace 更新超时,运行记录暂不可见。" } });
if (state.scenarioId === "sealed-final-response-diagnostics" && traceId === "trc_sealed_final_diag") return json(response, 504, { ok: false, status: 504, error: { code: "trace_hydration_timeout", message: "Trace 更新超时,运行记录暂不可见。" } });
if (state.scenarioId === "trace-hydration-timeout-diagnostic" && traceId === "trc_trace_hydration_timeout") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_hydration_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "33333333333333333333333333333333", requestId: "req_e2e_trace_hydration_timeout", layer: "workbench-read-model", category: "trace-hydration" });
if (state.scenarioId === "sealed-final-response-diagnostics" && traceId === "trc_sealed_final_diag") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_hydration_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "55555555555555555555555555555555", requestId: "req_e2e_sealed_final_trace_timeout", layer: "workbench-read-model", category: "trace-hydration" });
if (traceId === state.staleTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
return json(response, 200, workbenchTracePayload(traceId, url));
}
@@ -921,6 +921,20 @@ function toolCompletedProjectionRunningTrace(): JsonRecord {
}
function projectionDiagnostic(input: { code: string; message: string; health?: string; category?: string; layer?: string; traceId: string; runId?: string; commandId?: string; staleMs?: number }): JsonRecord {
const diagnostic = {
contractVersion: "hwlab-error-diagnostic-v1",
traceId: input.traceId,
requestId: `req_e2e_${input.code}`,
route: "/v1/workbench/events",
layer: input.layer ?? "agentrun",
category: input.category ?? "upstream-timeout",
code: input.code,
httpStatus: 0,
source: "server",
observedAt: new Date().toISOString(),
retryable: true,
valuesPrinted: false
};
return {
projectionStatus: "projecting",
projectionHealth: input.health ?? "degraded",
@@ -937,8 +951,15 @@ function projectionDiagnostic(input: { code: string; message: string; health?: s
timeoutMs: 2500,
runId: input.runId ?? "run_projection_diag",
commandId: input.commandId ?? "cmd_projection_diag",
traceId: diagnostic.traceId,
requestId: diagnostic.requestId,
route: diagnostic.route,
source: diagnostic.source,
httpStatus: diagnostic.httpStatus,
diagnostic,
valuesPrinted: false
},
diagnostic,
valuesRedacted: true
};
}
@@ -1884,17 +1905,17 @@ async function readJson(request: IncomingMessage): Promise<JsonRecord> {
}
}
function errorDiagnosticResponse(response: ServerResponse, status: number, route: string, code: string, message: string): void {
function errorDiagnosticResponse(response: ServerResponse, status: number, route: string, code: string, message: string, overrides: Partial<JsonRecord> = {}): void {
const diagnostic = {
contractVersion: "hwlab-error-diagnostic-v1",
traceId: "11111111111111111111111111111111",
requestId: "req_e2e_api_error_diagnostic",
traceId: String(overrides.traceId ?? "11111111111111111111111111111111"),
requestId: String(overrides.requestId ?? "req_e2e_api_error_diagnostic"),
route,
layer: "api",
category: "server",
layer: String(overrides.layer ?? "api"),
category: String(overrides.category ?? "server"),
code,
httpStatus: status,
source: "server",
source: String(overrides.source ?? "server"),
observedAt: new Date().toISOString(),
valuesPrinted: false
};
@@ -3,7 +3,8 @@
// Confirms Workbench message visibility after Vue render and keeps sealed final responses separate from diagnostics.
import { computed, nextTick, onMounted, ref, watch } from "vue";
import type { ChatMessage } from "@/types";
import type { ApiError, ChatMessage, ErrorDiagnostic } from "@/types";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
@@ -89,13 +90,95 @@ function messageDiagnosticText(message: ChatMessage): string | null {
return "消息内容缺失:Workbench read model 未返回 user text。";
}
function messageHasDiagnostic(message: ChatMessage): boolean {
return Boolean(messageDiagnosticText(message) || messageApiError(message) || messageErrorDiagnostic(message));
}
function projectionDiagnosticText(message: ChatMessage): string | null {
const projection = message.projection ?? message.runnerTrace?.projection ?? null;
const projection = messageProjection(message);
const health = projection?.projectionHealth ?? message.projectionHealth ?? message.runnerTrace?.projectionHealth;
if (!projection || health === "caught-up") return null;
return firstNonEmptyString(projection.blocker?.userMessage, projection.blocker?.message, projection.blocker?.summary, projection.blocker?.code ? `状态更新异常:${projection.blocker.code}` : null);
}
function messageProjection(message: ChatMessage): ChatMessage["projection"] {
return message.projection ?? message.runnerTrace?.projection ?? null;
}
function messageApiError(message: ChatMessage): ApiError | null {
const projection = messageProjection(message);
const blocker = recordValue(projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker);
const error = recordValue(message.error);
const apiError = recordValue(projection?.apiError ?? error?.apiError);
const diagnostic = messageErrorDiagnostic(message);
if (!projectionDiagnosticText(message) && !blocker && !error && !apiError && !diagnostic) return null;
const source = apiError ?? error ?? blocker ?? {};
const messageText = firstNonEmptyString(source.userMessage, source.message, blocker?.userMessage, blocker?.message, blocker?.summary, diagnostic?.code ? String(diagnostic.code) : null, messageDiagnosticText(message));
return {
...source,
message: messageText ?? "Workbench 诊断",
userMessage: firstNonEmptyString(source.userMessage, blocker?.userMessage, messageText),
code: firstValue(source.code, blocker?.code, diagnostic?.code),
retryable: firstBooleanValue(source.retryable, blocker?.retryable, diagnostic?.retryable),
layer: firstNonEmptyString(source.layer, blocker?.layer, diagnostic?.layer),
category: firstNonEmptyString(source.category, blocker?.category, diagnostic?.category),
route: firstNonEmptyString(source.route, blocker?.route, diagnostic?.route),
traceId: firstNonEmptyString(source.traceId, blocker?.traceId, diagnostic?.traceId, message.traceId, message.runnerTrace?.traceId),
requestId: firstNonEmptyString(source.requestId, blocker?.requestId, diagnostic?.requestId),
source: firstNonEmptyString(source.source, blocker?.source, diagnostic?.source),
diagnostic,
valuesPrinted: source.valuesPrinted === true || blocker?.valuesPrinted === true
} as ApiError;
}
function messageErrorDiagnostic(message: ChatMessage): ErrorDiagnostic | null {
const projection = messageProjection(message);
const blocker = recordValue(projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker);
const error = recordValue(message.error);
const apiError = recordValue(projection?.apiError ?? error?.apiError);
if (!projectionDiagnosticText(message) && !blocker && !error && !apiError && !projection?.diagnostic) return null;
const nested = normalizeErrorDiagnostic(projection?.diagnostic, blocker?.diagnostic, error?.diagnostic, apiError?.diagnostic);
if (nested) return nested;
const code = firstValue(error?.code, apiError?.code, blocker?.code);
const traceId = firstNonEmptyString(error?.traceId, apiError?.traceId, blocker?.traceId, message.traceId, message.runnerTrace?.traceId);
const requestId = firstNonEmptyString(error?.requestId, apiError?.requestId, blocker?.requestId);
const route = firstNonEmptyString(error?.route, apiError?.route, blocker?.route);
const layer = firstNonEmptyString(error?.layer, apiError?.layer, blocker?.layer);
const category = firstNonEmptyString(error?.category, apiError?.category, blocker?.category);
const source = firstNonEmptyString(error?.source, apiError?.source, blocker?.source);
const httpStatus = firstNumber(error?.httpStatus, apiError?.httpStatus, blocker?.httpStatus, error?.providerStatus);
if (!code && !traceId && !requestId && !route && !layer && !category && !source && httpStatus === null) return null;
return { contractVersion: "hwlab-error-diagnostic-v1", traceId, requestId, route, layer, category, code, httpStatus, source, retryable: firstBooleanValue(error?.retryable, apiError?.retryable, blocker?.retryable), valuesPrinted: false };
}
function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null {
for (const value of values) {
const record = recordValue(value);
if (!record) continue;
return {
...record,
contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"),
traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id),
requestId: firstNonEmptyString(record.requestId, record.request_id),
serviceId: firstNonEmptyString(record.serviceId, record.service_id),
route: firstNonEmptyString(record.route),
layer: firstNonEmptyString(record.layer),
category: firstNonEmptyString(record.category),
code: firstValue(record.code),
httpStatus: firstNumber(record.httpStatus, record.http_status),
source: firstNonEmptyString(record.source),
observedAt: firstNonEmptyString(record.observedAt, record.observed_at),
retryable: firstBooleanValue(record.retryable),
valuesPrinted: record.valuesPrinted === true
};
}
return null;
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function firstNonEmptyString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value !== "string") continue;
@@ -105,6 +188,27 @@ function firstNonEmptyString(...values: unknown[]): string | null {
return null;
}
function firstValue(...values: unknown[]): string | number | null {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function firstNumber(...values: unknown[]): number | null {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
}
return null;
}
function firstBooleanValue(...values: unknown[]): boolean | null {
for (const value of values) if (typeof value === "boolean") return value;
return null;
}
function acknowledgeVisibleMessages(): void {
acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.activeMessages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading });
}
@@ -176,7 +280,7 @@ function formatDuration(ms: number): string {
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="traceForDisplay(message)" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" />
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
<MessageMarkdown v-if="showMessageText(message)" class="message-text" :source="visibleMessageText(message)" />
<p v-if="messageDiagnosticText(message)" class="message-text projection-diagnostic">{{ messageDiagnosticText(message) }}</p>
<ApiErrorDiagnostic v-if="messageHasDiagnostic(message)" class="message-diagnostic projection-diagnostic" :error="messageDiagnosticText(message)" :api-error="messageApiError(message)" :diagnostic="messageErrorDiagnostic(message)" compact />
</article>
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败{{ workbench.error }}</div>
<div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话或从左侧选择 session</div>
@@ -190,6 +294,7 @@ function formatDuration(ms: number): string {
<button type="button" class="dialog-close" aria-label="关闭" @click="detailMessageId = null">x</button>
</header>
<CodeAgentStatusSummary :message="detailMessage" />
<ApiErrorDiagnostic v-if="messageHasDiagnostic(detailMessage)" class="message-detail-diagnostic" title="诊断" :error="messageDiagnosticText(detailMessage)" :api-error="messageApiError(detailMessage)" :diagnostic="messageErrorDiagnostic(detailMessage)" compact />
<MessageTraceDebugPanel :message="detailMessage" />
<MessageActions
:message="detailMessage"
+155 -14
View File
@@ -6,7 +6,7 @@ import { defineStore } from "pinia";
import { api } from "@/api";
import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, LiveSurface, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types";
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 { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, 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";
@@ -420,7 +420,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!id) return;
const response = await api.workbench.sessionMessages(id, { limit: 100 });
if (!response.ok || !response.data) {
applyProjectionDiagnostic(traceId, projectionDiagnosticFromFailure({ code: "message_projection_refresh_failed", message: response.error ?? "消息投影刷新失败,主消息正文保持上一份 canonical projection。", health: response.status === 0 ? "unavailable" : "degraded" }));
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)) : [];
@@ -466,7 +466,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (response.data.terminal === true || isTerminalMessageStatus(response.data.status)) completeTrace(id, response.data);
return;
}
applyProjectionDiagnostic(id, projectionDiagnosticFromFailure({ code: "turn_status_poll_failed", message: response.error ?? "状态更新失败,当前 turn 状态暂不可见。", health: response.status === 0 ? "unavailable" : "degraded" }));
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 {
@@ -538,7 +538,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
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 请求失败" });
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 请求失败";
markMessage(traceId, { status: "failed", text: failureMessage, error: failureError, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null });
applyProjectionDiagnostic(traceId, projection);
reduceServerState({ type: "turn.forget", traceId });
failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error");
await clearActiveTrace(traceId, steerMode && response.status === 404 ? "steer-trace-not-found" : "submit-not-admitted");
@@ -612,7 +616,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
for (let page = 0; page < TRACE_HYDRATION_MAX_PAGES; page += 1) {
const result = await fetchTraceHydrationPage(traceId, afterProjectedSeq);
if (!result.ok || !result.data) {
applyProjectionDiagnostic(traceId, projectionDiagnosticFromFailure({ code: "trace_hydration_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_hydration_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" }));
return;
}
applyTraceHydrationResult(traceId, result.data);
@@ -1081,7 +1085,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const traceId = firstNonEmptyString(event.traceId, realtimeTraceId());
if (!traceId) return;
const error = recordValue(event.error) ?? event;
applyProjectionDiagnostic(traceId, projectionDiagnosticFromFailure({ code: firstNonEmptyString(error.code, "workbench_realtime_error") ?? "workbench_realtime_error", message: firstNonEmptyString(error.message, error.summary, "Workbench 实时状态更新异常,最新运行记录暂不可见。") ?? "Workbench 实时状态更新异常,最新运行记录暂不可见。", health: "degraded" }));
const projection = normalizeProjectionDiagnostic(event) ?? projectionDiagnosticFromFailure({ code: firstNonEmptyString(error.code, "workbench_realtime_error") ?? "workbench_realtime_error", message: firstNonEmptyString(error.message, error.summary, "Workbench 实时状态更新异常,最新运行记录暂不可见。") ?? "Workbench 实时状态更新异常,最新运行记录暂不可见。", health: "degraded", diagnostic: normalizeErrorDiagnostic(error.diagnostic, recordValue(event.projection)?.diagnostic, recordValue(event)?.diagnostic), apiError: normalizeApiErrorRecord(error, "Workbench 实时状态更新异常,最新运行记录暂不可见。") });
applyProjectionDiagnostic(traceId, projection.projectionStatus ? projection : { ...projection, projectionStatus: "blocked", projectionHealth: projection.projectionHealth ?? "degraded" });
}
function applyProjectionDiagnostic(traceId: string, projection: ProjectionDiagnostic): void {
@@ -1093,11 +1098,12 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const now = new Date().toISOString();
reduceServerState({ type: "turn.status", turn: { traceId, status, running, terminal: existing?.terminal === true, sessionId, threadId: firstNonEmptyString(message?.threadId, message?.runnerTrace?.threadId) ?? existing?.threadId ?? null, updatedAt: now, projection, loadedAt: now } });
if (sessionId) reduceServerState({ type: "session.status", session: { sessionId, status: activeSession.value?.status ?? status, updatedAt: now, lastTraceId: traceId, projection, loadedAt: now } });
const projectionError = agentErrorFromProjection(projection);
updateActiveMessages((source) => source.map((item) => {
if (!shouldApplyTraceToMessage(item, traceId, sessionId)) 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, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null, updatedAt: now };
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 };
}));
}
@@ -1461,7 +1467,10 @@ function normalizeProjectionDiagnostic(value: unknown): ProjectionDiagnostic | n
if (!record) return null;
const nested = recordValue(record.projection);
const source = nested ?? record;
const blocker = recordValue(source.blocker ?? record.blocker);
const rawBlocker = recordValue(source.blocker ?? record.blocker);
const apiError = normalizeApiErrorRecord(source.apiError ?? record.apiError, null);
const diagnostic = normalizeErrorDiagnostic(source.diagnostic, record.diagnostic, rawBlocker?.diagnostic, apiError?.diagnostic);
const blocker = normalizeProjectionBlocker(rawBlocker, { diagnostic, apiError });
const projectionStatus = firstNonEmptyString(source.projectionStatus, record.projectionStatus);
const projectionHealth = firstNonEmptyString(source.projectionHealth, record.projectionHealth);
const lastProjectedSeq = firstFiniteNumber(source.lastProjectedSeq, record.lastProjectedSeq);
@@ -1469,11 +1478,132 @@ function normalizeProjectionDiagnostic(value: unknown): ProjectionDiagnostic | n
const sourceRunId = firstNonEmptyString(source.sourceRunId, record.sourceRunId);
const sourceCommandId = firstNonEmptyString(source.sourceCommandId, record.sourceCommandId);
if (!projectionStatus && !projectionHealth && !blocker && lastProjectedSeq == null && staleMs == null && !sourceRunId && !sourceCommandId) return null;
return { ...source, projectionStatus, projectionHealth, lastProjectedSeq: lastProjectedSeq ?? null, staleMs: staleMs ?? null, sourceRunId: sourceRunId ?? null, sourceCommandId: sourceCommandId ?? null, blocker: blocker as ProjectionDiagnostic["blocker"] ?? null, valuesRedacted: source.valuesRedacted !== false } as ProjectionDiagnostic;
return { ...source, projectionStatus, projectionHealth, lastProjectedSeq: lastProjectedSeq ?? null, staleMs: staleMs ?? null, sourceRunId: sourceRunId ?? null, sourceCommandId: sourceCommandId ?? null, blocker, diagnostic, apiError, valuesRedacted: source.valuesRedacted !== false } as ProjectionDiagnostic;
}
function projectionDiagnosticFromFailure(input: { code: string; message: string; health: string }): ProjectionDiagnostic {
return { projectionStatus: "blocked", projectionHealth: input.health, blocker: { code: input.code, message: input.message, category: "workbench-projection", retryable: true }, valuesRedacted: true };
function projectionDiagnosticFromApiFailure(result: ApiResult<unknown>, input: { code: string; message: string; health: string }): ProjectionDiagnostic {
return projectionDiagnosticFromFailure({ ...input, status: result.status, apiError: result.apiError, diagnostic: result.diagnostic });
}
function projectionDiagnosticFromFailure(input: { code: string; message: string; health: string; status?: number | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; route?: string | null; source?: string | null }): ProjectionDiagnostic {
const apiError = input.apiError ?? null;
const diagnostic = normalizeErrorDiagnostic(input.diagnostic, apiError?.diagnostic, {
contractVersion: "hwlab-error-diagnostic-v1",
traceId: apiError?.traceId ?? null,
requestId: apiError?.requestId ?? null,
route: firstNonEmptyString(apiError?.route, input.route),
layer: apiError?.layer ?? "workbench",
category: apiError?.category ?? "workbench-projection",
code: firstStringOrNumber(apiError?.code, input.code),
httpStatus: input.status ?? null,
source: firstNonEmptyString(apiError?.source, input.source, "browser"),
retryable: apiError?.retryable ?? true,
valuesPrinted: false
});
const message = firstNonEmptyString(apiError?.userMessage, apiError?.message, input.message) ?? input.message;
const blocker = normalizeProjectionBlocker({
code: firstStringOrNumber(apiError?.code, diagnostic?.code, input.code),
message,
userMessage: firstNonEmptyString(apiError?.userMessage, message),
layer: firstNonEmptyString(apiError?.layer, diagnostic?.layer, "workbench"),
category: firstNonEmptyString(apiError?.category, diagnostic?.category, "workbench-projection"),
retryable: firstBoolean(apiError?.retryable, diagnostic?.retryable, true),
route: firstNonEmptyString(apiError?.route, diagnostic?.route, input.route),
traceId: firstNonEmptyString(apiError?.traceId, diagnostic?.traceId),
requestId: firstNonEmptyString(apiError?.requestId, diagnostic?.requestId),
source: firstNonEmptyString(apiError?.source, diagnostic?.source, input.source),
httpStatus: firstFiniteNumber(diagnostic?.httpStatus, input.status),
diagnostic,
valuesPrinted: false
}, { diagnostic, apiError, fallbackCode: input.code, fallbackMessage: message });
return { projectionStatus: "blocked", projectionHealth: input.health, blocker, diagnostic, apiError, valuesRedacted: true };
}
function normalizeProjectionBlocker(value: unknown, context: { diagnostic?: ErrorDiagnostic | null; apiError?: ApiError | null; fallbackCode?: string | null; fallbackMessage?: string | null } = {}): ProjectionBlocker | null {
const record = recordValue(value);
if (!record && !context.diagnostic && !context.apiError && !context.fallbackMessage && !context.fallbackCode) return null;
const source = record ?? {};
const diagnostic = context.diagnostic ?? normalizeErrorDiagnostic(source.diagnostic, context.apiError?.diagnostic);
const code = firstStringOrNumber(source.code, context.apiError?.code, diagnostic?.code, context.fallbackCode);
const message = firstNonEmptyString(source.message, source.userMessage, source.summary, context.apiError?.userMessage, context.apiError?.message, context.fallbackMessage);
const retryable = firstBoolean(source.retryable, context.apiError?.retryable, diagnostic?.retryable);
return {
...source,
code: code === null ? undefined : String(code),
message: message ?? null,
userMessage: firstNonEmptyString(source.userMessage, context.apiError?.userMessage, message),
summary: firstNonEmptyString(source.summary),
layer: firstNonEmptyString(source.layer, context.apiError?.layer, diagnostic?.layer),
category: firstNonEmptyString(source.category, context.apiError?.category, diagnostic?.category, "workbench-projection"),
retryable: retryable ?? true,
route: firstNonEmptyString(source.route, context.apiError?.route, diagnostic?.route),
traceId: firstNonEmptyString(source.traceId, context.apiError?.traceId, diagnostic?.traceId),
requestId: firstNonEmptyString(source.requestId, context.apiError?.requestId, diagnostic?.requestId),
source: firstNonEmptyString(source.source, context.apiError?.source, diagnostic?.source),
httpStatus: firstFiniteNumber(source.httpStatus, source.http_status, diagnostic?.httpStatus) ?? null,
diagnostic,
valuesPrinted: source.valuesPrinted === true
} as ProjectionBlocker;
}
function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null {
for (const value of values) {
const record = recordValue(value);
if (!record) continue;
const httpStatus = firstFiniteNumber(record.httpStatus, record.http_status);
return {
...record,
contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"),
traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id),
requestId: firstNonEmptyString(record.requestId, record.request_id),
serviceId: firstNonEmptyString(record.serviceId, record.service_id),
route: firstNonEmptyString(record.route),
layer: firstNonEmptyString(record.layer),
category: firstNonEmptyString(record.category),
code: firstStringOrNumber(record.code),
httpStatus: httpStatus ?? null,
source: firstNonEmptyString(record.source),
observedAt: firstNonEmptyString(record.observedAt, record.observed_at),
retryable: firstBoolean(record.retryable),
valuesPrinted: record.valuesPrinted === true
};
}
return null;
}
function normalizeApiErrorRecord(value: unknown, fallback: string | null = null, diagnostic: ErrorDiagnostic | null = null): ApiError | null {
const record = recordValue(value);
if (!record) return null;
const normalizedDiagnostic = normalizeErrorDiagnostic(record.diagnostic, diagnostic);
const message = firstNonEmptyString(record.message, record.userMessage, record.reason, fallback);
return {
...record,
message: message ?? fallback ?? "Workbench 请求失败",
userMessage: firstNonEmptyString(record.userMessage, record.message, fallback),
code: firstStringOrNumber(record.code, normalizedDiagnostic?.code),
reason: firstNonEmptyString(record.reason),
retryable: firstBoolean(record.retryable, normalizedDiagnostic?.retryable),
layer: firstNonEmptyString(record.layer, normalizedDiagnostic?.layer),
category: firstNonEmptyString(record.category, normalizedDiagnostic?.category),
route: firstNonEmptyString(record.route, normalizedDiagnostic?.route),
traceId: firstNonEmptyString(record.traceId, normalizedDiagnostic?.traceId),
requestId: firstNonEmptyString(record.requestId, normalizedDiagnostic?.requestId),
source: firstNonEmptyString(record.source, normalizedDiagnostic?.source),
diagnostic: normalizedDiagnostic,
valuesPrinted: record.valuesPrinted === true
} as ApiError;
}
function agentErrorFromApiFailure(result: ApiResult<unknown>, projection: ProjectionDiagnostic, fallback: string): ChatMessage["error"] {
return normalizeAgentError(result.apiError ?? projection.apiError ?? projection.blocker ?? { message: result.error ?? fallback, diagnostic: result.diagnostic ?? projection.diagnostic }) ?? { message: result.error ?? fallback, diagnostic: result.diagnostic ?? projection.diagnostic };
}
function agentErrorFromProjection(projection: ProjectionDiagnostic): ChatMessage["error"] | null {
const blocker = projection.blocker ?? null;
const diagnostic = projection.diagnostic ?? blocker?.diagnostic ?? projection.apiError?.diagnostic ?? null;
const message = firstNonEmptyString(blocker?.userMessage, blocker?.message, blocker?.summary, projection.apiError?.userMessage, projection.apiError?.message, diagnostic?.code ? String(diagnostic.code) : null);
if (!blocker && !projection.apiError && !diagnostic && !message) return null;
return normalizeAgentError({ ...(projection.apiError ?? {}), ...(blocker ?? {}), message, code: firstStringOrNumber(blocker?.code, projection.apiError?.code, diagnostic?.code), diagnostic, traceId: firstNonEmptyString(blocker?.traceId, projection.apiError?.traceId, diagnostic?.traceId), requestId: firstNonEmptyString(blocker?.requestId, projection.apiError?.requestId, diagnostic?.requestId) });
}
function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean {
@@ -1518,11 +1648,12 @@ function normalizeAgentError(value: unknown): ChatMessage["error"] | 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 diagnostic = normalizeErrorDiagnostic(record.diagnostic, recordValue(record.apiError)?.diagnostic, recordValue(record.error)?.diagnostic);
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 code = firstStringOrNumber(record.code, record.failureKind, record.name, diagnostic?.code);
const category = firstNonEmptyString(record.category, diagnostic?.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 { ...record, code: code ?? undefined, message: message ?? undefined, userMessage: firstNonEmptyString(record.userMessage, message), category: category ?? undefined, layer: firstNonEmptyString(record.layer, diagnostic?.layer), route: firstNonEmptyString(record.route, diagnostic?.route), traceId: firstNonEmptyString(record.traceId, diagnostic?.traceId), requestId: firstNonEmptyString(record.requestId, diagnostic?.requestId), source: firstNonEmptyString(record.source, diagnostic?.source), retryable: firstBoolean(record.retryable, diagnostic?.retryable), diagnostic, providerStatus };
}
return { message: String(value) };
}
@@ -1569,6 +1700,16 @@ function firstFiniteNumber(...values: unknown[]): number | undefined {
return undefined;
}
function firstStringOrNumber(...values: unknown[]): string | number | null {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") continue;
const text = value.trim();
if (text) return text;
}
return null;
}
function firstBoolean(...values: unknown[]): boolean | undefined {
for (const value of values) {
if (typeof value === "boolean") return value;
+9 -1
View File
@@ -135,6 +135,12 @@ export interface ProjectionBlocker {
layer?: string | null;
category?: string | null;
retryable?: boolean | null;
route?: string | null;
traceId?: string | null;
requestId?: string | null;
source?: "server" | "browser" | string | null;
httpStatus?: number | null;
diagnostic?: ErrorDiagnostic | null;
runId?: string | null;
commandId?: string | null;
timeoutMs?: number | null;
@@ -149,6 +155,8 @@ export interface ProjectionDiagnostic {
sourceCommandId?: string | null;
staleMs?: number | null;
blocker?: ProjectionBlocker | null;
diagnostic?: ErrorDiagnostic | null;
apiError?: ApiError | null;
updatedAt?: string | null;
valuesRedacted?: boolean;
[key: string]: unknown;
@@ -265,7 +273,7 @@ export interface ChatMessage {
lastEventAt?: string | null;
finishedAt?: string | null;
durationMs?: number | null;
error?: { code?: string; message?: string; category?: string; providerStatus?: number; [key: string]: unknown } | null;
error?: { code?: string | number | null; message?: string; userMessage?: string | null; category?: string | null; layer?: string | null; providerStatus?: number; retryable?: boolean | null; route?: string | null; traceId?: string | null; requestId?: string | null; source?: "server" | "browser" | string | null; diagnostic?: ErrorDiagnostic | null; [key: string]: unknown } | null;
traceAutoLifecycle?: "running" | "terminal" | null;
projection?: ProjectionDiagnostic | null;
projectionStatus?: string | null;
@@ -13,6 +13,11 @@ test.describe("projection diagnostics visibility", () => {
await expect(page.locator(sessionTab("ses_projection_degraded"))).toHaveAttribute("data-projection-health", "degraded");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
await expect(card).toContainText("状态更新超时");
await expect(card.locator(".projection-diagnostic")).toContainText("agentrun_result_timeout");
await expect(card.locator(".projection-diagnostic")).toContainText("trace_id");
await expect(card.locator(".projection-diagnostic")).toContainText("trc_projection_degraded");
await expect(card.locator(".projection-diagnostic")).toContainText("valuesPrinted");
await expect(card.locator(".projection-diagnostic")).toContainText("false");
await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded");
await expect(card.locator(".trace-empty")).toContainText("状态更新超时");
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
@@ -27,6 +32,9 @@ test.describe("SSE projection errors", () => {
await gotoWorkbench(page, "/workbench/sessions/ses_projection_sse_error");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
await expect(card).toContainText("SSE 投影事件异常");
await expect(card.locator(".projection-diagnostic")).toContainText("workbench_sse_projection_error");
await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_workbench_sse_projection_error");
await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/events");
await expect(page.locator(sessionTab("ses_projection_sse_error"))).toHaveAttribute("data-projection-health", "degraded");
await saveScreenshot(page, testInfo, "projection-sse-error-visible");
});
@@ -39,6 +47,10 @@ test.describe("REST gap hydration errors", () => {
await gotoWorkbench(page, "/workbench/sessions/ses_trace_hydration_timeout");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last();
await expect(card).toContainText("Trace 更新超时");
await expect(card.locator(".projection-diagnostic")).toContainText("trace_hydration_timeout");
await expect(card.locator(".projection-diagnostic")).toContainText("33333333333333333333333333333333");
await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_trace_hydration_timeout");
await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events");
await expect(card).toHaveAttribute("data-status", "running");
await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded");
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel");
@@ -53,6 +65,8 @@ test.describe("sealed final response diagnostics separation", () => {
await gotoWorkbench(page, "/workbench/sessions/ses_sealed_final_diag");
const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last();
await expect(card.locator(".projection-diagnostic")).toContainText("Trace 更新超时");
await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_sealed_final_trace_timeout");
await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_sealed_final_diag/events");
const finalResponse = card.locator(".message-text").filter({ hasText: "已完成的 sealed final response 不应被诊断覆盖。" });
await expect(finalResponse).toBeVisible();
await expect(finalResponse).not.toContainText("Trace 更新超时");