feat(chat): surface failed turns and add error diagnostics to the status report

A turn that OpenCode stopped could end with nothing on screen: the
session.error event was only turned into a sidebar badge, its message was
dropped (the notification expected a different shape than OpenCode sends),
and a send that was accepted but never answered looked the same as success.

- The chat shows what OpenCode reported under the last message while that
  turn is the latest one, and names a user message an idle session has left
  unanswered for five seconds.
- The last 20 session errors are kept in memory and listed in the status
  report (Ctrl/Cmd+Shift+L, also `__opencodeDebug.statusReport()`), next to
  rejected sends, the managed OpenCode process's last error and stderr
  tail, and the OpenCode and desktop log file locations.
- The OpenCode health probe hits /global/health instead of a route that
  does not exist, and probe URLs resolve against the page for web runtimes.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 23:22:27 +03:00
parent d8e223bf49
commit b18933f19c
21 changed files with 390 additions and 7 deletions
+16
View File
@@ -202,6 +202,22 @@ Rules:
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
## Failed-turn diagnostics
A `session.error` event is the only account of a turn OpenCode stopped, and
it can arrive with no assistant message to attach to. `session-error-log.ts`
keeps the last 20 of them in memory (`recordSessionError`, fed from the
event pipeline next to the error notification) and `summarizeOpenCodeError`
reads the `{ name, data: { message } }` payload. The chat shows the newest
error for the open session under its last message while that turn is the
latest one (`SessionErrorNotice`), and also names a user message that an idle
session has left unanswered for five seconds, since an accepted send that
produced neither a message nor an error would otherwise look like nothing
happened. Both buffers — session errors and rejected sends — appear in the
status report (`buildOpenCodeStatusReport`, Ctrl/Cmd+Shift+L or
`__opencodeDebug.statusReport()`) together with the managed OpenCode
process's last error and stderr tail and the expected log file locations.
## Loading diagnostics
Session loading instrumentation is disabled by default. Set `localStorage.openchamber_session_load_perf` to `"1"`, reproduce the interaction, then inspect `window.__openchamberSessionLoadPerformance.events`.
+14 -1
View File
@@ -24,7 +24,8 @@ type TurnCompleteNotification = NotificationBase & {
type ErrorNotification = NotificationBase & {
type: "error"
error?: { message?: string; code?: string }
/** What OpenCode reported for the failed turn; both null when it gave no details. */
error?: { name: string | null; message: string | null }
}
export type Notification = TurnCompleteNotification | ErrorNotification
@@ -161,3 +162,15 @@ export function useSessionUnseenCount(sessionId: string): number {
return useNotificationStore((s) => s.index.session.unseenCount[sessionId] ?? 0)
}
/** The newest error OpenCode reported for this session, viewed or not. */
export function useLatestSessionError(sessionId: string): ErrorNotification | null {
return useNotificationStore((s) => {
if (!sessionId) return null
for (let index = s.list.length - 1; index >= 0; index -= 1) {
const notification = s.list[index]
if (notification.session === sessionId && notification.type === "error") return notification
}
return null
})
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test';
import { getRecentSessionErrors, recordSessionError, summarizeOpenCodeError } from './session-error-log';
describe('summarizeOpenCodeError', () => {
test('reads the OpenCode shape: name plus data.message', () => {
expect(summarizeOpenCodeError({ name: 'ProviderAuthError', data: { providerID: 'openai', message: 'Invalid API key' } }))
.toEqual({ name: 'ProviderAuthError', message: 'Invalid API key' });
});
test('falls back to a top-level message and reports missing details as null', () => {
expect(summarizeOpenCodeError({ message: 'socket hang up' })).toEqual({ name: null, message: 'socket hang up' });
expect(summarizeOpenCodeError({ name: 'UnknownError', data: { message: ' ' } })).toEqual({ name: 'UnknownError', message: null });
expect(summarizeOpenCodeError(undefined)).toEqual({ name: null, message: null });
});
test('bounds the message length', () => {
const summary = summarizeOpenCodeError({ name: 'UnknownError', data: { message: 'x'.repeat(1000) } });
expect(summary.message?.length).toBe(400);
});
});
describe('recordSessionError', () => {
test('keeps the newest records first and caps the buffer', () => {
for (let index = 0; index < 25; index += 1) {
recordSessionError({ sessionId: `ses_${index}`, directory: null, name: 'UnknownError', message: `error ${index}` });
}
const records = getRecentSessionErrors();
expect(records.length).toBe(20);
expect(records[0]?.sessionId).toBe('ses_24');
expect(records[19]?.sessionId).toBe('ses_5');
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Recent OpenCode session errors, kept in memory for diagnostics.
*
* OpenCode reports a failed turn as a `session.error` event. The message it
* carries is the only account of what went wrong, and it may arrive without
* an assistant message to attach itself to, so a turn can end with nothing
* on screen. This buffer keeps the last errors until someone asks for them,
* via the status report (Ctrl/Cmd+Shift+L) or `__opencodeDebug`. In-memory
* only: never persisted, never sent anywhere, dropped on reload.
*/
import type { EventSessionError } from '@opencode-ai/sdk/v2'
const MAX_RECORDED_SESSION_ERRORS = 20
const MAX_MESSAGE_LENGTH = 400
export type OpenCodeErrorSummary = {
name: string | null
message: string | null
}
export type SessionErrorRecord = OpenCodeErrorSummary & {
at: number
sessionId: string
directory: string | null
}
/**
* OpenCode error payloads are `{ name, data: { message, ... } }`; older or
* foreign shapes carry `message` at the top. Returns nulls for anything
* else so a caller can tell "no details" from a real message.
*/
export type OpenCodeSessionErrorPayload = EventSessionError['properties']['error']
export function summarizeOpenCodeError(error: OpenCodeSessionErrorPayload | { message?: string } | null | undefined): OpenCodeErrorSummary {
if (!error || typeof error !== 'object') return { name: null, message: null }
// SAFETY: the SDK union is `{ name, data: { message } }` per variant; a
// top-level `message` covers foreign shapes. Every field is checked before use.
const record = error as { name?: unknown; message?: unknown; data?: { message?: unknown } }
const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : null
const dataMessage = typeof record.data?.message === 'string' ? record.data.message.trim() : ''
const topMessage = typeof record.message === 'string' ? record.message.trim() : ''
const message = dataMessage || topMessage || null
return { name, message: message ? message.slice(0, MAX_MESSAGE_LENGTH) : null }
}
const records: SessionErrorRecord[] = []
export function recordSessionError(record: Omit<SessionErrorRecord, 'at'>): void {
records.push({ ...record, at: Date.now() })
if (records.length > MAX_RECORDED_SESSION_ERRORS) {
records.splice(0, records.length - MAX_RECORDED_SESSION_ERRORS)
}
}
/** Newest first. */
export function getRecentSessionErrors(): SessionErrorRecord[] {
return [...records].reverse()
}
+8 -3
View File
@@ -53,6 +53,7 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { toast } from "@/components/ui"
import { appendNotification } from "./notification-store"
import { recordSessionError, summarizeOpenCodeError, type OpenCodeSessionErrorPayload } from "./session-error-log"
import {
applyGlobalSessionStatusEvent,
applyGlobalSessionStatusEvents,
@@ -1771,8 +1772,12 @@ export function handleEvent(
// Notification dispatch for session turn-complete and error events.
// These are NOT handled by the event reducer — only the notification store.
if (payload.type === "session.idle" || payload.type === "session.error") {
const props = payload.properties as { sessionID?: string; error?: { message?: string; code?: string } }
const props = payload.properties as { sessionID?: string; error?: OpenCodeSessionErrorPayload }
const sessionID = props.sessionID
const errorSummary = payload.type === "session.error" ? summarizeOpenCodeError(props.error) : null
if (errorSummary && sessionID) {
recordSessionError({ sessionId: sessionID, directory: resolvedDirectory ?? null, ...errorSummary })
}
// Skip subtask sessions — only top-level sessions generate notifications
const storeState = getDirectoryEventState(store, batch)
const session = storeState.session.find((s) => s.id === sessionID)
@@ -1784,8 +1789,8 @@ export function handleEvent(
session: sessionID,
time: Date.now(),
viewed: isViewedInCurrentSession(resolvedDirectory, sessionID),
...(payload.type === "session.error"
? { type: "error" as const, error: props.error }
...(errorSummary
? { type: "error" as const, error: errorSummary }
: { type: "turn-complete" as const }),
})
}