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:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '目標',
|
||||
|
||||
@@ -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': '목표',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ç',
|
||||
|
||||
@@ -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': '目標',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user