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

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sliceTailPage } from '@/shared/utils.js';
const ITEMS = ['a', 'b', 'c', 'd', 'e'];
test('offset 0 returns the most recent page', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 2, 0);
assert.deepEqual(page, ['d', 'e']);
assert.equal(hasMore, true);
});
test('increasing offsets walk backwards in time', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 2, 2);
assert.deepEqual(page, ['b', 'c']);
assert.equal(hasMore, true);
});
test('the oldest page reports hasMore false', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 2, 4);
assert.deepEqual(page, ['a']);
assert.equal(hasMore, false);
});
test('null limit returns everything', () => {
const { page, hasMore } = sliceTailPage(ITEMS, null, 0);
assert.deepEqual(page, ITEMS);
assert.equal(hasMore, false);
});
test('offsets past the start return an empty page', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 3, 10);
assert.deepEqual(page, []);
assert.equal(hasMore, false);
});
test('zero limit returns an empty page but keeps hasMore accurate', () => {
const { page, hasMore } = sliceTailPage(ITEMS, 0, 0);
assert.deepEqual(page, []);
assert.equal(hasMore, true);
});

View File

@@ -175,6 +175,30 @@ export type MessageKind =
| 'interactive_prompt'
| 'task_notification';
/**
* Event kinds added by the chat gateway layer on top of provider message kinds.
*
* These are app-level realtime events (subscription acks, sidebar deltas,
* project loading progress, protocol failures) that are not produced by any
* provider adapter. Together with `MessageKind` they form the complete set of
* `kind` values a websocket client can receive, so the frontend only ever
* needs one kind-based switch.
*/
export type GatewayEventKind =
| 'chat_subscribed'
| 'session_upserted'
| 'loading_progress'
| 'protocol_error';
/**
* Complete set of `kind` values emitted to websocket clients.
*
* Every server-to-client websocket frame carries a `kind` from this union.
* Provider runtimes emit `MessageKind` values; gateway services emit
* `GatewayEventKind` values.
*/
export type ServerEventKind = MessageKind | GatewayEventKind;
/**
* Provider-neutral message envelope used in REST responses and realtime channels.
*
@@ -187,6 +211,13 @@ export type NormalizedMessage = {
timestamp: string;
provider: LLMProvider;
kind: MessageKind;
/**
* Monotonic per-run sequence number assigned by the chat run registry when a
* live event is forwarded to the websocket. History messages loaded over
* REST do not carry it. Clients use it with `chat.subscribe` to replay only
* the live events they missed across websocket reconnects.
*/
seq?: number;
role?: 'user' | 'assistant';
content?: string;
/**
@@ -237,11 +268,18 @@ export type NormalizedMessage = {
*
* Consumers should pass provider-specific lookup hints (`projectPath`) only
* when the selected provider requires them.
*
* `providerSessionId` is the provider-native session id from the sessions
* index (transcript file name / provider database key). Provider adapters
* must use it — never the app-facing session id they were called with — when
* matching transcript rows on disk, because app-created sessions use an
* app-allocated id that the provider has never seen.
*/
export type FetchHistoryOptions = {
projectPath?: string;
limit?: number | null;
offset?: number;
providerSessionId?: string;
};
/**

View File

@@ -383,6 +383,47 @@ export function createCompleteMessage(opts: {
});
}
// ---------------------------
//----------------- CONVERSATION HISTORY PAGINATION UTILITIES ------------
/**
* Slices one page from the END of a chronologically ordered message list.
*
* This is the single pagination contract for conversation history across all
* providers: `offset = 0` returns the most recent `limit` items, increasing
* offsets walk backwards in time (for "scroll up to load older" UIs), and a
* `null` limit returns everything. Items must already be sorted oldest-first;
* the returned page preserves that order.
*
* Every provider history reader must use this helper instead of slicing
* manually so `offset`/`limit` query params behave identically regardless of
* which provider produced the session.
*/
export function sliceTailPage<T>(
items: T[],
limit: number | null,
offset: number,
): { page: T[]; hasMore: boolean } {
const total = items.length;
const normalizedOffset = Math.max(0, offset);
if (limit === null) {
// A null limit returns the full list; offset still trims newest entries
// so "everything before the page I already have" stays expressible.
const end = Math.max(0, total - normalizedOffset);
return {
page: items.slice(0, end),
hasMore: false,
};
}
const end = Math.max(0, total - normalizedOffset);
const start = Math.max(0, end - Math.max(0, limit));
return {
page: items.slice(start, end),
hasMore: start > 0,
};
}
// ---------------------------
//----------------- MCP CONFIG PARSING UTILITIES ------------
/**