diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 7b73b4d6..344a433b 100644 --- a/docs/content/1.guide/12.in-page-channel.md +++ b/docs/content/1.guide/12.in-page-channel.md @@ -64,7 +64,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names ## The page script endpoint -The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. A function with `agent` metadata is available to coding agents through MCP; the field implicitly enables strict JSON serialization (an explicit `jsonSerializable: false` conflicts). `defineChannelFunction` retains the named definition shape for lower-level authoring. +The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. A function with `agent` metadata is forwarded to coding agents [over the node's MCP endpoint](/guide/agent-native#in-page-tools-over-mcp); the field implicitly enables strict JSON serialization (an explicit `jsonSerializable: false` conflicts). `defineChannelFunction` retains the named definition shape for lower-level authoring. `call()` accepts names from `functions`, including actions returning `void` or `Promise`: callers can await completion and catch errors or timeouts. `emit()` and `on()` use the names declared in `events`. Function and event names have separate namespaces. diff --git a/docs/content/1.guide/15.agent-native.md b/docs/content/1.guide/15.agent-native.md index 4ed4357d..de959f09 100644 --- a/docs/content/1.guide/15.agent-native.md +++ b/docs/content/1.guide/15.agent-native.md @@ -160,6 +160,27 @@ rpc.client.register({ > [!WARNING] > WebMCP is an experimental proposal; `registerWebMcpTools` tracks the current draft (`AbortSignal`-based unregistration) and earlier handle-returning drafts, but the browser API may still change. +## In-page tools over MCP + +An [in-page channel](/guide/in-page-channel) function carrying an `agent` field is forwarded to the node side over the page's RPC connection and served from the same MCP endpoint as node-side tools, under the id `:`. The page keeps executing the handler; the node relays the call and the result. + +```ts +const channel = createPageScriptChannel({ + name: 'my-plugin', + functions: { + 'selected-node': { + type: 'query', + agent: { description: 'Return the node the user selected in the page. Call it before proposing an edit.' }, + handler: () => getSelectedNode(), + }, + }, +}) +``` + +Every browser tab exposing such tools syncs its own manifest, so the same app open in several tabs exposes each tool once, plus a built-in `devframe:agent:list-clients` tool (wire name `devframe_agent_list-clients`) listing the connected tabs: a per-tab `id` (stable across reloads), `url`, `title`, `visible`/`focused`, `connectedAt`, and the tool ids that tab exposes. `devframe connect` nests the same list under `mcp.clients` for each instance. + +A forwarded tool accepts a reserved `client_id` argument to run on one tab. Without it, the call goes to the most recently focused visible tab, falling back to the tab that synced last; an unknown `client_id` fails with [DF0081](/errors/DF0081), which lists the live ids. Tabs re-sync on focus and visibility changes, so an unaddressed call follows the tab the user looked at last. + ## Writing descriptions agents act on Describe *when* to use a tool, not just its return: diff --git a/docs/content/6.errors/DF0081.md b/docs/content/6.errors/DF0081.md new file mode 100644 index 00000000..97f57bb4 --- /dev/null +++ b/docs/content/6.errors/DF0081.md @@ -0,0 +1,20 @@ +--- +title: 'DF0081: Addressed Client Not Connected' +description: 'Tool "{tool}" was addressed to client "{clientId}", but no connected browser tab has that id and the tool.' +--- + +## Message + +> Tool "`{tool}`" was addressed to client "`{clientId}`", but no connected browser tab has that id and the tool. Connected clients: `{live}`. + +## Cause + +A forwarded in-page tool was called with a `client_id` that matches none of the browser tabs currently connected to this devframe (or the tab with that id does not expose the tool). Tab ids survive reloads but not closing the tab, so an id an agent listed earlier may have gone away since. + +## Fix + +Call `devframe:agent:list-clients` (wire name `devframe_agent_list-clients`; `mcp.clients` in `devframe connect`'s `list-instances`) to get the live ids and retry with one of them, or omit `client_id` to target the most recently focused tab. + +## Source + +- [`packages/devframe/src/node/client-agent.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/client-agent.ts): the forwarded tool's handler throws this when no connected session matches the requested `client_id`. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index eee53bd3..22c07c9e 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -86,6 +86,8 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi | [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered | | [DF0078](/errors/DF0078) | warn | Agent Surface Without @devframes/agentic | | [DF0079](/errors/DF0079) | error | MCP Enabled Without @devframes/agentic | +| [DF0080](/errors/DF0080) | error | In-Page Channel Agent Function Not JSON-Serializable | +| [DF0081](/errors/DF0081) | error | Addressed Client Not Connected | ## Hub: context & lifecycle (DF80xx) diff --git a/packages/agentic/src/connect/index.ts b/packages/agentic/src/connect/index.ts index 24761213..d4183ea5 100644 --- a/packages/agentic/src/connect/index.ts +++ b/packages/agentic/src/connect/index.ts @@ -1,9 +1,9 @@ import type { Tool } from '@modelcontextprotocol/server' -import type { DevframeInstanceRecord } from 'devframe/internal' +import type { ConnectedClient, DevframeInstanceRecord } from 'devframe/internal' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' import { Server } from '@modelcontextprotocol/server' import { StdioServerTransport } from '@modelcontextprotocol/server/stdio' -import { diagnostics, listLiveDevframeInstances, probeDevframeOrigin } from 'devframe/internal' +import { diagnostics, LIST_CLIENTS_TOOL, listLiveDevframeInstances, probeDevframeOrigin } from 'devframe/internal' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { Diagnostic } from 'devframe/utils/nostics' import { joinURL, withLeadingSlash, withTrailingSlash } from 'devframe/utils/url' @@ -82,11 +82,16 @@ interface IndexedInstance extends Omit { mcp: { url: string tools?: IndexedInstanceTools[] + /** Browser tabs connected to the instance, when it forwards client tools. */ + clients?: ConnectedClient[] error?: string } | null hint?: string } +/** Wire name of the built-in tab-listing tool an instance exposes once a browser tab connects. */ +const LIST_CLIENTS_NAME = toAgentToolName(LIST_CLIENTS_TOOL) + // Gateway tool ids follow the `devframe::` convention; the wire // names are their sanitized forms (`devframe_connect_list-instances`, …). const INDEX_TOOL = toAgentToolName('devframe:connect:list-instances') @@ -99,7 +104,7 @@ const GATEWAY_TOOLS: Tool[] = [ { name: INDEX_TOOL, title: 'Discover running devframes', - description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools. Call this FIRST, before assuming which devtools are available; the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.', + description: 'Discover every running devframe dev server on this machine and list each one\'s MCP tools, plus the browser tabs connected to it (`mcp.clients`). Call this FIRST, before assuming which devtools are available; the result names the instance (id, project root, origin) and the port to pass to the call tool. Safe to call freely.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true, destructiveHint: false }, }, @@ -112,7 +117,7 @@ const GATEWAY_TOOLS: Tool[] = [ properties: { port: { type: 'number', description: 'The instance\'s port, from the list-instances tool.' }, tool: { type: 'string', description: 'Tool name, from the instance\'s tool list.' }, - args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools.' }, + args: { type: 'object', description: 'Arguments object for the tool. Omit for zero-argument tools. Tools forwarded from a browser tab accept `client_id` (from `mcp.clients` in list-instances) to target one tab; omitted, the most recently focused tab runs it.' }, }, required: ['port', 'tool'], additionalProperties: false, @@ -187,7 +192,7 @@ async function index(options: ConnectServerOptions): Promise { } const url = `${record.origin}${mcp.path}` try { - entry.mcp = { url, tools: await listInstanceTools(url, resolveAuthToken(options.authToken, record)) } + entry.mcp = { url, ...await indexInstanceMcp(url, resolveAuthToken(options.authToken, record)) } } catch (error) { entry.mcp = { url, error: error instanceof Error ? error.message : String(error) } @@ -228,8 +233,19 @@ export async function probePort(port: number, base = '/', timeoutMs?: number): P } } -async function listInstanceTools(url: string, token: string | undefined): Promise { - return withInstanceClient(url, token, async client => (await client.listTools()).tools) +async function indexInstanceMcp( + url: string, + token: string | undefined, +): Promise, 'tools' | 'clients'>> { + return withInstanceClient(url, token, async (client) => { + const tools: IndexedInstanceTools[] = (await client.listTools()).tools + if (!tools.some(tool => tool.name === LIST_CLIENTS_NAME)) + return { tools } + const result = await client.callTool({ name: LIST_CLIENTS_NAME, arguments: {} }) + // `structuredContent` is untyped on the wire; the tool's outputSchema fixes this shape. + const clients = (result.structuredContent as { clients?: ConnectedClient[] } | undefined)?.clients ?? [] + return { tools, clients } + }) } async function call( diff --git a/packages/agentic/src/mcp/__tests__/mcp-client-tools.test.ts b/packages/agentic/src/mcp/__tests__/mcp-client-tools.test.ts new file mode 100644 index 00000000..7041ca65 --- /dev/null +++ b/packages/agentic/src/mcp/__tests__/mcp-client-tools.test.ts @@ -0,0 +1,89 @@ +import type { StartedServer } from 'devframe/internal' +import type { DevframeDefinition, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' +import { createDevServer } from 'devframe/adapters/dev' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterEach, describe, expect, it } from 'vitest' + +const definition: DevframeDefinition = { + id: 'client-tools-test', + name: 'Client Tools Test', + version: '0.0.0', + packageName: '@devframe/client-tools-test', + homepage: 'https://example.com', + description: 'Fixture: a devframe whose only agent tools live in browser tabs.', + setup() {}, +} + +/** A browser tab: one RPC connection exposing `page:selection` and answering with its own id. */ +function connectTab(origin: string, client: { id: string, focused: boolean, visible?: boolean }) { + const clientFunctions = { + 'devframe:agent:invoke-client-tool': async (id: string, args: Record) => ({ tab: client.id, tool: id, args }), + } + const rpc = createRpcClient( + clientFunctions as any, + { channel: createWsRpcChannel({ url: `${origin.replace('http', 'ws')}/__ws` }) }, + ) + const sync = (focused = client.focused) => rpc.$call( + 'devframe:agent:sync-client-tools', + client.id, + [{ id: 'page:selection', description: 'Read the selection.', safety: 'read', inputSchema: { type: 'object', properties: {} } }], + { url: `http://app.local/${client.id}`, title: client.id, visible: client.visible ?? true, focused }, + ) + return { rpc, sync } +} + +describe('client tools over the MCP route', () => { + let server: StartedServer | undefined + afterEach(async () => { + await server?.close() + server = undefined + }) + + it('lists connected tabs and routes calls per tab', async () => { + server = await createDevServer(definition, { host: '127.0.0.1', port: 0, auth: false, mcp: true }) + const a = connectTab(server.origin, { id: 'tab-a', focused: false }) + const b = connectTab(server.origin, { id: 'tab-b', focused: true }) + await a.sync() + await b.sync() + + const mcp = new Client({ name: 'test', version: '0.0.0' }, { versionNegotiation: { mode: 'auto' } }) + await mcp.connect(new StreamableHTTPClientTransport(new URL(`${server.origin}/__mcp`), { + requestInit: { headers: { origin: server.origin } }, + })) + try { + const { tools } = await mcp.listTools() + const names = tools.map(t => t.name) + expect(names).toContain('devframe_agent_list-clients') + expect(names.filter(n => n === 'page_selection')).toHaveLength(1) + expect((tools.find(t => t.name === 'page_selection')!.inputSchema as any).properties.client_id).toMatchObject({ type: 'string' }) + + const listed = await mcp.callTool({ name: 'devframe_agent_list-clients', arguments: {} }) + expect(listed.structuredContent).toEqual({ + clients: [ + expect.objectContaining({ id: 'tab-a', url: 'http://app.local/tab-a', focused: false, tools: ['page:selection'] }), + expect.objectContaining({ id: 'tab-b', focused: true, tools: ['page:selection'] }), + ], + }) + + const unaddressed = await mcp.callTool({ name: 'page_selection', arguments: {} }) + expect(JSON.parse((unaddressed.content as any)[0].text)).toMatchObject({ tab: 'tab-b', args: {} }) + + const addressed = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'tab-a' } }) + expect(JSON.parse((addressed.content as any)[0].text)).toMatchObject({ tab: 'tab-a', args: {} }) + + const missing = await mcp.callTool({ name: 'page_selection', arguments: { client_id: 'gone' } }) + expect(missing.isError).toBe(true) + expect((missing.content as any)[0].text).toMatch(/DF0081.*tab-a, tab-b/s) + + // Focus moves to a: unaddressed calls follow it. + await a.sync(true) + const refocused = await mcp.callTool({ name: 'page_selection', arguments: {} }) + expect(JSON.parse((refocused.content as any)[0].text)).toMatchObject({ tab: 'tab-a' }) + } + finally { + await mcp.close() + } + }) +}) diff --git a/packages/devframe/src/client/browser-agent-rpc.test.ts b/packages/devframe/src/client/browser-agent-rpc.test.ts index d27fceff..c8e6f94a 100644 --- a/packages/devframe/src/client/browser-agent-rpc.test.ts +++ b/packages/devframe/src/client/browser-agent-rpc.test.ts @@ -1,4 +1,4 @@ -import type { BrowserAgentToolManifest } from './browser-agent' +import type { BrowserAgentClientInfo, BrowserAgentToolManifest } from './browser-agent' import type { BrowserAgentInvocationDefinition } from './browser-agent-rpc' import { afterEach, describe, expect, it, vi } from 'vitest' import { registerBrowserAgentTool } from './browser-agent' @@ -21,8 +21,9 @@ describe('browser agent RPC bridge', () => { method: 'devframe:agent:sync-client-tools', clientId: string, tools: BrowserAgentToolManifest[], + info: BrowserAgentClientInfo, ) { - return callOptional(method, clientId, tools) + return callOptional(method, clientId, tools, info) }, events: { on: () => () => {} }, } @@ -44,6 +45,7 @@ describe('browser agent RPC bridge', () => { safety: 'action', inputSchema: { type: 'object' }, }], + { url: expect.any(String), title: expect.any(String), visible: expect.any(Boolean), focused: expect.any(Boolean) }, )) await expect(handlers.get('devframe:agent:invoke-client-tool')!( diff --git a/packages/devframe/src/client/browser-agent-rpc.ts b/packages/devframe/src/client/browser-agent-rpc.ts index 205259e2..78f2d579 100644 --- a/packages/devframe/src/client/browser-agent-rpc.ts +++ b/packages/devframe/src/client/browser-agent-rpc.ts @@ -1,4 +1,4 @@ -import type { BrowserAgentToolManifest } from './browser-agent' +import type { BrowserAgentClientInfo, BrowserAgentToolManifest } from './browser-agent' import type { DevframeConnectionStatus } from './connection' import { listBrowserAgentTools, @@ -19,6 +19,7 @@ interface BrowserAgentRpcClient { method: 'devframe:agent:sync-client-tools', clientId: string, tools: BrowserAgentToolManifest[], + info: BrowserAgentClientInfo, ) => Promise events: { on: ( @@ -28,7 +29,23 @@ interface BrowserAgentRpcClient { } } -/** Mirror this document's browser-agent registry over its existing RPC connection. */ +/** Snapshot of this document as seen by a coding agent picking a tab. */ +function describeBrowserAgentClient(): BrowserAgentClientInfo { + const doc = typeof document === 'undefined' ? undefined : document + return { + url: doc?.location?.href ?? '', + title: doc?.title ?? '', + visible: doc ? doc.visibilityState === 'visible' : true, + focused: doc?.hasFocus() ?? true, + } +} + +/** + * Mirror this document's browser-agent registry over its existing RPC + * connection. Re-syncs on tool changes, reconnects, and focus/visibility + * changes so the node can route unaddressed calls to the tab the user + * looked at last. + */ export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => void { rpc.client.register({ name: 'devframe:agent:invoke-client-tool', @@ -59,7 +76,7 @@ export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => vo if (manifests.length === 0 && lastSyncedCount === 0) return lastSyncedCount = manifests.length - await rpc.callOptional('devframe:agent:sync-client-tools', resolveClientId(), manifests).catch(() => {}) + await rpc.callOptional('devframe:agent:sync-client-tools', resolveClientId(), manifests, describeBrowserAgentClient()).catch(() => {}) }) } @@ -68,11 +85,18 @@ export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => vo if (status === 'connected') sync() }) + const win = typeof window === 'undefined' ? undefined : window + win?.addEventListener('focus', sync) + win?.addEventListener('blur', sync) + win?.document.addEventListener('visibilitychange', sync) sync() return () => { disposed = true stopTools() stopConnection() + win?.removeEventListener('focus', sync) + win?.removeEventListener('blur', sync) + win?.document.removeEventListener('visibilitychange', sync) } } diff --git a/packages/devframe/src/client/browser-agent.ts b/packages/devframe/src/client/browser-agent.ts index bbccf986..a0e51525 100644 --- a/packages/devframe/src/client/browser-agent.ts +++ b/packages/devframe/src/client/browser-agent.ts @@ -7,6 +7,14 @@ export interface BrowserAgentToolManifest { inputSchema?: unknown } +/** What a connected document reports about itself alongside its tool manifest. */ +export interface BrowserAgentClientInfo { + url: string + title: string + visible: boolean + focused: boolean +} + export interface BrowserAgentTool extends BrowserAgentToolManifest { invoke: (args: Record) => unknown | Promise } diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index c49ce87a..16b51173 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -47,6 +47,8 @@ export { formatMcpError, stringifyForMcp } from '../agent/stringify' export { argsToJsonSchema, returnToJsonSchema } from '../agent/to-json-schema' export { importAgenticMcp } from '../node/agentic' export type { AgenticMcpModule, MountedMcpHttp, MountMcpHttpOptions } from '../node/agentic' +export { LIST_CLIENTS_TOOL } from '../node/client-agent' +export type { ConnectedClient } from '../node/client-agent' export { diagnostics } from '../node/diagnostics' export { DevframeAgentHost } from '../node/host-agent' export * from '../node/host-h3' diff --git a/packages/devframe/src/node/__tests__/client-agent.test.ts b/packages/devframe/src/node/__tests__/client-agent.test.ts index bd0912f3..2fe643ed 100644 --- a/packages/devframe/src/node/__tests__/client-agent.test.ts +++ b/packages/devframe/src/node/__tests__/client-agent.test.ts @@ -1,45 +1,144 @@ import type { AgentToolInput } from 'devframe/types' +import type { BrowserAgentClientInfo } from '../../client/browser-agent' import { describe, expect, it, vi } from 'vitest' -import { removeClientAgentSession, syncClientAgentTools } from '../client-agent' +import { LIST_CLIENTS_TOOL, removeClientAgentSession, syncClientAgentTools } from '../client-agent' -describe('client agent tools', () => { - it('projects a browser manifest and invokes its originating RPC session', async () => { - let provider: (() => readonly AgentToolInput[]) | undefined - const notifyChanged = vi.fn() - const context = { - agent: { - registerToolProvider(next: () => readonly AgentToolInput[]) { - provider = next - return { notifyChanged, unregister() {} } - }, +function harness() { + let provider: (() => readonly AgentToolInput[]) | undefined + const notifyChanged = vi.fn() + const context = { + agent: { + registerToolProvider(next: () => readonly AgentToolInput[]) { + provider = next + return { notifyChanged, unregister() {} } }, + }, + } + const refetch = { id: 'pinia-colada:refetch', description: 'Refetch matching queries.', safety: 'action' as const, inputSchema: { type: 'object', properties: { arg0: { type: 'string' } } } } + let nextId = 1 + function session(id: string, info: Partial = {}) { + const callRaw = vi.fn().mockResolvedValue(`from ${id}`) + return { + session: { meta: { id: nextId++, subscribedStates: new Set() }, rpc: { $callRaw: callRaw } }, + callRaw, + id, + info: { url: `http://localhost/${id}`, title: id, visible: true, focused: false, ...info }, } - const callRaw = vi.fn().mockResolvedValue(['refetched']) - const session = { - meta: { id: 1, subscribedStates: new Set() }, - rpc: { $callRaw: callRaw }, - } + } + /** Sync `tab` with `refetch`, optionally overriding its reported focus. */ + function sync(tab: ReturnType, focused?: boolean) { + syncClientAgentTools(context, tab.session, tab.id, [refetch], focused === undefined ? tab.info : { ...tab.info, focused }) + } + const tool = (id: string) => provider!().find(t => t.id === id)! + return { context, notifyChanged, session, sync, refetch, tool, tools: () => provider!() } +} - syncClientAgentTools(context, session, 'tab-abc', [{ - id: 'pinia-colada:refetch', - description: 'Refetch matching queries.', - safety: 'action', - inputSchema: { type: 'object' }, - }]) - const [tool] = provider!() - expect(tool).toMatchObject({ - id: 'pinia-colada:refetch', - description: 'Refetch matching queries.', - inputSchema: { type: 'object' }, +describe('client agent tools', () => { + it('projects a browser manifest and invokes its originating RPC session', async () => { + const h = harness() + const a = h.session('tab-a') + + h.sync(a) + const tool = h.tool('pinia-colada:refetch') + expect(tool).toMatchObject({ id: 'pinia-colada:refetch', description: 'Refetch matching queries.' }) + expect(tool.inputSchema).toMatchObject({ + type: 'object', + properties: { arg0: { type: 'string' }, client_id: { type: 'string' } }, }) - await expect(tool!.handler!({ arg0: {} })).resolves.toEqual(['refetched']) - expect(callRaw).toHaveBeenCalledWith({ + await expect(tool.handler({ arg0: 'x' })).resolves.toBe('from tab-a') + expect(a.callRaw).toHaveBeenCalledWith({ method: 'devframe:agent:invoke-client-tool', - args: ['pinia-colada:refetch', { arg0: {} }], + args: ['pinia-colada:refetch', { arg0: 'x' }], + }) + + removeClientAgentSession(h.context, a.session.meta) + expect(h.tools()).toEqual([]) + expect(h.notifyChanged).toHaveBeenCalledTimes(2) + }) + + it('lists every connected tab once and dedupes their shared tools', () => { + const h = harness() + const a = h.session('tab-a') + const b = h.session('tab-b', { focused: true }) + h.sync(a) + h.sync(b) + + expect(h.tools().map(t => t.id)).toEqual(['pinia-colada:refetch', LIST_CLIENTS_TOOL]) + expect(h.tool(LIST_CLIENTS_TOOL).handler({})).toEqual({ + clients: [ + expect.objectContaining({ id: 'tab-a', url: 'http://localhost/tab-a', focused: false, tools: ['pinia-colada:refetch'] }), + expect.objectContaining({ id: 'tab-b', focused: true, tools: ['pinia-colada:refetch'] }), + ], }) + }) + + it('routes an addressed call to that tab and strips client_id', async () => { + const h = harness() + const a = h.session('tab-a') + const b = h.session('tab-b', { focused: true }) + h.sync(a) + h.sync(b) + + await expect(h.tool('pinia-colada:refetch').handler({ client_id: 'tab-a', arg0: 'x' })).resolves.toBe('from tab-a') + expect(a.callRaw).toHaveBeenCalledWith({ + method: 'devframe:agent:invoke-client-tool', + args: ['pinia-colada:refetch', { arg0: 'x' }], + }) + expect(b.callRaw).not.toHaveBeenCalled() + }) + + it('fails an unknown client_id with the live ids', async () => { + const h = harness() + const a = h.session('tab-a') + h.sync(a) + + await expect(async () => h.tool('pinia-colada:refetch').handler({ client_id: 'gone' })) + .rejects + .toThrow(/client "gone".*Connected clients: tab-a/) + }) + + it('defaults to the most recently focused visible tab, then the last synced', async () => { + vi.useFakeTimers() + try { + const h = harness() + const a = h.session('tab-a') + const b = h.session('tab-b') + const c = h.session('tab-c', { visible: false }) + + vi.setSystemTime(1000) + h.sync(a, true) + vi.setSystemTime(2000) + h.sync(b, true) + vi.setSystemTime(3000) + h.sync(c, true) + // b focused last among visible tabs; c is hidden despite the latest sync. + await expect(h.tool('pinia-colada:refetch').handler({})).resolves.toBe('from tab-b') + + // b blurs, a regains focus later: a wins. + vi.setSystemTime(4000) + h.sync(b, false) + vi.setSystemTime(5000) + h.sync(a, true) + await expect(h.tool('pinia-colada:refetch').handler({})).resolves.toBe('from tab-a') + + // Nothing visible: the last synced tab takes it. + const d = h.session('tab-d', { visible: false }) + removeClientAgentSession(h.context, a.session.meta) + removeClientAgentSession(h.context, b.session.meta) + vi.setSystemTime(6000) + h.sync(d) + await expect(h.tool('pinia-colada:refetch').handler({})).resolves.toBe('from tab-d') + } + finally { + vi.useRealTimers() + } + }) - removeClientAgentSession(context, session.meta) - expect(provider!()).toEqual([]) - expect(notifyChanged).toHaveBeenCalledTimes(2) + it('does not republish the tool list on a focus-only re-sync', () => { + const h = harness() + const a = h.session('tab-a') + h.sync(a) + h.sync(a, true) + expect(h.notifyChanged).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/devframe/src/node/client-agent.ts b/packages/devframe/src/node/client-agent.ts index 2843ce5d..0b7b7bf2 100644 --- a/packages/devframe/src/node/client-agent.ts +++ b/packages/devframe/src/node/client-agent.ts @@ -1,5 +1,6 @@ import type { AgentToolInput, DevframeAgentHost, DevframeNodeRpcSessionMeta } from 'devframe/types' -import type { BrowserAgentToolManifest } from '../client/browser-agent' +import type { BrowserAgentClientInfo, BrowserAgentToolManifest } from '../client/browser-agent' +import { diagnostics } from './diagnostics' interface ClientAgentContext { agent: Pick @@ -12,39 +13,162 @@ interface ClientAgentSession { } } +interface ClientAgentEntry { + session: ClientAgentSession + /** Stable per-tab id the browser reports, so reconnecting tabs stay identifiable (see #394). */ + clientId: string + info: BrowserAgentClientInfo + tools: BrowserAgentToolManifest[] + connectedAt: number + syncedAt: number + /** Last sync that reported `focused: true`; 0 when the tab was never focused while connected. */ + focusedAt: number +} + +type ClientAgentSessions = Map + interface ClientAgentState { - sessions: Map + sessions: ClientAgentSessions notifyChanged: () => void } +/** Id of the built-in tool listing connected browser tabs (wire name `devframe_agent_list-clients`). */ +export const LIST_CLIENTS_TOOL = 'devframe:agent:list-clients' + +/** Reserved argument a forwarded client tool accepts to address one tab. */ +const CLIENT_ID_ARG = 'client_id' + +/** One connected browser tab as reported by {@link LIST_CLIENTS_TOOL}. */ +export interface ConnectedClient extends BrowserAgentClientInfo { + id: string + connectedAt: number + tools: string[] +} + const states = new WeakMap() +/** + * Prefer the most recently focused visible tab, then any visible tab, then + * whichever synced last. Ties go to the later entry, so a re-sync moves a + * tab ahead of its peers. + */ +function pickTarget(candidates: Iterable): ClientAgentEntry | undefined { + const rank = (e: ClientAgentEntry): number[] => e.info.visible ? [1, e.focusedAt, e.syncedAt] : [0, 0, e.syncedAt] + let best: ClientAgentEntry | undefined + for (const entry of candidates) { + if (!best) { + best = entry + continue + } + const a = rank(entry) + const b = rank(best) + const i = a.findIndex((v, i) => v !== b[i]) + if (i === -1 || a[i]! > b[i]!) + best = entry + } + return best +} + +function withClientIdArg(inputSchema: unknown): unknown { + const base = inputSchema && typeof inputSchema === 'object' ? inputSchema as { properties?: Record } : {} + return { + type: 'object', + ...base, + properties: { + ...base.properties, + [CLIENT_ID_ARG]: { + type: 'string', + description: `Id of the connected browser tab to run this on, from ${LIST_CLIENTS_TOOL}. Omit to target the most recently focused tab.`, + }, + }, + } +} + +function listClients(sessions: ClientAgentSessions): ConnectedClient[] { + return [...sessions.values()].map(entry => ({ + id: entry.clientId, + ...entry.info, + connectedAt: entry.connectedAt, + tools: entry.tools.map(tool => tool.id), + })) +} + +function forwardedTool(sessions: ClientAgentSessions, manifest: BrowserAgentToolManifest): AgentToolInput { + return { + ...manifest, + inputSchema: withClientIdArg(manifest.inputSchema), + handler: (args: Record | undefined) => { + const { [CLIENT_ID_ARG]: clientId, ...rest } = args ?? {} + const candidates = [...sessions.values()].filter(e => + e.tools.some(t => t.id === manifest.id) + && (clientId === undefined || e.clientId === clientId), + ) + const target = pickTarget(candidates) + if (!target) { + throw diagnostics.DF0081({ + clientId: String(clientId), + tool: manifest.id, + live: [...sessions.values()].map(e => e.clientId), + }) + } + return target.session.rpc.$callRaw({ + method: 'devframe:agent:invoke-client-tool', + args: [manifest.id, rest], + }) + }, + } +} + +function listClientsTool(sessions: ClientAgentSessions): AgentToolInput { + return { + id: LIST_CLIENTS_TOOL, + title: 'List connected browser tabs', + description: `List every browser tab connected to this devframe: its client id, URL, title, visibility/focus, and the tools it exposes. Pass a client id as \`${CLIENT_ID_ARG}\` to a client tool to run it on that tab. Safe to call freely.`, + safety: 'read', + inputSchema: { type: 'object', properties: {} }, + outputSchema: { + type: 'object', + properties: { + clients: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + url: { type: 'string' }, + title: { type: 'string' }, + visible: { type: 'boolean' }, + focused: { type: 'boolean' }, + connectedAt: { type: 'number' }, + tools: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + handler: () => ({ clients: listClients(sessions) }), + } +} + function getState(context: ClientAgentContext): ClientAgentState { let state = states.get(context) if (state) return state - const sessions: ClientAgentState['sessions'] = new Map() + const sessions: ClientAgentSessions = new Map() const provider = context.agent.registerToolProvider(() => { + if (sessions.size === 0) + return [] + // One entry per tool id however many tabs expose it; the handler picks + // the tab at call time. const tools = new Map() - for (const { session, tools: manifests } of sessions.values()) { - for (const manifest of manifests) { - if (tools.has(manifest.id)) - continue - tools.set(manifest.id, { - ...manifest, - handler: args => session.rpc.$callRaw({ - method: 'devframe:agent:invoke-client-tool', - args: [manifest.id, args], - }), - }) + for (const entry of sessions.values()) { + for (const manifest of entry.tools) { + if (!tools.has(manifest.id)) + tools.set(manifest.id, forwardedTool(sessions, manifest)) } } + tools.set(LIST_CLIENTS_TOOL, listClientsTool(sessions)) return [...tools.values()] }) state = { sessions, notifyChanged: provider.notifyChanged } @@ -57,10 +181,25 @@ export function syncClientAgentTools( session: ClientAgentSession, clientId: string, tools: BrowserAgentToolManifest[], + info: BrowserAgentClientInfo, ): void { const state = getState(context) - state.sessions.set(session.meta, { session, clientId, tools }) - state.notifyChanged() + const now = Date.now() + const previous = state.sessions.get(session.meta) + state.sessions.set(session.meta, { + session, + clientId, + info, + tools, + connectedAt: previous?.connectedAt ?? now, + syncedAt: now, + focusedAt: info.focused ? now : previous?.focusedAt ?? 0, + }) + // Focus/visibility re-syncs leave the tool surface as it was. + const sameTools = previous && previous.tools.length === tools.length + && previous.tools.every((tool, i) => tool.id === tools[i]!.id) + if (!sameTools) + state.notifyChanged() } export function removeClientAgentSession( diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 7a5b9a95..182dfc7d 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -220,5 +220,10 @@ export const diagnostics = defineDiagnostics({ `The \`mcp\` option is enabled, but the optional peer "@devframes/agentic" could not be loaded: ${p.reason}`, fix: 'Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting.', }, + DF0081: { + why: (p: { clientId: string, tool: string, live: string[] }) => + `Tool "${p.tool}" was addressed to client "${p.clientId}", but no connected browser tab has that id and the tool.${p.live.length ? ` Connected clients: ${p.live.join(', ')}.` : ' No browser tab is connected.'}`, + fix: 'Re-list the connected clients (`devframe:agent:list-clients`) and retry with a live `client_id`, or omit `client_id` to target the most recently focused tab.', + }, }, }) diff --git a/packages/devframe/src/node/rpc/agent-sync-client-tools.ts b/packages/devframe/src/node/rpc/agent-sync-client-tools.ts index ff39662d..19bb3c64 100644 --- a/packages/devframe/src/node/rpc/agent-sync-client-tools.ts +++ b/packages/devframe/src/node/rpc/agent-sync-client-tools.ts @@ -1,4 +1,4 @@ -import type { BrowserAgentToolManifest } from '../../client/browser-agent' +import type { BrowserAgentClientInfo, BrowserAgentToolManifest } from '../../client/browser-agent' import { defineRpcFunction } from 'devframe' import { syncClientAgentTools } from '../client-agent' @@ -7,10 +7,10 @@ export const agentSyncClientTools = defineRpcFunction({ type: 'action', jsonSerializable: true, setup: context => ({ - handler(clientId: string, tools: BrowserAgentToolManifest[]): void { + handler(clientId: string, tools: BrowserAgentToolManifest[], info: BrowserAgentClientInfo): void { const session = context.rpc.getCurrentRpcSession() if (session) - syncClientAgentTools(context, session, clientId, tools) + syncClientAgentTools(context, session, clientId, tools, info) }, }), }) diff --git a/packages/devframe/src/node/rpc/index.ts b/packages/devframe/src/node/rpc/index.ts index a48a5817..0e107475 100644 --- a/packages/devframe/src/node/rpc/index.ts +++ b/packages/devframe/src/node/rpc/index.ts @@ -23,6 +23,6 @@ declare module 'devframe/types' { 'devframe:agent:invoke-tool': (id: string, args: unknown) => Promise 'devframe:agent:list-resources': () => Promise 'devframe:agent:read-resource': (id: string) => Promise - 'devframe:agent:sync-client-tools': (clientId: string, tools: import('../../client/browser-agent').BrowserAgentToolManifest[]) => Promise + 'devframe:agent:sync-client-tools': (clientId: string, tools: import('../../client/browser-agent').BrowserAgentToolManifest[], info: import('../../client/browser-agent').BrowserAgentClientInfo) => Promise } } diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 05a5d2cf..5259ce0c 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -54,7 +54,7 @@ export interface DevframeRpcClientFunctions { */ export interface DevframeRpcServerFunctions { /** Replace this connection's browser-agent tool manifest, tagged with the calling tab's stable client id. @internal */ - 'devframe:agent:sync-client-tools': (clientId: string, tools: import('../client/browser-agent').BrowserAgentToolManifest[]) => Promise + 'devframe:agent:sync-client-tools': (clientId: string, tools: import('../client/browser-agent').BrowserAgentToolManifest[], info: import('../client/browser-agent').BrowserAgentClientInfo) => Promise /** * Authenticate a connection with a previously-issued bearer token; resolves * whether the connection is now trusted. The interactive handler is provided diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index f2bda763..736812e0 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -255,7 +255,7 @@ export interface DevframeRpcOptions { snapshot?: DevframeSnapshotRpcEntry[]; } export interface DevframeRpcServerFunctions { - 'devframe:agent:sync-client-tools': (_: string, _: BrowserAgentToolManifest[]) => Promise; + 'devframe:agent:sync-client-tools': (_: string, _: BrowserAgentToolManifest[], _: BrowserAgentClientInfo) => Promise; 'anonymous:devframe:auth': (_: { authToken: string; ua: string; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 4c08e1e9..10a27e47 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -2,6 +2,11 @@ * Generated by tsnapi — public API snapshot of `devframe/internal` */ // #region Interfaces +export interface ConnectedClient extends BrowserAgentClientInfo { + id: string; + connectedAt: number; + tools: string[]; +} export interface CreateH3DevframeHostOptions { origin: string | (() => string); mount?: (_: string, _: string | RemoteAssetsStore) => void | Promise; @@ -371,9 +376,18 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting."; }; + readonly DF0081: { + readonly why: (p: { + clientId: string; + tool: string; + live: string[]; + }) => string; + readonly fix: "Re-list the connected clients (`devframe:agent:list-clients`) and retry with a live `client_id`, or omit `client_id` to target the most recently focused tab."; + }; }, readonly [(d: import("nostics").Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void], never>; +export declare const LIST_CLIENTS_TOOL: string; // #endregion // #region Other diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js index 30cfdb0d..5679b360 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js @@ -17,6 +17,7 @@ export { DevframeAgentHost } export { diagnostics } export { importAgenticMcp } export { importRuntimeModule } +export { LIST_CLIENTS_TOOL } export { listLiveDevframeInstances } export { loadAutoMcpAdapter } export { normalizeBasePath }