From 472c7a80553401fed039e737fbe8f845faa9faef Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:07:50 +0300 Subject: [PATCH] fix(sessions): title app-created sessions from the first user message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-created sessions (started by sending a message from cloudcli) were titled with placeholder names — "Untitled Codex Session" from the disk indexer and, briefly, "New session" from the empty canonical upsert — before a later sync finally settled on the right text, causing the sidebar title to flicker. - Codex/OpenCode synchronizers now title app-created sessions (distinct app id mapped to a provider id) from the first user message, while sessions found purely by indexing keep their existing setup. Claude keeps its AI-generated titles; Cursor already used the first message. - Decode OpenCode's JSON-string-literal prompts so titles no longer surface wrapped in quotes; hoist unwrapJsonStringLiteral into shared/utils since it's now used by both the reader and synchronizer. - Guard the sidebar upsert merge so an empty summary can never blank out a title that is already set. - Add codex/opencode synchronizer tests for the app-created vs indexed naming paths. --- .../codex-session-synchronizer.provider.ts | 61 +++++++++- .../opencode-session-synchronizer.provider.ts | 29 ++++- .../opencode/opencode-sessions.provider.ts | 20 +--- .../providers/tests/codex-sessions.test.ts | 111 ++++++++++++++++++ .../providers/tests/opencode-sessions.test.ts | 103 ++++++++++++++++ server/shared/utils.ts | 24 ++++ src/hooks/useProjectsState.ts | 7 ++ 7 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 server/modules/providers/tests/codex-sessions.test.ts diff --git a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts index 818d71c9..f6c79ff9 100644 --- a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +++ b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts @@ -133,7 +133,23 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { }; } - let sessionName = nameMap.get(parsed.sessionId); + // Sessions started by sending a message from cloudcli carry a distinct + // app-allocated session_id mapped to the provider id. For these we title the + // conversation from the first user message the user typed, instead of the + // generic "Untitled Codex Session" placeholder. Sessions discovered purely + // by indexing (session_id === provider_session_id) keep the existing + // thread_name/last-agent-message setup below. + const isAppCreated = + existingSession != null && + existingSession.provider_session_id != null && + existingSession.session_id !== existingSession.provider_session_id; + + let sessionName = isAppCreated + ? await this.extractFirstUserMessageFromStart(filePath) + : undefined; + if (!sessionName) { + sessionName = nameMap.get(parsed.sessionId); + } if (!sessionName) { sessionName = await this.extractLastAgentMessageFromEnd(filePath); } @@ -144,6 +160,49 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { }; } + /** + * Returns the first user message text in a Codex transcript, used to title + * app-created sessions from the prompt the user sent from cloudcli. + * + * Reads the `event_msg`/`user_message` payload rather than the raw + * `response_item` user turn so injected `` boilerplate is + * never mistaken for the user's prompt. + */ + private async extractFirstUserMessageFromStart(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + const data = parsed as Record; + const eventType = typeof data.type === 'string' ? data.type : undefined; + const payload = data.payload as Record | undefined; + const payloadType = typeof payload?.type === 'string' ? payload.type : undefined; + const message = typeof payload?.message === 'string' ? payload.message : undefined; + + if (eventType === 'event_msg' && payloadType === 'user_message' && message?.trim()) { + return message; + } + } + } catch { + // Ignore missing/unreadable files so sync can continue. + } + + return undefined; + } + private async extractLastAgentMessageFromEnd(filePath: string): Promise { try { const content = await readFile(filePath, 'utf8'); 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 ea63c776..a3c2ba95 100644 --- a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +++ b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts @@ -11,6 +11,7 @@ import { normalizeSessionName, readJsonRecord, readOptionalString, + unwrapJsonStringLiteral, } from '@/shared/utils.js'; type OpenCodeSessionRow = { @@ -128,9 +129,26 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) ?? sessionsDb.getSessionById(sessionId); const existingName = existingSession?.custom_name; - const nextName = existingName && existingName !== fallbackTitle - ? existingName - : readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId); + + // Sessions started by sending a message from cloudcli carry a distinct + // app-allocated session_id mapped to the provider id. For these we title the + // conversation from the first user message the user typed, matching how the + // app titles a brand-new conversation. Sessions discovered purely by + // indexing (session_id === provider_session_id) keep OpenCode's own stored + // title. + const isAppCreated = + existingSession != null && + existingSession.provider_session_id != null && + existingSession.session_id !== existingSession.provider_session_id; + + let nextName: string | undefined; + if (existingName && existingName !== fallbackTitle) { + nextName = existingName; + } else if (isAppCreated) { + nextName = this.readFirstUserText(db, sessionId) ?? readOptionalString(row.title); + } else { + nextName = readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId); + } // 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. @@ -163,7 +181,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer `).get(sessionId) as { data: string | null } | undefined; const data = readJsonRecord(row?.data); - return readOptionalString(data?.text); + const text = readOptionalString(data?.text); + // OpenCode persists the first prompt as a JSON string literal (e.g. + // `"hello"`), so decode it to avoid titling the session with quotes. + return text === undefined ? undefined : unwrapJsonStringLiteral(text); } catch { return undefined; } diff --git a/server/modules/providers/list/opencode/opencode-sessions.provider.ts b/server/modules/providers/list/opencode/opencode-sessions.provider.ts index c34eac43..f406438b 100644 --- a/server/modules/providers/list/opencode/opencode-sessions.provider.ts +++ b/server/modules/providers/list/opencode/opencode-sessions.provider.ts @@ -14,6 +14,7 @@ import { readJsonRecord, readOptionalString, sliceTailPage, + unwrapJsonStringLiteral, } from '@/shared/utils.js'; const PROVIDER = 'opencode'; @@ -60,25 +61,6 @@ const formatToolContent = (value: unknown): 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); diff --git a/server/modules/providers/tests/codex-sessions.test.ts b/server/modules/providers/tests/codex-sessions.test.ts new file mode 100644 index 00000000..f6687856 --- /dev/null +++ b/server/modules/providers/tests/codex-sessions.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js'; + +const patchHomeDir = (nextHomeDir: string) => { + const original = os.homedir; + (os as any).homedir = () => nextHomeDir; + return () => { + (os as any).homedir = original; + }; +}; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'codex-provider-db-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +/** + * Writes one Codex rollout transcript. `firstUserMessage` mirrors the + * `event_msg`/`user_message` payload the runtime records for the prompt the + * user typed; omitting it produces a transcript with no user turn. + */ +const writeCodexTranscript = async ( + homeDir: string, + codexSessionId: string, + workspacePath: string, + firstUserMessage?: string, +): Promise => { + const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '07', '07'); + await mkdir(sessionsDir, { recursive: true }); + + const lines: string[] = [ + JSON.stringify({ type: 'session_meta', payload: { id: codexSessionId, cwd: workspacePath } }), + ]; + if (firstUserMessage !== undefined) { + lines.push(JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: firstUserMessage } })); + } + + const filePath = path.join(sessionsDir, `rollout-${codexSessionId}.jsonl`); + await writeFile(filePath, `${lines.join('\n')}\n`, 'utf8'); + return filePath; +}; + +test('Codex synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-app-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await writeCodexTranscript(tempRoot, 'codex-app-1', workspacePath, 'Fix the login redirect bug'); + await withIsolatedDatabase(async () => { + // The app allocates its own id and later maps the provider id onto it, + // exactly as a message sent from cloudcli does. + sessionsDb.createAppSession('app-1', 'codex', workspacePath); + sessionsDb.assignProviderSessionId('app-1', 'codex-app-1'); + + const synchronizer = new CodexSessionSynchronizer(); + await synchronizer.synchronize(); + + assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the login redirect bug'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Codex synchronizer leaves indexed sessions untitled when no name is available', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-indexed-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + // A CLI-created session has no app row; its first user message must NOT be + // used as the title, preserving the existing indexing behavior. + await writeCodexTranscript(tempRoot, 'codex-indexed-1', workspacePath, 'This prompt should be ignored'); + await withIsolatedDatabase(async () => { + const synchronizer = new CodexSessionSynchronizer(); + await synchronizer.synchronize(); + + assert.equal(sessionsDb.getSessionById('codex-indexed-1')?.custom_name, 'Untitled Codex Session'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/server/modules/providers/tests/opencode-sessions.test.ts b/server/modules/providers/tests/opencode-sessions.test.ts index a0682ba7..2866632e 100644 --- a/server/modules/providers/tests/opencode-sessions.test.ts +++ b/server/modules/providers/tests/opencode-sessions.test.ts @@ -417,3 +417,106 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur await rm(tempRoot, { recursive: true, force: true }); } }); + +/** + * Seeds a single OpenCode session with a controllable stored title and first + * user message. Uses a minimal schema (only the columns the synchronizer reads) + * with a plain-text user part so the derived name is unambiguous. + */ +const seedOpenCodeSession = async ( + homeDir: string, + workspacePath: string, + options: { sessionId: string; title: string | null; firstUserText: string }, +): Promise => { + const dataDir = path.join(homeDir, '.local', 'share', 'opencode'); + await mkdir(dataDir, { recursive: true }); + + const db = new Database(path.join(dataDir, 'opencode.db')); + try { + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER, + time_archived INTEGER + ); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT); + `); + + db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', workspacePath); + db.prepare(` + INSERT INTO session (id, project_id, directory, title, time_created, time_updated, time_archived) + VALUES (?, ?, ?, ?, ?, ?, NULL) + `).run(options.sessionId, 'project-1', workspacePath, options.title, 1_700_000_000_000, 1_700_000_001_000); + db.prepare('INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)') + .run('message-user', options.sessionId, 1_700_000_001_000, JSON.stringify({ role: 'user' })); + db.prepare('INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)') + .run( + 'part-user', + 'message-user', + options.sessionId, + 1_700_000_001_000, + // OpenCode persists the prompt as a JSON string literal inside the text + // field, so double-encode it here to exercise the unwrap on read. + JSON.stringify({ type: 'text', text: JSON.stringify(options.firstUserText) }), + ); + } finally { + db.close(); + } +}; + +test('OpenCode synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-app-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + // Stored title differs from the first message so we can prove the first + // message wins for sessions started from cloudcli. + await seedOpenCodeSession(tempRoot, workspacePath, { + sessionId: 'oc-app-1', + title: 'OpenCode generated title', + firstUserText: 'Fix the checkout crash', + }); + await withIsolatedDatabase(async () => { + sessionsDb.createAppSession('app-1', 'opencode', workspacePath); + sessionsDb.assignProviderSessionId('app-1', 'oc-app-1'); + + await new OpenCodeSessionSynchronizer().synchronize(); + + assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the checkout crash'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('OpenCode synchronizer keeps the stored title for indexed sessions', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-indexed-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await seedOpenCodeSession(tempRoot, workspacePath, { + sessionId: 'oc-indexed-1', + title: 'OpenCode generated title', + firstUserText: 'This prompt should be ignored', + }); + await withIsolatedDatabase(async () => { + await new OpenCodeSessionSynchronizer().synchronize(); + + assert.equal(sessionsDb.getSessionById('oc-indexed-1')?.custom_name, 'OpenCode generated title'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 7dbf20be..e448595c 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -1066,6 +1066,30 @@ export function getOpenCodeDatabasePath(): string { return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db'); } +/** + * Decodes an OpenCode text payload that was persisted as a JSON string literal. + * + * OpenCode can store the first user prompt (and other text parts) as `"hello"` + * instead of `hello`. Used by both the OpenCode session reader (transcript + * history) and the OpenCode synchronizer (session titling) so a session name or + * message body never surfaces with surrounding quote characters. Only fully + * quoted, valid JSON string literals are unwrapped; ordinary prose that merely + * happens to start/end with a quote is returned untouched. + */ +export function 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; + } +} + // --------------------------- //----------------- SAFE DIRECTORY NAME UTILITIES ------------ /** diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 051fd6d1..f5574adc 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -255,6 +255,13 @@ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent) for (const [index, session] of sessions.entries()) { if (index === existingIndex) { const updated = { ...session, ...normalizedSession }; + // Never let a later upsert that carries an empty summary blank out a + // title we already have. Fresh sessions momentarily broadcast an empty + // custom_name before the disk indexer fills it in, which would + // otherwise flash the row back to the "New session" placeholder. + if (!normalizedSession.summary?.trim() && session.summary?.trim()) { + updated.summary = session.summary; + } if (serialize(session) !== serialize(updated)) { changed = true; }