From 2ef61ec8d992f607419c1b9c4ac2a053365c97a5 Mon Sep 17 00:00:00 2001 From: UniDesk Codex Date: Wed, 1 Jul 2026 00:30:51 +0800 Subject: [PATCH] refactor: move workbench message projection runtime out of store --- docs/reference/cloud-workbench.md | 2 + web/hwlab-cloud-web/scripts/check.ts | 9 + ...rkbench-message-projection-runtime.test.ts | 84 +++ .../workbench-message-projection-runtime.ts | 478 ++++++++++++++++ web/hwlab-cloud-web/src/stores/workbench.ts | 512 ++---------------- 5 files changed, 613 insertions(+), 472 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts create mode 100644 web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 592fee03..891ba148 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -19,6 +19,8 @@ Workbench 状态对象必须服从 UniDesk OA Web SPEC 中已经定下来的单 Workbench 投影写路径必须做到 0 隐式 fallback。Admission、projection event、terminal/finalizer 等上游写入如果无法把 session/message/turn/checkpoint facts 写入 durable read model,不能 `catch` 后返回空值继续表现为成功;admission 阶段必须显式失败并把错误传给调用方,后台投影阶段必须至少写入 trace diagnostic 和 OTel error span。只有成功落库的 Workbench facts 才能驱动控制页、观察页、session rail、耗时和 final response;前端或 read path 不得用内存 trace、local optimistic state、历史 snapshot 或多来源仲裁去修补失败写入。 +Workbench 的 trace/message/projection 运行时必须是独立模块边界。`workbench.ts` 只保留 session/route authority 校验、Pinia state commit、刷新调度和用户动作编排;trace snapshot、terminal result、message timing/status patch、agent error normalize、projection diagnostic 裁剪和 final response 文本提取等纯算法统一由 `web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts` 提供。新增或修复浏览器 smoke 时不得把这些 helper 重新散写回 store 或组件,也不得通过 reload、repair、localStorage truth、GET read-through、测试专用后门或删除 guard 来绕过真实投影问题。web-probe origin、视口、采样、命令超时、provider/lane 和报警阈值只从选中 node/lane 的受控 YAML/source-of-truth 进入验证命令,不在 SPEC 或前端 runtime 中写第二份数值。 + MDTODO 发起 Workbench 执行时,HWPOD 执行上下文的唯一权威来源是 Project Management source registry。Workbench Launch 服务端必须通过 `taskRef -> sourceId/fileRef -> source` 解析 `launchContext.executionContext`,并把同一份 `contextFingerprint` 写入 session owner、Workbench facts、project-management link 和 OTel span;浏览器传入的 HWPOD 字段只能作为任务元数据,不能作为权威执行上下文。`sourceKind=hwpod-workspace` 但缺少 `hwpodId`、`nodeId` 或 `workspaceRootRef` 时,launch 必须显式失败,不能创建“看似成功但无法执行”的空 session。 MDTODO 首轮 Code Agent prompt 必须包含 `hwpodId`、`mdtodoRootRef` 和 `hwpodWorkspaceArgs`,并要求所有 `hwpod/hwpod-ctl` 命令携带该参数。Agent 不得猜测容器本地路径,不得创建、复制或修补本地 `.hwlab/hwpod-spec.yaml` fallback。Workbench session header 可以显示 source、HWPOD 和 MDTODO root 的只读短标识;workspace host path 的长期可观测性默认使用 basename/label、hash 或 redacted 形态。 diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 2fd21d3a..9fd23bae 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -40,6 +40,7 @@ const requiredFiles = Object.freeze([ "src/stores/auth.ts", "src/stores/workbench.ts", "src/stores/workbench-event-reducer.ts", + "src/stores/workbench-message-projection-runtime.ts", "src/stores/workbench-realtime-plan.ts", "src/stores/workbench-timeline-model.ts", "src/stores/workbench-session-cache.ts", @@ -85,6 +86,7 @@ const workbenchRealtimeRuntimeSource = `${readWeb("src/utils/workbench-realtime- const workbenchRefreshRuntimeSource = readWeb("src/utils/workbench-refresh-runtime.ts"); const workbenchPerformanceSource = readWeb("src/utils/workbench-performance.ts"); const workbenchEventReducerSource = readWeb("src/stores/workbench-event-reducer.ts"); +const workbenchMessageProjectionRuntimeSource = readWeb("src/stores/workbench-message-projection-runtime.ts"); const workbenchRealtimePlanSource = readWeb("src/stores/workbench-realtime-plan.ts"); const workbenchTimelineRuntimeSource = readWeb("src/stores/workbench-timeline-model.ts"); const workbenchScrollRuntimeSource = readWeb("src/composables/useWorkbenchScrollRuntime.ts"); @@ -161,6 +163,13 @@ assertIncludes(workbenchErrorRuntimeSource, "projectionDiagnosticFromFailure", " assertIncludes(workbenchErrorRuntimeSource, "messageDiagnosticView", "Error runtime must own Workbench message diagnostic view models"); assertIncludes(conversationPanelSource, "messageDiagnosticView", "ConversationPanel must consume message diagnostics from ErrorRuntime"); assert.doesNotMatch(conversationPanelSource, /function\s+(?:messageApiError|messageErrorDiagnostic|projectionDiagnosticText|normalizeErrorDiagnostic)\b/u, "ConversationPanel must not rebuild Workbench diagnostics locally"); +assertIncludes(workbenchMessageProjectionRuntimeSource, "mergeTerminalResultTrace", "Message projection runtime must own terminal result trace merge"); +assertIncludes(workbenchMessageProjectionRuntimeSource, "messageTimingPatchForMerge", "Message projection runtime must own timing patch merge"); +assertIncludes(workbenchMessageProjectionRuntimeSource, "messageStatusPatchForTerminalMerge", "Message projection runtime must own terminal status patch merge"); +assertIncludes(workbenchMessageProjectionRuntimeSource, "projectionFromResult", "Message projection runtime must own result projection extraction"); +assertIncludes(workbenchMessageProjectionRuntimeSource, "normalizeAgentError", "Message projection runtime must own agent error normalization"); +assertIncludes(workbenchStoreSource, "workbench-message-projection-runtime", "Workbench store must consume message projection runtime instead of owning pure merge helpers"); +assert.doesNotMatch(workbenchStoreSource, /function (?:mergeTerminalResultTrace|messageTimingPatchForMerge|messageStatusPatchForTerminalMerge|projectionFromResult|normalizeAgentError|clearRunnerTraceTransientDiagnostics|shouldClearCompletedTurnDiagnostics|nonBlockingProjection)\b/u, "Workbench store must not re-own pure trace/message/projection helper algorithms"); assertIncludes(workbenchHealthRuntimeSource, "createScopedCache", "ScopedCache must be used by a production Workbench runtime, not only by tests/checks"); assert.doesNotMatch(workbenchStoreSource, /function (?:normalizeProjectionDiagnostic|projectionDiagnosticFromApiFailure|projectionDiagnosticFromFailure|normalizeProjectionBlocker|normalizeErrorDiagnostic|normalizeApiErrorRecord|agentErrorFromProjection)\b/u, "Workbench store must call ErrorRuntime directly instead of reintroducing diagnostic wrapper ownership"); assertIncludes(workbenchStoreSource, "cleanupDroppedWorkbenchSessionCaches", "Workbench store must run OpenCode-style dropped session cache cleanup after trim"); diff --git a/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts new file mode 100644 index 00000000..6c300c3f --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts @@ -0,0 +1,84 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection. +// Responsibility: Regression tests for pure Workbench trace/message/projection merge helpers before Pinia integration. + +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { AgentChatResultResponse, ChatMessage } from "../src/types/index.ts"; +import { clearRunnerTraceTransientDiagnostics, mergeTerminalResultTrace, messageStatusPatchForTerminalMerge, messageTimingPatchForMerge, nonBlockingProjection, normalizeAgentError, projectionFromResult, shouldClearCompletedTurnDiagnostics, terminalMessageTimingPatchForNormalize } from "../src/stores/workbench-message-projection-runtime.ts"; + +test("terminal result merge preserves runner trace evidence and timing", () => { + const previous: ChatMessage["runnerTrace"] = { + traceId: "trc_1", + sessionId: "ses_1", + events: [{ id: "evt_old", type: "tool" } as never], + eventCount: 1, + projection: { projectionStatus: "blocked", projectionHealth: "degraded", blocker: { code: "old" } }, + updatedAt: "2026-01-01T00:00:00.000Z" + }; + const result = { + traceId: "trc_1", + sessionId: "ses_1", + status: "completed", + terminal: true, + events: [{ id: "evt_new", type: "finish" }], + eventCount: 1, + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:00:05.000Z", + durationMs: 5000, + projectionStatus: "caught-up", + projectionHealth: "healthy", + agentRun: { runId: "run_1", backendProfile: "codex-api" } + } as unknown as AgentChatResultResponse; + + const merged = mergeTerminalResultTrace(previous, result); + + assert.equal(merged.traceId, "trc_1"); + assert.equal(merged.sessionId, "ses_1"); + assert.equal(merged.eventCount, 1); + assert.equal(merged.events?.[0]?.id, "evt_old"); + assert.equal(merged.terminalEvidence, undefined); + assert.equal(merged.timing?.durationMs, 5000); + assert.equal((merged.agentRun as { runId?: string } | undefined)?.runId, "run_1"); + assert.equal(merged.projectionStatus, "caught-up"); +}); + +test("message projection runtime clears only non-blocking completed diagnostics", () => { + const error = normalizeAgentError({ message: "provider failed", diagnostic: { code: "provider_error", source: "provider" } }); + assert.equal(error?.message, "provider failed"); + assert.equal(error?.diagnostic?.code, "provider_error"); + assert.equal(shouldClearCompletedTurnDiagnostics("completed", error), false); + assert.equal(shouldClearCompletedTurnDiagnostics("completed", null), true); + + const projection = projectionFromResult({ projectionStatus: "caught-up", projectionHealth: "healthy" } as AgentChatResultResponse); + assert.equal(nonBlockingProjection(projection)?.projectionStatus, "caught-up"); + assert.equal(nonBlockingProjection({ projectionStatus: "blocked", projectionHealth: "degraded", blocker: { code: "read_model_gap" } }), null); + + const cleared = clearRunnerTraceTransientDiagnostics({ traceId: "trc_1", error, projection, projectionStatus: "caught-up", projectionHealth: "healthy", staleMs: 10, blocker: { code: "old" } }); + assert.equal(cleared.error, undefined); + assert.equal(cleared.projection, null); + assert.equal(cleared.blocker, null); +}); + +test("message timing and terminal status patches stay independent from store state", () => { + const message = { + role: "agent", + status: "running", + startedAt: "2026-01-01T00:00:00.000Z", + timing: { startedAt: "2026-01-01T00:00:00.000Z", valuesRedacted: true } + } as ChatMessage; + const result = { + status: "completed", + terminal: true, + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:00:03.500Z" + }; + + const timingPatch = messageTimingPatchForMerge(message, result); + assert.equal(timingPatch.durationMs, 3500); + assert.equal(timingPatch.finishedAt, "2026-01-01T00:00:03.500Z"); + assert.deepEqual(messageStatusPatchForTerminalMerge(message, "completed", true), { status: "completed" }); + + const terminalPatch = terminalMessageTimingPatchForNormalize(result); + assert.equal(terminalPatch.durationMs, 3500); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts new file mode 100644 index 00000000..95d306ae --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts @@ -0,0 +1,478 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection. +// Responsibility: Pure Workbench trace/message/projection merge helpers consumed by the store orchestration layer. + +import { mergeRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription"; +import type { AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ProjectionDiagnostic, TraceEvent, WorkbenchTurnTimingProjection } from "@/types"; +import { firstNonEmptyString } from "@/utils"; +import { normalizeErrorDiagnostic, normalizeProjectionDiagnostic } from "@/utils/workbench-error-runtime"; + +export function clearRunnerTraceTransientDiagnostics(trace: NonNullable): NonNullable { + return { ...trace, error: undefined, projection: null, projectionStatus: null, projectionHealth: null, staleMs: null, blocker: null }; +} + +export function shouldClearCompletedTurnDiagnostics(status: unknown, error: ChatMessage["error"] | null): boolean { + return normalizedStatusText(status) === "completed" && !error; +} + +export function nonBlockingProjection(projection: ProjectionDiagnostic | null): ProjectionDiagnostic | null { + if (!projection) return null; + const status = firstNonEmptyString(projection.projectionStatus); + const health = firstNonEmptyString(projection.projectionHealth); + if (status === "caught-up" || health === "healthy") return projection; + if (!projection.blocker && !projection.apiError && !projection.diagnostic) return projection; + return null; +} + +export function normalizedStatusText(value: unknown): string | null { + const text = firstNonEmptyString(value); + return text ? text.trim().toLowerCase().replace(/_/gu, "-") : null; +} + +export function optionalString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") continue; + const text = value.trim(); + if (text) return text; + } + return undefined; +} + +export function traceSnapshotError(value: unknown): Exclude | undefined { + const error = normalizeAgentError(value); + return error ? error as Exclude : undefined; +} + +export function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: AgentChatResultResponse): NonNullable { + 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); + const traceDetailStatus = firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus, resultTrace?.status); + const timing = normalizeTimingProjection(result); + const nextTrace = { + ...resultTrace, + traceId: firstNonEmptyString(result.traceId, resultTrace?.traceId, previous?.traceId) ?? 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), + retention: result.retention ?? resultTrace?.retention ?? previous?.retention, + terminalEvidence: result.terminalEvidence ?? resultTrace?.terminalEvidence ?? previous?.terminalEvidence, + traceSummary: result.traceSummary ?? resultTrace?.traceSummary ?? previous?.traceSummary, + agentRun: agentRun ?? undefined, + error: normalizeAgentError(result.error ?? resultTrace?.error ?? previous?.error) ?? undefined, + projection: projectionFromResult(result) ?? normalizeProjectionDiagnostic(resultTrace?.projection ?? resultTrace) ?? previous?.projection ?? null, + projectionStatus: result.projectionStatus ?? resultTrace?.projectionStatus ?? previous?.projectionStatus ?? null, + projectionHealth: result.projectionHealth ?? resultTrace?.projectionHealth ?? previous?.projectionHealth ?? null, + staleMs: result.staleMs ?? resultTrace?.staleMs ?? previous?.staleMs ?? null, + blocker: result.blocker ?? resultTrace?.blocker ?? previous?.blocker ?? null, + timing, + startedAt: timing?.startedAt ?? null, + lastEventAt: timing?.lastEventAt ?? null, + finishedAt: timing?.finishedAt ?? null, + durationMs: timing?.durationMs ?? null, + 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) ?? undefined, + updatedAt: new Date().toISOString() + } as NonNullable; + if (traceDetailStatus) nextTrace.status = traceDetailStatus; + const traceStatus = firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus); + if (traceStatus) nextTrace.traceStatus = traceStatus; + return mergeRunnerTrace(previous, nextTrace); +} + +export function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerTrace"] { + const trace = recordValue(message.runnerTrace); + const agentRun = agentRunFromMessage(message); + const error = normalizeAgentError(message.error ?? trace?.error); + const projection = projectionFromMessage(message, trace as ChatMessage["runnerTrace"] | null); + const timing = normalizeTimingProjection(message); + if (!trace && !agentRun && !error && !projection) 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, + projection, + projectionStatus: projection?.projectionStatus ?? trace?.projectionStatus, + projectionHealth: projection?.projectionHealth ?? trace?.projectionHealth, + staleMs: projection?.staleMs ?? trace?.staleMs, + blocker: projection?.blocker ?? trace?.blocker, + timing, + startedAt: timing?.startedAt ?? null, + lastEventAt: timing?.lastEventAt ?? null, + finishedAt: timing?.finishedAt ?? null, + durationMs: timing?.durationMs ?? null, + runnerKind: agentRun?.adapter ?? trace?.runnerKind, + sessionMode: agentRun?.backendProfile ?? trace?.sessionMode + } as ChatMessage["runnerTrace"]; +} + +export function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjection | null { + const record = recordValue(value); + if (!record) return null; + const source = recordValue(record.timing) ?? {}; + const startedAt = firstNonEmptyString(source.startedAt, record.startedAt); + const lastEventAt = firstNonEmptyString(source.lastEventAt, record.lastEventAt); + const finishedAt = firstNonEmptyString(source.finishedAt, record.finishedAt); + const durationMs = firstFiniteNumber(source.durationMs, record.durationMs); + const observedAt = firstNonEmptyString(source.observedAt, record.observedAt); + const lastEventAgeMs = firstFiniteNumber(source.lastEventAgeMs, record.lastEventAgeMs); + if (!startedAt && !lastEventAt && !finishedAt && durationMs == null && lastEventAgeMs == null) return null; + return { ...source, startedAt: startedAt ?? null, lastEventAt: lastEventAt ?? null, finishedAt: finishedAt ?? null, durationMs: durationMs ?? null, observedAt: observedAt ?? null, lastEventAgeMs: lastEventAgeMs ?? null, valuesRedacted: source.valuesRedacted !== false } as WorkbenchTurnTimingProjection; +} + +export function messageTimingPatch(value: unknown): Partial { + const timing = normalizeTimingProjection(value); + if (!timing) return {}; + return messageTimingPatchFromProjection(timing); +} + +export function messageTimingPatchFromProjection(timing: WorkbenchTurnTimingProjection): Partial { + return { timing, startedAt: timing.startedAt ?? null, lastEventAt: timing.lastEventAt ?? null, finishedAt: timing.finishedAt ?? null, durationMs: timing.durationMs ?? null }; +} + +export function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Partial { + const incomingTiming = normalizeTimingProjection(value); + if (!incomingTiming || !isTerminalTimingSource(value)) return messageTimingPatch(message); + const existingTiming = normalizeTimingProjection(message); + const existingDuration = firstPositiveFiniteNumber(existingTiming?.durationMs); + if (isTerminalMessageStatus(message.status) && existingTiming && existingDuration !== null) return messageTimingPatchFromProjection(existingTiming); + const incomingDuration = firstPositiveFiniteNumber(incomingTiming.durationMs) ?? positiveDurationBetween(incomingTiming.startedAt, incomingTiming.finishedAt) ?? positiveDurationBetween(incomingTiming.startedAt, incomingTiming.lastEventAt); + if (incomingDuration !== null) return messageTimingPatchFromProjection({ ...(existingTiming ?? {}), ...incomingTiming, durationMs: incomingDuration, valuesRedacted: incomingTiming.valuesRedacted !== false && existingTiming?.valuesRedacted !== false }); + const runningDuration = isTerminalMessageStatus(message.status) ? null : runningDurationFromTiming(existingTiming); + if (runningDuration !== null) { + const finishedAt = incomingTiming.finishedAt ?? incomingTiming.lastEventAt ?? null; + if (!finishedAt) return terminalMessageTimingPatchForNormalize(value); + return messageTimingPatchFromProjection({ ...(incomingTiming ?? {}), startedAt: existingTiming?.startedAt ?? incomingTiming.startedAt ?? null, lastEventAt: incomingTiming.lastEventAt ?? finishedAt, finishedAt, durationMs: runningDuration, valuesRedacted: incomingTiming.valuesRedacted !== false && existingTiming?.valuesRedacted !== false }); + } + return terminalMessageTimingPatchForNormalize(value); +} + +export function messageTimingSealPatchForProjectionMerge(message: ChatMessage, previous: ChatMessage | null): Partial { + if (previous && isProjectionRunningAgentMessageForTiming(previous) && isProjectionRunningAgentMessageForTiming(message)) return messageRunningSealPatchForProjectionMerge(message, previous); + if (previous && isProjectionRunningAgentMessageForTiming(previous) && isTerminalMessageStatus(message.status)) return messageTerminalFromRunningSealPatchForProjectionMerge(message, previous); + return messageTerminalSealPatchForProjectionMerge(message, previous); +} + +function messageRunningSealPatchForProjectionMerge(message: ChatMessage, previous: ChatMessage): Partial { + const previousTiming = normalizeTimingProjection(previous); + if (!previousTiming?.startedAt) return messageTimingPatch(message); + const incomingTiming = normalizeTimingProjection(message); + const previousStartedMs = timestampMs(previousTiming.startedAt); + const incomingStartedMs = timestampMs(incomingTiming?.startedAt); + const startedAt = incomingStartedMs !== null && previousStartedMs !== null && incomingStartedMs < previousStartedMs + ? incomingTiming?.startedAt ?? previousTiming.startedAt + : previousTiming.startedAt; + const previousLastMs = timestampMs(previousTiming.lastEventAt); + const incomingLastMs = timestampMs(incomingTiming?.lastEventAt); + const lastEventAt = incomingLastMs !== null && (previousLastMs === null || incomingLastMs >= previousLastMs) + ? incomingTiming?.lastEventAt ?? previousTiming.lastEventAt ?? startedAt + : previousTiming.lastEventAt ?? incomingTiming?.lastEventAt ?? startedAt; + const timing = { + ...previousTiming, + ...(incomingTiming ?? {}), + startedAt, + lastEventAt, + finishedAt: null, + durationMs: null, + valuesRedacted: incomingTiming?.valuesRedacted !== false && previousTiming.valuesRedacted !== false, + } as WorkbenchTurnTimingProjection; + return messageTimingPatchFromProjection(timing); +} + +function isProjectionRunningAgentMessageForTiming(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + const status = normalizedStatusText(message.status); + if (status && isTerminalMessageStatus(status)) return false; + return status === null || isTraceActiveStatus(status) || message.traceAutoLifecycle === "running"; +} + +function messageTerminalFromRunningSealPatchForProjectionMerge(message: ChatMessage, previous: ChatMessage): Partial { + const incomingPatch = terminalMessageTimingPatchForNormalize(message); + const incomingTiming = normalizeTimingProjection(incomingPatch) ?? normalizeTimingProjection(message); + const previousTiming = normalizeTimingProjection(previous); + const runningDuration = runningDurationFromTiming(previousTiming); + if (runningDuration === null) return incomingPatch; + const incomingDuration = firstPositiveFiniteNumber(incomingTiming?.durationMs) ?? positiveDurationBetween(incomingTiming?.startedAt, incomingTiming?.finishedAt) ?? positiveDurationBetween(incomingTiming?.startedAt, incomingTiming?.lastEventAt); + const durationMs = Math.max(incomingDuration ?? 0, runningDuration); + const finishedAt = incomingTiming?.finishedAt ?? incomingTiming?.lastEventAt ?? null; + if (!finishedAt) return incomingPatch; + const timing = { + ...(incomingTiming ?? {}), + startedAt: previousTiming?.startedAt ?? incomingTiming?.startedAt ?? null, + lastEventAt: incomingTiming?.lastEventAt ?? finishedAt, + finishedAt, + durationMs, + valuesRedacted: incomingTiming?.valuesRedacted !== false && previousTiming?.valuesRedacted !== false, + } as WorkbenchTurnTimingProjection; + return messageTimingPatchFromProjection(timing); +} + +function messageTerminalSealPatchForProjectionMerge(message: ChatMessage, previous: ChatMessage | null): Partial { + const incomingTimingPatch = messageTimingPatch(message); + if (!previous || !isTerminalMessageStatus(previous.status)) return incomingTimingPatch; + const patch: Partial = { status: previous.status, traceAutoLifecycle: previous.traceAutoLifecycle ?? "terminal" }; + if (typeof previous.text === "string" && previous.text.trim()) patch.text = previous.text; + const previousTiming = normalizeTimingProjection(previous); + if (firstPositiveFiniteNumber(previousTiming?.durationMs) === null) return { ...terminalMessageTimingPatchForNormalize(message), ...patch }; + return { ...messageTimingPatch(previous), ...patch }; +} + +export function terminalMessageTimingPatchForNormalize(value: unknown): Partial { + const timing = normalizeTimingProjection(value); + if (!timing) return {}; + const record = recordValue(value); + const durationMs = firstPositiveFiniteNumber(timing.durationMs) + ?? positiveDurationBetween(timing.startedAt, timing.finishedAt) + ?? positiveDurationBetween(timing.startedAt, timing.lastEventAt) + ?? positiveDurationBetween(timing.startedAt, record?.updatedAt) + ?? (timing.durationMs === 0 ? 0 : null); + return messageTimingPatchFromProjection({ ...timing, durationMs }); +} + +function isTerminalTimingSource(value: unknown): boolean { + const record = recordValue(value); + if (!record) return false; + const runnerTrace = recordValue(record.runnerTrace); + return record.terminal === true || isTerminalMessageStatus(record.status) || isTerminalMessageStatus(record.traceStatus) || isTerminalMessageStatus(runnerTrace?.status) || isTerminalMessageStatus(runnerTrace?.traceStatus) || normalizeTimingProjection(value)?.finishedAt != null; +} + +function runningDurationFromTiming(timing: WorkbenchTurnTimingProjection | null): number | null { + const startedMs = timestampMs(timing?.startedAt); + if (startedMs === null) return null; + const duration = Math.trunc(Date.now() - startedMs); + return duration > 0 ? Math.max(1000, duration) : 1000; +} + +export function positiveDurationBetween(startedAt: unknown, finishedAt: unknown): number | null { + const startedMs = timestampMs(startedAt); + const finishedMs = timestampMs(finishedAt); + if (startedMs === null || finishedMs === null || finishedMs <= startedMs) return null; + return Math.trunc(finishedMs - startedMs); +} + +function timestampMs(value: unknown): number | null { + if (typeof value !== "string") return null; + const text = value.trim(); + if (!text) return null; + const parsed = Date.parse(text); + return Number.isFinite(parsed) ? parsed : null; +} + +export function messageStatusPatchForTerminalMerge(message: ChatMessage, resultStatus: string | null, terminal: boolean): Partial { + if (!terminal || isTerminalMessageStatus(message.status)) return {}; + if (resultStatus && isTerminalMessageStatus(resultStatus)) return { status: resultStatus as ChatMessage["status"] }; + return {}; +} + +export function firstPositiveFiniteNumber(...values: unknown[]): number | null { + for (const value of values) { + const number = firstFiniteNumber(value); + if (number !== undefined && number > 0) return number; + } + return null; +} + +export function maxFiniteNumber(...values: unknown[]): number | null { + let max: number | null = null; + for (const value of values) { + const number = Number(value); + if (!Number.isFinite(number) || number < 0) continue; + const normalized = Math.trunc(number); + max = max === null ? normalized : Math.max(max, normalized); + } + return max; +} + +export function projectionFromResult(result: AgentChatResultResponse | TraceSnapshot | Record | null | undefined): ProjectionDiagnostic | null { + return normalizeProjectionDiagnostic(result) ?? normalizeProjectionDiagnostic(recordValue(result)?.runnerTrace) ?? null; +} + +export function projectionFromMessage(message: ChatMessage, trace: ChatMessage["runnerTrace"] | null | undefined): ProjectionDiagnostic | null { + return normalizeProjectionDiagnostic(message.projection ?? message) ?? normalizeProjectionDiagnostic(trace?.projection ?? trace) ?? null; +} + +export function shouldSuppressTransientWorkbenchReadFailure(result: ApiResult): boolean { + const diagnostic = normalizeErrorDiagnostic(result.diagnostic, result.apiError?.diagnostic); + const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code); + const category = firstNonEmptyString(result.apiError?.category, diagnostic?.category); + const source = firstNonEmptyString(result.apiError?.source, diagnostic?.source); + if (code === "workbench_read_hydration_cooldown" || code === "workbench_read_hydration_throttled") return true; + return result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network"; +} + +export function agentErrorFromApiFailure(result: ApiResult, 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 }; +} + +export function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + if (!isTerminalMessageStatus(message.status)) return false; + if (messageHasCompletedFinalResponse(message)) 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); +} + +export function messageNeedsTraceHydration(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); + if (!traceId) return false; + if (!messageHasActiveTraceForHydration(message)) 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 messageHasActiveTraceForHydration(message: ChatMessage): boolean { + const trace = message.runnerTrace; + return isTraceActiveStatus(message.status) + || isTraceActiveStatus(trace?.status) + || isTraceActiveStatus(trace?.traceStatus); +} + +export function traceHasCompletedFinalResponse(traceId: string | null | undefined, source: ChatMessage[]): boolean { + const id = firstNonEmptyString(traceId); + if (!id) return false; + return source.some((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) === id && messageHasCompletedFinalResponse(message)); +} + +export function messageHasCompletedFinalResponse(message: ChatMessage | null | undefined): boolean { + if (!message || message.role !== "agent") return false; + if (normalizedStatusText(message.status) !== "completed") return false; + return Boolean(firstNonEmptyString(message.text, finalResponseText((message as Record).finalResponse))); +} + +export function traceHasEvents(trace: ChatMessage["runnerTrace"]): boolean { + return Array.isArray(trace?.events) && trace.events.length > 0; +} + +export function agentRunFromResult(result: AgentChatResultResponse, runnerTrace: ChatMessage["runnerTrace"]): AgentRunProvenance | null { + return asAgentRun(result.agentRun ?? runnerTrace?.agentRun); +} + +export function agentRunFromMessage(message: ChatMessage): AgentRunProvenance | null { + return asAgentRun((message as Record).agentRun ?? message.runnerTrace?.agentRun); +} + +export function asAgentRun(value: unknown): AgentRunProvenance | null { + return value && typeof value === "object" ? value as AgentRunProvenance : null; +} + +export 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; + 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 = 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, 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) }; +} + +export 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); +} + +export 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) return true; + const events = Array.isArray(record.events) ? record.events : []; + return events.some((event) => traceEventHasTerminalEvidence(event)); +} + +export function traceResultHasTerminalEvidence(result: unknown): boolean { + const record = recordValue(result); + return traceSnapshotHasTerminalEvidence(result) || traceSnapshotHasTerminalEvidence(record?.runnerTrace); +} + +export function recordValue(value: unknown): Record | null { + return value && typeof value === "object" ? value as Record : null; +} + +function firstArray(...values: unknown[]): TraceEvent[] { + for (const value of values) { + if (Array.isArray(value)) return value as TraceEvent[]; + } + return []; +} + +export 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 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; + } + return undefined; +} + +export function finalResponseText(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message)); +} + +export function projectedAgentMessageText(input: { status: unknown; finalText?: string | null; errorText?: string | null; baseText?: string | null }): string { + const text = firstNonEmptyString(input.finalText, input.baseText); + if (!isFailureTerminalStatus(input.status)) return text ?? ""; + return firstNonEmptyString(input.errorText, text) ?? ""; +} + +function isFailureTerminalStatus(value: unknown): boolean { + return ["failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-")); +} + +export 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; + return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message), messageText(record.summary), messageText(record.preview), messageText(record.title)); + } + return null; +} + +export function isTraceActiveStatus(status: unknown): boolean { + return ["accepted", "pending", "queued", "dispatching", "streaming", "processing", "running", "retrying", "busy", "creating"].includes(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-")); +} diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index d18cbf07..d16e060e 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -6,7 +6,7 @@ import { defineStore } from "pinia"; import { api } from "@/api"; import { workbenchRuntimePolicy } from "@/config/workbench-runtime-policy"; import { createWorkbenchHealthProbeCache } from "@/utils/workbench-health"; -import { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagnostic, normalizeProjectionBlocker, normalizeProjectionDiagnostic, projectionDiagnosticFromApiFailure, projectionDiagnosticFromFailure } from "@/utils/workbench-error-runtime"; +import { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagnostic, normalizeProjectionDiagnostic, projectionDiagnosticFromApiFailure, projectionDiagnosticFromFailure } from "@/utils/workbench-error-runtime"; import { createWorkbenchReadHydrationRuntime, createWorkbenchScheduledTaskRuntime, createWorkbenchTraceHydrationQueueRuntime, shouldCooldownWorkbenchReadFailure as shouldCooldownWorkbenchReadRuntimeFailure } from "@/utils/workbench-refresh-runtime"; import { readWorkbenchJson, readWorkbenchNumber, readWorkbenchString, removeWorkbenchStorageKey, writeWorkbenchJson, writeWorkbenchString } from "@/utils/workbench-storage-runtime"; import { createWorkbenchStreamTransportRuntime, type WorkbenchRealtimeEvent, type WorkbenchStreamTransportRecovery } from "@/utils/workbench-realtime-runtime"; @@ -20,6 +20,45 @@ import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection"; import { cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state"; import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache"; import { reduceWorkbenchRealtimeEvent, type WorkbenchRealtimeAction } from "./workbench-event-reducer"; +import { + agentErrorDisplayText, + agentErrorFromApiFailure, + agentRunFromMessage, + agentRunFromResult, + asAgentRun, + clearRunnerTraceTransientDiagnostics, + finalResponseText, + firstFiniteNumber, + isTerminalMessageStatus, + isTraceActiveStatus, + messageHasCompletedFinalResponse, + messageNeedsTerminalDiagnostics, + messageNeedsTraceHydration, + messageStatusPatchForTerminalMerge, + messageText, + messageTimingPatch, + messageTimingPatchForMerge, + messageTimingPatchFromProjection, + messageTimingSealPatchForProjectionMerge, + mergeTerminalResultTrace, + nonBlockingProjection, + normalizeAgentError, + normalizeMessageRunnerTrace, + normalizedStatusText, + optionalString, + positiveDurationBetween, + projectedAgentMessageText, + projectionFromMessage, + projectionFromResult, + recordValue, + shouldClearCompletedTurnDiagnostics, + shouldSuppressTransientWorkbenchReadFailure, + terminalMessageTimingPatchForNormalize, + traceHasCompletedFinalResponse, + traceHasEvents, + traceResultHasTerminalEvidence, + traceSnapshotError +} from "./workbench-message-projection-runtime"; import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type WorkbenchRealtimeApplyStep, type WorkbenchRealtimeRecoveryStep } from "./workbench-realtime-plan"; const WORKBENCH_SESSION_PROJECTION_SIGNAL_CHANNEL = "hwlab.workbench.sessionProjection.v1"; @@ -650,23 +689,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { }); } - function clearRunnerTraceTransientDiagnostics(trace: NonNullable): NonNullable { - return { ...trace, error: undefined, projection: null, projectionStatus: null, projectionHealth: null, staleMs: null, blocker: null }; - } - -function shouldClearCompletedTurnDiagnostics(status: unknown, error: ChatMessage["error"] | null): boolean { - return normalizedStatusText(status) === "completed" && !error; -} - -function nonBlockingProjection(projection: ProjectionDiagnostic | null): ProjectionDiagnostic | null { - if (!projection) return null; - const status = firstNonEmptyString(projection.projectionStatus); - const health = firstNonEmptyString(projection.projectionHealth); - if (status === "caught-up" || health === "healthy") return projection; - if (!projection.blocker && !projection.apiError && !projection.diagnostic) return projection; - return null; -} - async function submitMessage(text: string): Promise { const value = text.trim(); if (!value) return false; @@ -1864,456 +1886,6 @@ function realtimeSnapshotToTraceSnapshot(traceId: string, snapshot: WorkbenchRea } as TraceSnapshot; } -function normalizedStatusText(value: unknown): string | null { - const text = firstNonEmptyString(value); - return text ? text.trim().toLowerCase().replace(/_/gu, "-") : null; -} - -function optionalString(...values: unknown[]): string | undefined { - for (const value of values) { - if (typeof value !== "string") continue; - const text = value.trim(); - if (text) return text; - } - return undefined; -} - -function traceSnapshotError(value: unknown): Exclude | undefined { - const error = normalizeAgentError(value); - return error ? error as Exclude : undefined; -} - -function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: AgentChatResultResponse): NonNullable { - 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); - const traceDetailStatus = firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus, resultTrace?.status); - const timing = normalizeTimingProjection(result); - const nextTrace = { - ...resultTrace, - traceId: firstNonEmptyString(result.traceId, resultTrace?.traceId, previous?.traceId) ?? 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), - retention: result.retention ?? resultTrace?.retention ?? previous?.retention, - terminalEvidence: result.terminalEvidence ?? resultTrace?.terminalEvidence ?? previous?.terminalEvidence, - traceSummary: result.traceSummary ?? resultTrace?.traceSummary ?? previous?.traceSummary, - agentRun: agentRun ?? undefined, - error: normalizeAgentError(result.error ?? resultTrace?.error ?? previous?.error) ?? undefined, - projection: projectionFromResult(result) ?? normalizeProjectionDiagnostic(resultTrace?.projection ?? resultTrace) ?? previous?.projection ?? null, - projectionStatus: result.projectionStatus ?? resultTrace?.projectionStatus ?? previous?.projectionStatus ?? null, - projectionHealth: result.projectionHealth ?? resultTrace?.projectionHealth ?? previous?.projectionHealth ?? null, - staleMs: result.staleMs ?? resultTrace?.staleMs ?? previous?.staleMs ?? null, - blocker: result.blocker ?? resultTrace?.blocker ?? previous?.blocker ?? null, - timing, - startedAt: timing?.startedAt ?? null, - lastEventAt: timing?.lastEventAt ?? null, - finishedAt: timing?.finishedAt ?? null, - durationMs: timing?.durationMs ?? null, - 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) ?? undefined, - updatedAt: new Date().toISOString() - } as NonNullable; - if (traceDetailStatus) nextTrace.status = traceDetailStatus; - const traceStatus = firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus); - if (traceStatus) nextTrace.traceStatus = traceStatus; - return mergeRunnerTrace(previous, nextTrace); -} - -function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerTrace"] { - const trace = recordValue(message.runnerTrace); - const agentRun = agentRunFromMessage(message); - const error = normalizeAgentError(message.error ?? trace?.error); - const projection = projectionFromMessage(message, trace as ChatMessage["runnerTrace"] | null); - const timing = normalizeTimingProjection(message); - if (!trace && !agentRun && !error && !projection) 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, - projection, - projectionStatus: projection?.projectionStatus ?? trace?.projectionStatus, - projectionHealth: projection?.projectionHealth ?? trace?.projectionHealth, - staleMs: projection?.staleMs ?? trace?.staleMs, - blocker: projection?.blocker ?? trace?.blocker, - timing, - startedAt: timing?.startedAt ?? null, - lastEventAt: timing?.lastEventAt ?? null, - finishedAt: timing?.finishedAt ?? null, - durationMs: timing?.durationMs ?? null, - runnerKind: agentRun?.adapter ?? trace?.runnerKind, - sessionMode: agentRun?.backendProfile ?? trace?.sessionMode - } as ChatMessage["runnerTrace"]; -} - -function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjection | null { - const record = recordValue(value); - if (!record) return null; - const source = recordValue(record.timing) ?? {}; - const startedAt = firstNonEmptyString(source.startedAt, record.startedAt); - const lastEventAt = firstNonEmptyString(source.lastEventAt, record.lastEventAt); - const finishedAt = firstNonEmptyString(source.finishedAt, record.finishedAt); - const durationMs = firstFiniteNumber(source.durationMs, record.durationMs); - const observedAt = firstNonEmptyString(source.observedAt, record.observedAt); - const lastEventAgeMs = firstFiniteNumber(source.lastEventAgeMs, record.lastEventAgeMs); - if (!startedAt && !lastEventAt && !finishedAt && durationMs == null && lastEventAgeMs == null) return null; - return { ...source, startedAt: startedAt ?? null, lastEventAt: lastEventAt ?? null, finishedAt: finishedAt ?? null, durationMs: durationMs ?? null, observedAt: observedAt ?? null, lastEventAgeMs: lastEventAgeMs ?? null, valuesRedacted: source.valuesRedacted !== false } as WorkbenchTurnTimingProjection; -} - -function messageTimingPatch(value: unknown): Partial { - const timing = normalizeTimingProjection(value); - if (!timing) return {}; - return messageTimingPatchFromProjection(timing); -} - -function messageTimingPatchFromProjection(timing: WorkbenchTurnTimingProjection): Partial { - return { timing, startedAt: timing.startedAt ?? null, lastEventAt: timing.lastEventAt ?? null, finishedAt: timing.finishedAt ?? null, durationMs: timing.durationMs ?? null }; -} - -function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Partial { - const incomingTiming = normalizeTimingProjection(value); - if (!incomingTiming || !isTerminalTimingSource(value)) return messageTimingPatch(message); - const existingTiming = normalizeTimingProjection(message); - const existingDuration = firstPositiveFiniteNumber(existingTiming?.durationMs); - if (isTerminalMessageStatus(message.status) && existingTiming && existingDuration !== null) return messageTimingPatchFromProjection(existingTiming); - const incomingDuration = firstPositiveFiniteNumber(incomingTiming.durationMs) ?? positiveDurationBetween(incomingTiming.startedAt, incomingTiming.finishedAt) ?? positiveDurationBetween(incomingTiming.startedAt, incomingTiming.lastEventAt); - if (incomingDuration !== null) return messageTimingPatchFromProjection({ ...(existingTiming ?? {}), ...incomingTiming, durationMs: incomingDuration, valuesRedacted: incomingTiming.valuesRedacted !== false && existingTiming?.valuesRedacted !== false }); - const runningDuration = isTerminalMessageStatus(message.status) ? null : runningDurationFromTiming(existingTiming); - if (runningDuration !== null) { - const finishedAt = incomingTiming.finishedAt ?? incomingTiming.lastEventAt ?? null; - if (!finishedAt) return terminalMessageTimingPatchForNormalize(value); - return messageTimingPatchFromProjection({ ...(incomingTiming ?? {}), startedAt: existingTiming?.startedAt ?? incomingTiming.startedAt ?? null, lastEventAt: incomingTiming.lastEventAt ?? finishedAt, finishedAt, durationMs: runningDuration, valuesRedacted: incomingTiming.valuesRedacted !== false && existingTiming?.valuesRedacted !== false }); - } - return terminalMessageTimingPatchForNormalize(value); -} - -function messageTimingSealPatchForProjectionMerge(message: ChatMessage, previous: ChatMessage | null): Partial { - if (previous && isProjectionRunningAgentMessageForTiming(previous) && isProjectionRunningAgentMessageForTiming(message)) return messageRunningSealPatchForProjectionMerge(message, previous); - if (previous && isProjectionRunningAgentMessageForTiming(previous) && isTerminalMessageStatus(message.status)) return messageTerminalFromRunningSealPatchForProjectionMerge(message, previous); - return messageTerminalSealPatchForProjectionMerge(message, previous); -} - -function messageRunningSealPatchForProjectionMerge(message: ChatMessage, previous: ChatMessage): Partial { - const previousTiming = normalizeTimingProjection(previous); - if (!previousTiming?.startedAt) return messageTimingPatch(message); - const incomingTiming = normalizeTimingProjection(message); - const previousStartedMs = timestampMs(previousTiming.startedAt); - const incomingStartedMs = timestampMs(incomingTiming?.startedAt); - const startedAt = incomingStartedMs !== null && previousStartedMs !== null && incomingStartedMs < previousStartedMs - ? incomingTiming?.startedAt ?? previousTiming.startedAt - : previousTiming.startedAt; - const previousLastMs = timestampMs(previousTiming.lastEventAt); - const incomingLastMs = timestampMs(incomingTiming?.lastEventAt); - const lastEventAt = incomingLastMs !== null && (previousLastMs === null || incomingLastMs >= previousLastMs) - ? incomingTiming?.lastEventAt ?? previousTiming.lastEventAt ?? startedAt - : previousTiming.lastEventAt ?? incomingTiming?.lastEventAt ?? startedAt; - const timing = { - ...previousTiming, - ...(incomingTiming ?? {}), - startedAt, - lastEventAt, - finishedAt: null, - durationMs: null, - valuesRedacted: incomingTiming?.valuesRedacted !== false && previousTiming.valuesRedacted !== false, - } as WorkbenchTurnTimingProjection; - return messageTimingPatchFromProjection(timing); -} - -function isProjectionRunningAgentMessageForTiming(message: ChatMessage): boolean { - if (message.role !== "agent") return false; - const status = normalizedStatusText(message.status); - if (status && isTerminalMessageStatus(status)) return false; - return status === null || isTraceActiveStatus(status) || message.traceAutoLifecycle === "running"; -} - -function messageTerminalFromRunningSealPatchForProjectionMerge(message: ChatMessage, previous: ChatMessage): Partial { - const incomingPatch = terminalMessageTimingPatchForNormalize(message); - const incomingTiming = normalizeTimingProjection(incomingPatch) ?? normalizeTimingProjection(message); - const previousTiming = normalizeTimingProjection(previous); - const runningDuration = runningDurationFromTiming(previousTiming); - if (runningDuration === null) return incomingPatch; - const incomingDuration = firstPositiveFiniteNumber(incomingTiming?.durationMs) ?? positiveDurationBetween(incomingTiming?.startedAt, incomingTiming?.finishedAt) ?? positiveDurationBetween(incomingTiming?.startedAt, incomingTiming?.lastEventAt); - const durationMs = Math.max(incomingDuration ?? 0, runningDuration); - const finishedAt = incomingTiming?.finishedAt ?? incomingTiming?.lastEventAt ?? null; - if (!finishedAt) return incomingPatch; - const timing = { - ...(incomingTiming ?? {}), - startedAt: previousTiming?.startedAt ?? incomingTiming?.startedAt ?? null, - lastEventAt: incomingTiming?.lastEventAt ?? finishedAt, - finishedAt, - durationMs, - valuesRedacted: incomingTiming?.valuesRedacted !== false && previousTiming?.valuesRedacted !== false, - } as WorkbenchTurnTimingProjection; - return messageTimingPatchFromProjection(timing); -} - -function messageTerminalSealPatchForProjectionMerge(message: ChatMessage, previous: ChatMessage | null): Partial { - const incomingTimingPatch = messageTimingPatch(message); - if (!previous || !isTerminalMessageStatus(previous.status)) return incomingTimingPatch; - const patch: Partial = { status: previous.status, traceAutoLifecycle: previous.traceAutoLifecycle ?? "terminal" }; - if (typeof previous.text === "string" && previous.text.trim()) patch.text = previous.text; - const previousTiming = normalizeTimingProjection(previous); - if (firstPositiveFiniteNumber(previousTiming?.durationMs) === null) return { ...terminalMessageTimingPatchForNormalize(message), ...patch }; - return { ...messageTimingPatch(previous), ...patch }; -} - -function terminalMessageTimingPatchForNormalize(value: unknown): Partial { - const timing = normalizeTimingProjection(value); - if (!timing) return {}; - const record = recordValue(value); - const durationMs = firstPositiveFiniteNumber(timing.durationMs) - ?? positiveDurationBetween(timing.startedAt, timing.finishedAt) - ?? positiveDurationBetween(timing.startedAt, timing.lastEventAt) - ?? positiveDurationBetween(timing.startedAt, record?.updatedAt) - ?? (timing.durationMs === 0 ? 0 : null); - return messageTimingPatchFromProjection({ ...timing, durationMs }); -} - -function isTerminalTimingSource(value: unknown): boolean { - const record = recordValue(value); - if (!record) return false; - const runnerTrace = recordValue(record.runnerTrace); - return record.terminal === true || isTerminalMessageStatus(record.status) || isTerminalMessageStatus(record.traceStatus) || isTerminalMessageStatus(runnerTrace?.status) || isTerminalMessageStatus(runnerTrace?.traceStatus) || normalizeTimingProjection(value)?.finishedAt != null; -} - -function runningDurationFromTiming(timing: WorkbenchTurnTimingProjection | null): number | null { - const startedMs = timestampMs(timing?.startedAt); - if (startedMs === null) return null; - const duration = Math.trunc(Date.now() - startedMs); - return duration > 0 ? Math.max(1000, duration) : 1000; -} - -function positiveDurationBetween(startedAt: unknown, finishedAt: unknown): number | null { - const startedMs = timestampMs(startedAt); - const finishedMs = timestampMs(finishedAt); - if (startedMs === null || finishedMs === null || finishedMs <= startedMs) return null; - return Math.trunc(finishedMs - startedMs); -} - -function timestampMs(value: unknown): number | null { - if (typeof value !== "string") return null; - const text = value.trim(); - if (!text) return null; - const parsed = Date.parse(text); - return Number.isFinite(parsed) ? parsed : null; -} - -function messageStatusPatchForTerminalMerge(message: ChatMessage, resultStatus: string | null, terminal: boolean): Partial { - if (!terminal || isTerminalMessageStatus(message.status)) return {}; - if (resultStatus && isTerminalMessageStatus(resultStatus)) return { status: resultStatus as ChatMessage["status"] }; - return {}; -} - -function firstPositiveFiniteNumber(...values: unknown[]): number | null { - for (const value of values) { - const number = firstFiniteNumber(value); - if (number !== undefined && number > 0) return number; - } - return null; -} - -function maxFiniteNumber(...values: unknown[]): number | null { - let max: number | null = null; - for (const value of values) { - const number = Number(value); - if (!Number.isFinite(number) || number < 0) continue; - const normalized = Math.trunc(number); - max = max === null ? normalized : Math.max(max, normalized); - } - return max; -} - -function projectionFromResult(result: AgentChatResultResponse | TraceSnapshot | Record | null | undefined): ProjectionDiagnostic | null { - return normalizeProjectionDiagnostic(result) ?? normalizeProjectionDiagnostic(recordValue(result)?.runnerTrace) ?? null; -} - -function projectionFromMessage(message: ChatMessage, trace: ChatMessage["runnerTrace"] | null | undefined): ProjectionDiagnostic | null { - return normalizeProjectionDiagnostic(message.projection ?? message) ?? normalizeProjectionDiagnostic(trace?.projection ?? trace) ?? null; -} - -function shouldSuppressTransientWorkbenchReadFailure(result: ApiResult): boolean { - const diagnostic = normalizeErrorDiagnostic(result.diagnostic, result.apiError?.diagnostic); - const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code); - const category = firstNonEmptyString(result.apiError?.category, diagnostic?.category); - const source = firstNonEmptyString(result.apiError?.source, diagnostic?.source); - if (code === "workbench_read_hydration_cooldown" || code === "workbench_read_hydration_throttled") return true; - return result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network"; -} - -function agentErrorFromApiFailure(result: ApiResult, 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 messageNeedsTerminalDiagnostics(message: ChatMessage): boolean { - if (message.role !== "agent") return false; - if (!isTerminalMessageStatus(message.status)) return false; - if (messageHasCompletedFinalResponse(message)) 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; - if (!messageHasActiveTraceForHydration(message)) 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 messageHasActiveTraceForHydration(message: ChatMessage): boolean { - const trace = message.runnerTrace; - return isTraceActiveStatus(message.status) - || isTraceActiveStatus(trace?.status) - || isTraceActiveStatus(trace?.traceStatus); -} - -function traceHasCompletedFinalResponse(traceId: string | null | undefined, source: ChatMessage[]): boolean { - const id = firstNonEmptyString(traceId); - if (!id) return false; - return source.some((message) => firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) === id && messageHasCompletedFinalResponse(message)); -} - -function messageHasCompletedFinalResponse(message: ChatMessage | null | undefined): boolean { - if (!message || message.role !== "agent") return false; - if (normalizedStatusText(message.status) !== "completed") return false; - return Boolean(firstNonEmptyString(message.text, finalResponseText((message as Record).finalResponse))); -} - -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).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; - 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 = 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, 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) }; -} - -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) return true; - const events = Array.isArray(record.events) ? record.events : []; - return events.some((event) => traceEventHasTerminalEvidence(event)); -} - -function traceResultHasTerminalEvidence(result: unknown): boolean { - const record = recordValue(result); - return traceSnapshotHasTerminalEvidence(result) || traceSnapshotHasTerminalEvidence(record?.runnerTrace); -} - -function recordValue(value: unknown): Record | null { - return value && typeof value === "object" ? value as Record : 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 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; - } - return undefined; -} - -function finalResponseText(value: unknown): string | null { - if (!value || typeof value !== "object") return null; - const record = value as Record; - return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message)); -} - -function projectedAgentMessageText(input: { status: unknown; finalText?: string | null; errorText?: string | null; baseText?: string | null }): string { - const text = firstNonEmptyString(input.finalText, input.baseText); - if (!isFailureTerminalStatus(input.status)) return text ?? ""; - return firstNonEmptyString(input.errorText, text) ?? ""; -} - -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; - 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(label: string, call: () => Promise>): Promise> { try { return await call(); @@ -2351,10 +1923,6 @@ function makeMessage(role: ChatMessage["role"], text: string, status: ChatMessag return { id: nextProtocolId("msg"), role, title: extra.title ?? (role === "user" ? "用户" : "Code Agent"), text, status, createdAt: new Date().toISOString(), ...extra }; } -function isTraceActiveStatus(status: unknown): boolean { - return ["accepted", "pending", "queued", "dispatching", "streaming", "processing", "running", "retrying", "busy", "creating"].includes(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-")); -} - function readString(key: string, fallback: string): string { return readWorkbenchString(key, fallback); }