diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md index 732bb6be8..5ddbf3f39 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. `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 must set `jsonSerializable: true` and is available to coding agents through MCP. `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()`, its deprecated alias `callEvent()`, and `on()` use the names declared in `events`. Function and event names have separate namespaces. diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index 8554feaac..8691444db 100644 --- a/docs/content/1.guide/17.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -69,6 +69,8 @@ A client-only dock can also carry `type: 'json-render'` with an inline [JSON-ren A client script is a `ClientScriptEntry`: `{ importFrom, importName? }` (`importName` defaults `'default'`). The field varies by entry kind: an `action` entry's `action` runs when the dock button is activated, a `custom-render` entry's `renderer` renders its panel, and an `iframe` entry's optional `clientScript` runs alongside the iframe panel inside the host page ([Hub API reference](/references/hub-api#dock-client-script-fields)). +An iframe entry normally runs its client script on first activation. Set `clientScript.eager: true` to run it once as soon as the client context and dock entry are available. + The exported function (`DockClientScriptContext`) receives the client context and two dock-scoped extras: - **`current`** holds this entry's state: `entryMeta`, `isActive`, `domElements`, `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`). diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index bb1ee1bfb..1cedb0002 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -127,11 +127,13 @@ The properties of `DevframeClientContext`: [The client context](/guide/client-co Which `ClientScriptEntry` field carries an entry's client script, and when it runs: [Dock client scripts](/guide/client-context#dock-client-scripts). +`ClientScriptEntry` requires `importFrom` and accepts `importName` (default: `default`). + | Entry kind | Field | Runs | |---|---|---| | `action` | `action` | when the dock button is activated | | `custom-render` | `renderer` | to render the entry's panel | -| `iframe` | `clientScript` (optional) | alongside the iframe panel, inside the host page | +| `iframe` | `clientScript` (optional) | on first activation, or when the client context and dock entry become available with `clientScript.eager: true` | ## Frame-nav messages diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts index f79370299..a3984507f 100644 --- a/packages/devframe/src/cli/connect.ts +++ b/packages/devframe/src/cli/connect.ts @@ -66,10 +66,12 @@ export interface ConnectServerHandle { } /** One discovered instance in the `list-instances` payload: the registry record plus its probed MCP surface. */ +interface IndexedInstanceTools extends Pick {} + interface IndexedInstance extends Omit { mcp: { url: string - tools?: { name: string, description?: string }[] + tools?: IndexedInstanceTools[] error?: string } | null hint?: string @@ -243,14 +245,8 @@ async function probePort(port: number, timeoutMs?: number): Promise { - return withInstanceClient(sdk, url, token, async (client) => { - const listed = await client.listTools() - return listed.tools.map((tool: { name: string, description?: string }) => ({ - name: tool.name, - description: tool.description, - })) - }) +async function listInstanceTools(sdk: ConnectSdk, url: string, token: string | undefined): Promise { + return withInstanceClient(sdk, url, token, async client => (await client.listTools()).tools) } async function call( @@ -265,7 +261,8 @@ async function call( instancesDir: options.instancesDir, timeoutMs: options.timeoutMs, }) - const record = live.find(r => r.port === args.port) ?? await probePort(args.port, options.timeoutMs) + const record = live.find(record => record.port === args.port && record.mcp) + ?? await probePort(args.port, options.timeoutMs) if (!record) throw diagnostics.DF0050({ port: args.port }) if (!record.mcp) diff --git a/packages/devframe/src/client/browser-agent-rpc.test.ts b/packages/devframe/src/client/browser-agent-rpc.test.ts new file mode 100644 index 000000000..66faea589 --- /dev/null +++ b/packages/devframe/src/client/browser-agent-rpc.test.ts @@ -0,0 +1,52 @@ +import type { BrowserAgentToolManifest } from './browser-agent' +import type { BrowserAgentInvocationDefinition } from './browser-agent-rpc' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { registerBrowserAgentTool } from './browser-agent' +import { setupBrowserAgentRpcBridge } from './browser-agent-rpc' + +describe('browser agent RPC bridge', () => { + const disposals: (() => void)[] = [] + afterEach(() => disposals.splice(0).forEach(dispose => dispose())) + + it('synchronizes manifests and invokes the original browser tool', async () => { + const handlers = new Map unknown>() + const callOptional = vi.fn().mockResolvedValue(undefined) + const rpc = { + client: { + register(definition: BrowserAgentInvocationDefinition) { + handlers.set(definition.name, definition.handler) + }, + }, + callOptional( + method: 'devframe:agent:sync-client-tools', + tools: BrowserAgentToolManifest[], + ) { + return callOptional(method, tools) + }, + events: { on: () => () => {} }, + } + + disposals.push(registerBrowserAgentTool({ + id: 'todos:add', + description: 'Add a todo.', + safety: 'action', + inputSchema: { type: 'object' }, + invoke: args => ({ added: args.text }), + })) + disposals.push(setupBrowserAgentRpcBridge(rpc)) + await vi.waitFor(() => expect(callOptional).toHaveBeenCalledWith( + 'devframe:agent:sync-client-tools', + [{ + id: 'todos:add', + description: 'Add a todo.', + safety: 'action', + inputSchema: { type: 'object' }, + }], + )) + + await expect(handlers.get('devframe:agent:invoke-client-tool')!( + 'todos:add', + { text: 'milk' }, + )).resolves.toEqual({ added: 'milk' }) + }) +}) diff --git a/packages/devframe/src/client/browser-agent-rpc.ts b/packages/devframe/src/client/browser-agent-rpc.ts new file mode 100644 index 000000000..d847fdb44 --- /dev/null +++ b/packages/devframe/src/client/browser-agent-rpc.ts @@ -0,0 +1,68 @@ +import type { BrowserAgentToolManifest } from './browser-agent' +import type { DevframeConnectionStatus } from './connection' +import { + listBrowserAgentTools, + onBrowserAgentToolsChanged, +} from './browser-agent' + +export interface BrowserAgentInvocationDefinition { + name: 'devframe:agent:invoke-client-tool' + type: 'action' + jsonSerializable: true + handler: (id: string, args: Record) => Promise +} + +interface BrowserAgentRpcClient { + client: { register: (definition: BrowserAgentInvocationDefinition) => void } + callOptional: ( + method: 'devframe:agent:sync-client-tools', + tools: BrowserAgentToolManifest[], + ) => Promise + events: { + on: ( + event: 'connection:status', + listener: (status: DevframeConnectionStatus, previous: DevframeConnectionStatus) => void, + ) => () => void + } +} + +/** Mirror this document's browser-agent registry over its existing RPC connection. */ +export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => void { + rpc.client.register({ + name: 'devframe:agent:invoke-client-tool', + type: 'action', + jsonSerializable: true, + handler: async (id: string, args: Record) => { + const tool = listBrowserAgentTools().find(tool => tool.id === id) + if (!tool) + throw new Error(`[devframe/agent] browser tool "${id}" not found`) + return await tool.invoke(args) + }, + }) + + let queued = false + let disposed = false + const sync = (): void => { + if (queued || disposed) + return + queued = true + queueMicrotask(async () => { + queued = false + const manifests = listBrowserAgentTools().map(({ invoke: _, ...manifest }) => manifest) + await rpc.callOptional('devframe:agent:sync-client-tools', manifests).catch(() => {}) + }) + } + + const stopTools = onBrowserAgentToolsChanged(sync) + const stopConnection = rpc.events.on('connection:status', (status) => { + if (status === 'connected') + sync() + }) + sync() + + return () => { + disposed = true + stopTools() + stopConnection() + } +} diff --git a/packages/devframe/src/client/browser-agent.ts b/packages/devframe/src/client/browser-agent.ts new file mode 100644 index 000000000..d01c6677a --- /dev/null +++ b/packages/devframe/src/client/browser-agent.ts @@ -0,0 +1,64 @@ +import type { RpcFunctionAgentOptions } from 'devframe/rpc' + +export interface BrowserAgentToolManifest { + id: string + title?: string + description: string + safety: 'read' | 'action' | 'destructive' + tags?: readonly string[] + inputSchema?: unknown +} + +export interface BrowserAgentTool extends BrowserAgentToolManifest { + invoke: (args: Record) => unknown | Promise +} + +interface BrowserAgentRegistryState { + tools: Map + listeners: Set<() => void> +} + +const REGISTRY_KEY = Symbol.for('devframe:browser-agent-registry') +const state = ((globalThis as any)[REGISTRY_KEY] ??= { + tools: new Map(), + listeners: new Set(), +}) as BrowserAgentRegistryState +const { tools, listeners } = state + +function notifyChanged(): void { + for (const listener of listeners) + listener() +} + +export function registerBrowserAgentTool(tool: BrowserAgentTool): () => void { + const key = Symbol(tool.id) + tools.set(key, tool) + notifyChanged() + return () => { + if (tools.delete(key)) + notifyChanged() + } +} + +export function listBrowserAgentTools(): BrowserAgentTool[] { + const unique = new Map() + for (const tool of tools.values()) { + if (!unique.has(tool.id)) + unique.set(tool.id, tool) + } + return [...unique.values()] +} + +export function onBrowserAgentToolsChanged(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resolveBrowserAgentSafety( + type: string | undefined, + agent: RpcFunctionAgentOptions, +): BrowserAgentToolManifest['safety'] { + if (agent.safety) + return agent.safety + return type === 'static' || type === 'query' || type == null ? 'read' : 'action' +} diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index c195a772d..ed9e63341 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -11,6 +11,7 @@ import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' import { createEventEmitter } from 'devframe/utils/events' import { withBase } from 'ufo' +import { setupBrowserAgentRpcBridge } from './browser-agent-rpc' import { setupDevframeConnection } from './connection' import { storeAuthToken } from './connection-storage' import { authenticateWithUrlOtp } from './otp' @@ -356,6 +357,7 @@ export async function getDevframeRpcClient( const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase(context) // No-op when the browser provides no WebMCP model context. const disposeWebMcp = options.webmcp === false ? undefined : registerWebMcpTools(clientRpc) + let disposeBrowserAgentBridge: (() => void) | undefined async function fetchJsonFromBases(path: string): Promise { const candidates = [ @@ -496,6 +498,7 @@ export async function getDevframeRpcClient( cacheManager, scope: undefined!, close: () => { + disposeBrowserAgentBridge?.() disposeWebMcp?.() mode.close?.() }, @@ -584,6 +587,8 @@ export async function getDevframeRpcClient( () => { bootstrapAuthSettled = true }, ) + disposeBrowserAgentBridge = setupBrowserAgentRpcBridge(rpc) + // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange). if (authChannel) { diff --git a/packages/devframe/src/in-page-channel/agent.test.ts b/packages/devframe/src/in-page-channel/agent.test.ts new file mode 100644 index 000000000..86bd34a63 --- /dev/null +++ b/packages/devframe/src/in-page-channel/agent.test.ts @@ -0,0 +1,75 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import { describe, expect, it } from 'vitest' +import { listBrowserAgentTools } from '../client/browser-agent' +import { createPageScriptChannel } from './page-script' + +interface TestProtocol { + pageScript: { + add: (a: number, b: number) => { sum: number } + hidden: () => string + } +} + +function schema(json: Record): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value: value as T }), + jsonSchema: { + input: () => json, + output: () => json, + }, + } as StandardSchemaV1['~standard'], + } +} + +describe('in-page channel agent tools', () => { + it('registers the original handler for browser-to-node agent transport', async () => { + const channel = createPageScriptChannel({ + name: 'devframes:test', + window: false, + heartbeat: false, + functions: { + add: { + jsonSerializable: true, + agent: { description: 'Add two numbers.' }, + args: [schema({ type: 'number' }), schema({ type: 'number' })], + returns: schema<{ sum: number }>({ type: 'object' }), + handler: (a, b) => ({ sum: a + b }), + }, + hidden: { handler: () => 'internal' }, + }, + }) + + const tool = listBrowserAgentTools().find(tool => tool.id === 'devframes:test:add')! + expect(tool).toMatchObject({ + description: 'Add two numbers.', + safety: 'read', + inputSchema: { + type: 'object', + properties: { arg0: { type: 'number' }, arg1: { type: 'number' } }, + required: ['arg0', 'arg1'], + additionalProperties: false, + }, + }) + await expect(tool.invoke({ arg0: 2, arg1: 3 })).resolves.toEqual({ sum: 5 }) + + channel.close() + expect(listBrowserAgentTools().some(tool => tool.id === 'devframes:test:add')).toBe(false) + }) + + it('rejects agent exposure without strict JSON serialization', () => { + expect(() => createPageScriptChannel({ + name: 'devframes:invalid', + window: false, + functions: { + add: { + agent: { description: 'Add two numbers.' }, + handler: (a, b) => ({ sum: a + b }), + }, + hidden: { handler: () => 'internal' }, + }, + })).toThrowError(/MCP requires JSON-serializable/) + }) +}) diff --git a/packages/devframe/src/in-page-channel/diagnostics.ts b/packages/devframe/src/in-page-channel/diagnostics.ts index e5dba6346..32dbc5d2e 100644 --- a/packages/devframe/src/in-page-channel/diagnostics.ts +++ b/packages/devframe/src/in-page-channel/diagnostics.ts @@ -7,5 +7,10 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({ why: (p: { name: string }) => `In-page channel function "${p.name}" is not registered on this endpoint.`, fix: 'Declare the function in this endpoint\'s `functions` option.', }, + DF0078: { + why: (p: { name: string }) => + `In-page channel function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\`; MCP requires JSON-serializable data.`, + fix: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it channel-only.', + }, }, }) diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index 88fc5d08e..8ebb3d58b 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -4,6 +4,9 @@ import type { RpcArgsSchema } from '../rpc/types' import type { InPageChannelControlFrame } from './protocol' import type { InPageFunctionDefinitionAny, InPageFunctionType } from './types' import { createBirpc } from 'birpc' +import { argsToJsonSchema } from '../adapters/mcp/to-json-schema' +import { registerBrowserAgentTool, resolveBrowserAgentSafety } from '../client/browser-agent' +import { coerceAgentPositionalArgs } from '../node/agent-args' import { diagnostics } from './diagnostics' import { isControlFrame } from './protocol' @@ -172,16 +175,22 @@ const FUNCTION_METHOD_PREFIX = channelMethod('function', '') * hook, `jsonSerializable` enforcement, Standard-Schema argument validation, * then serialize hook + `jsonSerializable` enforcement on the result. */ -export function createLocalFunctionRegistry(codec: InPageChannelSerialization): { +export interface InPageLocalFunctionRegistry { + readonly definitions: ReadonlyMap register: (definition: InPageFunctionDefinitionAny) => void registerInternal: (method: string, handler: (...args: unknown[]) => unknown) => void on: (name: string, listener: (...args: unknown[]) => void) => () => void resolve: (name: string) => ((...args: unknown[]) => unknown) | undefined -} { +} + +export function createLocalFunctionRegistry(codec: InPageChannelSerialization): InPageLocalFunctionRegistry { const definitions = new Map() const listeners = new Map void>>() return { + definitions, register(definition) { + if ('agent' in definition && definition.agent && definition.jsonSerializable !== true) + throw diagnostics.DF0078({ name: definition.name }) definitions.set(channelMethod(definition.type, definition.name), definition) }, // The shared-state layer keys its handlers by their own fully-qualified @@ -253,6 +262,36 @@ export function resolveLocalHandler( return undefined } +/** Register an endpoint's local agent functions for browser-backed transports. */ +export function registerInPageAgentTools( + channelName: string, + registry: InPageLocalFunctionRegistry, +): () => void { + const disposals: (() => void)[] = [] + for (const definition of registry.definitions.values()) { + if (definition.type === 'event' || !('agent' in definition) || !definition.agent) + continue + const agent = definition.agent + disposals.push(registerBrowserAgentTool({ + id: `${channelName}:${definition.name}`, + title: agent.title ?? definition.name, + description: agent.description, + safety: resolveBrowserAgentSafety(definition.type, agent), + tags: agent.tags, + inputSchema: argsToJsonSchema(definition.args), + invoke: (args) => { + const positional = coerceAgentPositionalArgs( + args, + definition.args as readonly unknown[] | undefined, + 'wrap', + ) + return registry.resolve(channelMethod(definition.type, definition.name))!(...positional) + }, + })) + } + return () => disposals.forEach(dispose => dispose()) +} + type RemoteFunctions = Record any> export interface AttachChannelPortOptions { diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index f00846c61..314751769 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -14,6 +14,7 @@ import { createLocalFunctionRegistry, DEFAULT_CALL_TIMEOUT_MS, deserializeResult, + registerInPageAgentTools, resolveHeartbeat, resolveLocalHandler, serializeArgs, @@ -69,6 +70,7 @@ export function createPageScriptChannel

( registry.register({ ...definition, name: fnName }) for (const [eventName, definition] of Object.entries(options.events ?? {})) registry.register({ ...definition, name: eventName, type: 'event' }) + const disposeAgentTools = registerInPageAgentTools(name, registry) const stateHost = createPageScriptStateHost

(function* () { for (const peer of peers.values()) { @@ -209,6 +211,7 @@ export function createPageScriptChannel

( if (closed) return closed = true + disposeAgentTools() win?.removeEventListener('message', onWindowMessage) for (const id of [...peers.keys()]) removePeer(id, { bye: true, reason: 'the page script closed the channel' }) diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 63ce2ed64..e5683f8d4 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -15,6 +15,7 @@ import { DEFAULT_CALL_TIMEOUT_MS, deserializeResult, InPageChannelError, + registerInPageAgentTools, resolveHeartbeat, resolveLocalHandler, serializeArgs, @@ -68,6 +69,7 @@ export function connectPanelChannel

( registry.register({ ...definition, name: fnName }) for (const [eventName, definition] of Object.entries(options.events ?? {})) registry.register({ ...definition, name: eventName, type: 'event' }) + const disposeAgentTools = registerInPageAgentTools(name, registry) let status: InPageChannelStatus = 'connecting' let attached: AttachedChannelPort | undefined @@ -299,6 +301,7 @@ export function connectPanelChannel

( if (status === 'closed') return setStatus('closed') + disposeAgentTools() stopTimers() win?.removeEventListener('message', onWindowMessage) attached?.dispose({ bye: true, reason: 'the panel closed the channel' }) diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts index 1da339e0d..eaab3abb8 100644 --- a/packages/devframe/src/in-page-channel/types.ts +++ b/packages/devframe/src/in-page-channel/types.ts @@ -1,6 +1,6 @@ import type { EventEmitter } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' -import type { RpcArgsSchema, RpcReturnSchema, Thenable } from '../rpc/types' +import type { RpcArgsSchema, RpcFunctionAgentOptions, RpcReturnSchema, Thenable } from '../rpc/types' import type { InferArgsType, InferReturnType } from '../rpc/utils' /** @@ -64,12 +64,17 @@ type ProtocolHandler = F extends (...args: any[]) => any */ export type InPageFunctionType = 'action' | 'event' | 'query' -interface InPageFunctionDefinitionBase { +interface InPageDefinitionBase { name: NAME jsonSerializable?: boolean } -interface InPageEventFunctionDefinition extends InPageFunctionDefinitionBase { +interface InPageFunctionDefinitionBase extends InPageDefinitionBase { + /** Expose this function through DevFrame's browser-to-node agent bridge. */ + agent?: RpcFunctionAgentOptions +} + +interface InPageEventFunctionDefinition extends InPageDefinitionBase { type: 'event' handler?: HANDLER } @@ -153,7 +158,7 @@ export type InPageFunctionDefinitionAny = InPageFunctionDefinition extends InPageFunctionOptionBase { +interface InPageFunctionOptionBase extends InPageDefinitionOptionBase { + /** Expose this function through DevFrame's browser-to-node agent bridge. */ + agent?: RpcFunctionAgentOptions +} + +interface InPageEventFunctionOption extends InPageDefinitionOptionBase { type?: 'event' handler?: ProtocolHandler } diff --git a/packages/devframe/src/node/__tests__/client-agent.test.ts b/packages/devframe/src/node/__tests__/client-agent.test.ts new file mode 100644 index 000000000..e7f3638ff --- /dev/null +++ b/packages/devframe/src/node/__tests__/client-agent.test.ts @@ -0,0 +1,45 @@ +import type { AgentToolInput } from 'devframe/types' +import { describe, expect, it, vi } from 'vitest' +import { 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() {} } + }, + }, + } + const callRaw = vi.fn().mockResolvedValue(['refetched']) + const session = { + meta: { id: 1, subscribedStates: new Set() }, + rpc: { $callRaw: callRaw }, + } + + syncClientAgentTools(context, session, [{ + 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' }, + }) + await expect(tool!.handler!({ arg0: {} })).resolves.toEqual(['refetched']) + expect(callRaw).toHaveBeenCalledWith({ + method: 'devframe:agent:invoke-client-tool', + args: ['pinia-colada:refetch', { arg0: {} }], + }) + + removeClientAgentSession(context, session.meta) + expect(provider!()).toEqual([]) + expect(notifyChanged).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/devframe/src/node/client-agent.ts b/packages/devframe/src/node/client-agent.ts new file mode 100644 index 000000000..1ce06c5d1 --- /dev/null +++ b/packages/devframe/src/node/client-agent.ts @@ -0,0 +1,70 @@ +import type { AgentToolInput, DevframeAgentHost, DevframeNodeRpcSessionMeta } from 'devframe/types' +import type { BrowserAgentToolManifest } from '../client/browser-agent' + +interface ClientAgentContext { + agent: Pick +} + +interface ClientAgentSession { + meta: DevframeNodeRpcSessionMeta + rpc: { + $callRaw: (request: { method: string, args: unknown[] }) => Promise + } +} + +interface ClientAgentState { + sessions: Map + notifyChanged: () => void +} + +const states = new WeakMap() + +function getState(context: ClientAgentContext): ClientAgentState { + let state = states.get(context) + if (state) + return state + + const sessions: ClientAgentState['sessions'] = new Map() + const provider = context.agent.registerToolProvider(() => { + 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], + }), + }) + } + } + return [...tools.values()] + }) + state = { sessions, notifyChanged: provider.notifyChanged } + states.set(context, state) + return state +} + +export function syncClientAgentTools( + context: ClientAgentContext, + session: ClientAgentSession, + tools: BrowserAgentToolManifest[], +): void { + const state = getState(context) + state.sessions.set(session.meta, { session, tools }) + state.notifyChanged() +} + +export function removeClientAgentSession( + context: ClientAgentContext, + meta: DevframeNodeRpcSessionMeta, +): void { + const state = states.get(context) + if (state?.sessions.delete(meta)) + state.notifyChanged() +} diff --git a/packages/devframe/src/node/host-functions.ts b/packages/devframe/src/node/host-functions.ts index 88344d0f7..ea2b2c9c7 100644 --- a/packages/devframe/src/node/host-functions.ts +++ b/packages/devframe/src/node/host-functions.ts @@ -3,6 +3,7 @@ import type { DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessio import type { AsyncLocalStorage } from 'node:async_hooks' import { RpcFunctionsCollectorBase } from 'devframe/rpc' import { createDebug } from 'obug' +import { removeClientAgentSession } from './client-agent' import { diagnostics } from './diagnostics' import { createRpcSharedStateServerHost } from './rpc-shared-state' import { createRpcStreamingServerHost } from './rpc-streaming' @@ -53,6 +54,7 @@ export class RpcFunctionsHostImpl extends RpcFunctionsCollectorBase ({ + handler(tools: BrowserAgentToolManifest[]): void { + const session = context.rpc.getCurrentRpcSession() + if (session) + syncClientAgentTools(context, session, tools) + }, + }), +}) diff --git a/packages/devframe/src/node/rpc/index.ts b/packages/devframe/src/node/rpc/index.ts index 88d8573d1..9a09dc2bb 100644 --- a/packages/devframe/src/node/rpc/index.ts +++ b/packages/devframe/src/node/rpc/index.ts @@ -2,6 +2,7 @@ import { agentInvokeTool } from './agent-invoke-tool' import { agentListResources } from './agent-list-resources' import { agentListTools } from './agent-list-tools' import { agentReadResource } from './agent-read-resource' +import { agentSyncClientTools } from './agent-sync-client-tools' /** * Built-in agent introspection RPC functions. Registered automatically @@ -13,6 +14,7 @@ export const BUILTIN_AGENT_RPC = [ agentInvokeTool, agentListResources, agentReadResource, + agentSyncClientTools, ] as const declare module 'devframe/types' { @@ -21,5 +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': (tools: import('../../client/browser-agent').BrowserAgentToolManifest[]) => Promise } } diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 6e39f18d5..6ca4d9242 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -336,6 +336,13 @@ export interface DevframeDockDefaults { * @default 'default' */ importName?: string + /** + * Import and execute this script as soon as the client context and dock + * entry are available, without waiting for the dock to be activated. + * + * When omitted, the client runtime keeps its normal loading policy. + */ + eager?: boolean } } diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index b851fd105..1a910c611 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -2,6 +2,8 @@ * To be extended */ export interface DevframeRpcClientFunctions { + /** Invoke a tool registered in this browser document. @internal */ + 'devframe:agent:invoke-client-tool': (id: string, args: Record) => Promise /** * Server→client notification that this connection's auth token has been * revoked. The client drops to untrusted on receipt. Broadcast by @@ -51,6 +53,8 @@ export interface DevframeRpcClientFunctions { * To be extended */ export interface DevframeRpcServerFunctions { + /** Replace this connection's browser-agent tool manifest. @internal */ + 'devframe:agent:sync-client-tools': (tools: import('../client/browser-agent').BrowserAgentToolManifest[]) => 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/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index eed68cadd..40714cc89 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -76,6 +76,45 @@ async function flushRestore(): Promise { } describe('createDocksContext', () => { + it('runs eager iframe client scripts before activation', async () => { + const { rpc, sharedStates } = createStubRpc() + const executeSetupScriptMock = vi.mocked(executeSetupScript) + executeSetupScriptMock.mockClear() + const context = await createDocksContext('embedded', rpc) + const eagerEntry = { + id: 'eager', + type: 'iframe', + title: 'Eager', + icon: 'ph:play', + url: '/eager', + clientScript: { importFrom: '/eager-client.js', eager: true }, + } satisfies DevframeDockEntry + const activationEntry = { + id: 'activation', + type: 'iframe', + title: 'Activation', + icon: 'ph:play', + url: '/activation', + clientScript: { importFrom: '/activation-client.js' }, + } satisfies DevframeDockEntry + + sharedStates.get('devframe:docks')!.push([eagerEntry, activationEntry]) + await flushRestore() + + expect(executeSetupScriptMock).toHaveBeenCalledOnce() + expect(executeSetupScriptMock).toHaveBeenLastCalledWith( + eagerEntry, + expect.objectContaining({ current: expect.objectContaining({ entryMeta: eagerEntry }) }), + ) + + await context.docks.switchEntry('activation') + expect(executeSetupScriptMock).toHaveBeenCalledTimes(2) + expect(executeSetupScriptMock).toHaveBeenLastCalledWith( + activationEntry, + expect.objectContaining({ current: expect.objectContaining({ entryMeta: activationEntry }) }), + ) + }) + it('exposes restored panel state and emits selected, hidden, and closed changes', async () => { expect.assertions(9) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 4ded43869..24243c9ec 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -641,6 +641,25 @@ export async function createDocksContext( clientType, }) + // Hub UI normally loads an iframe client script on first activation. Some + // page integrations need to observe the host app sooner, so start explicitly + // eager scripts once the entry and complete client context are available. + // executeSetupScript caches non-action scripts, preventing activation from + // executing the same script again. + watch( + entries, + (list) => { + for (const entry of list) { + if (entry.type !== 'iframe' || !entry.clientScript?.eager) + continue + runDockSetupScript(entry).catch(() => { + // executeSetupScript already reports import and execution failures. + }) + } + }, + { immediate: true, flush: 'post' }, + ) + registerMainFrameDockActionHandler(clientType, async (id) => { const entry = entries.value.find(e => e.id === id) if (!entry || entry.type !== 'action') diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index f7c36d413..e3b03aa62 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -188,6 +188,13 @@ export interface ClientScriptEntry { * @default 'default' */ importName?: string + /** + * Import and execute this script as soon as the client context and dock entry + * are available, without waiting for the dock to be activated. + * + * When omitted, the client runtime keeps its normal loading policy. + */ + eager?: boolean } declare module 'devframe/types' { diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 0c2878326..4831ddf37 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -5,6 +5,7 @@ export interface ClientScriptEntry { importFrom: string; importName?: string; + eager?: boolean; } export interface CreateHubContextOptions extends CreateHostContextOptions {} export interface DevframeChildProcessExecuteOptions { diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 7dbdabed2..81698058f 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -173,6 +173,7 @@ export interface DevframeDockDefaults { clientScript?: { importFrom: string; importName?: string; + eager?: boolean; }; } export interface DevframeHost {