From e052778b0a02dfc6c1f0b97a7cc2a982cef6fbf6 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 7 Feb 2026 09:09:06 -0800 Subject: [PATCH] feat: add gateway protocol attachment support Extends the gateway wire protocol with GatewayAttachment type and attachment event. agent.send handler now accepts optional attachments parameter and converts them for the agent pipeline. Includes 5 new tests for protocol and handler layers. --- src/gateway/handlers/agent.ts | 15 ++++++++-- src/gateway/handlers/handlers.test.ts | 40 ++++++++++++++++++++++++++- src/gateway/index.ts | 2 ++ src/gateway/protocol.test.ts | 18 ++++++++++++ src/gateway/protocol.ts | 22 +++++++++++++++ 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index 4255a1b..4f08138 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -1,7 +1,8 @@ -import type { GatewayRequest, OutboundMessage } from '../protocol.js'; +import type { GatewayRequest, GatewayAttachment, OutboundMessage } from '../protocol.js'; import type { SendFn } from '../router.js'; import { makeEvent, makeError, ErrorCode } from '../protocol.js'; import type { SessionBridge } from '../session-bridge.js'; +import type { Attachment } from '../../channels/types.js'; export interface AgentHandlerDeps { sessionBridge: SessionBridge; @@ -10,7 +11,7 @@ export interface AgentHandlerDeps { export function createAgentHandlers(deps: AgentHandlerDeps) { return { 'agent.send': async (request: GatewayRequest, send: SendFn): Promise => { - const params = request.params as { message?: string; connectionId?: string } | undefined; + const params = request.params as { message?: string; connectionId?: string; attachments?: GatewayAttachment[] } | undefined; if (!params?.message) { return makeError(request.id, ErrorCode.InvalidRequest, 'message is required'); } @@ -48,7 +49,15 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { }); try { - const response = await agent.process(params.message); + // Convert gateway attachments to channel attachments + const attachments: Attachment[] | undefined = params.attachments?.map(a => ({ + mimeType: a.mimeType, + data: a.data, + url: a.url, + filename: a.filename, + })); + + const response = await agent.process(params.message, attachments); send(makeEvent(request.id, 'done', { content: response })); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 4c4eac3..a2ed374 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -206,13 +206,51 @@ describe('agent handlers', () => { await handlers['agent.send'](req, send); - expect(mockAgent.process).toHaveBeenCalledWith('hello'); + expect(mockAgent.process).toHaveBeenCalledWith('hello', undefined); expect(sent).toHaveLength(1); const doneEvent = sent[0] as GatewayEvent; expect(doneEvent.event).toBe('done'); expect((doneEvent.data as any).content).toBe('response text'); }); + it('agent.send passes attachments to agent.process', async () => { + const attachments = [ + { mimeType: 'image/png', data: 'iVBOR...', filename: 'screenshot.png' }, + { mimeType: 'application/pdf', url: 'https://example.com/doc.pdf' }, + ]; + const req: GatewayRequest = { + id: 10, + method: 'agent.send', + params: { message: 'describe this', connectionId: 'conn-1', attachments }, + }; + const sent: OutboundMessage[] = []; + const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); + + await handlers['agent.send'](req, send); + + expect(mockAgent.process).toHaveBeenCalledWith('describe this', [ + { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: 'screenshot.png' }, + { mimeType: 'application/pdf', data: undefined, url: 'https://example.com/doc.pdf', filename: undefined }, + ]); + const doneEvent = sent[0] as GatewayEvent; + expect(doneEvent.event).toBe('done'); + }); + + it('agent.send works with empty attachments array', async () => { + const req: GatewayRequest = { + id: 11, + method: 'agent.send', + params: { message: 'hi', connectionId: 'conn-1', attachments: [] }, + }; + const sent: OutboundMessage[] = []; + const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); + + await handlers['agent.send'](req, send); + + expect(mockAgent.process).toHaveBeenCalledWith('hi', []); + expect(sent).toHaveLength(1); + }); + it('agent.send requires message', async () => { const req: GatewayRequest = { id: 2, method: 'agent.send', params: { connectionId: 'conn-1' } }; const send = vi.fn(); diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 2236b25..1a0235c 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -20,11 +20,13 @@ export type { GatewayResponse, GatewayError, GatewayEvent, + GatewayAttachment, OutboundMessage, EventType, ContentEventData, ToolStartEventData, ToolEndEventData, + AttachmentEventData, DoneEventData, ErrorEventData, } from './protocol.js'; diff --git a/src/gateway/protocol.test.ts b/src/gateway/protocol.test.ts index 4206d04..6ba4198 100644 --- a/src/gateway/protocol.test.ts +++ b/src/gateway/protocol.test.ts @@ -86,5 +86,23 @@ describe('protocol', () => { data: { text: 'hello' }, }); }); + + it('creates an attachment event message', () => { + const data = { mimeType: 'image/png', data: 'iVBOR...', filename: 'screenshot.png' }; + expect(makeEvent(1, 'attachment', data)).toEqual({ + id: 1, + event: 'attachment', + data, + }); + }); + + it('creates an attachment event with url', () => { + const data = { mimeType: 'application/pdf', url: 'https://example.com/doc.pdf', filename: 'doc.pdf' }; + expect(makeEvent(2, 'attachment', data)).toEqual({ + id: 2, + event: 'attachment', + data, + }); + }); }); }); diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts index 40374fa..15c38b6 100644 --- a/src/gateway/protocol.ts +++ b/src/gateway/protocol.ts @@ -29,12 +29,27 @@ export interface GatewayEvent { data: unknown; } +// ── Attachment data for gateway protocol messages ─────────────── + +/** Attachment data sent in agent.send params or emitted as events. */ +export interface GatewayAttachment { + /** MIME type (e.g. "image/jpeg", "audio/ogg") */ + mimeType: string; + /** Base64-encoded data */ + data?: string; + /** URL to the resource */ + url?: string; + /** Filename hint */ + filename?: string; +} + // ── Event types emitted during agent.send ────────────────────── export type EventType = | 'content' | 'tool_start' | 'tool_end' + | 'attachment' | 'done' | 'error'; @@ -56,6 +71,13 @@ export interface ToolEndEventData { }; } +export interface AttachmentEventData { + mimeType: string; + data?: string; + url?: string; + filename?: string; +} + export interface DoneEventData { content: string; }