refactor: move workbench message projection runtime out of store
This commit is contained in:
@@ -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 形态。
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<ChatMessage["runnerTrace"]>): NonNullable<ChatMessage["runnerTrace"]> {
|
||||
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<TraceSnapshot["error"], null> | undefined {
|
||||
const error = normalizeAgentError(value);
|
||||
return error ? error as Exclude<TraceSnapshot["error"], null> : undefined;
|
||||
}
|
||||
|
||||
export function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: AgentChatResultResponse): NonNullable<ChatMessage["runnerTrace"]> {
|
||||
const resultTrace = recordValue(result.runnerTrace);
|
||||
const events = firstArray(result.events, result.traceEvents, resultTrace?.events, previous?.events);
|
||||
const agentRun = asAgentRun(result.agentRun ?? resultTrace?.agentRun ?? previous?.agentRun);
|
||||
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<ChatMessage["runnerTrace"]>;
|
||||
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<ChatMessage> {
|
||||
const timing = normalizeTimingProjection(value);
|
||||
if (!timing) return {};
|
||||
return messageTimingPatchFromProjection(timing);
|
||||
}
|
||||
|
||||
export function messageTimingPatchFromProjection(timing: WorkbenchTurnTimingProjection): Partial<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
const incomingTimingPatch = messageTimingPatch(message);
|
||||
if (!previous || !isTerminalMessageStatus(previous.status)) return incomingTimingPatch;
|
||||
const patch: Partial<ChatMessage> = { 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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<string, unknown> | 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<unknown>): 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<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 };
|
||||
}
|
||||
|
||||
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<string, unknown>).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<string, unknown>).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<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 = 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<string, unknown> | null {
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function firstArray(...values: unknown[]): TraceEvent[] {
|
||||
for (const value of values) {
|
||||
if (Array.isArray(value)) return value as TraceEvent[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message), messageText(record.summary), messageText(record.preview), messageText(record.title));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isTraceActiveStatus(status: unknown): boolean {
|
||||
return ["accepted", "pending", "queued", "dispatching", "streaming", "processing", "running", "retrying", "busy", "creating"].includes(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
|
||||
}
|
||||
@@ -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<ChatMessage["runnerTrace"]>): NonNullable<ChatMessage["runnerTrace"]> {
|
||||
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<boolean> {
|
||||
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<TraceSnapshot["error"], null> | undefined {
|
||||
const error = normalizeAgentError(value);
|
||||
return error ? error as Exclude<TraceSnapshot["error"], null> : undefined;
|
||||
}
|
||||
|
||||
function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: AgentChatResultResponse): NonNullable<ChatMessage["runnerTrace"]> {
|
||||
const resultTrace = recordValue(result.runnerTrace);
|
||||
const events = firstArray(result.events, result.traceEvents, resultTrace?.events, previous?.events);
|
||||
const agentRun = asAgentRun(result.agentRun ?? resultTrace?.agentRun ?? previous?.agentRun);
|
||||
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<ChatMessage["runnerTrace"]>;
|
||||
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<ChatMessage> {
|
||||
const timing = normalizeTimingProjection(value);
|
||||
if (!timing) return {};
|
||||
return messageTimingPatchFromProjection(timing);
|
||||
}
|
||||
|
||||
function messageTimingPatchFromProjection(timing: WorkbenchTurnTimingProjection): Partial<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
const incomingTimingPatch = messageTimingPatch(message);
|
||||
if (!previous || !isTerminalMessageStatus(previous.status)) return incomingTimingPatch;
|
||||
const patch: Partial<ChatMessage> = { 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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<string, unknown> | 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<unknown>): 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<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 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<string, unknown>).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<string, unknown>).agentRun ?? message.runnerTrace?.agentRun);
|
||||
}
|
||||
|
||||
function asAgentRun(value: unknown): AgentRunProvenance | null {
|
||||
return value && typeof value === "object" ? value as AgentRunProvenance : null;
|
||||
}
|
||||
|
||||
function normalizeAgentError(value: unknown): ChatMessage["error"] | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") return value.trim() ? { message: value.trim() } : null;
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
const 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<string, unknown> | null {
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function firstArray(...values: unknown[]): TraceEvent[] {
|
||||
for (const value of values) {
|
||||
if (Array.isArray(value)) return value as TraceEvent[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function firstFiniteNumber(...values: unknown[]): number | undefined {
|
||||
for (const value of values) {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
if (Number.isFinite(number)) return number;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
return firstNonEmptyString(messageText(record.text), messageText(record.content), messageText(record.message), messageText(record.summary), messageText(record.preview), messageText(record.title));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function liveCall<T>(label: string, call: () => Promise<ApiResult<T>>): Promise<ApiResult<T>> {
|
||||
try {
|
||||
return await call();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user