Skip to content

Commit a0daf7c

Browse files
refactor: use generated types for session store RPC handlers
- Replace hand-written onRequest registrations with generated registerClientApiHandlers() and a routing SessionStoreHandler - Re-export SessionStoreHandler and ClientApiHandlers from index.ts - Fix listSessions concurrency: replace singleton listSessionsStore with a Map keyed by incrementing ID - Add cleanup of sessionStoreConfigs and listSessionsStores on stop() and forceStop() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1bea893 commit a0daf7c

3 files changed

Lines changed: 87 additions & 68 deletions

File tree

nodejs/src/client.ts

Lines changed: 64 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import {
2323
StreamMessageReader,
2424
StreamMessageWriter,
2525
} from "vscode-jsonrpc/node.js";
26-
import { createServerRpc } from "./generated/rpc.js";
26+
import { createServerRpc, registerClientApiHandlers } from "./generated/rpc.js";
27+
import type { SessionStoreHandler } from "./generated/rpc.js";
2728
import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
2829
import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js";
2930
import { getTraceContext } from "./telemetry.js";
@@ -179,8 +180,12 @@ export class CopilotClient {
179180
private negotiatedProtocolVersion: number | null = null;
180181
/** Per-session store configs, keyed by sessionId. Used to route sessionStore.* RPC requests. */
181182
private sessionStoreConfigs: Map<string, SessionStoreConfig> = new Map();
182-
/** Temporary store used during listSessions() calls with a session store. */
183-
private listSessionsStore: SessionStoreConfig | null = null;
183+
/**
184+
* Stores used during listSessions() calls. Keyed by an incrementing ID to support concurrent calls.
185+
* The latest registered store is used when the server calls sessionStore.list.
186+
*/
187+
private listSessionsStores: Map<number, SessionStoreConfig> = new Map();
188+
private listSessionsStoreNextId = 0;
184189

185190
/**
186191
* Typed server-scoped RPC methods.
@@ -403,6 +408,8 @@ export class CopilotClient {
403408
}
404409
}
405410
this.sessions.clear();
411+
this.sessionStoreConfigs.clear();
412+
this.listSessionsStores.clear();
406413

407414
// Close connection
408415
if (this.connection) {
@@ -487,6 +494,8 @@ export class CopilotClient {
487494

488495
// Clear sessions immediately without trying to destroy them
489496
this.sessions.clear();
497+
this.sessionStoreConfigs.clear();
498+
this.listSessionsStores.clear();
490499

491500
// Force close connection
492501
if (this.connection) {
@@ -1005,9 +1014,11 @@ export class CopilotClient {
10051014
? { filter: filterOrOptions as SessionListFilter }
10061015
: undefined;
10071016

1008-
// Temporarily register the store for the duration of this RPC call
1017+
// Register the store for the duration of this RPC call (supports concurrent calls)
1018+
let storeId: number | undefined;
10091019
if (options?.sessionStore) {
1010-
this.listSessionsStore = options.sessionStore;
1020+
storeId = this.listSessionsStoreNextId++;
1021+
this.listSessionsStores.set(storeId, options.sessionStore);
10111022
}
10121023

10131024
try {
@@ -1037,8 +1048,8 @@ export class CopilotClient {
10371048
context: s.context,
10381049
}));
10391050
} finally {
1040-
if (options?.sessionStore) {
1041-
this.listSessionsStore = null;
1051+
if (storeId !== undefined) {
1052+
this.listSessionsStores.delete(storeId);
10421053
}
10431054
}
10441055
}
@@ -1505,78 +1516,63 @@ export class CopilotClient {
15051516
}): Promise<{ output?: unknown }> => await this.handleHooksInvoke(params)
15061517
);
15071518

1508-
// Register session store RPC handlers (server→client callbacks for event persistence)
1509-
this.connection.onRequest(
1510-
"sessionStore.load",
1511-
async (params: { sessionId: string }): Promise<{ events: SessionEvent[] }> => {
1512-
const store = this.sessionStoreConfigs.get(params.sessionId);
1513-
if (!store) {
1514-
throw new Error(`No session store registered for session ${params.sessionId}`);
1515-
}
1516-
const events = await store.onLoad(params.sessionId);
1517-
return { events };
1518-
}
1519-
);
1519+
// Register session store RPC handlers via generated registration function.
1520+
// The handler routes each call to the appropriate SessionStoreConfig based on sessionId.
1521+
registerClientApiHandlers(this.connection, {
1522+
sessionStore: this.createSessionStoreHandler(),
1523+
});
15201524

1521-
this.connection.onRequest(
1522-
"sessionStore.append",
1523-
async (params: { sessionId: string; events: SessionEvent[] }): Promise<void> => {
1524-
const store = this.sessionStoreConfigs.get(params.sessionId);
1525-
if (!store) {
1526-
throw new Error(`No session store registered for session ${params.sessionId}`);
1527-
}
1528-
await store.onAppend(params.events, params.sessionId);
1529-
}
1530-
);
1525+
this.connection.onClose(() => {
1526+
this.state = "disconnected";
1527+
});
15311528

1532-
this.connection.onRequest(
1533-
"sessionStore.truncate",
1534-
async (params: {
1535-
sessionId: string;
1536-
upToEventId: string;
1537-
}): Promise<{ eventsRemoved: number; eventsKept: number }> => {
1538-
const store = this.sessionStoreConfigs.get(params.sessionId);
1539-
if (!store) {
1540-
throw new Error(`No session store registered for session ${params.sessionId}`);
1541-
}
1542-
return await store.onTruncate(params.upToEventId, params.sessionId);
1529+
this.connection.onError((_error) => {
1530+
this.state = "disconnected";
1531+
});
1532+
}
1533+
1534+
/**
1535+
* Create a {@link SessionStoreHandler} that routes each RPC call to the
1536+
* appropriate {@link SessionStoreConfig} registered for the given session.
1537+
*/
1538+
private createSessionStoreHandler(): SessionStoreHandler {
1539+
const getStore = (sessionId: string): SessionStoreConfig => {
1540+
const store = this.sessionStoreConfigs.get(sessionId);
1541+
if (!store) {
1542+
throw new Error(`No session store registered for session ${sessionId}`);
15431543
}
1544-
);
1544+
return store;
1545+
};
15451546

1546-
this.connection.onRequest(
1547-
"sessionStore.list",
1548-
async (): Promise<{
1549-
sessions: Array<{ sessionId: string; mtime: string; birthtime: string }>;
1550-
}> => {
1547+
return {
1548+
load: async (params) => {
1549+
const store = getStore(params.sessionId);
1550+
const events = await store.onLoad(params.sessionId);
1551+
// Events are opaque on the wire but typed in the SDK consumer API
1552+
return { events: events as Record<string, unknown>[] };
1553+
},
1554+
append: async (params) => {
1555+
const store = getStore(params.sessionId);
1556+
await store.onAppend(params.events as SessionEvent[], params.sessionId);
1557+
},
1558+
truncate: async (params) => {
1559+
const store = getStore(params.sessionId);
1560+
return store.onTruncate(params.upToEventId, params.sessionId);
1561+
},
1562+
list: async () => {
15511563
// Use the most recently registered store for listing.
1552-
// In practice, listSessions() registers a temporary store before the RPC call.
1553-
const store = this.listSessionsStore;
1564+
const store = Array.from(this.listSessionsStores.values()).pop();
15541565
if (!store) {
15551566
throw new Error("No session store registered for listing");
15561567
}
15571568
const sessions = await store.onListSessions();
15581569
return { sessions };
1559-
}
1560-
);
1561-
1562-
this.connection.onRequest(
1563-
"sessionStore.delete",
1564-
async (params: { sessionId: string }): Promise<void> => {
1565-
const store = this.sessionStoreConfigs.get(params.sessionId);
1566-
if (!store) {
1567-
throw new Error(`No session store registered for session ${params.sessionId}`);
1568-
}
1570+
},
1571+
delete: async (params) => {
1572+
const store = getStore(params.sessionId);
15691573
await store.onDelete(params.sessionId);
1570-
}
1571-
);
1572-
1573-
this.connection.onClose(() => {
1574-
this.state = "disconnected";
1575-
});
1576-
1577-
this.connection.onError((_error) => {
1578-
this.state = "disconnected";
1579-
});
1574+
},
1575+
};
15801576
}
15811577

