Add canvas artifact RPC foundation for A2UI

This commit is contained in:
William Valentin
2026-02-16 12:36:02 -08:00
parent fe8674e108
commit 8a0b4f3dbb
11 changed files with 497 additions and 3 deletions
+89
View File
@@ -0,0 +1,89 @@
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeError, makeResponse, ErrorCode } from '../protocol.js';
import type { CanvasStore } from '../canvas-store.js';
export interface CanvasHandlerDeps {
store: CanvasStore;
}
export function createCanvasHandlers(deps: CanvasHandlerDeps) {
return {
'canvas.list': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
const artifacts = deps.store.list(params.sessionId);
return makeResponse(request.id, { artifacts });
},
'canvas.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string; artifactId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
if (!params?.artifactId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'artifactId is required');
}
const artifact = deps.store.get(params.sessionId, params.artifactId);
if (!artifact) {
return makeError(request.id, ErrorCode.SessionNotFound, 'Canvas artifact not found');
}
return makeResponse(request.id, { artifact });
},
'canvas.put': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as {
sessionId?: string;
artifactId?: string;
type?: string;
title?: string;
content?: unknown;
metadata?: Record<string, unknown>;
} | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
if (!params?.type || typeof params.type !== 'string' || !params.type.trim()) {
return makeError(request.id, ErrorCode.InvalidRequest, 'type is required');
}
if (params.content === undefined) {
return makeError(request.id, ErrorCode.InvalidRequest, 'content is required');
}
const artifact = deps.store.put(params.sessionId, {
id: params.artifactId,
type: params.type,
title: params.title,
content: params.content,
metadata: params.metadata,
});
return makeResponse(request.id, { artifact, upserted: true });
},
'canvas.delete': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string; artifactId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
if (!params?.artifactId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'artifactId is required');
}
const deleted = deps.store.delete(params.sessionId, params.artifactId);
return makeResponse(request.id, { deleted });
},
'canvas.clear': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { sessionId?: string } | undefined;
if (!params?.sessionId) {
return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required');
}
const cleared = deps.store.clear(params.sessionId);
return makeResponse(request.id, { cleared });
},
};
}
+78
View File
@@ -7,10 +7,12 @@ import { createAgentHandlers } from './agent.js';
import { createIntentHandlers } from './intents.js';
import { createRoutingHandlers } from './routing.js';
import { createHistoryHandlers } from './history.js';
import { createCanvasHandlers } from './canvas.js';
import { createConfigHandlers, redactConfig } from './config.js';
import { createPairingHandlers } from './pairing.js';
import { PairingManager } from '../../channels/pairing.js';
import { LaneQueue } from '../lane-queue.js';
import { CanvasStore } from '../canvas-store.js';
import { ErrorCode } from '../protocol.js';
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
import { ComponentRegistry } from '../../intents/index.js';
@@ -350,6 +352,82 @@ describe('session handlers', () => {
});
});
describe('canvas handlers', () => {
it('supports put/get/list/delete/clear lifecycle', async () => {
const handlers = createCanvasHandlers({ store: new CanvasStore() });
const putReq: GatewayRequest = {
id: 1,
method: 'canvas.put',
params: {
sessionId: 'ws:abc',
artifactId: 'card-1',
type: 'note',
title: 'Draft',
content: { text: 'hello' },
},
};
const putRes = await handlers['canvas.put'](putReq) as GatewayResponse;
expect(getPath(putRes.result, 'artifact', 'id')).toBe('card-1');
const getRes = await handlers['canvas.get']({
id: 2,
method: 'canvas.get',
params: { sessionId: 'ws:abc', artifactId: 'card-1' },
}) as GatewayResponse;
expect(getPath(getRes.result, 'artifact', 'title')).toBe('Draft');
const listRes = await handlers['canvas.list']({
id: 3,
method: 'canvas.list',
params: { sessionId: 'ws:abc' },
}) as GatewayResponse;
expect((getPath(listRes.result, 'artifacts') as unknown[]).length).toBe(1);
const delRes = await handlers['canvas.delete']({
id: 4,
method: 'canvas.delete',
params: { sessionId: 'ws:abc', artifactId: 'card-1' },
}) as GatewayResponse;
expect(getPath(delRes.result, 'deleted')).toBe(true);
await handlers['canvas.put']({
id: 5,
method: 'canvas.put',
params: { sessionId: 'ws:abc', artifactId: 'card-2', type: 'note', content: 'a' },
});
await handlers['canvas.put']({
id: 6,
method: 'canvas.put',
params: { sessionId: 'ws:abc', artifactId: 'card-3', type: 'note', content: 'b' },
});
const clearRes = await handlers['canvas.clear']({
id: 7,
method: 'canvas.clear',
params: { sessionId: 'ws:abc' },
}) as GatewayResponse;
expect(getPath(clearRes.result, 'cleared')).toBe(2);
});
it('validates required params', async () => {
const handlers = createCanvasHandlers({ store: new CanvasStore() });
const missingSession = await handlers['canvas.list']({
id: 8,
method: 'canvas.list',
params: {},
}) as GatewayError;
expect(missingSession.error.code).toBe(ErrorCode.InvalidRequest);
const missingContent = await handlers['canvas.put']({
id: 9,
method: 'canvas.put',
params: { sessionId: 'ws:abc', type: 'note' },
}) as GatewayError;
expect(missingContent.error.code).toBe(ErrorCode.InvalidRequest);
});
});
describe('tool handlers', () => {
const mockTool = {
name: 'test.tool',
+2
View File
@@ -17,5 +17,7 @@ export { createRoutingHandlers } from './routing.js';
export type { RoutingHandlerDeps } from './routing.js';
export { createHistoryHandlers } from './history.js';
export type { HistoryHandlerDeps } from './history.js';
export { createCanvasHandlers } from './canvas.js';
export type { CanvasHandlerDeps } from './canvas.js';
export { createNodeHandlers } from './node.js';
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState, NodeLocation } from './node.js';