From 21aceda665e484f1ccd43dbdca154014fe75f532 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:43:48 +0800 Subject: [PATCH] fix(workbench): seal terminal message status timing (#2030) --- web/hwlab-cloud-web/src/stores/workbench.ts | 43 ++++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 520b3832..c5e9e1f5 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -732,7 +732,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const error = resultError ?? (terminal || clearCompletedDiagnostics ? null : normalizeAgentError(runnerTrace?.error ?? message.error)); const agentRun = agentRunFromResult(result as AgentChatResultResponse, runnerTrace) ?? agentRunFromMessage(message); const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null); - return { ...message, ...messageTimingPatchForMerge(message, result), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); } @@ -1004,7 +1004,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null); const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); rememberTraceAuthority(runnerTrace); - return { ...message, ...messageTimingPatchForMerge(message, result), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; })); markWorkbenchTraceProjected(traceId); if (traceResultHasTerminalEvidence(result) && result.terminal !== true && !isTerminalMessageStatus(result.status)) { @@ -1343,7 +1343,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project const error = resultError ?? (terminal || clearCompletedDiagnostics ? null : normalizeAgentError(runnerTrace?.error ?? message.error)); const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? (terminal ? null : runnerTrace.projection ?? message.projection ?? null); - return { ...message, ...messageTimingPatchForMerge(message, result), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; })); void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []); scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); @@ -1831,13 +1831,46 @@ function messageTimingPatch(value: unknown): Partial { } function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Partial { - void value; // Trace/result hydration may update runnerTrace and TraceTimeline, but the // main Code Agent card timing is a durable message projection field. Do not // let trace snapshots or terminal result polling rewrite message.timing; // otherwise visible card elapsed can jump or diverge from the sealed message // projection when trace rows arrive out of order. - return messageTimingPatch(message); + const current = messageTimingPatch(message); + const currentTiming = current.timing ?? null; + const incoming = normalizeTimingProjection(value); + if (!incoming || isTerminalMessageStatus(message.status) || currentTiming?.finishedAt || currentTiming?.durationMs != null) return current; + const record = recordValue(value); + const incomingStatus = normalizedStatusText(firstNonEmptyString(record?.status)); + const incomingTerminal = isTerminalMessageStatus(incomingStatus) || traceResultHasTerminalEvidence(value); + if (!incomingTerminal) return current; + const startedAt = firstNonEmptyString(currentTiming?.startedAt, incoming.startedAt) ?? null; + const durationMs = firstFiniteNumber(incoming.durationMs, record?.durationMs) ?? inferredDurationMs(startedAt, incoming.finishedAt) ?? currentTiming?.durationMs ?? null; + const inferredFinishedAt = inferFinishedAtFromDuration(startedAt, durationMs); + const finishedAt = firstNonEmptyString(incoming.finishedAt, inferredFinishedAt, incoming.lastEventAt, currentTiming?.finishedAt) ?? null; + const lastEventAt = firstNonEmptyString(incoming.lastEventAt, finishedAt, currentTiming?.lastEventAt, startedAt) ?? null; + const timing = { ...currentTiming, ...incoming, startedAt, lastEventAt, finishedAt, durationMs, valuesRedacted: incoming.valuesRedacted !== false && currentTiming?.valuesRedacted !== false } as WorkbenchTurnTimingProjection; + return { ...current, timing, startedAt, lastEventAt, finishedAt, durationMs }; +} + +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 { status: "completed" }; +} + +function inferFinishedAtFromDuration(startedAt: unknown, durationMs: number | null): string | null { + if (durationMs === null || !Number.isFinite(durationMs) || durationMs < 0) return null; + const startedAtMs = timestampMs(startedAt); + if (startedAtMs === null) return null; + return new Date(startedAtMs + Math.trunc(durationMs)).toISOString(); +} + +function inferredDurationMs(startedAt: unknown, finishedAt: unknown): number | null { + const startedAtMs = timestampMs(startedAt); + const finishedAtMs = timestampMs(finishedAt); + if (startedAtMs === null || finishedAtMs === null || finishedAtMs < startedAtMs) return null; + return Math.trunc(finishedAtMs - startedAtMs); } function firstPositiveFiniteNumber(...values: unknown[]): number | null {