From 57aece12e62884adc96ff181f05607cde81e2463 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 13 May 2026 18:44:07 +0300 Subject: [PATCH] fix: stabilize opencode session startup --- server/modules/database/index.ts | 1 + .../opencode/opencode-sessions.provider.ts | 43 +++++++++++++-- .../providers/tests/opencode-sessions.test.ts | 28 ++++++++-- .../chat/hooks/useChatComposerState.ts | 17 ++---- .../chat/hooks/useChatRealtimeHandlers.ts | 53 +++++++------------ .../chat/hooks/useChatSessionState.ts | 14 ++--- src/components/chat/view/ChatInterface.tsx | 2 +- 7 files changed, 97 insertions(+), 61 deletions(-) diff --git a/server/modules/database/index.ts b/server/modules/database/index.ts index 787521ed..da87740c 100644 --- a/server/modules/database/index.ts +++ b/server/modules/database/index.ts @@ -1,4 +1,5 @@ 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 { appConfigDb } from '@/modules/database/repositories/app-config.js'; export { credentialsDb } from '@/modules/database/repositories/credentials.js'; diff --git a/server/modules/providers/list/opencode/opencode-sessions.provider.ts b/server/modules/providers/list/opencode/opencode-sessions.provider.ts index f582569d..f5b8ba84 100644 --- a/server/modules/providers/list/opencode/opencode-sessions.provider.ts +++ b/server/modules/providers/list/opencode/opencode-sessions.provider.ts @@ -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; } + 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); - return readOptionalString(record?.text) + const text = readOptionalString(record?.text) ?? 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 => { @@ -158,6 +189,12 @@ export class OpenCodeSessionsProvider implements IProviderSessions { ?? generateMessageId('opencode'); 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); if (!content.trim()) { return []; diff --git a/server/modules/providers/tests/opencode-sessions.test.ts b/server/modules/providers/tests/opencode-sessions.test.ts index b065576a..139a28aa 100644 --- a/server/modules/providers/tests/opencode-sessions.test.ts +++ b/server/modules/providers/tests/opencode-sessions.test.ts @@ -6,8 +6,7 @@ import test from 'node:test'; import Database from 'better-sqlite3'; -import { closeConnection } from '@/modules/database/connection.js'; -import { initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { OpenCodeSessionSynchronizer } from '@/modules/providers/list/opencode/opencode-session-synchronizer.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, JSON.stringify({ type: 'text', - text: 'Build the OpenCode integration.', + text: JSON.stringify('Build the OpenCode integration.'), }), ); 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 () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-history-')); 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.messages[0]?.kind, 'text'); 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[2]?.content, 'The provider is wired.'); assert.equal(history.messages[3]?.kind, 'tool_use'); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 141033e1..0b70f306 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -27,7 +27,6 @@ import { useFileMentions } from './useFileMentions'; import { type SlashCommand, useSlashCommands } from './useSlashCommands'; type PendingViewSession = { - sessionId: string | null; startedAt: number; }; @@ -566,13 +565,9 @@ export function useChatComposerState({ setTimeout(() => scrollToBottom(), 100); if (!effectiveSessionId && !selectedSession?.id) { - if (typeof window !== 'undefined') { - // Reset stale pending IDs from previous interrupted runs before creating a new one. - sessionStorage.removeItem('pendingSessionId'); - } - // 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() }; + // This tracks only that a request is in flight before the provider has + // emitted its real session id; routing still waits for session_created. + pendingViewSessionRef.current = { startedAt: Date.now() }; } if (effectiveSessionId) { onSessionActive?.(effectiveSessionId); @@ -884,15 +879,11 @@ export function useChatComposerState({ return; } - const pendingSessionId = - typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; const cursorSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null; const candidateSessionIds = [ currentSessionId, - pendingViewSessionRef.current?.sessionId || null, - pendingSessionId, provider === 'cursor' ? cursorSessionId : null, selectedSession?.id || null, ]; @@ -910,7 +901,7 @@ export function useChatComposerState({ sessionId: targetSessionId, provider, }); - }, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]); + }, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]); const handleGrantToolPermission = useCallback( (suggestion: { entry: string; toolName: string }) => { diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 86c85469..6a5a1b81 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -7,7 +7,6 @@ import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; type PendingViewSession = { - sessionId: string | null; startedAt: number; }; @@ -63,6 +62,7 @@ interface UseChatRealtimeHandlersArgs { streamTimerRef: MutableRefObject; accumulatedStreamRef: MutableRefObject; onSessionInactive?: (sessionId?: string | null) => void; + onSessionActive?: (sessionId?: string | null) => void; onSessionProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void; onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void; @@ -89,6 +89,7 @@ export function useChatRealtimeHandlers({ streamTimerRef, accumulatedStreamRef, onSessionInactive, + onSessionActive, onSessionProcessing, onSessionNotProcessing, onNavigateToSession, @@ -104,7 +105,7 @@ export function useChatRealtimeHandlers({ lastProcessedMessageRef.current = latestMessage; const activeViewSessionId = - selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; + selectedSession?.id || currentSessionId || null; /* ---------------------------------------------------------------- */ /* Legacy messages (no `kind` field) — handle and return */ @@ -151,10 +152,12 @@ export function useChatRealtimeHandlers({ statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); if (msg.isProcessing) { + onSessionActive?.(statusSessionId); onSessionProcessing?.(statusSessionId); if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); } return; } + onSessionInactive?.(statusSessionId); onSessionNotProcessing?.(statusSessionId); if (isCurrentSession) { @@ -235,15 +238,21 @@ export function useChatRealtimeHandlers({ if (!currentSessionId) { console.log('Session created with ID:', newSessionId); console.log('Existing session ID:', currentSessionId); - sessionStorage.setItem('pendingSessionId', newSessionId); - if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { - pendingViewSessionRef.current.sessionId = newSessionId; - } setCurrentSessionId(newSessionId); setPendingPermissionRequests((prev) => 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); break; } @@ -266,6 +275,7 @@ export function useChatRealtimeHandlers({ setPendingPermissionRequests([]); onSessionInactive?.(sid); onSessionNotProcessing?.(sid); + pendingViewSessionRef.current = null; // Handle aborted case if (msg.aborted) { @@ -279,16 +289,10 @@ export function useChatRealtimeHandlers({ typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0 ? msg.actualSessionId : null; - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0; const isVisibleSession = Boolean( sid - && ( - sid === activeViewSessionId - || sid === pendingSessionId - || pendingViewSessionRef.current?.sessionId === sid - ), + && sid === activeViewSessionId, ); if (actualSessionId && sid && actualSessionId !== sid) { @@ -296,17 +300,6 @@ export function useChatRealtimeHandlers({ if (isVisibleSession) { 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) { @@ -316,16 +309,6 @@ export function useChatRealtimeHandlers({ 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; } @@ -335,6 +318,7 @@ export function useChatRealtimeHandlers({ setClaudeStatus(null); onSessionInactive?.(sid); onSessionNotProcessing?.(sid); + pendingViewSessionRef.current = null; break; } @@ -399,6 +383,7 @@ export function useChatRealtimeHandlers({ streamTimerRef, accumulatedStreamRef, onSessionInactive, + onSessionActive, onSessionProcessing, onSessionNotProcessing, onNavigateToSession, diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 95278ffd..b96e6646 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -13,7 +13,6 @@ const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; type PendingViewSession = { - sessionId: string | null; startedAt: number; }; @@ -160,7 +159,7 @@ export function useChatSessionState({ * Why this is essential: * - Chat keeps local state that is not fully derived from `selectedSession`: * `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 * selected session, parent state updates can be idempotent and this local * state would otherwise persist, making the click appear to "do nothing". @@ -177,7 +176,6 @@ export function useChatSessionState({ setIsLoading(false); setCurrentSessionId(null); setPendingUserMessage(null); - sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('cursorSessionId'); messagesOffsetRef.current = 0; setHasMoreMessages(false); @@ -396,6 +394,12 @@ export function useChatSessionState({ // Main session loading effect — store-based useEffect(() => { 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(); pendingViewSessionRef.current = null; setClaudeStatus(null); @@ -537,10 +541,6 @@ export function useChatSessionState({ } }, [selectedSession]); - useEffect(() => { - if (selectedSession?.id) pendingViewSessionRef.current = null; - }, [pendingViewSessionRef, selectedSession?.id]); - // Scroll to search target useEffect(() => { if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index c53ef49c..4a0781d3 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -17,7 +17,6 @@ import ChatComposer from './subcomponents/ChatComposer'; type PendingViewSession = { - sessionId: string | null; startedAt: number; }; @@ -240,6 +239,7 @@ function ChatInterface({ streamTimerRef, accumulatedStreamRef, onSessionInactive, + onSessionActive, onSessionProcessing, onSessionNotProcessing, onNavigateToSession,