From 5b9adbbdee8561439a27ad90744388225823427b Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:22:11 +0300 Subject: [PATCH] fix(opencode): bind watcher sessions to app rows early --- .../database/repositories/sessions.db.ts | 37 ++++++++++++++ .../opencode-session-synchronizer.provider.ts | 17 +++++-- .../providers/tests/opencode-sessions.test.ts | 49 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 698c3e16..407e4f80 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -257,6 +257,43 @@ export const sessionsDb = { return normalizeSessionRow(row) ?? null; }, + /** + * Finds the newest app-created session for a project that is still waiting + * for its provider-native id to be recorded. + * + * Primary intention: OpenCode can expose a new session in its shared + * `opencode.db` before the websocket runtime reports that same provider id + * back to our app. At that moment the sidebar already has an optimistic + * app-owned session row, but the watcher only knows the provider-native id. + * + * Without this lookup, the synchronizer would insert a second row keyed by + * the provider id, then `assignProviderSessionId()` would merge it a moment + * later. That eventually self-heals, but on slow networks the user can still + * briefly see two sidebar sessions for the same conversation. + * + * This helper lets the synchronizer claim the pending app row first, so the + * provider id is attached before any watcher-created row exists. The result + * is simpler than frontend dedupe and keeps the race resolved at the source. + */ + findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null { + const db = getConnection(); + const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath); + const row = db + .prepare( + `SELECT ${SESSION_ROW_COLUMNS} + FROM sessions + WHERE provider = ? + AND project_path = ? + AND provider_session_id IS NULL + AND isArchived = 0 + ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC + LIMIT 1` + ) + .get(provider, normalizedProjectPath) as SessionRow | undefined; + + return normalizeSessionRow(row) ?? null; + }, + getAllSessions(): SessionRow[] { const db = getConnection(); const rows = db diff --git a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts index 8e7ee213..ea63c776 100644 --- a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +++ b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts @@ -112,6 +112,17 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer } const fallbackTitle = 'Untitled OpenCode Session'; + const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId) + ?? sessionsDb.getSessionById(sessionId) + ?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath); + if (pendingAppSession && !pendingAppSession.provider_session_id) { + // Slow networks can let the sqlite watcher index opencode.db before the + // runtime reports its provider id back through the websocket mapping. + // Bind that id to the fresh app row first so the watcher does not create + // a temporary provider-id sidebar entry for the same session. + sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId); + } + // App-created sessions are keyed by an app id, so disk-discovered provider // ids must be resolved through the provider-id mapping first. const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) @@ -123,7 +134,9 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer // OpenCode stores every session in one shared sqlite database, so jsonl_path // must stay null to avoid deleting opencode.db when one app session is removed. - sessionsDb.createSession( + // Return the canonical stored row id so watcher-triggered sidebar updates + // stay on the app session once provider_session_id has already been mapped. + return sessionsDb.createSession( sessionId, this.provider, projectPath, @@ -132,8 +145,6 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer normalizeProviderTimestamp(row.time_updated ?? row.time_created), null, ); - - return sessionId; } private readFirstUserText(db: Database.Database, sessionId: string): string | undefined { diff --git a/server/modules/providers/tests/opencode-sessions.test.ts b/server/modules/providers/tests/opencode-sessions.test.ts index d5b65e4e..2a98ea6f 100644 --- a/server/modules/providers/tests/opencode-sessions.test.ts +++ b/server/modules/providers/tests/opencode-sessions.test.ts @@ -272,6 +272,55 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr } }); +test('OpenCode session synchronizer returns the app session id once provider mapping exists', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-mapped-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await createOpenCodeDatabase(tempRoot, workspacePath); + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-session-1', 'opencode', workspacePath); + sessionsDb.assignProviderSessionId('app-session-1', 'open-session-1'); + + const synchronizer = new OpenCodeSessionSynchronizer(); + return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => { + assert.equal(sessionId, 'app-session-1'); + assert.equal(sessionsDb.getAllSessions().length, 1); + assert.equal(sessionsDb.getSessionById('app-session-1')?.provider_session_id, 'open-session-1'); + }); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('OpenCode session synchronizer adopts the pending app session before watcher sync creates a duplicate', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-race-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await createOpenCodeDatabase(tempRoot, workspacePath); + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-session-race', 'opencode', workspacePath); + + const synchronizer = new OpenCodeSessionSynchronizer(); + return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => { + assert.equal(sessionId, 'app-session-race'); + assert.equal(sessionsDb.getAllSessions().length, 1); + assert.equal(sessionsDb.getSessionById('app-session-race')?.provider_session_id, 'open-session-1'); + }); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => { const provider = new OpenCodeSessionsProvider(); const normalized = provider.normalizeMessage({