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

@@ -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,
})),
};
},
/**