diff --git a/internal/cloud/workbench-projection-writer.test.ts b/internal/cloud/workbench-projection-writer.test.ts index d4c54a8c..5d4368d7 100644 --- a/internal/cloud/workbench-projection-writer.test.ts +++ b/internal/cloud/workbench-projection-writer.test.ts @@ -353,6 +353,9 @@ test("workbench projection writer seals failed AgentRun turns with failure final assert.equal(facts.turns[0].terminal, true); assert.equal(facts.turns[0].sealed, true); assert.match(facts.turns[0].finalResponse.text, /provider-stream-disconnected/u); + assert.equal(facts.turns[0].diagnostic.projectionStatus, "caught-up"); + assert.equal(facts.turns[0].diagnostic.projectionHealth, "healthy"); + assert.equal(facts.turns[0].diagnostic.blocker, null); assert.equal(facts.checkpoints[0].projectionStatus, "caught_up"); assert.equal(facts.checkpoints[0].terminal, true); assert.equal(facts.checkpoints[0].sealed, true); diff --git a/internal/cloud/workbench-turn-projection.ts b/internal/cloud/workbench-turn-projection.ts index 84c7fb52..fc6221ad 100644 --- a/internal/cloud/workbench-turn-projection.ts +++ b/internal/cloud/workbench-turn-projection.ts @@ -119,22 +119,22 @@ export function createWorkbenchTurnTimingProjection({ result = null, session = n export function projectionDiagnostics({ traceId = null, projection = null, result = null, trace = null, refreshError = null } = {}) { const turn = projection ?? createWorkbenchTurnProjection({ traceId, result, trace }); - const sealedCompleted = turn.terminal === true && turn.status === "completed" && Boolean(turn.finalResponse?.text); + const sealedTerminal = turn.terminal === true && Boolean(turn.finalResponse?.text); const waitingFor = textValue(turn.waitingFor) || null; - const source = sealedCompleted ? null : projectionDiagnosticSource(trace); - const rawBlocker = sealedCompleted ? null : refreshError ?? source?.blocker ?? trace?.blocker ?? result?.blocker ?? result?.error ?? null; - const retryingProviderInterruption = !sealedCompleted && turn.running === true ? retryableProviderInterruptionEvidence(rawBlocker) : null; + const source = sealedTerminal ? null : projectionDiagnosticSource(trace); + const rawBlocker = sealedTerminal ? null : refreshError ?? source?.blocker ?? trace?.blocker ?? result?.blocker ?? result?.error ?? null; + const retryingProviderInterruption = !sealedTerminal && turn.running === true ? retryableProviderInterruptionEvidence(rawBlocker) : null; const blocker = retryingProviderInterruption ? null : rawBlocker; const hasProjectionInput = hasTraceProjection(trace) || Boolean(result || result?.agentRun); - const sourceStatus = sealedCompleted ? null : normalizeProjectionStatus(source?.projectionStatus ?? trace?.projectionStatus); + const sourceStatus = sealedTerminal ? null : normalizeProjectionStatus(source?.projectionStatus ?? trace?.projectionStatus); const effectiveSourceStatus = retryingProviderInterruption && sourceStatus === "blocked" ? "projecting" : sourceStatus; - const status = sealedCompleted ? "caught-up" : waitingFor ? "projecting" : effectiveSourceStatus ?? (blocker ? "blocked" : turn.terminal ? "caught-up" : hasProjectionInput ? "projecting" : "unknown"); + const status = sealedTerminal ? "caught-up" : waitingFor ? "projecting" : effectiveSourceStatus ?? (blocker ? "blocked" : turn.terminal ? "caught-up" : hasProjectionInput ? "projecting" : "unknown"); const diagnostic = blocker ? diagnosticBlocker(blocker) : null; - const sourceHealth = sealedCompleted ? null : normalizeProjectionHealth(source?.projectionHealth ?? trace?.projectionHealth); + const sourceHealth = sealedTerminal ? null : normalizeProjectionHealth(source?.projectionHealth ?? trace?.projectionHealth); const effectiveSourceHealth = retryingProviderInterruption && (sourceHealth === "degraded" || sourceHealth === "unavailable" || sourceHealth === "stalled") ? "projecting" : sourceHealth; - const projectionHealth = sealedCompleted ? "healthy" : waitingFor ? "projecting" : effectiveSourceHealth + const projectionHealth = sealedTerminal ? "healthy" : waitingFor ? "projecting" : effectiveSourceHealth ?? projectionHealthFor({ status, turn, hasProjectionInput, blocker: diagnostic }); - const staleMs = sealedCompleted ? null : projectionStaleMs(source?.staleMs ?? trace?.staleMs, turn.updatedAt ?? trace?.updatedAt ?? result?.updatedAt); + const staleMs = sealedTerminal ? null : projectionStaleMs(source?.staleMs ?? trace?.staleMs, turn.updatedAt ?? trace?.updatedAt ?? result?.updatedAt); return { projectionStatus: status, projectionHealth, diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts index 895e189b..af2a300c 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.test.ts @@ -47,3 +47,22 @@ test("turn result merge seals completed result with final response", () => { assert.equal((patch?.finalResponse as any)?.text, "final answer"); assert.equal(patch?.text, "final answer"); }); + +test("turn result merge seals failed result with failure final response", () => { + const traceId = "trc_frontend_terminal_failed_with_body"; + const message = { id: "msg_frontend_terminal_failed_with_body", role: "agent", status: "running", traceId } as any; + const result = { + traceId, + status: "failed", + terminal: true, + finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected", status: "failed" } + } as any; + + const patch = terminalMessagePatchFromTurnResult(message, result); + assert.equal(turnResultStatusForMerge(result), "failed"); + assert.equal(turnResultIsTerminalForMerge(result), true); + assert.equal(patch?.status, "failed"); + assert.equal((patch?.finalResponse as any)?.status, "failed"); + assert.equal((patch?.finalResponse as any)?.text, "Workbench terminal failed: provider-stream-disconnected"); + assert.equal(patch?.text, "Workbench terminal failed: provider-stream-disconnected"); +}); 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 index 2ddf2742..195373be 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts @@ -325,20 +325,21 @@ export function terminalMessagePatchFromTurnResult(message: ChatMessage, result: } function terminalMessageBodyPatchFromTurnResult(message: ChatMessage, result: AgentChatResultResponse, resultStatus: string | null): Partial { - if (normalizedStatusText(resultStatus) !== "completed") return {}; + const status = normalizedStatusText(resultStatus); + if (!isTerminalMessageStatus(status)) return {}; const finalText = terminalFinalResponseTextFromTurnResult(result); if (!finalText) return {}; const traceId = firstNonEmptyString(result.traceId, message.traceId, message.runnerTrace?.traceId) ?? null; return { finalResponse: { text: finalText, - status: "completed", + status, traceId, sealed: true, source: "turn-result", valuesRedacted: true }, - text: projectedAgentMessageText({ status: "completed", finalText }) + text: projectedAgentMessageText({ status, finalText }) }; } diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts index 57cc2af8..5ffa64ce 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts @@ -55,3 +55,20 @@ test("server state reducer keeps sealed terminal messages when a stale partial s assert.equal(terminal.finalResponse.text, "final answer"); assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "completed"); }); + +test("server state reducer promotes failed terminal message authority and ignores running list rollback", () => { + const sessionId = "ses_state_failed_terminal_authority"; + const traceId = "trc_state_failed_terminal_authority"; + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", lastTraceId: traceId, messages: [] } as any }); + state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [ + { id: "msg_state_failed_user", role: "user", status: "sent", text: "one", sessionId, traceId } as any, + { id: "msg_state_failed_agent", role: "agent", status: "failed", text: "Workbench terminal failed: provider-stream-disconnected", finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:05.000Z", durationMs: 5000 } as any + ] }); + + assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "failed"); + state = reduceWorkbenchServerState(state, { type: "session.list", sessions: [{ sessionId, status: "running", lastTraceId: traceId, messages: [] } as any] }); + + assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "failed"); + assert.equal(selectActiveMessages(state, sessionId).find((message) => message.id === "msg_state_failed_agent")?.status, "failed"); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index a26ef96d..68d7e639 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -400,11 +400,12 @@ function sessionStatusAuthorityFromDetail(session: WorkbenchSessionRecord): Sess } function sessionStatusAuthorityFromMessages(sessionId: string, messages: ChatMessage[]): SessionStatusAuthority | null { - const message = [...messages].reverse().find((item) => messageHasCompletedFinalResponse(item)); + const message = [...messages].reverse().find((item) => messageHasTerminalResponse(item)); if (!message) return null; + const status = canonicalTerminalMessageStatus(message.status); return { sessionId, - status: "completed", + status, updatedAt: textValue(message.updatedAt) ?? textValue(message.finishedAt) ?? textValue(message.lastEventAt), lastTraceId: textValue(message.traceId) ?? textValue(message.runnerTrace?.traceId), projection: projectionFromMessageRecord(message), @@ -413,7 +414,7 @@ function sessionStatusAuthorityFromMessages(sessionId: string, messages: ChatMes } function mergeSessionStatusAuthority(existing: SessionStatusAuthority | undefined, incoming: SessionStatusAuthority): SessionStatusAuthority { - if (existing?.status === "completed" && incoming.status !== "completed" && isSameTraceAuthority(existing, incoming) && isRunningSessionStatus(incoming.status)) return existing; + if (isTerminalSessionStatusAuthority(existing) && !isTerminalSessionStatusAuthority(incoming) && isSameTraceAuthority(existing, incoming) && isRunningSessionStatus(incoming.status)) return existing; return { ...(existing ?? {}), ...incoming, @@ -422,6 +423,11 @@ function mergeSessionStatusAuthority(existing: SessionStatusAuthority | undefine }; } +function isTerminalSessionStatusAuthority(session: SessionStatusAuthority | undefined): boolean { + if (!session) return false; + return isTerminalMessageStatus(session.status) && !isRunningSessionStatus(session.status); +} + function mergeTurnStatusAuthority(existing: TurnStatusAuthority | undefined, incoming: TurnStatusAuthority): TurnStatusAuthority { if (existing && isTerminalTurnStatusAuthority(existing) && !isTerminalTurnStatusAuthority(incoming) && isRunningSessionStatus(incoming.status)) return existing; return { @@ -459,6 +465,19 @@ function messageHasCompletedFinalResponse(message: ChatMessage): boolean { return Boolean(messageFinalResponseText(message)); } +function messageHasTerminalResponse(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + const status = normalizedMessageStatus(message.status); + if (!isTerminalMessageStatus(status)) return false; + if (status === "completed") return messageHasCompletedFinalResponse(message); + return Boolean(messageFinalResponseText(message)); +} + +function canonicalTerminalMessageStatus(value: unknown): string { + const status = normalizedMessageStatus(value); + return status === "cancelled" ? "canceled" : status; +} + function messageFinalResponseText(message: ChatMessage): string | null { return textValue(message.text) ?? textValue(message.content) ?? nestedTextValue((message as Record).finalResponse); }