fix: stabilize opencode session startup

This commit is contained in:
Haileyesus
2026-05-13 18:44:07 +03:00
parent 421bdd2f0f
commit 57aece12e6
7 changed files with 97 additions and 61 deletions

View File

@@ -1,4 +1,5 @@
export { initializeDatabase } from '@/modules/database/init-db.js'; export { initializeDatabase } from '@/modules/database/init-db.js';
export { closeConnection, getConnection, getDatabasePath } from '@/modules/database/connection.js';
export { apiKeysDb } from '@/modules/database/repositories/api-keys.js'; export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
export { appConfigDb } from '@/modules/database/repositories/app-config.js'; export { appConfigDb } from '@/modules/database/repositories/app-config.js';
export { credentialsDb } from '@/modules/database/repositories/credentials.js'; export { credentialsDb } from '@/modules/database/repositories/credentials.js';

View File

@@ -58,15 +58,46 @@ const formatToolContent = (value: unknown): string => {
} }
}; };
const extractText = (value: unknown): string => { /**
if (typeof value === 'string') { * OpenCode can persist the first prompt as a JSON string literal inside a text
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
* string literals so normal assistant/user prose remains untouched.
*/
const unwrapJsonStringLiteral = (value: string): string => {
const trimmed = value.trim();
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
return value; return value;
} }
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : value;
} catch {
return value;
}
};
const extractText = (value: unknown): string => {
if (typeof value === 'string') {
return unwrapJsonStringLiteral(value);
}
const record = readObjectRecord(value); const record = readObjectRecord(value);
return readOptionalString(record?.text) const text = readOptionalString(record?.text)
?? readOptionalString(record?.content) ?? readOptionalString(record?.content)
?? ''; ?? '';
return unwrapJsonStringLiteral(text);
};
const hasUserRole = (value: unknown): boolean => {
const record = readObjectRecord(value);
return readOptionalString(record?.role) === 'user';
};
const isUserTextEcho = (raw: AnyRecord): boolean => {
return readOptionalString(raw.role) === 'user'
|| hasUserRole(raw.message)
|| hasUserRole(raw.part);
}; };
const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => { const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => {
@@ -158,6 +189,12 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
?? generateMessageId('opencode'); ?? generateMessageId('opencode');
if (type === 'text') { if (type === 'text') {
// The client already renders an optimistic user bubble, so provider user
// echoes must not be streamed back as assistant text.
if (isUserTextEcho(raw)) {
return [];
}
const content = extractText(raw.text ?? raw.delta ?? raw.message); const content = extractText(raw.text ?? raw.delta ?? raw.message);
if (!content.trim()) { if (!content.trim()) {
return []; return [];

View File

@@ -6,8 +6,7 @@ import test from 'node:test';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { closeConnection } from '@/modules/database/connection.js'; import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js'; import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.provider.js';
import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js'; import { OpenCodeSessionsProvider } from '@/modules/providers/list/opencode/opencode-sessions.provider.js';
@@ -183,7 +182,7 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
1_700_000_001_000, 1_700_000_001_000,
JSON.stringify({ JSON.stringify({
type: 'text', type: 'text',
text: 'Build the OpenCode integration.', text: JSON.stringify('Build the OpenCode integration.'),
}), }),
); );
insertPart.run( insertPart.run(
@@ -261,6 +260,28 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr
} }
}); });
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
const provider = new OpenCodeSessionsProvider();
const normalized = provider.normalizeMessage({
type: 'text',
sessionID: 'open-session-live',
text: JSON.stringify('hello bro'),
}, null);
assert.equal(normalized.length, 1);
assert.equal(normalized[0]?.kind, 'stream_delta');
assert.equal(normalized[0]?.content, 'hello bro');
const userEcho = provider.normalizeMessage({
type: 'text',
sessionID: 'open-session-live',
role: 'user',
text: 'hello bro',
}, null);
assert.deepEqual(userEcho, []);
});
test('OpenCode sessions provider reads sqlite history and token usage', { concurrency: false }, async () => { test('OpenCode sessions provider reads sqlite history and token usage', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-')); const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-'));
const workspacePath = path.join(tempRoot, 'workspace'); const workspacePath = path.join(tempRoot, 'workspace');
@@ -275,6 +296,7 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
assert.equal(history.total, 4); assert.equal(history.total, 4);
assert.equal(history.messages[0]?.kind, 'text'); assert.equal(history.messages[0]?.kind, 'text');
assert.equal(history.messages[0]?.role, 'user'); assert.equal(history.messages[0]?.role, 'user');
assert.equal(history.messages[0]?.content, 'Build the OpenCode integration.');
assert.equal(history.messages[1]?.kind, 'thinking'); assert.equal(history.messages[1]?.kind, 'thinking');
assert.equal(history.messages[2]?.content, 'The provider is wired.'); assert.equal(history.messages[2]?.content, 'The provider is wired.');
assert.equal(history.messages[3]?.kind, 'tool_use'); assert.equal(history.messages[3]?.kind, 'tool_use');

View File

@@ -27,7 +27,6 @@ import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands'; import { type SlashCommand, useSlashCommands } from './useSlashCommands';
type PendingViewSession = { type PendingViewSession = {
sessionId: string | null;
startedAt: number; startedAt: number;
}; };
@@ -566,13 +565,9 @@ export function useChatComposerState({
setTimeout(() => scrollToBottom(), 100); setTimeout(() => scrollToBottom(), 100);
if (!effectiveSessionId && !selectedSession?.id) { if (!effectiveSessionId && !selectedSession?.id) {
if (typeof window !== 'undefined') { // This tracks only that a request is in flight before the provider has
// Reset stale pending IDs from previous interrupted runs before creating a new one. // emitted its real session id; routing still waits for session_created.
sessionStorage.removeItem('pendingSessionId'); pendingViewSessionRef.current = { startedAt: Date.now() };
}
// For new sessions we intentionally keep this as `null` until the backend
// emits `session_created` with the canonical provider session id.
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
} }
if (effectiveSessionId) { if (effectiveSessionId) {
onSessionActive?.(effectiveSessionId); onSessionActive?.(effectiveSessionId);
@@ -884,15 +879,11 @@ export function useChatComposerState({
return; return;
} }
const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const cursorSessionId = const cursorSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null; typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
const candidateSessionIds = [ const candidateSessionIds = [
currentSessionId, currentSessionId,
pendingViewSessionRef.current?.sessionId || null,
pendingSessionId,
provider === 'cursor' ? cursorSessionId : null, provider === 'cursor' ? cursorSessionId : null,
selectedSession?.id || null, selectedSession?.id || null,
]; ];
@@ -910,7 +901,7 @@ export function useChatComposerState({
sessionId: targetSessionId, sessionId: targetSessionId,
provider, provider,
}); });
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]); }, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
const handleGrantToolPermission = useCallback( const handleGrantToolPermission = useCallback(
(suggestion: { entry: string; toolName: string }) => { (suggestion: { entry: string; toolName: string }) => {

View File

@@ -7,7 +7,6 @@ import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type PendingViewSession = { type PendingViewSession = {
sessionId: string | null;
startedAt: number; startedAt: number;
}; };
@@ -63,6 +62,7 @@ interface UseChatRealtimeHandlersArgs {
streamTimerRef: MutableRefObject<number | null>; streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>; accumulatedStreamRef: MutableRefObject<string>;
onSessionInactive?: (sessionId?: string | null) => void; onSessionInactive?: (sessionId?: string | null) => void;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void; onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void; onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
@@ -89,6 +89,7 @@ export function useChatRealtimeHandlers({
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,
onSessionInactive, onSessionInactive,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onSessionNotProcessing, onSessionNotProcessing,
onNavigateToSession, onNavigateToSession,
@@ -104,7 +105,7 @@ export function useChatRealtimeHandlers({
lastProcessedMessageRef.current = latestMessage; lastProcessedMessageRef.current = latestMessage;
const activeViewSessionId = const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; selectedSession?.id || currentSessionId || null;
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Legacy messages (no `kind` field) — handle and return */ /* Legacy messages (no `kind` field) — handle and return */
@@ -151,10 +152,12 @@ export function useChatRealtimeHandlers({
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (msg.isProcessing) { if (msg.isProcessing) {
onSessionActive?.(statusSessionId);
onSessionProcessing?.(statusSessionId); onSessionProcessing?.(statusSessionId);
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); } if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
return; return;
} }
onSessionInactive?.(statusSessionId); onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId); onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) { if (isCurrentSession) {
@@ -235,15 +238,21 @@ export function useChatRealtimeHandlers({
if (!currentSessionId) { if (!currentSessionId) {
console.log('Session created with ID:', newSessionId); console.log('Session created with ID:', newSessionId);
console.log('Existing session ID:', currentSessionId); console.log('Existing session ID:', currentSessionId);
sessionStorage.setItem('pendingSessionId', newSessionId);
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
pendingViewSessionRef.current.sessionId = newSessionId;
}
setCurrentSessionId(newSessionId); setCurrentSessionId(newSessionId);
setPendingPermissionRequests((prev) => setPendingPermissionRequests((prev) =>
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })), prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
); );
} }
pendingViewSessionRef.current = null;
onSessionActive?.(newSessionId);
onSessionProcessing?.(newSessionId);
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
onNavigateToSession?.(newSessionId); onNavigateToSession?.(newSessionId);
break; break;
} }
@@ -266,6 +275,7 @@ export function useChatRealtimeHandlers({
setPendingPermissionRequests([]); setPendingPermissionRequests([]);
onSessionInactive?.(sid); onSessionInactive?.(sid);
onSessionNotProcessing?.(sid); onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
// Handle aborted case // Handle aborted case
if (msg.aborted) { if (msg.aborted) {
@@ -279,16 +289,10 @@ export function useChatRealtimeHandlers({
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0 typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId ? msg.actualSessionId
: null; : null;
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0;
const isVisibleSession = const isVisibleSession =
Boolean( Boolean(
sid sid
&& ( && sid === activeViewSessionId,
sid === activeViewSessionId
|| sid === pendingSessionId
|| pendingViewSessionRef.current?.sessionId === sid
),
); );
if (actualSessionId && sid && actualSessionId !== sid) { if (actualSessionId && sid && actualSessionId !== sid) {
@@ -296,17 +300,6 @@ export function useChatRealtimeHandlers({
if (isVisibleSession) { if (isVisibleSession) {
setCurrentSessionId(actualSessionId); setCurrentSessionId(actualSessionId);
if (pendingViewSessionRef.current) {
const pendingSession = pendingViewSessionRef.current.sessionId;
if (!pendingSession || pendingSession === sid) {
pendingViewSessionRef.current.sessionId = actualSessionId;
}
}
}
if (completedSuccessfully && pendingSessionId === sid) {
sessionStorage.removeItem('pendingSessionId');
} }
if (isVisibleSession) { if (isVisibleSession) {
@@ -316,16 +309,6 @@ export function useChatRealtimeHandlers({
break; break;
} }
// Clear pending session
if (pendingSessionId && !currentSessionId && completedSuccessfully) {
const resolvedSessionId = actualSessionId || pendingSessionId;
setCurrentSessionId(resolvedSessionId);
if (actualSessionId) {
onNavigateToSession?.(resolvedSessionId, { replace: true });
}
sessionStorage.removeItem('pendingSessionId');
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
}
break; break;
} }
@@ -335,6 +318,7 @@ export function useChatRealtimeHandlers({
setClaudeStatus(null); setClaudeStatus(null);
onSessionInactive?.(sid); onSessionInactive?.(sid);
onSessionNotProcessing?.(sid); onSessionNotProcessing?.(sid);
pendingViewSessionRef.current = null;
break; break;
} }
@@ -399,6 +383,7 @@ export function useChatRealtimeHandlers({
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,
onSessionInactive, onSessionInactive,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onSessionNotProcessing, onSessionNotProcessing,
onNavigateToSession, onNavigateToSession,

View File

@@ -13,7 +13,6 @@ const MESSAGES_PER_PAGE = 20;
const INITIAL_VISIBLE_MESSAGES = 100; const INITIAL_VISIBLE_MESSAGES = 100;
type PendingViewSession = { type PendingViewSession = {
sessionId: string | null;
startedAt: number; startedAt: number;
}; };
@@ -160,7 +159,7 @@ export function useChatSessionState({
* Why this is essential: * Why this is essential:
* - Chat keeps local state that is not fully derived from `selectedSession`: * - Chat keeps local state that is not fully derived from `selectedSession`:
* `currentSessionId`, `pendingUserMessage`, streaming/status flags, message * `currentSessionId`, `pendingUserMessage`, streaming/status flags, message
* pagination/scroll bookkeeping, and pending session IDs in sessionStorage. * pagination/scroll bookkeeping, and provider-specific sessionStorage keys.
* - If the user clicks New Session while already on the same route with no * - If the user clicks New Session while already on the same route with no
* selected session, parent state updates can be idempotent and this local * selected session, parent state updates can be idempotent and this local
* state would otherwise persist, making the click appear to "do nothing". * state would otherwise persist, making the click appear to "do nothing".
@@ -177,7 +176,6 @@ export function useChatSessionState({
setIsLoading(false); setIsLoading(false);
setCurrentSessionId(null); setCurrentSessionId(null);
setPendingUserMessage(null); setPendingUserMessage(null);
sessionStorage.removeItem('pendingSessionId');
sessionStorage.removeItem('cursorSessionId'); sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0; messagesOffsetRef.current = 0;
setHasMoreMessages(false); setHasMoreMessages(false);
@@ -396,6 +394,12 @@ export function useChatSessionState({
// Main session loading effect — store-based // Main session loading effect — store-based
useEffect(() => { useEffect(() => {
if (!selectedSession || !selectedProject) { if (!selectedSession || !selectedProject) {
// A new provider run can be in flight before the router has a canonical
// selectedSession. Keep the processing banner alive until complete/error.
if (pendingViewSessionRef.current) {
return;
}
resetStreamingState(); resetStreamingState();
pendingViewSessionRef.current = null; pendingViewSessionRef.current = null;
setClaudeStatus(null); setClaudeStatus(null);
@@ -537,10 +541,6 @@ export function useChatSessionState({
} }
}, [selectedSession]); }, [selectedSession]);
useEffect(() => {
if (selectedSession?.id) pendingViewSessionRef.current = null;
}, [pendingViewSessionRef, selectedSession?.id]);
// Scroll to search target // Scroll to search target
useEffect(() => { useEffect(() => {
if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return; if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;

View File

@@ -17,7 +17,6 @@ import ChatComposer from './subcomponents/ChatComposer';
type PendingViewSession = { type PendingViewSession = {
sessionId: string | null;
startedAt: number; startedAt: number;
}; };
@@ -240,6 +239,7 @@ function ChatInterface({
streamTimerRef, streamTimerRef,
accumulatedStreamRef, accumulatedStreamRef,
onSessionInactive, onSessionInactive,
onSessionActive,
onSessionProcessing, onSessionProcessing,
onSessionNotProcessing, onSessionNotProcessing,
onNavigateToSession, onNavigateToSession,