fix(sessions): title app-created sessions from the first user message

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.
This commit is contained in:
Haileyesus
2026-07-07 15:07:50 +03:00
parent 6336d94fd6
commit 472c7a8055
7 changed files with 331 additions and 24 deletions

View File

@@ -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) { if (!sessionName) {
sessionName = await this.extractLastAgentMessageFromEnd(filePath); 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 `<environment_context>` boilerplate is
* never mistaken for the user's prompt.
*/
private async extractFirstUserMessageFromStart(filePath: string): Promise<string | undefined> {
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<string, unknown>;
const eventType = typeof data.type === 'string' ? data.type : undefined;
const payload = data.payload as Record<string, unknown> | 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<string | undefined> { private async extractLastAgentMessageFromEnd(filePath: string): Promise<string | undefined> {
try { try {
const content = await readFile(filePath, 'utf8'); const content = await readFile(filePath, 'utf8');

View File

@@ -11,6 +11,7 @@ import {
normalizeSessionName, normalizeSessionName,
readJsonRecord, readJsonRecord,
readOptionalString, readOptionalString,
unwrapJsonStringLiteral,
} from '@/shared/utils.js'; } from '@/shared/utils.js';
type OpenCodeSessionRow = { type OpenCodeSessionRow = {
@@ -128,9 +129,26 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId); ?? sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name; const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName // Sessions started by sending a message from cloudcli carry a distinct
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId); // 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 // 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. // 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; `).get(sessionId) as { data: string | null } | undefined;
const data = readJsonRecord(row?.data); 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 { } catch {
return undefined; return undefined;
} }

View File

@@ -14,6 +14,7 @@ import {
readJsonRecord, readJsonRecord,
readOptionalString, readOptionalString,
sliceTailPage, sliceTailPage,
unwrapJsonStringLiteral,
} from '@/shared/utils.js'; } from '@/shared/utils.js';
const PROVIDER = 'opencode'; 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 => { const extractText = (value: unknown): string => {
if (typeof value === 'string') { if (typeof value === 'string') {
return unwrapJsonStringLiteral(value); return unwrapJsonStringLiteral(value);

View File

@@ -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<void>): Promise<void> {
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<string> => {
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 });
}
});

View File

@@ -417,3 +417,106 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
await rm(tempRoot, { recursive: true, force: true }); 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<void> => {
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 });
}
});

View File

@@ -1066,6 +1066,30 @@ export function getOpenCodeDatabasePath(): string {
return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db'); 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 ------------ //----------------- SAFE DIRECTORY NAME UTILITIES ------------
/** /**

View File

@@ -255,6 +255,13 @@ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent)
for (const [index, session] of sessions.entries()) { for (const [index, session] of sessions.entries()) {
if (index === existingIndex) { if (index === existingIndex) {
const updated = { ...session, ...normalizedSession }; 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)) { if (serialize(session) !== serialize(updated)) {
changed = true; changed = true;
} }