Files
openwork/apps/app/tests/session-error-resilience.test.tsx
T

228 lines
8.0 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test"
import type { UIMessage } from "ai"
import { renderToStaticMarkup } from "react-dom/server"
import { MessageList } from "../src/components/chat/message-list"
import { MessageListProvider } from "../src/components/chat/message-list-provider"
import { getReactQueryClient } from "../src/react-app/infra/query-client"
import { createSessionErrorUIMessage } from "../src/react-app/domains/session/sync/usechat-adapter"
import {
presentOpencodeSessionError,
sessionErrorPresentationFromUIMessage,
} from "../src/react-app/domains/session/sync/session-error"
import {
__applySessionSyncEventForTest,
__createWorkspaceSessionSyncForTest,
trackWorkspaceSessionSync,
transcriptKey,
} from "../src/react-app/domains/session/sync/session-sync"
afterEach(() => {
getReactQueryClient().clear()
})
describe("session error resilience", () => {
test("classifies an OpenCode abort and retains its diagnostic payload", () => {
const presentation = presentOpencodeSessionError({
name: "MessageAbortedError",
data: {
message: "Aborted",
providerID: "openai",
code: "ABORT_ERR",
},
})
expect(presentation.kind).toBe("aborted")
expect(presentation.title).toBe("Task interrupted")
expect(presentation.description).toContain("Output and files already produced are kept")
expect(presentation.technicalDetails).toContain("Error type: MessageAbortedError")
expect(presentation.technicalDetails).toContain("Provider: openai")
expect(presentation.technicalDetails).toContain("Code: ABORT_ERR")
expect(presentation.recoveryPrompt).toContain("do not repeat side effects")
})
test("distinguishes a provider header timeout from an engine abort", () => {
const presentation = presentOpencodeSessionError({
name: "ProviderHeaderTimeoutError",
data: {
message: "Provider response headers timed out after 10000ms",
providerID: "openai",
retries: 2,
},
})
expect(presentation.kind).toBe("provider-timeout")
expect(presentation.title).toBe("Provider did not respond in time")
expect(presentation.technicalDetails).toContain("Retries: 2")
expect(presentation.recoveryPrompt).not.toBeNull()
})
test("stores structured error data on the synthetic message for reload-safe rendering", () => {
const presentation = presentOpencodeSessionError({
name: "MessageAbortedError",
data: { message: "Aborted" },
})
const message = createSessionErrorUIMessage("assistant-turn", presentation)
expect(sessionErrorPresentationFromUIMessage(message)).toEqual(presentation)
})
test("keeps partial output and adds the recoverable error beside the failed turn", () => {
const syncInput = {
workspaceId: "workspace-1",
baseUrl: "http://127.0.0.1:1234",
openworkToken: "token",
}
const cleanup = __createWorkspaceSessionSyncForTest(syncInput)
const release = trackWorkspaceSessionSync(syncInput, "session-1")
const partialMessage: UIMessage = {
id: "assistant-turn",
role: "assistant",
parts: [{ type: "text", text: "I finished the first step.", state: "done" }],
}
getReactQueryClient().setQueryData(
transcriptKey("workspace-1", "session-1"),
[partialMessage],
)
try {
__applySessionSyncEventForTest(syncInput, {
type: "session.error",
properties: {
sessionID: "session-1",
error: { name: "MessageAbortedError", data: { message: "Aborted" } },
},
})
const transcript = getReactQueryClient().getQueryData<UIMessage[]>(
transcriptKey("workspace-1", "session-1"),
)
expect(transcript?.[0]).toEqual(partialMessage)
expect(transcript?.[1]?.id).toBe("session-error:assistant-turn")
const errorMessage = transcript?.[1]
if (!errorMessage) throw new Error("Expected the session error message")
expect(sessionErrorPresentationFromUIMessage(errorMessage)).toMatchObject({
kind: "aborted",
title: "Task interrupted",
})
} finally {
release()
cleanup()
}
})
test("renders interrupted sessions without the intrusive recovery panel", () => {
const message = createSessionErrorUIMessage(
"assistant-turn",
presentOpencodeSessionError({
name: "MessageAbortedError",
data: { message: "Aborted" },
}),
)
const html = renderToStaticMarkup(
<MessageListProvider
workspaceId="workspace-1"
sessionId="session-1"
showThinking={false}
developerMode={false}
displaySuggestions={false}
providerConnectedCount={1}
dispatchAction={() => undefined}
setPrompt={() => undefined}
onRevertToUserMessage={() => undefined}
onForkAtMessage={() => undefined}
onEditUserMessage={() => undefined}
onMcpReconnect={async () => "connected"}
onMcpReopenAuthorization={async () => undefined}
onMcpRetry={() => undefined}
>
<MessageList messages={[message]} status="ready" />
</MessageListProvider>,
)
expect(html).toContain("Task interrupted")
expect(html).not.toContain("Output and files already produced are kept")
expect(html).not.toContain("Prepare recovery")
expect(html).not.toContain('aria-label="Show error details"')
expect(html).not.toContain('data-testid="session-error-details-trigger"')
})
const renderErrorTranscriptWithResume = (error: unknown) => {
const message = createSessionErrorUIMessage(
"assistant-turn",
presentOpencodeSessionError(error),
)
return renderToStaticMarkup(
<MessageListProvider
workspaceId="workspace-1"
sessionId="session-1"
showThinking={false}
developerMode={false}
displaySuggestions={false}
providerConnectedCount={1}
dispatchAction={() => undefined}
setPrompt={() => undefined}
onRevertToUserMessage={() => undefined}
onForkAtMessage={() => undefined}
onEditUserMessage={() => undefined}
onResumeInterrupted={() => undefined}
onMcpReconnect={async () => "connected"}
onMcpReopenAuthorization={async () => undefined}
onMcpRetry={() => undefined}
>
<MessageList messages={[message]} status="ready" />
</MessageListProvider>,
)
}
test("offers Resume on the error card for an engine abort", () => {
const html = renderErrorTranscriptWithResume({
name: "MessageAbortedError",
data: { message: "Aborted" },
})
expect(html).toContain('data-testid="session-error-resume"')
expect(html).toContain("Resume")
})
test("renders a resumable interruption as a quiet status line, not an error card", () => {
const html = renderErrorTranscriptWithResume({
name: "MessageAbortedError",
data: { message: "Aborted" },
})
expect(html).toContain("Task interrupted")
expect(html).toContain('data-testid="session-error-interrupted"')
expect(html).not.toContain("border-destructive/30")
expect(html).not.toContain("bg-destructive/5")
})
test("keeps the destructive card for errors that cannot be resumed", () => {
const html = renderErrorTranscriptWithResume({
name: "ProviderAuthError",
data: { message: "Provider authentication failed" },
})
expect(html).toContain("border-destructive/30")
})
test("offers Resume on the error card for a provider timeout", () => {
const html = renderErrorTranscriptWithResume({
name: "ProviderHeaderTimeoutError",
data: { message: "Provider response headers timed out after 10000ms" },
})
expect(html).toContain('data-testid="session-error-resume"')
})
test("hides Resume for errors that cannot be resumed", () => {
const html = renderErrorTranscriptWithResume({
name: "ProviderAuthError",
data: { message: "Provider authentication failed" },
})
expect(html).not.toContain('data-testid="session-error-resume"')
expect(html).not.toContain(">Resume<")
})
})