feat(chat): unify session gateway with stable IDs and a single WS protocol

The frontend previously juggled placeholder IDs, provider-native IDs, and session_created handoffs, which caused race conditions and provider-specific branching. This introduces app-allocated session IDs, a chat run registry with event replay, delta sidebar updates, and one kind-based websocket contract so the UI can treat every provider the same while JSONL remains the source of truth.
This commit is contained in:
Haileyesus
2026-06-11 18:47:19 +03:00
parent 3d948217ef
commit f5eac2ec12
40 changed files with 2451 additions and 1226 deletions

View File

@@ -111,7 +111,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
return null;
}
const existingSession = sessionsDb.getSessionById(parsed.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(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
return {

View File

@@ -5,7 +5,7 @@ import readline from 'node:readline';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
import { sessionsDb } from '@/modules/database/index.js';
const PROVIDER = 'claude';
@@ -103,10 +103,13 @@ async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
async function getSessionMessages(
sessionId: string,
providerSessionId: string,
limit: number | null,
offset: number,
): Promise<ClaudeHistoryMessagesResult> {
try {
// The DB row is keyed by the app-facing session id, while the JSONL rows
// on disk carry the provider-native id — both ids are needed here.
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
if (!jsonLPath) {
@@ -133,7 +136,7 @@ async function getSessionMessages(
try {
const entry = JSON.parse(line) as AnyRecord;
if (entry.sessionId === sessionId) {
if (entry.sessionId === providerSessionId) {
messages.push(entry);
}
} catch {
@@ -553,12 +556,13 @@ export class ClaudeSessionsProvider implements IProviderSessions {
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
const providerSessionId = options.providerSessionId ?? sessionId;
let result: ClaudeHistoryResult;
try {
// Load full history first so `total` reflects frontend-normalized messages,
// not raw JSONL records.
result = await getSessionMessages(sessionId, null, 0);
result = await getSessionMessages(sessionId, providerSessionId, null, 0);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
@@ -606,7 +610,6 @@ export class ClaudeSessionsProvider implements IProviderSessions {
}
}
const totalNormalized = normalized.length;
let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
@@ -615,18 +618,10 @@ export class ClaudeSessionsProvider implements IProviderSessions {
}
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
Math.max(0, totalNormalized - normalizedOffset),
);
const hasMore = normalizedLimit === null
? false
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
return {
messages,
messages: page,
total,
hasMore,
offset: normalizedOffset,

View File

@@ -43,11 +43,12 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
continue;
}
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
if (existingSession) {
// If session name is untitled and we now have a name, update it
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName);
}
}
@@ -120,7 +121,10 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
return null;
}
const existingSession = sessionsDb.getSessionById(parsed.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(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
return {

View File

@@ -4,7 +4,7 @@ import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
const PROVIDER = 'codex';
@@ -552,7 +552,6 @@ export class CodexSessionsProvider implements IProviderSessions {
}
}
const totalNormalized = normalized.length;
let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
@@ -561,18 +560,10 @@ export class CodexSessionsProvider implements IProviderSessions {
}
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
Math.max(0, totalNormalized - normalizedOffset),
);
const hasMore = normalizedLimit === null
? false
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
return {
messages,
messages: page,
total,
hasMore,
offset: normalizedOffset,

View File

@@ -9,6 +9,7 @@ import {
generateMessageId,
readObjectRecord,
sanitizeLeafDirectoryName,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'cursor';
@@ -363,42 +364,32 @@ export class CursorSessionsProvider implements IProviderSessions {
/**
* Fetches and paginates Cursor session history from its project-scoped store.db.
*
* Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is
* the most recent page, matching every other provider.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options;
// The store.db folder on disk is named after the provider-native id, not
// the app-facing session id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
try {
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const blobs = await this.loadCursorBlobs(providerSessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
const total = renderableMessages.length;
if (limit !== null) {
const start = offset;
const page = limit === 0
? []
: renderableMessages.slice(start, start + limit);
const hasMore = limit === 0
? start < total
: start + limit < total;
return {
messages: page,
total,
hasMore,
offset,
limit,
};
}
const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset);
return {
messages: renderableMessages,
messages: page,
total,
hasMore: false,
offset: 0,
limit: null,
hasMore,
offset,
limit,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

View File

@@ -5,7 +5,7 @@ import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
const PROVIDER = 'gemini';
@@ -518,9 +518,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
const messages = pageLimit === null
? normalized.slice(start)
: normalized.slice(start, start + pageLimit);
// Tail pagination via the shared contract: offset 0 returns the most
// recent page, matching every other provider.
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
@@ -529,9 +529,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
}
return {
messages,
messages: page,
total,
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
hasMore,
offset: start,
limit: pageLimit,
tokenUsage: result.tokenUsage,

View File

@@ -112,7 +112,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
}
const fallbackTitle = 'Untitled OpenCode Session';
const existingSession = sessionsDb.getSessionById(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)
?? sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName

View File

@@ -12,6 +12,7 @@ import {
readObjectRecord,
readJsonRecord,
readOptionalString,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'opencode';
@@ -325,6 +326,9 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
// OpenCode's shared sqlite database keys messages by the provider-native
// session id, not the app-facing id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
const db = openOpenCodeDatabase();
if (!db) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
@@ -349,27 +353,20 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
m.id,
COALESCE(p.time_created, 0),
p.id
`).all(sessionId) as OpenCodeHistoryRow[];
`).all(providerSessionId) as OpenCodeHistoryRow[];
const normalized = this.normalizeHistoryRows(rows, sessionId);
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const total = normalized.length;
const messages = normalizedLimit === null
? normalized
: normalized.slice(
Math.max(0, total - normalizedOffset - normalizedLimit),
Math.max(0, total - normalizedOffset),
);
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
return {
messages,
messages: page,
total,
hasMore: normalizedLimit === null
? false
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
hasMore,
offset: normalizedOffset,
limit: normalizedLimit,
tokenUsage,

View File

@@ -1,6 +1,7 @@
import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
@@ -382,7 +383,43 @@ router.post(
}),
);
router.get(
'/capabilities',
asyncHandler(async (_req: Request, res: Response) => {
res.json(createApiSuccessResponse({
providers: providerCapabilitiesService.listAllProviderCapabilities(),
}));
}),
);
router.get(
'/:provider/capabilities',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
res.json(createApiSuccessResponse(
providerCapabilitiesService.getProviderCapabilities(provider),
));
}),
);
// ----------------- Session routes -----------------
/**
* Session gateway entry point: allocates the stable app-facing session id for
* a brand-new chat. The frontend must call this before the first `chat.send`
* so the session id in the URL, the store, and the websocket all agree from
* the very first message — there is no client-visible session-id handoff.
*/
router.post(
'/sessions',
asyncHandler(async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const provider = parseProvider(body.provider);
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : '';
const result = sessionsService.createAppSession(provider, projectPath);
res.status(201).json(createApiSuccessResponse(result));
}),
);
router.get(
'/sessions/archived',
asyncHandler(async (_req: Request, res: Response) => {
@@ -459,7 +496,7 @@ router.get(
limit,
offset,
});
res.json(result);
res.json(createApiSuccessResponse(result));
}),
);

View File

@@ -0,0 +1,91 @@
import type { LLMProvider } from '@/shared/types.js';
/**
* Static, backend-owned description of what one provider integration supports.
*
* The frontend renders its composer UI (permission mode picker, image upload,
* abort button, ...) purely from this shape, which is what keeps the frontend
* free of per-provider conditionals. New provider features should be exposed
* here instead of branching on the provider id in React components.
*/
type ProviderCapabilities = {
provider: LLMProvider;
/** Permission modes the provider runtime understands, in cycle order. */
permissionModes: string[];
defaultPermissionMode: string;
/** Whether image attachments can be included in a chat.send. */
supportsImages: boolean;
/** Whether an in-flight run can be cancelled via chat.abort. */
supportsAbort: boolean;
/** Whether interactive tool permission prompts can reach the UI. */
supportsPermissionRequests: boolean;
/** Whether the token-usage endpoint has data for this provider. */
supportsTokenUsage: boolean;
};
/**
* The capability matrix mirrors what each runtime actually implements today:
* - permission modes match the option sets accepted by each CLI/SDK.
* - only the Claude SDK integration surfaces interactive permission requests.
* - Cursor has no token usage endpoint support (its store.db has no usage rows).
*/
const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
claude: {
provider: 'claude',
permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: true,
supportsAbort: true,
supportsPermissionRequests: true,
supportsTokenUsage: true,
},
cursor: {
provider: 'cursor',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: false,
},
codex: {
provider: 'codex',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
gemini: {
provider: 'gemini',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
opencode: {
provider: 'opencode',
permissionModes: ['default'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
};
/**
* Application service exposing the provider capability matrix.
*/
export const providerCapabilitiesService = {
getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
return PROVIDER_CAPABILITIES[provider];
},
listAllProviderCapabilities(): ProviderCapabilities[] {
return Object.values(PROVIDER_CAPABILITIES);
},
};

View File

@@ -4,10 +4,11 @@ import { promises as fsPromises } from 'node:fs';
import chokidar, { type FSWatcher } from 'chokidar';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
import type { LLMProvider } from '@/shared/types.js';
import { getProjectsWithSessions } from '@/modules/projects/index.js';
import { generateDisplayName } from '@/modules/projects/index.js';
type WatcherEventType = 'add' | 'change';
@@ -58,6 +59,11 @@ const watchers: FSWatcher[] = [];
type PendingWatcherUpdate = {
providers: Set<LLMProvider>;
changeTypes: Set<WatcherEventType>;
/**
* Provider-native session ids reported by the synchronizers. They are
* translated back to app-facing session rows at flush time, because the
* transcript file names on disk only ever contain provider ids.
*/
updatedSessionIds: Set<string>;
};
@@ -131,6 +137,50 @@ function queuePendingWatcherUpdate(
schedulePendingWatcherFlush();
}
/**
* Builds one `session_upserted` delta event for a provider-native session id.
*
* The event carries everything a sidebar needs to upsert the session in place
* (session summary plus owning-project metadata), so clients never need a full
* project-list refetch when a transcript file changes on disk. Returns `null`
* when the id cannot be resolved to an indexed session row.
*/
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
?? sessionsDb.getSessionById(updatedProviderSessionId);
if (!row || row.isArchived) {
return null;
}
const projectPath = row.project_path;
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
const displayName = project?.custom_project_name?.trim()
? project.custom_project_name
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
return JSON.stringify({
kind: 'session_upserted',
sessionId: row.session_id,
provider: row.provider,
session: {
id: row.session_id,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
},
project: project
? {
projectId: project.project_id,
path: project.project_path,
fullPath: project.project_path,
displayName,
isStarred: Boolean(project.isStarred),
}
: null,
timestamp: new Date().toISOString(),
});
}
async function flushPendingWatcherUpdate(): Promise<void> {
clearPendingWatcherFlushTimer();
@@ -149,33 +199,29 @@ async function flushPendingWatcherUpdate(): Promise<void> {
watcherRefreshInFlight = true;
try {
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
const changeTypes = Array.from(queuedUpdate.changeTypes);
const watchProviders = Array.from(queuedUpdate.providers);
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
// Backward-compatible fields stay populated with the first queued values.
const updateMessage = JSON.stringify({
type: 'projects_updated',
projects: updatedProjects,
timestamp: new Date().toISOString(),
changeType: changeTypes[0] ?? 'change',
updatedSessionId: updatedSessionIds[0] ?? undefined,
watchProvider: watchProviders[0] ?? undefined,
changeTypes,
updatedSessionIds,
watchProviders,
batched: true,
});
connectedClients.forEach(client => {
if (client.readyState === WS_OPEN_STATE) {
client.send(updateMessage);
// Per-session deltas instead of full project snapshots: an upsert of one
// session can never clobber unrelated client state, so the frontend needs
// no "suppress updates while a run is active" protection logic.
const events: string[] = [];
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
const event = await buildSessionUpsertedEvent(updatedSessionId);
if (event) {
events.push(event);
}
});
}
if (events.length > 0) {
connectedClients.forEach(client => {
if (client.readyState === WS_OPEN_STATE) {
for (const event of events) {
client.send(event);
}
}
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message });
} finally {
watcherRefreshInFlight = false;

View File

@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import path from 'node:path';
@@ -11,6 +12,12 @@ import type {
} from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
type CreateAppSessionResult = {
sessionId: string;
provider: LLMProvider;
projectPath: string;
};
type ArchivedSessionListItem = {
sessionId: string;
provider: LLMProvider;
@@ -89,12 +96,43 @@ export const sessionsService = {
},
/**
* Fetches persisted history by session id.
* Allocates a stable app-facing session id before any provider run happens.
*
* This is the entry point of the session gateway: the frontend calls this
* (via `POST /api/providers/sessions`) when the user starts a brand-new
* chat, navigates to the returned id immediately, and the id never changes
* for the lifetime of the conversation. The provider-native id is mapped to
* this row later, when the provider runtime announces it mid-run.
*/
createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult {
const normalizedProjectPath = projectPath.trim();
if (!normalizedProjectPath) {
throw new AppError('projectPath is required.', {
code: 'PROJECT_PATH_REQUIRED',
statusCode: 400,
});
}
const sessionId = randomUUID();
sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath);
return {
sessionId,
provider,
projectPath: normalizedProjectPath,
};
},
/**
* Fetches persisted history by app session id.
*
* Provider and provider-specific lookup hints are resolved from the indexed
* session metadata in the database.
* session metadata in the database. The provider adapter receives the
* provider-native session id (the one written into transcripts on disk),
* and every returned message is remapped back to the app session id so
* provider ids never reach the frontend.
*/
fetchHistory(
async fetchHistory(
sessionId: string,
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
): Promise<FetchHistoryResult> {
@@ -106,12 +144,33 @@ export const sessionsService = {
});
}
// App-created sessions that never produced a provider transcript yet
// (e.g. first message still streaming) simply have no history.
if (!session.provider_session_id) {
return {
messages: [],
total: 0,
hasMore: false,
offset: options.offset ?? 0,
limit: options.limit ?? null,
};
}
const provider = session.provider as LLMProvider;
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
limit: options.limit ?? null,
offset: options.offset ?? 0,
projectPath: session.project_path ?? '',
providerSessionId: session.provider_session_id,
});
return {
...result,
messages: result.messages.map((message) => ({
...message,
sessionId,
})),
};
},
/**