Review found the owning documentation still describing the behaviour this branch replaced, in one case stacked directly above the new docstring saying the opposite. Holding a session proves containment, not ownership, so every text that called store membership the authoritative mapping was actively misleading for the module whose wrong answer misroutes every send. Corrected in the module docstring, the resolution module's precedence description, the sync-refs helper it points at, and the sync DOCUMENTATION.md table and rules. The debug report built its authoritative value membership-first, so for exactly the scenario this branch fixes it reported the parent directory and could raise a source-disagreement alert while routing was in fact correct. It now uses the same record-first order as the resolver. The CLI timeout comment claimed the wait and provisioning windows were additive while the code took the larger of the two. The server provisions the worktree inside session creation, before it waits for the session to go idle, so they do run in sequence: the windows are now summed and the tests pin both cases.
830 lines
30 KiB
TypeScript
830 lines
30 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { useSessionUIStore, getRememberedSessionDirectory } from '@/sync/session-ui-store';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
|
import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard';
|
|
import { getSyncSessions, getSyncMessages, getSyncParts, getAllSyncSessions, getSyncSessionDirectory } from '@/sync/sync-refs';
|
|
import {
|
|
describeSessionDirectorySources,
|
|
resolveSessionDirectoryFromSources,
|
|
} from '@/sync/session-directory-resolution';
|
|
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
|
|
import { getRecentSendFailures } from '@/sync/send-failure-log';
|
|
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
|
|
import { useStreamingStore } from '@/sync/streaming';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
|
|
export interface DebugMessageInfo {
|
|
messageId: string;
|
|
role: string;
|
|
timestamp: number;
|
|
partsCount: number;
|
|
parts: Array<{
|
|
id: string;
|
|
type: string;
|
|
text?: string;
|
|
textLength?: number;
|
|
tool?: string;
|
|
state?: any;
|
|
}>;
|
|
isEmpty: boolean;
|
|
isEmptyResponse: boolean;
|
|
raw: any;
|
|
}
|
|
|
|
export const debugUtils = {
|
|
|
|
getLastAssistantMessage(): DebugMessageInfo | null {
|
|
const state = useSessionUIStore.getState();
|
|
const currentSessionId = state.currentSessionId;
|
|
|
|
if (!currentSessionId) {
|
|
console.log('[ERROR] No active session');
|
|
return null;
|
|
}
|
|
|
|
const messages = getSyncMessages(currentSessionId);
|
|
if (!messages || messages.length === 0) {
|
|
console.log('[ERROR] No messages in current session');
|
|
return null;
|
|
}
|
|
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const msg = messages[i];
|
|
if (msg.role === 'assistant') {
|
|
const msgParts = getSyncParts(msg.id) || [];
|
|
const parts = msgParts.map((part: any) => {
|
|
const info: any = {
|
|
id: part.id,
|
|
type: part.type,
|
|
};
|
|
|
|
if (part.type === 'text') {
|
|
info.text = part.text;
|
|
info.textLength = part.text?.length || 0;
|
|
} else if (part.type === 'tool') {
|
|
info.tool = part.tool;
|
|
info.state = part.state?.status;
|
|
} else if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
info.isStepMarker = true;
|
|
}
|
|
|
|
return info;
|
|
});
|
|
|
|
const hasText = parts.some((p: any) => p.type === 'text' && p.text && p.text.trim().length > 0);
|
|
const hasTools = parts.some((p: any) => p.type === 'tool');
|
|
const hasStepMarkers = parts.some((p: any) => p.type === 'step-start' || p.type === 'step-finish');
|
|
const isEmpty = parts.length === 0;
|
|
const isEmptyResponse = !hasText && !hasTools && (!isEmpty || hasStepMarkers);
|
|
|
|
const info: DebugMessageInfo = {
|
|
messageId: msg.id,
|
|
role: msg.role,
|
|
timestamp: (msg as any).time?.created || 0,
|
|
partsCount: parts.length,
|
|
parts,
|
|
isEmpty,
|
|
isEmptyResponse,
|
|
raw: msg,
|
|
};
|
|
|
|
console.log('[INSPECT] Last Assistant Message:', info);
|
|
console.log('[SUMMARY] Summary:', {
|
|
messageId: info.messageId,
|
|
partsCount: info.partsCount,
|
|
isEmpty: info.isEmpty,
|
|
isEmptyResponse: info.isEmptyResponse,
|
|
hasText,
|
|
hasTools,
|
|
hasStepMarkers,
|
|
onlyStepMarkers: hasStepMarkers && !hasText && !hasTools,
|
|
});
|
|
|
|
if (info.isEmpty) {
|
|
console.warn('[WARNING] Message has NO parts!');
|
|
}
|
|
|
|
if (info.isEmptyResponse) {
|
|
console.warn('[WARNING] Message has parts but NO meaningful content (empty text, no tools)!');
|
|
|
|
if (hasStepMarkers && !hasText && !hasTools) {
|
|
console.warn('[CRITICAL] CLAUDE EMPTY RESPONSE BUG: Only step-start/step-finish markers, no actual content!');
|
|
console.log('This is a known issue with Claude models (anthropic provider)');
|
|
console.log('Recommendation: Send a follow-up message or try a different model');
|
|
}
|
|
}
|
|
|
|
return info;
|
|
}
|
|
}
|
|
|
|
console.log('[ERROR] No assistant messages found in current session');
|
|
return null;
|
|
},
|
|
|
|
truncateString(value: string | undefined, maxLength: number = 80): string | undefined {
|
|
if (!value || typeof value !== 'string') return value;
|
|
if (value.length <= maxLength) return value;
|
|
return value.substring(0, maxLength) + '…';
|
|
},
|
|
|
|
truncateMessages(messages: any[]): any[] {
|
|
return messages.map((msg) => ({
|
|
...msg,
|
|
parts: (getSyncParts(msg.id) || []).map((part: any) => {
|
|
const truncatedPart: any = { ...part };
|
|
|
|
if ('text' in part) {
|
|
truncatedPart.text = this.truncateString(part.text);
|
|
}
|
|
if ('textPreview' in part) {
|
|
truncatedPart.textPreview = this.truncateString(part.textPreview);
|
|
}
|
|
|
|
if (part.state) {
|
|
truncatedPart.state = { ...part.state };
|
|
|
|
if ('output' in part.state) {
|
|
truncatedPart.state.output = this.truncateString(part.state.output);
|
|
}
|
|
if ('error' in part.state) {
|
|
truncatedPart.state.error = this.truncateString(part.state.error);
|
|
}
|
|
if (part.state.metadata && 'preview' in part.state.metadata) {
|
|
truncatedPart.state.metadata = {
|
|
...part.state.metadata,
|
|
preview: this.truncateString(part.state.metadata.preview),
|
|
};
|
|
}
|
|
}
|
|
|
|
return truncatedPart;
|
|
}),
|
|
}));
|
|
},
|
|
|
|
getAllMessages(truncate: boolean = false) {
|
|
const state = useSessionUIStore.getState();
|
|
const currentSessionId = state.currentSessionId;
|
|
|
|
if (!currentSessionId) {
|
|
console.log('[ERROR] No active session');
|
|
return [];
|
|
}
|
|
|
|
const messages = getSyncMessages(currentSessionId);
|
|
console.log(`[MESSAGES] Total messages in session: ${messages.length}`);
|
|
|
|
messages.forEach((msg, idx) => {
|
|
const msgParts = getSyncParts(msg.id) || [];
|
|
console.log(`[${idx}] ${msg.role} - ${msg.id} - ${msgParts.length} parts`);
|
|
});
|
|
|
|
return truncate ? this.truncateMessages(messages as any[]) : messages;
|
|
},
|
|
|
|
async getAppStatus() {
|
|
const directoryState = useDirectoryStore.getState();
|
|
const sessionState = useSessionUIStore.getState();
|
|
const projectsState = useProjectsStore.getState();
|
|
const currentDirectory = directoryState.currentDirectory || null;
|
|
const opencodeDirectory = opencodeClient.getDirectory() ?? null;
|
|
|
|
const sessions = getSyncSessions();
|
|
const sessionDirectories = new Set<string>();
|
|
const sessionDirectoryCounts: Record<string, number> = {};
|
|
|
|
sessions.forEach((session) => {
|
|
const directory = (session as { directory?: string | null }).directory;
|
|
if (typeof directory === 'string' && directory.trim().length > 0) {
|
|
sessionDirectories.add(directory);
|
|
sessionDirectoryCounts[directory] = (sessionDirectoryCounts[directory] ?? 0) + 1;
|
|
} else {
|
|
sessionDirectoryCounts['(none)'] = (sessionDirectoryCounts['(none)'] ?? 0) + 1;
|
|
}
|
|
});
|
|
|
|
const localStorageSnapshot = (() => {
|
|
if (typeof window === 'undefined') {
|
|
return { available: false };
|
|
}
|
|
try {
|
|
return {
|
|
available: true,
|
|
lastDirectory: window.localStorage.getItem('lastDirectory'),
|
|
homeDirectory: window.localStorage.getItem('homeDirectory'),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
available: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
})();
|
|
|
|
const runtimeApis = getRegisteredRuntimeAPIs();
|
|
const isDesktopRuntime = typeof window !== 'undefined' && Boolean((window as { __OPENCHAMBER_ELECTRON__?: unknown }).__OPENCHAMBER_ELECTRON__);
|
|
|
|
const safeJson = async (resp: Response) => {
|
|
try {
|
|
return await resp.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const safeText = async (resp: Response) => {
|
|
try {
|
|
return await resp.text();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const safeFetchJson = async (url: string): Promise<unknown> => {
|
|
try {
|
|
const resp = await runtimeFetch(url);
|
|
return resp.ok ? await safeJson(resp) : { status: resp.status };
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
};
|
|
|
|
let pathInfo: unknown = null;
|
|
let projectInfo: unknown = null;
|
|
let settingsInfo: unknown = null;
|
|
let opencodeHealth: unknown = null;
|
|
|
|
try {
|
|
const pathResult = await opencodeClient.getSdkClient().path.get(
|
|
currentDirectory ? { directory: currentDirectory } : undefined
|
|
);
|
|
pathInfo = pathResult.error ? { error: pathResult.error } : pathResult.data;
|
|
} catch (error) {
|
|
pathInfo = { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
|
|
try {
|
|
const projectResult = await opencodeClient.getSdkClient().project.current(
|
|
currentDirectory ? { directory: currentDirectory } : undefined
|
|
);
|
|
projectInfo = projectResult.error ? { error: projectResult.error } : projectResult.data;
|
|
} catch (error) {
|
|
projectInfo = { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
|
|
settingsInfo = await safeFetchJson('/api/config/settings');
|
|
|
|
try {
|
|
const resp = await runtimeFetch('/api/health');
|
|
const contentType = resp.headers.get('content-type') || '';
|
|
const body = await safeText(resp);
|
|
const isJson = contentType.toLowerCase().includes('application/json');
|
|
let parsed: Record<string, unknown> | null = null;
|
|
if (isJson && body) {
|
|
try {
|
|
const candidate = JSON.parse(body) as unknown;
|
|
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
|
parsed = candidate as Record<string, unknown>;
|
|
}
|
|
} catch {
|
|
parsed = null;
|
|
}
|
|
}
|
|
opencodeHealth = {
|
|
status: resp.status,
|
|
ok: resp.ok,
|
|
contentType,
|
|
type: isJson ? 'json' : 'html',
|
|
openCodePort: parsed?.openCodePort ?? null,
|
|
openCodeRunning: parsed?.openCodeRunning ?? null,
|
|
openCodeSecureConnection: parsed?.openCodeSecureConnection ?? null,
|
|
openCodeAuthSource: parsed?.openCodeAuthSource ?? null,
|
|
isOpenCodeReady: parsed?.isOpenCodeReady ?? null,
|
|
lastOpenCodeError: parsed?.lastOpenCodeError ?? null,
|
|
preview: body ? body.slice(0, 120) : null,
|
|
};
|
|
} catch (error) {
|
|
opencodeHealth = { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
|
|
let gitCheck: { isGitRepo: boolean | null; error?: string } = { isGitRepo: null };
|
|
if (currentDirectory) {
|
|
try {
|
|
gitCheck.isGitRepo = await checkIsGitRepository(currentDirectory);
|
|
} catch (error) {
|
|
gitCheck = {
|
|
isGitRepo: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
const projectSamples = projectsState.projects.map((project) => ({
|
|
id: project.id,
|
|
path: project.path,
|
|
label: project.label,
|
|
}));
|
|
|
|
const report = {
|
|
runtime: {
|
|
platform: runtimeApis?.runtime?.platform ?? null,
|
|
isDesktop: isDesktopRuntime,
|
|
isVSCode: Boolean(runtimeApis?.runtime?.isVSCode),
|
|
hasRuntimeApis: Boolean(runtimeApis),
|
|
desktopServerOrigin: null,
|
|
},
|
|
location: typeof window !== 'undefined'
|
|
? {
|
|
href: window.location?.href ?? null,
|
|
origin: window.location?.origin ?? null,
|
|
}
|
|
: null,
|
|
directories: {
|
|
currentDirectory,
|
|
opencodeDirectory: (pathInfo as { directory?: string; worktree?: string } | null)?.directory
|
|
|| (pathInfo as { worktree?: string } | null)?.worktree
|
|
|| opencodeDirectory,
|
|
homeDirectory: directoryState.homeDirectory || null,
|
|
isHomeReady: directoryState.isHomeReady,
|
|
hasPersistedDirectory: directoryState.hasPersistedDirectory,
|
|
isSwitchingDirectory: directoryState.isSwitchingDirectory,
|
|
},
|
|
projects: {
|
|
total: projectsState.projects.length,
|
|
activeProjectId: projectsState.activeProjectId,
|
|
samples: projectSamples,
|
|
},
|
|
sessions: {
|
|
total: sessions.length,
|
|
currentSessionId: sessionState.currentSessionId,
|
|
lastLoadedDirectory: sessionState.lastLoadedDirectory,
|
|
uniqueDirectories: sessionDirectories.size,
|
|
directorySamples: Array.from(sessionDirectories).slice(0, 5),
|
|
directoryCounts: sessionDirectoryCounts,
|
|
},
|
|
worktrees: {
|
|
available: sessionState.availableWorktrees.length,
|
|
metadataEntries: sessionState.worktreeMetadata.size,
|
|
},
|
|
git: gitCheck,
|
|
localStorage: localStorageSnapshot,
|
|
opencode: {
|
|
pathInfo,
|
|
projectInfo,
|
|
health: opencodeHealth,
|
|
},
|
|
openchamber: {
|
|
settingsInfo,
|
|
},
|
|
// Empty is a meaningful answer here: it means no prompt was rejected in
|
|
// this session, so a "my message disappeared" report is not a rejected
|
|
// send and needs a different explanation.
|
|
recentSendFailures: getRecentSendFailures(),
|
|
currentSessionDirectoryResolution: sessionState.currentSessionId
|
|
? this.diagnoseSessionDirectory(sessionState.currentSessionId)
|
|
: null,
|
|
};
|
|
|
|
console.log('[DEBUG] App status snapshot:', report);
|
|
return report;
|
|
},
|
|
|
|
/**
|
|
* Prompt sends that were rejected and rolled back in this app session.
|
|
* Newest first; empty means no send was rejected.
|
|
*/
|
|
getRecentSendFailures() {
|
|
const failures = getRecentSendFailures();
|
|
if (failures.length === 0) {
|
|
console.log('[OK] No prompt sends were rejected in this session.');
|
|
} else {
|
|
console.warn(`[ALERT] ${failures.length} rejected prompt send(s):`);
|
|
console.table(failures);
|
|
}
|
|
return failures;
|
|
},
|
|
|
|
/**
|
|
* Report how a session's directory is resolved, from every source, in
|
|
* precedence order. A send is routed by the winning value, so a disagreement
|
|
* here explains a prompt that vanishes without an error: it was posted
|
|
* against a directory that does not own the session.
|
|
*/
|
|
diagnoseSessionDirectory(sessionId?: string) {
|
|
const sessionState = useSessionUIStore.getState();
|
|
const targetSessionId = sessionId ?? sessionState.currentSessionId;
|
|
|
|
if (!targetSessionId) {
|
|
console.log('[ERROR] No session selected and no session id passed');
|
|
return null;
|
|
}
|
|
|
|
const attachment = getAttachedSessionDirectory(
|
|
useSessionWorktreeStore.getState().getAttachment(targetSessionId),
|
|
);
|
|
const worktreeMetadata = sessionState.worktreeMetadata.get(targetSessionId)?.path ?? null;
|
|
const owningStoreDirectory = getSyncSessionDirectory(targetSessionId);
|
|
const sessionRecord = getAllSyncSessions().find((session) => session.id === targetSessionId);
|
|
const recordDirectory = (sessionRecord as { directory?: string | null } | undefined)?.directory ?? null;
|
|
const selected = targetSessionId === sessionState.currentSessionId
|
|
? sessionState.currentSessionDirectory
|
|
: null;
|
|
|
|
const remembered = getRememberedSessionDirectory(targetSessionId);
|
|
|
|
const sources = {
|
|
attachment,
|
|
worktreeMetadata,
|
|
// Record first, matching the resolver: holding a session proves
|
|
// containment, not ownership, so the parent repository holds its
|
|
// worktrees' sessions too. Reporting membership first made this
|
|
// diagnostic contradict the routing it exists to explain.
|
|
authoritative: recordDirectory ?? owningStoreDirectory,
|
|
selected,
|
|
remembered: remembered.runtime,
|
|
};
|
|
|
|
const resolution = resolveSessionDirectoryFromSources(sources);
|
|
const routedDirectory = sessionState.getDirectoryForSession(targetSessionId);
|
|
|
|
const report = {
|
|
sessionId: targetSessionId,
|
|
isCurrentSession: targetSessionId === sessionState.currentSessionId,
|
|
routedDirectory,
|
|
resolvedFrom: resolution.source,
|
|
conflict: resolution.conflict,
|
|
sources: describeSessionDirectorySources(sources),
|
|
details: {
|
|
owningChildStore: owningStoreDirectory,
|
|
sessionRecordDirectory: recordDirectory,
|
|
sessionIndexed: Boolean(sessionRecord),
|
|
currentSessionDirectory: sessionState.currentSessionDirectory,
|
|
rememberedForRuntime: remembered.runtime,
|
|
persistedAcrossRestarts: remembered.persisted,
|
|
activeDirectory: useDirectoryStore.getState().currentDirectory ?? null,
|
|
opencodeClientDirectory: opencodeClient.getDirectory() ?? null,
|
|
},
|
|
};
|
|
|
|
console.log('[DEBUG] Session directory resolution:', report);
|
|
if (resolution.conflict) {
|
|
console.warn(
|
|
`[ALERT] Directory sources disagree: using "${resolution.directory}" (${resolution.source}) `
|
|
+ `while "${resolution.conflict.directory}" came from ${resolution.conflict.source}.`,
|
|
);
|
|
} else if (!routedDirectory) {
|
|
console.warn('[ALERT] No directory resolved for this session — sends fall back to the active directory.');
|
|
} else {
|
|
console.log('[OK] All known sources agree on the session directory.');
|
|
}
|
|
|
|
return report;
|
|
},
|
|
|
|
async buildDiagnosticsReport() {
|
|
const report = await this.getAppStatus();
|
|
return JSON.stringify(report, null, 2);
|
|
},
|
|
|
|
async copyTextToClipboard(text: string) {
|
|
return copyPlainTextToClipboard(text);
|
|
},
|
|
|
|
async copyDiagnosticsReport() {
|
|
const report = await this.buildDiagnosticsReport();
|
|
const result = await this.copyTextToClipboard(report);
|
|
return { ...result, report } as const;
|
|
},
|
|
|
|
checkLastMessage() {
|
|
const info = this.getLastAssistantMessage();
|
|
if (!info) return false;
|
|
|
|
const isProblematic = info.isEmpty || info.isEmptyResponse;
|
|
|
|
if (isProblematic) {
|
|
console.error('[ALERT] PROBLEMATIC MESSAGE DETECTED!');
|
|
console.log('Details:', {
|
|
messageId: info.messageId,
|
|
isEmpty: info.isEmpty,
|
|
isEmptyResponse: info.isEmptyResponse,
|
|
partsCount: info.partsCount,
|
|
});
|
|
|
|
if (info.parts.length > 0) {
|
|
console.log('Parts:', info.parts);
|
|
}
|
|
} else {
|
|
console.log('[OK] Last message looks good!');
|
|
}
|
|
|
|
return isProblematic;
|
|
},
|
|
|
|
getStreamingState() {
|
|
const sessionState = useSessionUIStore.getState();
|
|
const streamingState = useStreamingStore.getState();
|
|
const currentStreamingId = sessionState.currentSessionId
|
|
? streamingState.streamingMessageIds.get(sessionState.currentSessionId) ?? null
|
|
: null;
|
|
console.log('[STREAM] Streaming State:', {
|
|
streamingMessageId: currentStreamingId,
|
|
streamingMessageIds: Array.from(streamingState.streamingMessageIds.entries()),
|
|
messageStreamStates: Array.from(streamingState.messageStreamStates.entries()),
|
|
});
|
|
return {
|
|
streamingMessageId: currentStreamingId,
|
|
streamingMessageIds: streamingState.streamingMessageIds,
|
|
streamStates: streamingState.messageStreamStates,
|
|
};
|
|
},
|
|
|
|
findEmptyMessages() {
|
|
const state = useSessionUIStore.getState();
|
|
const currentSessionId = state.currentSessionId;
|
|
|
|
if (!currentSessionId) {
|
|
console.log('[ERROR] No active session');
|
|
return [];
|
|
}
|
|
|
|
const messages = getSyncMessages(currentSessionId);
|
|
const emptyMessages = messages
|
|
.filter((msg) => msg.role === 'assistant')
|
|
.filter((msg) => {
|
|
const parts = getSyncParts(msg.id) || [];
|
|
const hasTextContent = parts.some(
|
|
(p: any) => p.type === 'text' && p.text && p.text.trim().length > 0
|
|
);
|
|
const hasTools = parts.some((p: any) => p.type === 'tool');
|
|
|
|
return parts.length === 0 || (!hasTextContent && !hasTools);
|
|
});
|
|
|
|
console.log(`[INSPECT] Found ${emptyMessages.length} empty assistant messages`);
|
|
|
|
emptyMessages.forEach((msg, idx) => {
|
|
const parts = getSyncParts(msg.id) || [];
|
|
console.log(`[${idx}] Empty message:`, {
|
|
messageId: msg.id,
|
|
partsCount: parts.length,
|
|
provider: (msg as any).providerID,
|
|
model: (msg as any).modelID,
|
|
timestamp: (msg as any).time?.created,
|
|
});
|
|
});
|
|
|
|
return emptyMessages;
|
|
},
|
|
|
|
showRetryHelp() {
|
|
console.log('[DEBUG] How to handle empty Claude responses:\n');
|
|
console.log('1. Check the last message:');
|
|
console.log(' __opencodeDebug.getLastAssistantMessage()\n');
|
|
console.log('2. Find all empty messages in session:');
|
|
console.log(' __opencodeDebug.findEmptyMessages()\n');
|
|
console.log('3. To retry, you can:');
|
|
console.log(' - Edit your last user message and resend');
|
|
console.log(' - Send a follow-up message like "Please provide the response"');
|
|
console.log(' - Try a different model (OpenAI models tend to be more reliable)\n');
|
|
console.log('[TIP] Empty responses are usually due to:');
|
|
console.log(' - Model rate limits');
|
|
console.log(' - Context length issues');
|
|
console.log(' - Model refusing to respond to certain prompts');
|
|
console.log(' - API errors from provider');
|
|
},
|
|
|
|
analyzeMessageCompletionConsistency(options: {
|
|
includeNonAssistant?: boolean;
|
|
verbose?: boolean;
|
|
maxTableRows?: number;
|
|
} = {}) {
|
|
const { includeNonAssistant = false, verbose = true, maxTableRows = 25 } = options;
|
|
const state = useSessionUIStore.getState();
|
|
const currentSessionId = state.currentSessionId;
|
|
|
|
if (!currentSessionId) {
|
|
console.log('[ERROR] No active session');
|
|
return { summary: null, rows: [] };
|
|
}
|
|
|
|
const messages = getSyncMessages(currentSessionId);
|
|
const targetMessages = includeNonAssistant
|
|
? messages
|
|
: messages.filter((msg) => msg.role === 'assistant');
|
|
|
|
const summary = {
|
|
totalMessages: messages.length,
|
|
analyzedMessages: targetMessages.length,
|
|
completedMissing: 0,
|
|
completedBeforeTool: 0,
|
|
completedBeforeReasoning: 0,
|
|
runningToolsWhenCompleted: 0,
|
|
reasoningOpenWhenCompleted: 0,
|
|
withTools: 0,
|
|
withReasoning: 0,
|
|
};
|
|
|
|
const toNumber = (value: unknown): number | null =>
|
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
|
|
const getLatestTimestamp = (timestamps: Array<number | null>): number | null => {
|
|
const filtered = timestamps.filter((value): value is number => typeof value === 'number');
|
|
return filtered.length > 0 ? Math.max(...filtered) : null;
|
|
};
|
|
|
|
const rows = targetMessages.map((message, index) => {
|
|
const info = message as any;
|
|
const parts = getSyncParts(message.id) || [];
|
|
|
|
const timeInfo = (info.time ?? {}) as { completed?: number };
|
|
const completedAt = toNumber(timeInfo.completed);
|
|
const hasCompleted = completedAt !== null;
|
|
|
|
const toolParts = parts.filter((part: any) => part.type === 'tool');
|
|
const reasoningParts = parts.filter((part: any) => part.type === 'reasoning');
|
|
|
|
const latestToolTimestamp = getLatestTimestamp(
|
|
toolParts.map((part: any) =>
|
|
toNumber(part.state?.time?.end ?? part.state?.time?.start)
|
|
)
|
|
);
|
|
const latestReasoningTimestamp = getLatestTimestamp(
|
|
reasoningParts.map((part: any) => toNumber(part.time?.end ?? part.time?.start))
|
|
);
|
|
const latestPartTimestamp = getLatestTimestamp(
|
|
[latestToolTimestamp, latestReasoningTimestamp].filter((value) => value !== null)
|
|
);
|
|
|
|
const hasRunningTool = toolParts.some((part: any) =>
|
|
['pending', 'running', 'started'].includes(part.state?.status)
|
|
);
|
|
const reasoningIncomplete = reasoningParts.some(
|
|
(part: any) => typeof part.time?.end !== 'number'
|
|
);
|
|
|
|
if (toolParts.length > 0) {
|
|
summary.withTools += 1;
|
|
}
|
|
if (reasoningParts.length > 0) {
|
|
summary.withReasoning += 1;
|
|
}
|
|
|
|
if (!hasCompleted) {
|
|
summary.completedMissing += 1;
|
|
}
|
|
|
|
const completedBeforeTool = Boolean(
|
|
hasCompleted &&
|
|
typeof latestToolTimestamp === 'number' &&
|
|
completedAt! < latestToolTimestamp
|
|
);
|
|
if (completedBeforeTool) {
|
|
summary.completedBeforeTool += 1;
|
|
}
|
|
|
|
const completedBeforeReasoning = Boolean(
|
|
hasCompleted &&
|
|
typeof latestReasoningTimestamp === 'number' &&
|
|
completedAt! < latestReasoningTimestamp
|
|
);
|
|
if (completedBeforeReasoning) {
|
|
summary.completedBeforeReasoning += 1;
|
|
}
|
|
|
|
if (hasCompleted && hasRunningTool) {
|
|
summary.runningToolsWhenCompleted += 1;
|
|
}
|
|
if (hasCompleted && reasoningIncomplete) {
|
|
summary.reasoningOpenWhenCompleted += 1;
|
|
}
|
|
|
|
return {
|
|
index,
|
|
messageId: info.id,
|
|
role: info.role,
|
|
completedAt,
|
|
latestToolTimestamp,
|
|
latestReasoningTimestamp,
|
|
latestPartTimestamp,
|
|
completed_missing: !hasCompleted,
|
|
completed_before_tool: completedBeforeTool,
|
|
completed_before_reasoning: completedBeforeReasoning,
|
|
running_tools_when_completed: Boolean(hasCompleted && hasRunningTool),
|
|
reasoning_open_when_completed: Boolean(hasCompleted && reasoningIncomplete),
|
|
has_tools: toolParts.length > 0,
|
|
has_reasoning: reasoningParts.length > 0,
|
|
};
|
|
});
|
|
|
|
if (verbose) {
|
|
console.table(rows.slice(0, maxTableRows));
|
|
if (rows.length > maxTableRows) {
|
|
console.log(`Displayed first ${maxTableRows} rows out of ${rows.length}.`);
|
|
}
|
|
console.log('[SUMMARY] Message completion timing:', summary);
|
|
}
|
|
|
|
return { summary, rows };
|
|
},
|
|
|
|
checkCompletionStatus() {
|
|
const state = useSessionUIStore.getState();
|
|
const currentSessionId = state.currentSessionId;
|
|
|
|
if (!currentSessionId) {
|
|
console.log('[ERROR] No active session');
|
|
return null;
|
|
}
|
|
|
|
const messages = getSyncMessages(currentSessionId);
|
|
const assistantMessages = messages.filter(m => m.role === 'assistant');
|
|
|
|
if (assistantMessages.length === 0) {
|
|
console.log('[ERROR] No assistant messages');
|
|
return null;
|
|
}
|
|
|
|
const lastMessage = assistantMessages[assistantMessages.length - 1];
|
|
const lastParts = getSyncParts(lastMessage.id) || [];
|
|
const stepFinishParts = lastParts.filter((p: any) => p.type === 'step-finish');
|
|
const hasStopReason = (lastMessage as any).finish === 'stop';
|
|
|
|
const timeInfo = (lastMessage as any).time;
|
|
const completedAt = timeInfo?.completed;
|
|
const messageStatus = (lastMessage as any).status;
|
|
const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed';
|
|
const messageIsComplete = Boolean(hasCompletedFlag && hasStopReason);
|
|
|
|
const streamingState = useStreamingStore.getState();
|
|
const streamingMessageId = (lastMessage as any).sessionID
|
|
? streamingState.streamingMessageIds.get((lastMessage as any).sessionID as string) ?? null
|
|
: null;
|
|
const lifecycle = streamingState.messageStreamStates.get(lastMessage.id);
|
|
const isStreamingCandidate = lastMessage.id === streamingMessageId;
|
|
|
|
console.log('[SUMMARY] Completion Status:');
|
|
console.log('Message ID:', lastMessage.id);
|
|
console.log('time.completed:', completedAt, '(type:', typeof completedAt, ')');
|
|
console.log('status:', messageStatus);
|
|
console.log('hasCompletedFlag:', hasCompletedFlag);
|
|
console.log('hasStopReason:', hasStopReason);
|
|
console.log('messageIsComplete:', messageIsComplete);
|
|
console.log('lifecycle phase:', lifecycle?.phase);
|
|
console.log('isStreamingCandidate:', isStreamingCandidate);
|
|
console.log('streamingMessageId:', streamingMessageId);
|
|
console.log('Step-finish parts:', stepFinishParts);
|
|
|
|
return {
|
|
messageId: lastMessage.id,
|
|
completed: completedAt,
|
|
status: messageStatus,
|
|
hasCompletedFlag,
|
|
hasStopReason,
|
|
messageIsComplete,
|
|
stepFinishParts,
|
|
raw: lastMessage,
|
|
};
|
|
},
|
|
};
|
|
|
|
if (typeof window !== 'undefined') {
|
|
(window as any).__opencodeDebug = debugUtils;
|
|
if (streamDebugEnabled()) {
|
|
console.log('[DEBUG] OpenCode Debug Utils loaded! Use window.__opencodeDebug in console');
|
|
console.log('Available commands:');
|
|
console.log(' __opencodeDebug.getLastAssistantMessage() - Get last assistant message details');
|
|
console.log(' __opencodeDebug.getAllMessages(truncate?) - List all messages (truncate=true for short preview)');
|
|
console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array');
|
|
console.log(' __opencodeDebug.getAppStatus() - Show app status snapshot');
|
|
console.log(' __opencodeDebug.diagnoseSessionDirectory(sessionId?) - Show how the session directory is resolved');
|
|
console.log(' __opencodeDebug.getRecentSendFailures() - List prompt sends that were rejected and rolled back');
|
|
console.log(' __opencodeDebug.checkLastMessage() - Check if last message is problematic');
|
|
console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages');
|
|
console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses');
|
|
console.log(' __opencodeDebug.getStreamingState() - Get streaming state info');
|
|
console.log(' __opencodeDebug.analyzeMessageCompletionConsistency(opts?) - Compare time.completed vs part timings');
|
|
console.log(' __opencodeDebug.checkCompletionStatus() - Check completion status of last message');
|
|
}
|
|
|
|
window.addEventListener('error', (event) => {
|
|
try {
|
|
const message = event.message || '';
|
|
const source = event.filename || '';
|
|
if (
|
|
typeof message === 'string' &&
|
|
message.includes("this._renderer.value.dimensions") &&
|
|
/xterm/i.test(String(source))
|
|
) {
|
|
event.preventDefault();
|
|
}
|
|
} catch { /* ignored */ }
|
|
});
|
|
}
|