15821578
private handleSessionEventNotification(notification: unknown): void {

nodejs/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export type {
4444
SessionListFilter,
4545
SessionMetadata,
4646
SessionStoreConfig,
47+
SessionStoreHandler,
48+
ClientApiHandlers,
4749
SystemMessageAppendConfig,
4850
SystemMessageConfig,
4951
SystemMessageReplaceConfig,

nodejs/src/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@
1010
import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js";
1111
export type SessionEvent = GeneratedSessionEvent;
1212

13+
// Re-export generated client API types
14+
export type {
15+
SessionStoreHandler,
16+
SessionStoreLoadParams,
17+
SessionStoreLoadResult,
18+
SessionStoreAppendParams,
19+
SessionStoreTruncateParams,
20+
SessionStoreTruncateResult,
21+
SessionStoreListResult,
22+
SessionStoreDeleteParams,
23+
ClientApiHandlers,
24+
} from "./generated/rpc.js";
25+
1326
/**
1427
* Options for creating a CopilotClient
1528
*/
@@ -1022,6 +1035,14 @@ export interface SessionContext {
10221035
* the server routes all event persistence through these callbacks over RPC instead
10231036
* of using its default file-based storage.
10241037
*/
1038+
/**
1039+
* Configuration for a custom session store backend.
1040+
*
1041+
* The `descriptor` identifies the storage backend. The callback methods
1042+
* correspond to the generated {@link SessionStoreHandler} interface but
1043+
* use a consumer-friendly signature where `sessionId` is a plain argument
1044+
* rather than embedded in a params object.
1045+
*/
10251046
export interface SessionStoreConfig {
10261047
/**
10271048
* Opaque descriptor identifying this storage backend.

0 commit comments

Comments
 (0)