mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-13 01:22:06 +08:00
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:
42
server/shared/tests/slice-tail-page.test.ts
Normal file
42
server/shared/tests/slice-tail-page.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ------------
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user