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:
@@ -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`.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user