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
@@ -28,6 +28,7 @@ import { QuestionCard } from './QuestionCard';
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import { SessionErrorNotice } from '@/components/chat/SessionErrorNotice';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
@@ -375,6 +376,7 @@ const ChatViewport = React.memo(({
</div>
)}
<SessionErrorNotice sessionId={currentSessionId} directory={directory} />
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
@@ -0,0 +1,112 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { useLatestSessionError } from '@/sync/notification-store';
import { useDirectoryStore, useSessionStatus } from '@/sync/sync-context';
interface SessionErrorNoticeProps {
sessionId: string;
directory?: string;
}
// How long a user message may sit unanswered on an idle session before the
// notice calls it a reply that never began.
const UNANSWERED_AFTER_MS = 5_000;
type LastMessageState = {
role: string;
timestamp: number;
hasError: boolean;
} | null;
// The last message of a session, with whether it already carries an error of
// its own: an assistant message that OpenCode marked failed renders its error
// inline, so the session-level notice must not repeat it.
const useLastMessageState = (sessionId: string, directory?: string): LastMessageState => {
const store = useDirectoryStore(directory);
const cacheRef = React.useRef<LastMessageState>(null);
const getSnapshot = React.useCallback((): LastMessageState => {
if (!sessionId) return null;
const messages = store.getState().message[sessionId];
const last = messages && messages.length > 0 ? messages[messages.length - 1] : null;
// SAFETY: store messages are SDK `Message` records; `error` is the optional
// assistant-message error the SDK types carry, read here only for presence.
const info = last as { role?: string; time?: { completed?: number; created?: number }; error?: unknown } | null;
if (!info) {
cacheRef.current = null;
return null;
}
const next: LastMessageState = {
role: typeof info.role === 'string' ? info.role : '',
timestamp: info.time?.completed ?? info.time?.created ?? 0,
hasError: Boolean(info.error),
};
const cached = cacheRef.current;
if (cached && cached.role === next.role && cached.timestamp === next.timestamp && cached.hasError === next.hasError) {
return cached;
}
cacheRef.current = next;
return next;
}, [sessionId, store]);
const subscribe = React.useCallback((notify: () => void) => {
if (!sessionId) return () => undefined;
return store.subscribe(notify);
}, [sessionId, store]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/**
* Shows what OpenCode reported when it stopped a turn without producing a
* reply. Rendered under the last message, only while that turn is the latest
* one: sending again moves the last message past the error and hides it.
*/
export const SessionErrorNotice: React.FC<SessionErrorNoticeProps> = ({ sessionId, directory }) => {
const { t } = useI18n();
const latestError = useLatestSessionError(sessionId);
const status = useSessionStatus(sessionId, directory);
const lastMessage = useLastMessageState(sessionId, directory);
const isIdle = !status || status.type === 'idle';
const reportedError = latestError && isIdle
&& (!lastMessage || latestError.time >= lastMessage.timestamp)
&& !(lastMessage?.role === 'assistant' && lastMessage.hasError)
? latestError
: null;
// A user message that the session is idle on, with nothing after it for a
// while, is a reply that never began: the send was accepted but OpenCode
// produced neither a message nor an error for it.
const unansweredSince = !reportedError && isIdle && lastMessage?.role === 'user' ? lastMessage.timestamp : null;
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (unansweredSince === null) return undefined;
const remaining = UNANSWERED_AFTER_MS - (Date.now() - unansweredSince);
if (remaining <= 0) return undefined;
const timer = window.setTimeout(() => setNow(Date.now()), remaining + 50);
return () => window.clearTimeout(timer);
}, [unansweredSince]);
const unanswered = unansweredSince !== null && Math.max(now, Date.now()) - unansweredSince >= UNANSWERED_AFTER_MS;
if (!reportedError && !unanswered) return null;
const detail = reportedError
? (reportedError.error?.message ?? t('chat.sessionError.noDetails'))
: t('chat.sessionError.noDetails');
const name = reportedError?.error?.name;
return (
<div className="chat-message-column">
<div
role="status"
className="mt-3 max-w-full break-words rounded-2xl border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3 text-base leading-relaxed"
>
<div className="flex items-start gap-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-error)]" />
<div className="min-w-0 flex-1 break-words">
<div className="font-medium text-foreground">{reportedError ? t('chat.sessionError.title') : t('chat.sessionError.noReply')}</div>
<div className="mt-1 text-foreground/80">{name ? `${name}: ${detail}` : detail}</div>
</div>
</div>
</div>
</div>
);
};
+15
View File
@@ -13,6 +13,8 @@ import {
} from '@/sync/session-directory-resolution';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getRecentSendFailures } from '@/sync/send-failure-log';
import { getRecentSessionErrors } from '@/sync/session-error-log';
import { buildOpenCodeStatusReport } from '@/lib/openCodeStatus';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useStreamingStore } from '@/sync/streaming';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -386,6 +388,9 @@ export const debugUtils = {
// this session, so a "my message disappeared" report is not a rejected
// send and needs a different explanation.
recentSendFailures: getRecentSendFailures(),
// Same reasoning: empty means OpenCode reported no failed turn in this
// app session.
recentSessionErrors: getRecentSessionErrors(),
currentSessionDirectoryResolution: sessionState.currentSessionId
? this.diagnoseSessionDirectory(sessionState.currentSessionId)
: null,
@@ -395,6 +400,16 @@ export const debugUtils = {
return report;
},
/**
* The same text the status report dialog (Ctrl/Cmd+Shift+L) shows, for a
* console or remote session that cannot press the shortcut.
*/
async statusReport() {
const text = await buildOpenCodeStatusReport();
console.log(text);
return text;
},
/**
* Prompt sends that were rejected and rolled back in this app session.
* Newest first; empty means no send was rejected.
+3
View File
@@ -1553,6 +1553,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminalpanel ({shortcut})',
'chat.recap.aria': 'Sitzungs-Zusammenfassung',
'chat.recap.label': 'Zusammenfassung:',
'chat.sessionError.title': 'OpenCode hat diese Antwort abgebrochen',
'chat.sessionError.noDetails': 'OpenCode hat keine Details gemeldet. Öffne den Statusbericht (Strg/Cmd+Umschalt+L), um die letzten Fehler zu sehen.',
'chat.sessionError.noReply': 'OpenCode hat keine Antwort auf diese Nachricht begonnen.',
'chat.goal.dialog.titleCreate': 'Sitzungsziel festlegen',
'chat.goal.dialog.titleManage': 'Sitzungsziel',
'chat.goal.dialog.objectiveLabel': 'Ziel',
+3
View File
@@ -1750,6 +1750,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})',
'chat.recap.aria': 'Session recap',
'chat.recap.label': 'Recap:',
'chat.sessionError.title': 'OpenCode stopped this reply',
'chat.sessionError.noDetails': 'OpenCode reported no details. Open the status report (Ctrl/Cmd+Shift+L) to see recent errors.',
'chat.sessionError.noReply': 'OpenCode did not start a reply to this message.',
'chat.goal.dialog.titleCreate': 'Set Session Goal',
'chat.goal.dialog.titleManage': 'Session Goal',
'chat.goal.dialog.objectiveLabel': 'Objective',
+3
View File
@@ -1728,6 +1728,9 @@ export const dict: Record<I18nKey, string> = {
"header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})",
"chat.recap.aria": "Resumen de la sesión",
"chat.recap.label": "Resumen:",
"chat.sessionError.title": "OpenCode detuvo esta respuesta",
"chat.sessionError.noDetails": "OpenCode no informó detalles. Abre el informe de estado (Ctrl/Cmd+Mayús+L) para ver los errores recientes.",
"chat.sessionError.noReply": "OpenCode no comenzó una respuesta a este mensaje.",
"chat.goal.dialog.titleCreate": "Definir objetivo de sesión",
"chat.goal.dialog.titleManage": "Objetivo de sesión",
"chat.goal.dialog.objectiveLabel": "Objetivo",
+3
View File
@@ -1507,6 +1507,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Panneau à bornes ({shortcut})',
'chat.recap.aria': 'Récapitulatif de la session',
'chat.recap.label': 'Récap :',
'chat.sessionError.title': 'OpenCode a interrompu cette réponse',
'chat.sessionError.noDetails': 'OpenCode n\'a fourni aucun détail. Ouvrez le rapport d\'état (Ctrl/Cmd+Maj+L) pour voir les erreurs récentes.',
'chat.sessionError.noReply': 'OpenCode n\'a pas commencé de réponse à ce message.',
'chat.goal.dialog.titleCreate': 'Définir un objectif de session',
'chat.goal.dialog.titleManage': 'Objectif de session',
'chat.goal.dialog.objectiveLabel': 'Objectif',
+3
View File
@@ -1746,6 +1746,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut}',
'chat.recap.aria': 'セッションの要約',
'chat.recap.label': '要約:',
'chat.sessionError.title': 'OpenCode がこの返答を停止しました',
'chat.sessionError.noDetails': 'OpenCode から詳細は報告されませんでした。ステータスレポート(Ctrl/Cmd+Shift+L)で最近のエラーを確認してください。',
'chat.sessionError.noReply': 'OpenCode はこのメッセージへの返答を開始しませんでした。',
'chat.goal.dialog.titleCreate': 'セッションゴールを設定',
'chat.goal.dialog.titleManage': 'セッションゴール',
'chat.goal.dialog.objectiveLabel': '目標',
+3
View File
@@ -1752,6 +1752,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': '터미널 패널 ({shortcut})',
'chat.recap.aria': '세션 요약',
'chat.recap.label': '요약:',
'chat.sessionError.title': 'OpenCode가 이 응답을 중단했습니다',
'chat.sessionError.noDetails': 'OpenCode가 세부 정보를 보고하지 않았습니다. 상태 보고서(Ctrl/Cmd+Shift+L)에서 최근 오류를 확인하세요.',
'chat.sessionError.noReply': 'OpenCode가 이 메시지에 대한 응답을 시작하지 않았습니다.',
'chat.goal.dialog.titleCreate': '세션 목표 설정',
'chat.goal.dialog.titleManage': '세션 목표',
'chat.goal.dialog.objectiveLabel': '목표',
+3
View File
@@ -2435,6 +2435,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': 'Panel terminala ({shortcut})',
'chat.recap.aria': 'Podsumowanie sesji',
'chat.recap.label': 'Podsumowanie:',
'chat.sessionError.title': 'OpenCode przerwał tę odpowiedź',
'chat.sessionError.noDetails': 'OpenCode nie podał szczegółów. Otwórz raport stanu (Ctrl/Cmd+Shift+L), aby zobaczyć ostatnie błędy.',
'chat.sessionError.noReply': 'OpenCode nie rozpoczął odpowiedzi na tę wiadomość.',
'chat.goal.dialog.titleCreate': 'Ustaw cel sesji',
'chat.goal.dialog.titleManage': 'Cel sesji',
'chat.goal.dialog.objectiveLabel': 'Cel',
@@ -1728,6 +1728,9 @@ export const dict: Record<I18nKey, string> = {
"header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})",
"chat.recap.aria": "Resumo da sessão",
"chat.recap.label": "Resumo:",
"chat.sessionError.title": "O OpenCode interrompeu esta resposta",
"chat.sessionError.noDetails": "O OpenCode não informou detalhes. Abra o relatório de status (Ctrl/Cmd+Shift+L) para ver os erros recentes.",
"chat.sessionError.noReply": "O OpenCode não iniciou uma resposta a esta mensagem.",
"chat.goal.dialog.titleCreate": "Definir objetivo da sessão",
"chat.goal.dialog.titleManage": "Objetivo da sessão",
"chat.goal.dialog.objectiveLabel": "Objetivo",
+3
View File
@@ -1712,6 +1712,9 @@ export const dict = {
'header.actions.terminalPanelWithShortcut': 'Terminal paneli ({shortcut})',
'chat.recap.aria': 'Session özeti',
'chat.recap.label': 'Özet:',
'chat.sessionError.title': 'OpenCode bu yanıtı durdurdu',
'chat.sessionError.noDetails': 'OpenCode ayrıntı bildirmedi. Son hataları görmek için durum raporunu açın (Ctrl/Cmd+Shift+L).',
'chat.sessionError.noReply': 'OpenCode bu mesaja yanıt vermeye başlamadı.',
'chat.goal.dialog.titleCreate': 'Session hedefi belirle',
'chat.goal.dialog.titleManage': 'Session hedefi',
'chat.goal.dialog.objectiveLabel': 'Amaç',
+3
View File
@@ -1728,6 +1728,9 @@ export const dict: Record<I18nKey, string> = {
"header.actions.terminalPanelWithShortcut": "Термінальна панель ({shortcut})",
"chat.recap.aria": "Підсумок сесії",
"chat.recap.label": "Підсумок:",
"chat.sessionError.title": "OpenCode зупинив цю відповідь",
"chat.sessionError.noDetails": "OpenCode не повідомив деталей. Відкрий звіт про стан (Ctrl/Cmd+Shift+L), щоб побачити останні помилки.",
"chat.sessionError.noReply": "OpenCode не почав відповідь на це повідомлення.",
"chat.goal.dialog.titleCreate": "Встановити ціль сесії",
"chat.goal.dialog.titleManage": "Ціль сесії",
"chat.goal.dialog.objectiveLabel": "Ціль",
@@ -1716,6 +1716,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': '终端面板({shortcut}',
'chat.recap.aria': '会话回顾',
'chat.recap.label': '回顾:',
'chat.sessionError.title': 'OpenCode 停止了本次回复',
'chat.sessionError.noDetails': 'OpenCode 未报告任何详情。打开状态报告(Ctrl/Cmd+Shift+L)查看最近的错误。',
'chat.sessionError.noReply': 'OpenCode 没有开始回复这条消息。',
'chat.goal.dialog.titleCreate': '设置会话目标',
'chat.goal.dialog.titleManage': '会话目标',
'chat.goal.dialog.objectiveLabel': '目标',
@@ -1720,6 +1720,9 @@ export const dict: Record<I18nKey, string> = {
'header.actions.terminalPanelWithShortcut': '終端機面板({shortcut}',
'chat.recap.aria': '工作階段回顧',
'chat.recap.label': '回顧:',
'chat.sessionError.title': 'OpenCode 停止了本次回覆',
'chat.sessionError.noDetails': 'OpenCode 未回報任何詳情。開啟狀態報告(Ctrl/Cmd+Shift+L)查看最近的錯誤。',
'chat.sessionError.noReply': 'OpenCode 沒有開始回覆這則訊息。',
'chat.goal.dialog.titleCreate': '設定工作階段目標',
'chat.goal.dialog.titleManage': '工作階段目標',
'chat.goal.dialog.objectiveLabel': '目標',
+96 -3
View File
@@ -4,6 +4,8 @@ import { useUIStore } from '@/stores/useUIStore';
import { getRuntimeUrlResolver } from './runtime-url';
import { opencodeClient } from './opencode/client';
import { runtimeFetch } from './runtime-fetch';
import { getRecentSendFailures } from '@/sync/send-failure-log';
import { getRecentSessionErrors } from '@/sync/session-error-log';
declare const __APP_VERSION__: string | undefined;
@@ -21,6 +23,8 @@ type OpenChamberHealthSnapshot = {
openCodeAuthSource?: unknown;
isOpenCodeReady?: unknown;
lastOpenCodeError?: unknown;
lastOpenCodeHealthFailure?: unknown;
lastManagedOpenCodeProcess?: unknown;
lastOpenCodeLaunchDiagnostics?: unknown;
opencodeBinaryResolved?: unknown;
opencodeBinarySource?: unknown;
@@ -128,6 +132,15 @@ const normalizePort = (value: unknown): number | null => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const STDERR_TAIL_LINES = 12;
const RECENT_RECORD_LINES = 8;
const joinPath = (base: string, relative: string, windows: boolean): string => {
const separator = windows ? '\\' : '/';
const trimmed = base.replace(/[\\/]+$/, '');
return `${trimmed}${separator}${windows ? relative.replace(/\//g, '\\') : relative}`;
};
const formatUnknown = (value: unknown, fallback = '(n/a)'): string => {
if (typeof value === 'string') return value.trim() || fallback;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
@@ -148,7 +161,7 @@ const formatLaunchRuntime = (wrapperType: string, node: string, bun: string): st
return 'direct executable';
};
const buildOpenCodeStatusReport = async (): Promise<string> => {
export const buildOpenCodeStatusReport = async (): Promise<string> => {
const now = new Date();
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
const platform = typeof navigator !== 'undefined' ? navigator.userAgent : '(no navigator)';
@@ -159,6 +172,7 @@ const buildOpenCodeStatusReport = async (): Promise<string> => {
const healthUrl = urls.health();
const apiBase = urls.api('/api/');
const openChamberHealth: OpenChamberHealthSnapshot | null = await (async () => {
if (!healthUrl) return null;
const controller = new AbortController();
@@ -227,15 +241,36 @@ const buildOpenCodeStatusReport = async (): Promise<string> => {
const buildProbeUrl = (pathname: string, includeDirectory = true): string | null => {
if (!apiBase) return null;
const url = new URL(pathname.replace(/^\/+/, ''), apiBase);
// A web runtime resolves its API base relative to the page; a relative
// base is not a valid URL base on its own.
const absoluteBase = /^[a-z][a-z0-9+.-]*:/i.test(apiBase) || !origin ? apiBase : new URL(apiBase, origin).toString();
const url = new URL(pathname.replace(/^\/+/, ''), absoluteBase);
if (includeDirectory && directory) {
url.searchParams.set('directory', directory);
}
return url.toString();
};
// OpenCode's own view of its directories; `home` anchors the log path below.
const pathInfo: { home?: unknown } | null = await (async () => {
const url = buildProbeUrl('/path', true);
if (!url) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const resp = await runtimeFetch(url, { signal: controller.signal, cache: 'no-store' });
if (!resp.ok) return null;
const json = (await resp.json().catch(() => null)) as unknown;
return isRecord(json) ? json : null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
})();
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
{ label: 'health', path: '/health', includeDirectory: false },
{ label: 'health', path: '/global/health', includeDirectory: false },
{ label: 'config', path: '/config', includeDirectory: true },
{ label: 'providers', path: '/config/providers', includeDirectory: true },
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
@@ -278,6 +313,64 @@ const buildOpenCodeStatusReport = async (): Promise<string> => {
lines.push(`OpenCode auth source: ${openChamberHealth.openCodeAuthSource}`);
}
// What the managed OpenCode process last said for itself. A turn that stops
// with nothing on screen usually left its reason here or in the session
// errors below, not in the UI.
const lastOpenCodeError = formatUnknown(openChamberHealth?.lastOpenCodeError, '');
const managedProcess = isRecord(openChamberHealth?.lastManagedOpenCodeProcess)
? openChamberHealth.lastManagedOpenCodeProcess
: null;
const stderrTail = managedProcess && typeof managedProcess.stderrTail === 'string'
? managedProcess.stderrTail.trim()
: '';
if (lastOpenCodeError || managedProcess) {
lines.push('');
lines.push('OpenCode process:');
if (lastOpenCodeError) lines.push(`- last error: ${lastOpenCodeError}`);
if (managedProcess) {
lines.push(`- pid: ${formatUnknown(managedProcess.pid, '(none)')} exit=${formatUnknown(managedProcess.exitCode, '(running)')} signal=${formatUnknown(managedProcess.signalCode, '(none)')}`);
}
if (stderrTail) {
const tailLines = stderrTail.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-STDERR_TAIL_LINES);
lines.push(`- stderr (last ${tailLines.length} lines):`);
for (const line of tailLines) lines.push(` ${line.slice(0, 300)}`);
}
}
const sessionErrors = getRecentSessionErrors();
lines.push('');
lines.push(`Recent OpenCode session errors: ${sessionErrors.length === 0 ? '(none this app session)' : ''}`.trimEnd());
for (const record of sessionErrors.slice(0, RECENT_RECORD_LINES)) {
const detail = record.message ?? '(no message)';
lines.push(`- ${formatIso(record.at)} session=${record.sessionId.slice(0, 16)} ${record.name ? `${record.name}: ` : ''}${detail}`);
}
const sendFailures = getRecentSendFailures();
lines.push('');
lines.push(`Recent rejected sends: ${sendFailures.length === 0 ? '(none this app session)' : ''}`.trimEnd());
for (const record of sendFailures.slice(0, RECENT_RECORD_LINES)) {
lines.push(`- ${formatIso(record.at)} session=${record.sessionId.slice(0, 16)} status=${record.status ?? 'transport'}${record.ambiguous ? ' ambiguous' : ''} ${record.reason}`);
}
// Where to look next. OpenCode keeps its own log under the XDG data
// directory (the same default on every platform, which is why Windows users
// do not find it under AppData); the desktop app writes the server console,
// including OpenCode lifecycle lines, through electron-log.
const opencodeHome = typeof pathInfo?.home === 'string' ? pathInfo.home : '';
const isWindows = /Windows NT/.test(platform);
const isDesktop = origin.startsWith('openchamber-ui://');
lines.push('');
lines.push('Log files:');
lines.push(`- OpenCode: ${opencodeHome ? joinPath(opencodeHome, '.local/share/opencode/log', isWindows) : '<home>/.local/share/opencode/log'} (or $XDG_DATA_HOME/opencode/log when set)`);
if (isDesktop) {
const isMacDesktop = /Mac OS X|Macintosh/.test(platform);
lines.push(`- OpenChamber desktop: ${isWindows
? '%APPDATA%\\OpenChamber\\logs\\main.log'
: isMacDesktop
? '~/Library/Logs/OpenChamber/main.log'
: '~/.config/OpenChamber/logs/main.log'}`);
}
if (typeof window !== 'undefined') {
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
+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 }),
})
}