Skip to content

Commit 08122d8

Browse files
antfubotagent
andcommitted
feat: give each browser client a stable id reported to node
Groundwork for #394. Each browser tab mints a stable client id (`sessionStorage`-backed nanoid that survives reloads and RPC reconnects) and tags its `devframe:agent:sync-client-tools` payload with it, so the node-side client-agent registry records which tab each session belongs to. This is the identifier only: no per-tab listing or routing yet. Tab duplication copies `sessionStorage`, so duplicated tabs can briefly share an id until the wider tab-metadata work adds disambiguation. Co-authored-by: agent <agent@opencode>
1 parent 6ff544e commit 08122d8

9 files changed

Lines changed: 48 additions & 10 deletions

File tree

packages/devframe/src/client/browser-agent-rpc.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ describe('browser agent RPC bridge', () => {
1919
},
2020
callOptional(
2121
method: 'devframe:agent:sync-client-tools',
22+
clientId: string,
2223
tools: BrowserAgentToolManifest[],
2324
) {
24-
return callOptional(method, tools)
25+
return callOptional(method, clientId, tools)
2526
},
2627
events: { on: () => () => {} },
2728
}
@@ -36,6 +37,7 @@ describe('browser agent RPC bridge', () => {
3637
disposals.push(setupBrowserAgentRpcBridge(rpc))
3738
await vi.waitFor(() => expect(callOptional).toHaveBeenCalledWith(
3839
'devframe:agent:sync-client-tools',
40+
expect.any(String),
3941
[{
4042
id: 'todos:add',
4143
description: 'Add a todo.',

packages/devframe/src/client/browser-agent-rpc.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
listBrowserAgentTools,
55
onBrowserAgentToolsChanged,
66
} from './browser-agent'
7+
import { resolveClientId } from './client-id'
78

89
export interface BrowserAgentInvocationDefinition {
910
name: 'devframe:agent:invoke-client-tool'
@@ -16,6 +17,7 @@ interface BrowserAgentRpcClient {
1617
client: { register: (definition: BrowserAgentInvocationDefinition) => void }
1718
callOptional: (
1819
method: 'devframe:agent:sync-client-tools',
20+
clientId: string,
1921
tools: BrowserAgentToolManifest[],
2022
) => Promise<unknown>
2123
events: {
@@ -57,7 +59,7 @@ export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => vo
5759
if (manifests.length === 0 && lastSyncedCount === 0)
5860
return
5961
lastSyncedCount = manifests.length
60-
await rpc.callOptional('devframe:agent:sync-client-tools', manifests).catch(() => {})
62+
await rpc.callOptional('devframe:agent:sync-client-tools', resolveClientId(), manifests).catch(() => {})
6163
})
6264
}
6365

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { nanoid } from 'devframe/utils/nanoid'
2+
3+
const CLIENT_ID_STORAGE_KEY = 'devframe:client-id'
4+
let memoryClientId: string | undefined
5+
6+
/**
7+
* This browser tab's stable client id: one nanoid per tab, persisted in
8+
* `sessionStorage` so it survives page reloads and RPC reconnects. The node
9+
* side uses it to tell connected tabs apart across reconnects (see #394).
10+
*
11+
* Tab duplication copies `sessionStorage`, so two tabs can briefly share an id
12+
* until per-tab disambiguation lands with the wider tab-metadata work.
13+
*/
14+
export function resolveClientId(win: Window | undefined = globalThis.window): string {
15+
try {
16+
const storage = win?.sessionStorage
17+
if (storage) {
18+
let id = storage.getItem(CLIENT_ID_STORAGE_KEY)
19+
if (!id) {
20+
id = nanoid()
21+
storage.setItem(CLIENT_ID_STORAGE_KEY, id)
22+
}
23+
return id
24+
}
25+
}
26+
catch {
27+
// Storage unavailable (sandboxed iframe, disabled cookies); fall through.
28+
}
29+
memoryClientId ??= nanoid()
30+
return memoryClientId
31+
}

packages/devframe/src/node/__tests__/client-agent.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('client agent tools', () => {
2020
rpc: { $callRaw: callRaw },
2121
}
2222

23-
syncClientAgentTools(context, session, [{
23+
syncClientAgentTools(context, session, 'tab-abc', [{
2424
id: 'pinia-colada:refetch',
2525
description: 'Refetch matching queries.',
2626
safety: 'action',

packages/devframe/src/node/client-agent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ interface ClientAgentSession {
1515
interface ClientAgentState {
1616
sessions: Map<DevframeNodeRpcSessionMeta, {
1717
session: ClientAgentSession
18+
/** Stable per-tab id the browser reports, so reconnecting tabs stay identifiable (see #394). */
19+
clientId: string
1820
tools: BrowserAgentToolManifest[]
1921
}>
2022
notifyChanged: () => void
@@ -53,10 +55,11 @@ function getState(context: ClientAgentContext): ClientAgentState {
5355
export function syncClientAgentTools(
5456
context: ClientAgentContext,
5557
session: ClientAgentSession,
58+
clientId: string,
5659
tools: BrowserAgentToolManifest[],
5760
): void {
5861
const state = getState(context)
59-
state.sessions.set(session.meta, { session, tools })
62+
state.sessions.set(session.meta, { session, clientId, tools })
6063
state.notifyChanged()
6164
}
6265

packages/devframe/src/node/rpc/agent-sync-client-tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ export const agentSyncClientTools = defineRpcFunction({
77
type: 'action',
88
jsonSerializable: true,
99
setup: context => ({
10-
handler(tools: BrowserAgentToolManifest[]): void {
10+
handler(clientId: string, tools: BrowserAgentToolManifest[]): void {
1111
const session = context.rpc.getCurrentRpcSession()
1212
if (session)
13-
syncClientAgentTools(context, session, tools)
13+
syncClientAgentTools(context, session, clientId, tools)
1414
},
1515
}),
1616
})

packages/devframe/src/node/rpc/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ declare module 'devframe/types' {
2323
'devframe:agent:invoke-tool': (id: string, args: unknown) => Promise<unknown>
2424
'devframe:agent:list-resources': () => Promise<readonly import('devframe/types').AgentResource[]>
2525
'devframe:agent:read-resource': (id: string) => Promise<import('devframe/types').AgentResourceContent>
26-
'devframe:agent:sync-client-tools': (tools: import('../../client/browser-agent').BrowserAgentToolManifest[]) => Promise<void>
26+
'devframe:agent:sync-client-tools': (clientId: string, tools: import('../../client/browser-agent').BrowserAgentToolManifest[]) => Promise<void>
2727
}
2828
}

packages/devframe/src/types/rpc-augments.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ export interface DevframeRpcClientFunctions {
5353
* To be extended
5454
*/
5555
export interface DevframeRpcServerFunctions {
56-
/** Replace this connection's browser-agent tool manifest. @internal */
57-
'devframe:agent:sync-client-tools': (tools: import('../client/browser-agent').BrowserAgentToolManifest[]) => Promise<void>
56+
/** Replace this connection's browser-agent tool manifest, tagged with the calling tab's stable client id. @internal */
57+
'devframe:agent:sync-client-tools': (clientId: string, tools: import('../client/browser-agent').BrowserAgentToolManifest[]) => Promise<void>
5858
/**
5959
* Authenticate a connection with a previously-issued bearer token; resolves
6060
* whether the connection is now trusted. The interactive handler is provided

tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export interface DevframeRpcOptions {
255255
snapshot?: DevframeSnapshotRpcEntry[];
256256
}
257257
export interface DevframeRpcServerFunctions {
258-
'devframe:agent:sync-client-tools': (_: BrowserAgentToolManifest[]) => Promise<void>;
258+
'devframe:agent:sync-client-tools': (_: string, _: BrowserAgentToolManifest[]) => Promise<void>;
259259
'anonymous:devframe:auth': (_: {
260260
authToken: string;
261261
ua: string;

0 commit comments

Comments
 (0)