mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-13 09:42:02 +08:00
fix(opencode): bind watcher sessions to app rows early
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user