diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 602a01fd4..dd5cae3bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,12 +240,11 @@ jobs: source ./bin/activate-hermit just check-acp-schema - - name: Test ACP Client SDK + - name: Check ACP Client SDK run: | source ./bin/activate-hermit cd ui/sdk - pnpm test - pnpm run typecheck:test + pnpm run lint desktop-lint: name: Test and Lint Electron Desktop App diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index 672b6ec45..a1587d15f 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -87,6 +87,8 @@ module.exports = [ URLSearchParams: 'readonly', Response: 'readonly', ReadableStream: 'readonly', + ReadableStreamDefaultController: 'readonly', + WritableStream: 'readonly', AbortController: 'readonly', RequestCredentials: 'readonly', HeadersInit: 'readonly', diff --git a/ui/desktop/package.json b/ui/desktop/package.json index ef8418d71..c8c9ba233 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@aaif/goose-sdk": "workspace:*", - "@agentclientprotocol/sdk": "^0.19.0", + "@agentclientprotocol/sdk": "^1.3.0", "@mcp-ui/client": "6.1.0", "@modelcontextprotocol/ext-apps": "^1.1.1", "@radix-ui/react-accordion": "^1.2.12", diff --git a/ui/desktop/src/acp/__tests__/acpConnection.test.ts b/ui/desktop/src/acp/__tests__/acpConnection.test.ts index 8ef5a812b..45a9205cd 100644 --- a/ui/desktop/src/acp/__tests__/acpConnection.test.ts +++ b/ui/desktop/src/acp/__tests__/acpConnection.test.ts @@ -1,24 +1,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GOOSE_SERVE_EXITED_USER_MESSAGE } from '../../gooseServeLeaseRegistry'; -const sdk = vi.hoisted(() => { +const mockClientFactory = vi.hoisted(() => { const initialize = vi.fn(); - const instances: MockGooseClient[] = []; + type MockStream = object; + type MockClient = { + connection: { + agent: { request: typeof initialize }; + closed: Promise; + close: ReturnType; + }; + goose: Record; + }; + const instances: Array<{ client: MockClient; resolveClosed: () => void }> = []; + const connectGooseAcpClient = vi.fn((_stream: MockStream): MockClient => { + let resolveClosed: () => void = () => undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const client: MockClient = { + connection: { + agent: { request: initialize }, + closed, + close: vi.fn(), + }, + goose: {}, + }; + instances.push({ client, resolveClosed }); + return client; + }); - class MockGooseClient { - readonly initialize = initialize; - readonly closed: Promise; - resolveClosed: () => void = () => undefined; - - constructor() { - this.closed = new Promise((resolve) => { - this.resolveClosed = resolve; - }); - instances.push(this); - } - } - - return { GooseClient: MockGooseClient, initialize, instances }; + return { connectGooseAcpClient, initialize, instances }; }); const transport = vi.hoisted(() => ({ @@ -27,10 +39,13 @@ const transport = vi.hoisted(() => ({ vi.mock('@aaif/goose-sdk', () => ({ DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: {}, - GooseClient: sdk.GooseClient, })); -vi.mock('../createWebSocketStream', () => ({ +vi.mock('../gooseAcpClient', () => ({ + connectGooseAcpClient: mockClientFactory.connectGooseAcpClient, +})); + +vi.mock('@agentclientprotocol/sdk/experimental/ws-client', () => ({ createWebSocketStream: transport.createWebSocketStream, })); @@ -39,12 +54,11 @@ describe('ACP connection ownership', () => { vi.useFakeTimers(); vi.resetModules(); vi.spyOn(Math, 'random').mockReturnValue(0.5); - sdk.initialize.mockReset().mockResolvedValue({}); - sdk.instances.length = 0; + mockClientFactory.initialize.mockReset().mockResolvedValue({}); + mockClientFactory.instances.length = 0; transport.createWebSocketStream.mockReset().mockImplementation(() => ({ readable: {}, writable: {}, - close: vi.fn(), })); window.electron.getAcpUrl = vi.fn().mockResolvedValue('ws://localhost/acp'); }); @@ -60,56 +74,56 @@ describe('ACP connection ownership', () => { const [first, second] = await Promise.all([getAcpClient(), getAcpClient()]); expect(first).toBe(second); - expect(sdk.instances).toHaveLength(1); - expect(sdk.initialize).toHaveBeenCalledTimes(1); - expect(transport.createWebSocketStream).toHaveBeenCalledTimes(1); + expect(mockClientFactory.instances).toHaveLength(1); + expect(mockClientFactory.initialize).toHaveBeenCalledTimes(1); + expect(transport.createWebSocketStream).toHaveBeenCalledWith('ws://localhost/acp', { + protocols: [], + }); }); it('automatically reconnects after close and shares the result between callers', async () => { const { getAcpClient } = await import('../acpConnection'); const firstClient = await getAcpClient(); - const firstStream = transport.createWebSocketStream.mock.results[0].value; - - sdk.instances[0].resolveClosed(); + mockClientFactory.instances[0].resolveClosed(); await Promise.resolve(); const firstCaller = getAcpClient(); const secondCaller = getAcpClient(); await vi.advanceTimersByTimeAsync(249); - expect(sdk.instances).toHaveLength(1); + expect(mockClientFactory.instances).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); const [firstResult, secondResult] = await Promise.all([firstCaller, secondCaller]); - expect(firstStream.close).toHaveBeenCalledOnce(); + expect(mockClientFactory.instances[0].client.connection.close).toHaveBeenCalledOnce(); expect(firstResult).toBe(secondResult); expect(firstResult).not.toBe(firstClient); - expect(sdk.instances).toHaveLength(2); + expect(mockClientFactory.instances).toHaveLength(2); expect(transport.createWebSocketStream).toHaveBeenCalledTimes(2); }); it('increases the backoff after a failed reconnect attempt', async () => { - sdk.initialize + mockClientFactory.initialize .mockResolvedValueOnce({}) .mockRejectedValueOnce(new Error('server unavailable')) .mockResolvedValueOnce({}); const { getAcpClient } = await import('../acpConnection'); await getAcpClient(); - sdk.instances[0].resolveClosed(); + mockClientFactory.instances[0].resolveClosed(); await Promise.resolve(); const reconnected = getAcpClient(); await vi.advanceTimersByTimeAsync(250); - expect(sdk.instances).toHaveLength(2); + expect(mockClientFactory.instances).toHaveLength(2); await vi.advanceTimersByTimeAsync(499); - expect(sdk.instances).toHaveLength(2); + expect(mockClientFactory.instances).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); await reconnected; - expect(sdk.instances).toHaveLength(3); + expect(mockClientFactory.instances).toHaveLength(3); }); it('stops reconnecting when the Goose backend has exited', async () => { @@ -124,7 +138,7 @@ describe('ACP connection ownership', () => { new Error(`Error invoking remote method 'get-acp-url': ${GOOSE_SERVE_EXITED_USER_MESSAGE}`) ); window.electron.getAcpUrl = getAcpUrl; - sdk.instances[0].resolveClosed(); + mockClientFactory.instances[0].resolveClosed(); await Promise.resolve(); const connection = expect(getAcpClient()).rejects.toThrow(GOOSE_SERVE_EXITED_USER_MESSAGE); @@ -146,7 +160,7 @@ describe('ACP connection ownership', () => { new Error(`Error invoking remote method 'get-acp-url': ${GOOSE_SERVE_EXITED_USER_MESSAGE}`) ); window.electron.getAcpUrl = getAcpUrl; - sdk.instances[0].resolveClosed(); + mockClientFactory.instances[0].resolveClosed(); await Promise.resolve(); const failedRecovery = expect(getAcpClient()).rejects.toThrow(GOOSE_SERVE_EXITED_USER_MESSAGE); @@ -156,20 +170,18 @@ describe('ACP connection ownership', () => { await expect(getAcpClient()).rejects.toThrow(GOOSE_SERVE_EXITED_USER_MESSAGE); expect(getAcpUrl).toHaveBeenCalledTimes(2); - expect(sdk.instances).toHaveLength(1); + expect(mockClientFactory.instances).toHaveLength(1); }); it('reconnects immediately after system resume', async () => { const { getAcpClient, reconnectAcpAfterSystemResume } = await import('../acpConnection'); await getAcpClient(); - const firstStream = transport.createWebSocketStream.mock.results[0].value; - reconnectAcpAfterSystemResume(); const reconnected = getAcpClient(); await reconnected; - expect(firstStream.close).toHaveBeenCalledOnce(); - expect(sdk.instances).toHaveLength(2); + expect(mockClientFactory.instances[0].client.connection.close).toHaveBeenCalledOnce(); + expect(mockClientFactory.instances).toHaveLength(2); }); it('does nothing on system resume before ACP has been used', async () => { @@ -178,12 +190,12 @@ describe('ACP connection ownership', () => { reconnectAcpAfterSystemResume(); await Promise.resolve(); - expect(sdk.instances).toHaveLength(0); + expect(mockClientFactory.instances).toHaveLength(0); expect(transport.createWebSocketStream).not.toHaveBeenCalled(); }); it('uses normal backoff when the immediate resume attempt fails', async () => { - sdk.initialize + mockClientFactory.initialize .mockResolvedValueOnce({}) .mockRejectedValueOnce(new Error('network is not ready')) .mockResolvedValueOnce({}); @@ -193,30 +205,30 @@ describe('ACP connection ownership', () => { reconnectAcpAfterSystemResume(); const reconnected = getAcpClient(); await Promise.resolve(); - expect(sdk.instances).toHaveLength(2); + expect(mockClientFactory.instances).toHaveLength(2); await vi.advanceTimersByTimeAsync(249); - expect(sdk.instances).toHaveLength(2); + expect(mockClientFactory.instances).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); await reconnected; - expect(sdk.instances).toHaveLength(3); + expect(mockClientFactory.instances).toHaveLength(3); }); it('supersedes an older retry loop after system resume', async () => { const { getAcpClient, reconnectAcpAfterSystemResume } = await import('../acpConnection'); await getAcpClient(); - sdk.instances[0].resolveClosed(); + mockClientFactory.instances[0].resolveClosed(); await Promise.resolve(); reconnectAcpAfterSystemResume(); await getAcpClient(); - expect(sdk.instances).toHaveLength(2); + expect(mockClientFactory.instances).toHaveLength(2); await vi.advanceTimersByTimeAsync(250); - expect(sdk.instances).toHaveLength(2); + expect(mockClientFactory.instances).toHaveLength(2); }); it('notifies subscribers while reconnecting and after recovery', async () => { @@ -225,7 +237,7 @@ describe('ACP connection ownership', () => { subscribeToAcpRecovery(listener); await getAcpClient(); - sdk.instances[0].resolveClosed(); + mockClientFactory.instances[0].resolveClosed(); await Promise.resolve(); expect(listener).toHaveBeenCalledWith(true); diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts index c3fb846c6..e66ef999f 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -1,8 +1,4 @@ -import type { - CreateElicitationRequest, - RequestPermissionRequest, - SessionNotification, -} from '@agentclientprotocol/sdk'; +import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; import { act, renderHook } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import type { Message } from '../../types/message'; @@ -15,6 +11,7 @@ import { acpChatSessionStore, useAcpChatSessionSnapshot, } from '../chatSessionStore'; +import type { AcpElicitationRequest } from '../elicitationRequests'; function message(id: string, text: string): Message { return { @@ -67,14 +64,7 @@ function permissionRequest(sessionId: string, toolCallId = 'tool-1'): RequestPer }; } -function elicitationRequest(sessionId: string): { - id: string; - sessionId: string; - request: CreateElicitationRequest & { - mode: 'form'; - sessionId: string; - }; -} { +function elicitationRequest(sessionId: string): AcpElicitationRequest { return { id: 'acp_elicitation_1', sessionId, diff --git a/ui/desktop/src/acp/__tests__/createWebSocketStream.test.ts b/ui/desktop/src/acp/__tests__/createWebSocketStream.test.ts deleted file mode 100644 index 071edf9e9..000000000 --- a/ui/desktop/src/acp/__tests__/createWebSocketStream.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createWebSocketStream } from '../createWebSocketStream'; - -class FakeWebSocket extends window.EventTarget { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readonly sent: string[] = []; - readyState = FakeWebSocket.CONNECTING; - - constructor(readonly url: string) { - super(); - fakeWebSockets.push(this); - } - - open(): void { - this.readyState = FakeWebSocket.OPEN; - this.dispatchEvent(new Event('open')); - } - - send(message: string): void { - this.sent.push(message); - } - - close(): void { - if (this.readyState === FakeWebSocket.CLOSED) { - return; - } - this.readyState = FakeWebSocket.CLOSED; - this.dispatchEvent(new Event('close')); - } - - fail(): void { - this.dispatchEvent(new Event('error')); - this.close(); - } -} - -const fakeWebSockets: FakeWebSocket[] = []; - -function latestWebSocket(): FakeWebSocket { - const ws = fakeWebSockets[fakeWebSockets.length - 1]; - if (!ws) { - throw new Error('Expected a WebSocket to be created'); - } - return ws; -} - -function testRequest() { - return { - jsonrpc: '2.0' as const, - id: 1, - method: 'test', - }; -} - -describe('createWebSocketStream', () => { - beforeEach(() => { - fakeWebSockets.length = 0; - vi.stubGlobal('WebSocket', FakeWebSocket); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('waits for the socket to open before sending JSON', async () => { - const stream = createWebSocketStream('ws://localhost/acp'); - const writer = stream.writable.getWriter(); - const write = writer.write(testRequest()); - const ws = latestWebSocket(); - - expect(ws.sent).toEqual([]); - - ws.open(); - await write; - - expect(ws.sent).toEqual(['{"jsonrpc":"2.0","id":1,"method":"test"}']); - }); - - it('closes the readable stream when the socket closes', async () => { - const stream = createWebSocketStream('ws://localhost/acp'); - const reader = stream.readable.getReader(); - const ws = latestWebSocket(); - - ws.open(); - ws.close(); - - await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }); - }); - - it.each([ - { - event: 'closes', - trigger: (ws: FakeWebSocket) => ws.close(), - error: 'ACP WebSocket closed before connection opened', - }, - { - event: 'errors', - trigger: (ws: FakeWebSocket) => ws.fail(), - error: 'ACP WebSocket connection failed', - }, - ])( - 'rejects a pending write when the socket $event before opening', - async ({ trigger, error }) => { - const stream = createWebSocketStream('ws://localhost/acp'); - const writer = stream.writable.getWriter(); - const write = writer.write(testRequest()); - - trigger(latestWebSocket()); - - await expect(write).rejects.toThrow(error); - } - ); - - it('rejects a write when the socket has closed', async () => { - const stream = createWebSocketStream('ws://localhost/acp'); - const writer = stream.writable.getWriter(); - const ws = latestWebSocket(); - - ws.open(); - ws.close(); - - await expect(writer.write(testRequest())).rejects.toThrow('ACP WebSocket connection lost'); - }); -}); diff --git a/ui/desktop/src/acp/__tests__/gooseAcpClient.test.ts b/ui/desktop/src/acp/__tests__/gooseAcpClient.test.ts new file mode 100644 index 000000000..995b2a37e --- /dev/null +++ b/ui/desktop/src/acp/__tests__/gooseAcpClient.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AnyMessage, Stream } from '@agentclientprotocol/sdk'; +import { connectGooseAcpClient, type GooseAcpCallbacks } from '../gooseAcpClient'; + +function createTestStream(): Stream & { + push(message: AnyMessage): void; + writes: AnyMessage[]; +} { + let controller: ReadableStreamDefaultController | undefined; + const writes: AnyMessage[] = []; + + return { + readable: new ReadableStream({ + start(nextController) { + controller = nextController; + }, + }), + writable: new WritableStream({ + write(message) { + writes.push(message); + }, + }), + push(message) { + controller?.enqueue(message); + }, + writes, + }; +} + +function callbacks(): GooseAcpCallbacks { + return { + requestPermission: vi.fn().mockResolvedValue({ + outcome: { outcome: 'selected', optionId: 'allow' }, + }), + sessionUpdate: vi.fn(), + unstable_createElicitation: vi.fn(), + unstable_sessionRecipeRequestParams: vi.fn().mockResolvedValue({ + action: 'submit', + values: { name: 'Ada' }, + }), + unstable_sessionUpdate: vi.fn(), + }; +} + +async function waitForWrites(stream: { writes: AnyMessage[] }, count: number): Promise { + await vi.waitFor(() => { + expect(stream.writes).toHaveLength(count); + }); +} + +describe('Goose ACP client composition', () => { + it('registers standard and Goose-specific handlers on a live ACP connection', async () => { + const stream = createTestStream(); + const handlers = callbacks(); + const client = connectGooseAcpClient(stream, handlers); + + stream.push({ + jsonrpc: '2.0', + id: 1, + method: 'session/request_permission', + params: { + sessionId: 'session-1', + toolCall: { toolCallId: 'tool-1' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }, + }); + await waitForWrites(stream, 1); + + expect(handlers.requestPermission).toHaveBeenCalledOnce(); + expect(stream.writes[0]).toMatchObject({ + id: 1, + result: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }); + + stream.push({ + jsonrpc: '2.0', + method: '_goose/unstable/session/update', + params: { + sessionId: 'session-1', + update: { + sessionUpdate: 'status_message', + status: { type: 'notice', message: 'ready' }, + }, + }, + }); + + await vi.waitFor(() => { + expect(handlers.unstable_sessionUpdate).toHaveBeenCalledOnce(); + }); + + stream.push({ + jsonrpc: '2.0', + id: 2, + method: '_goose/unstable/session/recipe/request-params', + params: { + sessionId: 'session-1', + parameters: [ + { + key: 'name', + input_type: 'string', + requirement: 'user_prompt', + description: 'Name', + }, + ], + }, + }); + + await waitForWrites(stream, 2); + expect(handlers.unstable_sessionRecipeRequestParams).toHaveBeenCalledOnce(); + expect(stream.writes[1]).toMatchObject({ + id: 2, + result: { action: 'submit', values: { name: 'Ada' } }, + }); + + const toolsRequest = client.goose.toolsList_unstable({ sessionId: 'session-1' }); + await waitForWrites(stream, 3); + const outboundRequest = stream.writes[2] as { id: number; method: string }; + expect(outboundRequest.method).toBe('_goose/unstable/tools/list'); + stream.push({ + jsonrpc: '2.0', + id: outboundRequest.id, + result: { tools: [] }, + }); + await expect(toolsRequest).resolves.toEqual({ tools: [] }); + + client.connection.close(); + await client.connection.closed; + }); +}); diff --git a/ui/desktop/src/acp/__tests__/providers.test.ts b/ui/desktop/src/acp/__tests__/providers.test.ts index 0d419e244..544e62032 100644 --- a/ui/desktop/src/acp/__tests__/providers.test.ts +++ b/ui/desktop/src/acp/__tests__/providers.test.ts @@ -1,3 +1,4 @@ +import { methods } from '@agentclientprotocol/sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getAcpClient } from '../acpConnection'; import { acpSetSessionProviderModel } from '../providers'; @@ -23,27 +24,31 @@ describe('ACP providers', () => { it('sets thinking effort after provider and model, then returns the final config response', async () => { const client = { - setSessionConfigOption: vi - .fn() - .mockResolvedValueOnce({ - configOptions: [ - selectConfigOption('provider', 'anthropic'), - selectConfigOption('model', 'provider-default-model'), - ], - }) - .mockResolvedValueOnce({ - configOptions: [ - selectConfigOption('provider', 'anthropic'), - selectConfigOption('model', 'claude-sonnet-4-5'), - ], - }) - .mockResolvedValueOnce({ - configOptions: [ - selectConfigOption('provider', 'anthropic'), - selectConfigOption('model', 'claude-sonnet-4-5'), - selectConfigOption('thinking_effort', 'high'), - ], - }), + connection: { + agent: { + request: vi + .fn() + .mockResolvedValueOnce({ + configOptions: [ + selectConfigOption('provider', 'anthropic'), + selectConfigOption('model', 'provider-default-model'), + ], + }) + .mockResolvedValueOnce({ + configOptions: [ + selectConfigOption('provider', 'anthropic'), + selectConfigOption('model', 'claude-sonnet-4-5'), + ], + }) + .mockResolvedValueOnce({ + configOptions: [ + selectConfigOption('provider', 'anthropic'), + selectConfigOption('model', 'claude-sonnet-4-5'), + selectConfigOption('thinking_effort', 'high'), + ], + }), + }, + }, }; vi.mocked(getAcpClient).mockResolvedValue( client as unknown as Awaited> @@ -56,22 +61,34 @@ describe('ACP providers', () => { 'high' ); - expect(client.setSessionConfigOption).toHaveBeenCalledTimes(3); - expect(client.setSessionConfigOption).toHaveBeenNthCalledWith(1, { - sessionId: 'session-1', - configId: 'provider', - value: 'anthropic', - }); - expect(client.setSessionConfigOption).toHaveBeenNthCalledWith(2, { - sessionId: 'session-1', - configId: 'model', - value: 'claude-sonnet-4-5', - }); - expect(client.setSessionConfigOption).toHaveBeenNthCalledWith(3, { - sessionId: 'session-1', - configId: 'thinking_effort', - value: 'high', - }); + expect(client.connection.agent.request).toHaveBeenCalledTimes(3); + expect(client.connection.agent.request).toHaveBeenNthCalledWith( + 1, + methods.agent.session.setConfigOption, + { + sessionId: 'session-1', + configId: 'provider', + value: 'anthropic', + } + ); + expect(client.connection.agent.request).toHaveBeenNthCalledWith( + 2, + methods.agent.session.setConfigOption, + { + sessionId: 'session-1', + configId: 'model', + value: 'claude-sonnet-4-5', + } + ); + expect(client.connection.agent.request).toHaveBeenNthCalledWith( + 3, + methods.agent.session.setConfigOption, + { + sessionId: 'session-1', + configId: 'thinking_effort', + value: 'high', + } + ); expect(applied).toEqual({ providerId: 'anthropic', modelId: 'claude-sonnet-4-5', diff --git a/ui/desktop/src/acp/__tests__/sessions.test.ts b/ui/desktop/src/acp/__tests__/sessions.test.ts index a88d17677..f18c93079 100644 --- a/ui/desktop/src/acp/__tests__/sessions.test.ts +++ b/ui/desktop/src/acp/__tests__/sessions.test.ts @@ -1,4 +1,4 @@ -import type { SessionInfo } from '@agentclientprotocol/sdk'; +import { methods, type SessionInfo } from '@agentclientprotocol/sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getAcpClient } from '../acpConnection'; import { @@ -54,13 +54,17 @@ describe('ACP sessions', () => { }, }); const client = { + connection: { + agent: { + request: vi.fn().mockResolvedValue({}), + }, + }, goose: { sessionInfo_unstable: vi .fn() .mockResolvedValueOnce({ session: sessionInfo() }) .mockResolvedValueOnce({ session: loadedSessionInfo }), }, - loadSession: vi.fn().mockResolvedValue({}), }; vi.mocked(getAcpClient).mockResolvedValue( client as unknown as Awaited> @@ -68,7 +72,7 @@ describe('ACP sessions', () => { const result = await acpLoadSession('session-1'); - expect(client.loadSession).toHaveBeenCalledWith({ + expect(client.connection.agent.request).toHaveBeenCalledWith(methods.agent.session.load, { sessionId: 'session-1', cwd: '/tmp', mcpServers: [], @@ -84,10 +88,14 @@ describe('ACP sessions', () => { it('carries the recipe parameter scope id in new-session metadata', async () => { const createdSessionInfo = sessionInfo(); const client = { + connection: { + agent: { + request: vi.fn().mockResolvedValue({ sessionId: 'session-1' }), + }, + }, goose: { sessionInfo_unstable: vi.fn().mockResolvedValue({ session: createdSessionInfo }), }, - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }), }; vi.mocked(getAcpClient).mockResolvedValue( client as unknown as Awaited> @@ -98,7 +106,7 @@ describe('ACP sessions', () => { recipeParameterScopeId: 'scope-1', }); - expect(client.newSession).toHaveBeenCalledWith({ + expect(client.connection.agent.request).toHaveBeenCalledWith(methods.agent.session.new, { cwd: '/tmp', mcpServers: [], _meta: { diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts index 6f394536d..ffa77a53b 100644 --- a/ui/desktop/src/acp/acpConnection.ts +++ b/ui/desktop/src/acp/acpConnection.ts @@ -1,23 +1,23 @@ -import { - DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, - GooseClient, - type GooseClientCallbacks, -} from '@aaif/goose-sdk'; -import { PROTOCOL_VERSION, type InitializeResponse } from '@agentclientprotocol/sdk'; +import { DEFAULT_GOOSE_MCP_HOST_CAPABILITIES } from '@aaif/goose-sdk'; +import { methods, PROTOCOL_VERSION, type InitializeResponse } from '@agentclientprotocol/sdk'; +import { createWebSocketStream } from '@agentclientprotocol/sdk/experimental/ws-client'; import packageJson from '../../package.json'; import { GOOSE_SERVE_EXITED_USER_MESSAGE } from '../gooseServeLeaseRegistry'; import { handleAcpGooseSessionNotification, handleAcpSessionNotification, } from './chatNotifications'; -import { createWebSocketStream } from './createWebSocketStream'; import { requestAcpElicitation } from './elicitationRequests'; +import { + connectGooseAcpClient, + type GooseAcpCallbacks, + type GooseAcpClient, +} from './gooseAcpClient'; import { requestAcpPermission } from './permissionRequests'; import { requestAcpRecipeParams } from './recipeParamRequests'; type AcpConnection = { - client: GooseClient; - stream: ReturnType; + client: GooseAcpClient; initializeResponse: InitializeResponse; }; @@ -33,7 +33,7 @@ let connectionGeneration = 0; let recovering = false; const recoveryListeners = new Set(); -export async function getAcpClient(): Promise { +export async function getAcpClient(): Promise { return (await getConnection()).client; } @@ -77,7 +77,7 @@ function recoverConnection(immediate: boolean): void { connectionGeneration += 1; currentConnection = null; pendingConnection = null; - previousConnection?.stream.close(); + previousConnection?.client.connection.close(); const generation = connectionGeneration; const recoveryAttempt = immediate @@ -132,12 +132,13 @@ async function openConnection(generation: number): Promise { throw new Error('ACP URL is not available'); } - const stream = createWebSocketStream(wsUrl); - const client = new GooseClient(createClientCallbacks(), stream); + // Electron treats an explicitly passed undefined protocol as a subprotocol. + const stream = createWebSocketStream(wsUrl, { protocols: [] }); + const client = connectGooseAcpClient(stream, createClientCallbacks()); try { const initializeResponse = await withTimeout( - client.initialize({ + client.connection.agent.request(methods.agent.initialize, { protocolVersion: PROTOCOL_VERSION, _meta: { 'goose/useLoginShellPath': true, @@ -165,17 +166,17 @@ async function openConnection(generation: number): Promise { throw new Error('ACP connection attempt is no longer current'); } - const connection = { client, stream, initializeResponse }; + const connection = { client, initializeResponse }; currentConnection = connection; const handleClose = () => { if (currentConnection === connection) { recoverConnection(false); } }; - connection.client.closed.then(handleClose, handleClose); + connection.client.connection.closed.then(handleClose, handleClose); return connection; } catch (error) { - stream.close(); + client.connection.close(error); throw error; } } @@ -212,14 +213,14 @@ function delay(delayMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)); } -function createClientCallbacks(): () => GooseClientCallbacks { - return () => ({ +function createClientCallbacks(): GooseAcpCallbacks { + return { requestPermission: requestAcpPermission, unstable_createElicitation: requestAcpElicitation, unstable_sessionRecipeRequestParams: requestAcpRecipeParams, sessionUpdate: handleAcpSessionNotification, unstable_sessionUpdate: handleAcpGooseSessionNotification, - }); + }; } async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { diff --git a/ui/desktop/src/acp/createWebSocketStream.ts b/ui/desktop/src/acp/createWebSocketStream.ts deleted file mode 100644 index 9614ce928..000000000 --- a/ui/desktop/src/acp/createWebSocketStream.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { Stream } from '@aaif/goose-sdk'; - -export type ClosableAcpStream = Stream & { - close: () => void; -}; - -export function createWebSocketStream(wsUrl: string): ClosableAcpStream { - const ws = new window.WebSocket(wsUrl); - - const incoming: unknown[] = []; - const waiters: Array<() => void> = []; - let closed = false; - - function pushMessage(message: unknown): void { - incoming.push(message); - waiters.shift()?.(); - } - - function waitForMessage(): Promise { - if (incoming.length > 0 || closed) { - return Promise.resolve(); - } - return new Promise((resolve) => waiters.push(resolve)); - } - - const openPromise = new Promise((resolve, reject) => { - ws.addEventListener('open', () => resolve(), { once: true }); - ws.addEventListener('error', () => reject(new Error('ACP WebSocket connection failed')), { - once: true, - }); - ws.addEventListener( - 'close', - () => reject(new Error('ACP WebSocket closed before connection opened')), - { once: true } - ); - }); - - ws.addEventListener('message', (event) => { - if (typeof event.data !== 'string') { - return; - } - try { - pushMessage(JSON.parse(event.data)); - } catch { - // Ignore malformed messages from the transport. - } - }); - - const closeWaiters = () => { - closed = true; - for (const waiter of waiters) { - waiter(); - } - waiters.length = 0; - }; - - ws.addEventListener('close', closeWaiters); - ws.addEventListener('error', closeWaiters); - - const readable = new window.ReadableStream({ - async pull(controller) { - await waitForMessage(); - while (incoming.length > 0) { - controller.enqueue(incoming.shift()); - } - if (closed && incoming.length === 0) { - controller.close(); - } - }, - }); - - const writable = new window.WritableStream({ - async write(message) { - await openPromise; - if (closed || ws.readyState !== window.WebSocket.OPEN) { - throw new Error('ACP WebSocket connection lost'); - } - ws.send(JSON.stringify(message)); - }, - close() { - ws.close(); - }, - abort() { - ws.close(); - }, - }); - - return { - readable, - writable, - close: () => ws.close(), - } as ClosableAcpStream; -} diff --git a/ui/desktop/src/acp/gooseAcpClient.ts b/ui/desktop/src/acp/gooseAcpClient.ts new file mode 100644 index 000000000..03c4f091e --- /dev/null +++ b/ui/desktop/src/acp/gooseAcpClient.ts @@ -0,0 +1,61 @@ +import { + client, + methods, + type Client, + type ClientConnection, + type Stream, +} from '@agentclientprotocol/sdk'; +import { + GOOSE_EXT_AGENT_REQUESTS, + GOOSE_EXT_NOTIFICATIONS, + GooseExtClient, + type GooseSessionNotification_unstable, + type RecipeParamsResponse_unstable, + type RequestRecipeParams_unstable, + zGooseSessionNotification_unstable, + zRequestRecipeParams_unstable, +} from '@aaif/goose-sdk'; + +const [gooseSessionUpdate] = GOOSE_EXT_NOTIFICATIONS; +const [gooseRecipeParamsRequest] = GOOSE_EXT_AGENT_REQUESTS; + +export type GooseAcpCallbacks = Required< + Pick +> & { + unstable_sessionRecipeRequestParams: ( + request: RequestRecipeParams_unstable + ) => Promise; + unstable_sessionUpdate: (notification: GooseSessionNotification_unstable) => Promise; +}; + +export type GooseAcpClient = { + connection: ClientConnection; + goose: GooseExtClient; +}; + +export function connectGooseAcpClient( + stream: Stream, + callbacks: GooseAcpCallbacks +): GooseAcpClient { + const app = client({ name: 'goose' }) + .onRequest(methods.client.session.requestPermission, (context) => + callbacks.requestPermission(context.params) + ) + .onNotification(methods.client.session.update, (context) => + callbacks.sessionUpdate(context.params) + ) + .onRequest(methods.client.elicitation.create, (context) => + callbacks.unstable_createElicitation(context.params) + ) + .onRequest(gooseRecipeParamsRequest.method, zRequestRecipeParams_unstable, (context) => + callbacks.unstable_sessionRecipeRequestParams(context.params) + ) + .onNotification(gooseSessionUpdate.method, zGooseSessionNotification_unstable, (context) => + callbacks.unstable_sessionUpdate(context.params) + ); + + const connection = app.connect(stream); + const goose = new GooseExtClient(connection.agent); + + return { connection, goose }; +} diff --git a/ui/desktop/src/acp/prompt.ts b/ui/desktop/src/acp/prompt.ts index 93fb13ba2..872b5e0e2 100644 --- a/ui/desktop/src/acp/prompt.ts +++ b/ui/desktop/src/acp/prompt.ts @@ -1,4 +1,4 @@ -import type { ContentBlock, PromptResponse } from '@agentclientprotocol/sdk'; +import { methods, type ContentBlock, type PromptResponse } from '@agentclientprotocol/sdk'; import type { SteerSessionRequest_unstable, SteerSessionResponse_unstable } from '@aaif/goose-sdk'; import type { Message } from '../types/message'; import { getAcpClient } from './acpConnection'; @@ -8,7 +8,7 @@ export async function acpPromptSession( message: Message ): Promise { const client = await getAcpClient(); - return client.prompt({ + return client.connection.agent.request(methods.agent.session.prompt, { sessionId, prompt: messageToAcpPromptContent(message), }); @@ -16,7 +16,7 @@ export async function acpPromptSession( export async function acpCancelPrompt(sessionId: string): Promise { const client = await getAcpClient(); - await client.cancel({ sessionId }); + await client.connection.agent.notify(methods.agent.session.cancel, { sessionId }); } export async function acpSteerSession( diff --git a/ui/desktop/src/acp/providers.ts b/ui/desktop/src/acp/providers.ts index e9e967f40..471d4ce87 100644 --- a/ui/desktop/src/acp/providers.ts +++ b/ui/desktop/src/acp/providers.ts @@ -6,7 +6,12 @@ import type { ProviderTemplateCatalogEntryDto, ProviderTemplateDto, } from '@aaif/goose-sdk'; -import type { ProviderDetails, ThinkingEffort, UpdateCustomProviderRequest } from '../types/providers'; +import { methods } from '@agentclientprotocol/sdk'; +import type { + ProviderDetails, + ThinkingEffort, + UpdateCustomProviderRequest, +} from '../types/providers'; import { getAcpClient } from './acpConnection'; export type { CanonicalModelInfoDto, ProviderSecretDto }; @@ -260,20 +265,20 @@ export async function acpSetSessionProviderModel( thinkingEffort?: ThinkingEffort | null ): Promise { const client = await getAcpClient(); - let response = await client.setSessionConfigOption({ + let response = await client.connection.agent.request(methods.agent.session.setConfigOption, { sessionId, configId: 'provider', value: providerId, }); if (modelId) { - response = await client.setSessionConfigOption({ + response = await client.connection.agent.request(methods.agent.session.setConfigOption, { sessionId, configId: 'model', value: modelId, }); } if (thinkingEffort != null) { - response = await client.setSessionConfigOption({ + response = await client.connection.agent.request(methods.agent.session.setConfigOption, { sessionId, configId: 'thinking_effort', value: thinkingEffort, diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index 17fdfc9eb..773409711 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -1,9 +1,10 @@ -import type { - ForkSessionRequest, - ListSessionsRequest, - LoadSessionResponse, - NewSessionRequest, - SessionInfo, +import { + methods, + type ForkSessionRequest, + type ListSessionsRequest, + type LoadSessionResponse, + type NewSessionRequest, + type SessionInfo, } from '@agentclientprotocol/sdk'; import type { GooseExtension, SessionExportFormat, SessionImportSource } from '@aaif/goose-sdk'; import { getAcpClient } from './acpConnection'; @@ -151,7 +152,7 @@ export async function acpListSessions( meta.query = keyword; } request._meta = meta; - const response = await client.listSessions(request); + const response = await client.connection.agent.request(methods.agent.session.list, request); return { sessions: response.sessions.map(sessionInfoToListItem), nextCursor: response.nextCursor ?? null, @@ -164,7 +165,9 @@ export async function acpListRecentSessions(maxSessions: number): Promise const client = await getAcpClient(); const initialSessionInfoResponse = await client.goose.sessionInfo_unstable({ sessionId }); const initialSessionInfo = initialSessionInfoResponse.session; - const response = await client.loadSession({ + const response = await client.connection.agent.request(methods.agent.session.load, { sessionId, cwd: initialSessionInfo.cwd, mcpServers: [], @@ -245,7 +248,7 @@ export async function acpNewSession( meta.recipeParameterScopeId = recipe.recipeParameterScopeId; } const request: NewSessionRequest = { cwd, mcpServers: [], _meta: meta }; - const response = await client.newSession(request); + const response = await client.connection.agent.request(methods.agent.session.new, request); const sessionId = String(response.sessionId); const sessionInfoResponse = await client.goose.sessionInfo_unstable({ sessionId }); @@ -258,12 +261,12 @@ export async function acpNewSession( export async function acpDeleteSession(sessionId: string): Promise { const client = await getAcpClient(); - await client.goose.sessionDelete({ sessionId }); + await client.connection.agent.request(methods.agent.session.delete, { sessionId }); } export async function acpCloseSession(sessionId: string): Promise { const client = await getAcpClient(); - await client.unstable_closeSession({ sessionId }); + await client.connection.agent.request(methods.agent.session.close, { sessionId }); } export async function acpRenameSession(sessionId: string, title: string): Promise { @@ -295,7 +298,7 @@ export async function acpForkSession( if (conversationBefore !== undefined) { request._meta = { conversationBefore }; } - const response = await client.unstable_forkSession(request); + const response = await client.connection.agent.request(methods.agent.session.fork, request); return String(response.sessionId); } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index aca76ed58..c8fd00cf8 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: workspace:* version: link:../sdk '@agentclientprotocol/sdk': - specifier: ^0.19.0 - version: 0.19.0(zod@3.25.76) + specifier: ^1.3.0 + version: 1.3.0(zod@3.25.76) '@mcp-ui/client': specifier: 6.1.0 version: 6.1.0(@preact/signals-core@1.14.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -358,8 +358,8 @@ importers: version: 3.25.76 devDependencies: '@agentclientprotocol/sdk': - specifier: ^0.19.0 - version: 0.19.0(zod@3.25.76) + specifier: ^1.3.0 + version: 1.3.0(zod@3.25.76) '@hey-api/openapi-ts': specifier: ^0.92.3 version: 0.92.4(magicast@0.5.2)(typescript@5.9.3) @@ -400,8 +400,8 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - '@agentclientprotocol/sdk@0.19.0': - resolution: {integrity: sha512-U9I8ws9WTOk6jCBAWpXefGSDgVXn14/kV6HFzwWGcstQ02mOQgClMAROHmoIn9GqZbDBDEOkdIbP4P4TEMQdug==} + '@agentclientprotocol/sdk@1.3.0': + resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -6879,7 +6879,7 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@agentclientprotocol/sdk@0.19.0(zod@3.25.76)': + '@agentclientprotocol/sdk@1.3.0(zod@3.25.76)': dependencies: zod: 3.25.76 diff --git a/ui/scripts/publish.sh b/ui/scripts/publish.sh index 331ddc894..8c23ae6e8 100755 --- a/ui/scripts/publish.sh +++ b/ui/scripts/publish.sh @@ -209,7 +209,7 @@ trap cleanup_npmrc EXIT # Publish order matters: dependencies first echo "==> Publishing @aaif/goose-sdk" -(cd "${REPO_ROOT}/ui" && pnpm publish "${PUBLISH_ARGS[@]}" acp) +(cd "${REPO_ROOT}/ui" && pnpm publish "${PUBLISH_ARGS[@]}" sdk) echo "==> Publishing native binary packages" for plat in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do diff --git a/ui/sdk/README.md b/ui/sdk/README.md index 32dd40096..a345ce74b 100644 --- a/ui/sdk/README.md +++ b/ui/sdk/README.md @@ -3,13 +3,14 @@ TypeScript client library for the Goose Agent Client Protocol (ACP). This package provides: + - TypeScript types and Zod validators for Goose ACP extension methods - A client for communicating with the Goose ACP server ## Installation ```bash -npm install @aaif/goose-sdk +npm install @aaif/goose-sdk @agentclientprotocol/sdk ``` The native `goose` binaries are distributed as optional dependencies @@ -76,13 +77,13 @@ npm run build:schema Platform-specific npm packages for the `goose` binary are located in `ui/goose-binary/`: -| Package | Platform | -|---------|----------| +| Package | Platform | +| --------------------------------- | ------------------- | | `@aaif/goose-binary-darwin-arm64` | macOS Apple Silicon | -| `@aaif/goose-binary-darwin-x64` | macOS Intel | -| `@aaif/goose-binary-linux-arm64` | Linux ARM64 | -| `@aaif/goose-binary-linux-x64` | Linux x64 | -| `@aaif/goose-binary-win32-x64` | Windows x64 | +| `@aaif/goose-binary-darwin-x64` | macOS Intel | +| `@aaif/goose-binary-linux-arm64` | Linux ARM64 | +| `@aaif/goose-binary-linux-x64` | Linux x64 | +| `@aaif/goose-binary-win32-x64` | Windows x64 | These are published separately from `@aaif/goose-sdk`. @@ -111,20 +112,36 @@ For manual publishing: ``` This will: + 1. Build and publish `@aaif/goose-sdk` 2. Publish all native binary packages ## Usage -```typescript -import { GooseClient } from "@aaif/goose-sdk"; +Compose the ACP client with the standard ACP SDK, then use `GooseExtClient` for +typed Goose extension methods: -const client = new GooseClient({ - // ... configuration +```typescript +import { + client as createAcpClient, + methods, + PROTOCOL_VERSION, +} from "@agentclientprotocol/sdk"; +import { createWebSocketStream } from "@agentclientprotocol/sdk/experimental/ws-client"; +import { GooseExtClient } from "@aaif/goose-sdk"; + +const app = createAcpClient({ name: "my-client" }); +const stream = createWebSocketStream("ws://localhost:3000/acp"); +const connection = app.connect(stream); +const goose = new GooseExtClient(connection.agent); + +await connection.agent.request(methods.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "my-client", version: "1.0.0" }, + clientCapabilities: {}, }); -// Use the client -const result = await client.someMethod({ ... }); +const providers = await goose.providersList_unstable({ providerIds: [] }); ``` See the [main documentation](../../README.md) for more details. diff --git a/ui/sdk/generate-schema.ts b/ui/sdk/generate-schema.ts index b9dc61120..a7b4426e0 100644 --- a/ui/sdk/generate-schema.ts +++ b/ui/sdk/generate-schema.ts @@ -7,7 +7,6 @@ */ import { createClient } from "@hey-api/openapi-ts"; -import { execSync } from "child_process"; import * as fs from "fs/promises"; import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; @@ -138,38 +137,6 @@ interface MethodMeta { responseType: string | null; } -interface NotificationMeta { - method: string; - paramsType: string | null; -} - -interface AgentRequestMeta { - method: string; - requestType: string | null; - responseType: string | null; -} - -function methodToHandlerName(method: string): string { - let methodParts = method.split(/[/_]/).filter((part) => part.length > 0); - let prefix = ""; - if (methodParts[0] == "goose" && methodParts[1] == "unstable") { - methodParts.shift(); - methodParts.shift(); - prefix = "unstable_"; - } else if (methodParts[0] == "goose") { - methodParts.shift(); - } - const body = methodParts - .map((part) => - part.replace(/[^a-zA-Z0-9]+(.)/g, (_, chr: string) => chr.toUpperCase()), - ) - .map((part, i) => - i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1), - ) - .join(""); - return `${prefix}${body}`; -} - function methodToCamelCase(method: string): string { let methodParts = method.split(/[/_]/).filter((part) => part.length > 0); @@ -194,14 +161,10 @@ function methodToCamelCase(method: string): string { return `${prefix}${suffix}`; } -async function generateClient(meta: { - methods: MethodMeta[]; - notifications?: NotificationMeta[]; - agentRequests?: AgentRequestMeta[]; -}) { +async function generateClient(meta: { methods: MethodMeta[] }) { const typeImports = new Set(); const zodImports = new Set(); - const upstreamTypeImports = new Set(["Client"]); + const upstreamTypeImports = new Set(["ClientContext"]); const methodDefs: string[] = []; @@ -228,18 +191,16 @@ async function generateClient(meta: { zodImports.add(zodName); returnType = m.responseType; bodyLines = [ - `const raw = await this.conn.extMethod("${fullMethod}", ${callParams});`, + `const raw = await this.conn.request("${fullMethod}", ${callParams});`, `return ${zodName}.parse(raw) as ${returnType};`, ]; } else if (m.responseType === "EmptyResponse") { returnType = "void"; - bodyLines = [ - `await this.conn.extMethod("${fullMethod}", ${callParams});`, - ]; + bodyLines = [`await this.conn.request("${fullMethod}", ${callParams});`]; } else { returnType = "Record"; bodyLines = [ - `return await this.conn.extMethod("${fullMethod}", ${callParams ? callParams : "{}"});`, + `return await this.conn.request>("${fullMethod}", ${callParams ? callParams : "{}"});`, ]; } @@ -249,134 +210,6 @@ async function generateClient(meta: { }`); } - const handlerFields: string[] = []; - const dispatchCases: string[] = []; - - for (const n of meta.notifications ?? []) { - const handlerName = methodToHandlerName(n.method); - if (!n.paramsType) { - handlerFields.push( - ` ${handlerName}?: (params: Record) => Promise;`, - ); - dispatchCases.push( - ` case "${n.method}": { - await callbacks.${handlerName}?.(params); - return; - }`, - ); - continue; - } - typeImports.add(n.paramsType); - const zodName = `z${n.paramsType}`; - zodImports.add(zodName); - handlerFields.push( - ` ${handlerName}?: (notification: ${n.paramsType}) => Promise;`, - ); - dispatchCases.push( - ` case "${n.method}": { - const parsed = ${zodName}.parse(params) as ${n.paramsType}; - await callbacks.${handlerName}?.(parsed); - return; - }`, - ); - } - - const agentRequestHandlerFields: string[] = []; - const agentRequestDispatchCases: string[] = []; - - for (const r of meta.agentRequests ?? []) { - const handlerName = methodToHandlerName(r.method); - const argType = r.requestType ?? "Record"; - const retType = r.responseType ?? "Record"; - - if (r.requestType) typeImports.add(r.requestType); - if (r.responseType) typeImports.add(r.responseType); - - agentRequestHandlerFields.push( - ` ${handlerName}?: (request: ${argType}) => Promise<${retType}>;`, - ); - - const parseLine = r.requestType - ? (() => { - zodImports.add(`z${r.requestType}`); - return `const parsed = z${r.requestType}.parse(params) as ${r.requestType};`; - })() - : `const parsed = params as Record;`; - - agentRequestDispatchCases.push( - ` case "${r.method}": { - if (callbacks.${handlerName}) { - ${parseLine} - return await callbacks.${handlerName}(parsed); - } - if (callbacks.extMethod) { - return await callbacks.extMethod(method, params); - } - throw new Error(\`unhandled ext method: \${method}\`); - }`, - ); - } - - const handlersInterface = `export interface GooseExtNotifications { -${handlerFields.join("\n")} -}`; - - const agentRequestsInterface = `export interface GooseExtAgentRequests { -${agentRequestHandlerFields.join("\n")} -}`; - - const agentRequestDispatcherFn = `export function installGooseExtAgentRequestDispatcher( - callbacks: GooseClientCallbacks, -): Client { - const dispatcher: Pick = { - extMethod: async (method, params) => { - switch (method) { -${agentRequestDispatchCases.join("\n")} - default: - if (callbacks.extMethod) { - return await callbacks.extMethod(method, params); - } - throw new Error(\`unhandled ext method: \${method}\`); - } - }, - }; - return new Proxy(callbacks, { - get(target, property) { - if (property === "extMethod") { - return dispatcher.extMethod; - } - - const value = Reflect.get(target, property, target); - return typeof value === "function" ? value.bind(target) : value; - }, - }) as Client; -}`; - - const dispatcherFn = `export function installGooseExtNotificationDispatcher( - callbacks: GooseClientCallbacks, -): Client { - const dispatcher: Pick = { - extNotification: async (method, params) => { - switch (method) { -${dispatchCases.join("\n")} - default: - await callbacks.extNotification?.(method, params); - return; - } - }, - }; - return new Proxy(callbacks, { - get(target, property) { - if (property === "extNotification") { - return dispatcher.extNotification; - } - - const value = Reflect.get(target, property, target); - return typeof value === "function" ? value.bind(target) : value; - }, - }) as Client; -}`; - const upstreamImportLine = `import type { ${[...upstreamTypeImports].sort().join(", ")} } from "@agentclientprotocol/sdk";`; const typeImportLine = typeImports.size ? `import type { ${[...typeImports].sort().join(", ")} } from "./types.gen.js";` @@ -387,32 +220,14 @@ ${dispatchCases.join("\n")} let src = `// This file is auto-generated — do not edit manually. -export interface ExtMethodProvider { - extMethod(method: string, params: Record): Promise>; -} - ${upstreamImportLine} ${typeImportLine} ${zodImportLine} export class GooseExtClient { - constructor(private conn: ExtMethodProvider) {} + constructor(private conn: Pick) {} ${methodDefs.join("\n")} } - -${handlersInterface} - -${agentRequestsInterface} - -export type GooseClientCallbacks = - Omit & - Partial> & - GooseExtNotifications & - GooseExtAgentRequests; - -${dispatcherFn} - -${agentRequestDispatcherFn} `; src = await prettier.format(src, { parser: "typescript" }); diff --git a/ui/sdk/package.json b/ui/sdk/package.json index de6d236ee..9e1f7f954 100644 --- a/ui/sdk/package.json +++ b/ui/sdk/package.json @@ -37,8 +37,6 @@ "build:native:all": "tsx scripts/build-native.ts --all", "generate": "tsx generate-schema.ts", "lint": "tsc --noEmit", - "test": "node --import tsx --test tests/*.test.ts", - "typecheck:test": "tsc -p tsconfig.test.json --noEmit", "format": "prettier --write src/", "check:compat": "node scripts/check-binary-compat.mjs" }, @@ -48,7 +46,7 @@ "zod": "^3.25.76" }, "peerDependencies": { - "@agentclientprotocol/sdk": "^0.19.0" + "@agentclientprotocol/sdk": "^1.3.0" }, "optionalDependencies": { "@aaif/goose-binary-darwin-arm64": "workspace:*", @@ -58,7 +56,7 @@ "@aaif/goose-binary-win32-x64": "workspace:*" }, "devDependencies": { - "@agentclientprotocol/sdk": "^0.19.0", + "@agentclientprotocol/sdk": "^1.3.0", "@hey-api/openapi-ts": "^0.92.3", "@types/node": "^26.1.2", "prettier": "^3.8.1", diff --git a/ui/sdk/scripts/check-binary-compat.mjs b/ui/sdk/scripts/check-binary-compat.mjs index 08af96cb4..44ba41edf 100644 --- a/ui/sdk/scripts/check-binary-compat.mjs +++ b/ui/sdk/scripts/check-binary-compat.mjs @@ -35,10 +35,13 @@ if (!GOOSE_BINARY || !existsSync(GOOSE_BINARY)) { process.exit(1); } -const { GooseClient } = await import(join(SDK_DIST, "goose-client.js")); -const { PROTOCOL_VERSION, ndJsonStream } = await import( - "@agentclientprotocol/sdk" -); +const { GooseExtClient } = await import(join(SDK_DIST, "index.js")); +const { + client: createAcpClient, + methods, + PROTOCOL_VERSION, + ndJsonStream, +} = await import("@agentclientprotocol/sdk"); // Each entry is a read-only ACP method we expect to succeed against a fresh, // unconfigured goose install. Platform-specific skips keep hardware-sensitive @@ -123,15 +126,16 @@ const stream = ndJsonStream( Readable.toWeb(child.stdout), ); -const client = new GooseClient( - () => ({ - requestPermission: async () => ({ - outcome: { outcome: "cancelled" }, - }), - sessionUpdate: async () => {}, - }), - stream, -); +const app = createAcpClient({ name: "publish-npm-compat" }) + .onRequest(methods.client.session.requestPermission, async () => ({ + outcome: { outcome: "cancelled" }, + })) + .onNotification(methods.client.session.update, async () => {}); +const connection = app.connect(stream); +const client = { + connection, + goose: new GooseExtClient(connection.agent), +}; let failed = 0; let passed = 0; @@ -143,7 +147,7 @@ const timeout = (ms, label) => try { await Promise.race([ - client.initialize({ + client.connection.agent.request(methods.agent.initialize, { protocolVersion: PROTOCOL_VERSION, clientInfo: { name: "publish-npm-compat", version: "0.0.0" }, clientCapabilities: {}, @@ -164,13 +168,15 @@ try { passed += 1; } catch (err) { failed += 1; - const msg = err instanceof Error ? (err.stack ?? err.message) : String(err); + const msg = + err instanceof Error ? (err.stack ?? err.message) : String(err); console.error(`[compat] ❌ ${check.name}`); console.error(indent(msg, " ")); } } } finally { exitedEarly = true; + connection.close(); child.kill("SIGTERM"); try { rmSync(sandbox, { recursive: true, force: true }); diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index cb57b1ea5..0ffffce55 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -1,13 +1,6 @@ // This file is auto-generated — do not edit manually. -export interface ExtMethodProvider { - extMethod( - method: string, - params: Record, - ): Promise>; -} - -import type { Client } from "@agentclientprotocol/sdk"; +import type { ClientContext } from "@agentclientprotocol/sdk"; import type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, @@ -84,7 +77,6 @@ import type { GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, - GooseSessionNotification_unstable, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImportSessionRequest_unstable, @@ -163,7 +155,6 @@ import type { ProviderSupportedModelsListResponse_unstable, ReadResourceRequest_unstable, ReadResourceResponse_unstable, - RecipeParamsResponse_unstable, RecipeToYamlRequest_unstable, RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, @@ -171,7 +162,6 @@ import type { RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, - RequestRecipeParams_unstable, ResetPromptRequest_unstable, RunScheduleNowRequest_unstable, RunScheduleNowResponse_unstable, @@ -230,7 +220,6 @@ import { zGetSessionExtensionsResponse_unstable, zGetSessionInfoResponse_unstable, zGetToolsResponse_unstable, - zGooseSessionNotification_unstable, zGooseToolCallResponse_unstable, zImportSessionResponse_unstable, zImportSourcesResponse_unstable, @@ -268,7 +257,6 @@ import { zReadResourceResponse_unstable, zRecipeToYamlResponse_unstable, zRefreshProviderInventoryResponse_unstable, - zRequestRecipeParams_unstable, zRunScheduleNowResponse_unstable, zSaveRecipeResponse_unstable, zScanRecipeResponse_unstable, @@ -280,18 +268,18 @@ import { } from './zod.gen.js'; export class GooseExtClient { - constructor(private conn: ExtMethodProvider) {} + constructor(private conn: Pick) {} async sessionExtensionsAdd_unstable( params: AddSessionExtensionRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/session/extensions/add", params); + await this.conn.request("_goose/unstable/session/extensions/add", params); } async sessionExtensionsRemove_unstable( params: RemoveSessionExtensionRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/session/extensions/remove", params, ); @@ -300,14 +288,14 @@ export class GooseExtClient { async toolsList_unstable( params: GetToolsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod("_goose/unstable/tools/list", params); + const raw = await this.conn.request("_goose/unstable/tools/list", params); return zGetToolsResponse_unstable.parse(raw) as GetToolsResponse_unstable; } async toolsPermissionsSet_unstable( params: SetToolPermissionsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/tools/permissions/set", params, ); @@ -319,7 +307,7 @@ export class GooseExtClient { async toolsCall_unstable( params: GooseToolCallRequest_unstable, ): Promise { - const raw = await this.conn.extMethod("_goose/unstable/tools/call", params); + const raw = await this.conn.request("_goose/unstable/tools/call", params); return zGooseToolCallResponse_unstable.parse( raw, ) as GooseToolCallResponse_unstable; @@ -328,7 +316,7 @@ export class GooseExtClient { async resourcesRead_unstable( params: ReadResourceRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/resources/read", params, ); @@ -340,17 +328,14 @@ export class GooseExtClient { async appsList_unstable( params: AppsListRequest_unstable, ): Promise { - const raw = await this.conn.extMethod("_goose/unstable/apps/list", params); + const raw = await this.conn.request("_goose/unstable/apps/list", params); return zAppsListResponse_unstable.parse(raw) as AppsListResponse_unstable; } async appsExport_unstable( params: AppsExportRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/apps/export", - params, - ); + const raw = await this.conn.request("_goose/unstable/apps/export", params); return zAppsExportResponse_unstable.parse( raw, ) as AppsExportResponse_unstable; @@ -359,10 +344,7 @@ export class GooseExtClient { async appsImport_unstable( params: AppsImportRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/apps/import", - params, - ); + const raw = await this.conn.request("_goose/unstable/apps/import", params); return zAppsImportResponse_unstable.parse( raw, ) as AppsImportResponse_unstable; @@ -371,10 +353,7 @@ export class GooseExtClient { async appsDelete_unstable( params: AppsDeleteRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/apps/delete", - params, - ); + const raw = await this.conn.request("_goose/unstable/apps/delete", params); return zAppsDeleteResponse_unstable.parse( raw, ) as AppsDeleteResponse_unstable; @@ -383,7 +362,7 @@ export class GooseExtClient { async sessionWorkingDirUpdate_unstable( params: UpdateWorkingDirRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/session/working-dir/update", params, ); @@ -392,7 +371,7 @@ export class GooseExtClient { async sessionSystemPromptSet_unstable( params: SetSessionSystemPromptRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/session/system-prompt/set", params, ); @@ -401,7 +380,7 @@ export class GooseExtClient { async sessionSteer_unstable( params: SteerSessionRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/session/steer", params, ); @@ -413,7 +392,7 @@ export class GooseExtClient { async diagnosticsGet_unstable( params: DiagnosticsGetRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/diagnostics/get", params, ); @@ -425,7 +404,7 @@ export class GooseExtClient { async configPromptsList_unstable( params: ListPromptsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/config/prompts/list", params, ); @@ -437,7 +416,7 @@ export class GooseExtClient { async configPromptsGet_unstable( params: GetPromptRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/config/prompts/get", params, ); @@ -447,7 +426,7 @@ export class GooseExtClient { async configPromptsSave_unstable( params: SavePromptRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/config/prompts/save", params, ); @@ -459,7 +438,7 @@ export class GooseExtClient { async configPromptsReset_unstable( params: ResetPromptRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/config/prompts/reset", params, ); @@ -469,13 +448,13 @@ export class GooseExtClient { } async sessionDelete(params: DeleteSessionRequest): Promise { - await this.conn.extMethod("session/delete", params); + await this.conn.request("session/delete", params); } async configExtensionsList_unstable( params: GetConfigExtensionsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/config/extensions/list", params, ); @@ -487,7 +466,7 @@ export class GooseExtClient { async extensionsAvailable_unstable( params: GetAvailableExtensionsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/extensions/available", params, ); @@ -499,22 +478,19 @@ export class GooseExtClient { async configExtensionsAdd_unstable( params: AddConfigExtensionRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/config/extensions/add", params); + await this.conn.request("_goose/unstable/config/extensions/add", params); } async configExtensionsRemove_unstable( params: RemoveConfigExtensionRequest_unstable, ): Promise { - await this.conn.extMethod( - "_goose/unstable/config/extensions/remove", - params, - ); + await this.conn.request("_goose/unstable/config/extensions/remove", params); } async configExtensionsSetEnabled_unstable( params: SetConfigExtensionEnabledRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/config/extensions/set-enabled", params, ); @@ -523,7 +499,7 @@ export class GooseExtClient { async sessionExtensionsList_unstable( params: GetSessionExtensionsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/session/extensions/list", params, ); @@ -535,7 +511,7 @@ export class GooseExtClient { async providersList_unstable( params: ListProvidersRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/list", params, ); @@ -547,7 +523,7 @@ export class GooseExtClient { async providersSupportedModelsList_unstable( params: ProviderSupportedModelsListRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/supported-models/list", params, ); @@ -559,7 +535,7 @@ export class GooseExtClient { async providersCatalogList_unstable( params: ProviderCatalogListRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/catalog/list", params, ); @@ -571,7 +547,7 @@ export class GooseExtClient { async providersSetupCatalogList_unstable( params: ProviderSetupCatalogListRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/setup/catalog/list", params, ); @@ -583,7 +559,7 @@ export class GooseExtClient { async providersCatalogTemplate_unstable( params: ProviderCatalogTemplateRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/catalog/template", params, ); @@ -595,7 +571,7 @@ export class GooseExtClient { async providersCustomCreate_unstable( params: CustomProviderCreateRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/custom/create", params, ); @@ -607,7 +583,7 @@ export class GooseExtClient { async providersCustomRead_unstable( params: CustomProviderReadRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/custom/read", params, ); @@ -619,7 +595,7 @@ export class GooseExtClient { async providersCustomUpdate_unstable( params: CustomProviderUpdateRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/custom/update", params, ); @@ -631,7 +607,7 @@ export class GooseExtClient { async providersCustomDelete_unstable( params: CustomProviderDeleteRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/custom/delete", params, ); @@ -643,7 +619,7 @@ export class GooseExtClient { async providersInventoryRefresh_unstable( params: RefreshProviderInventoryRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/inventory/refresh", params, ); @@ -655,7 +631,7 @@ export class GooseExtClient { async providersConfigRead_unstable( params: ProviderConfigReadRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/config/read", params, ); @@ -667,7 +643,7 @@ export class GooseExtClient { async providersConfigStatus_unstable( params: ProviderConfigStatusRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/config/status", params, ); @@ -679,7 +655,7 @@ export class GooseExtClient { async providersConfigSave_unstable( params: ProviderConfigSaveRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/config/save", params, ); @@ -691,7 +667,7 @@ export class GooseExtClient { async providersConfigDelete_unstable( params: ProviderConfigDeleteRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/config/delete", params, ); @@ -703,7 +679,7 @@ export class GooseExtClient { async providersConfigAuthenticate_unstable( params: ProviderConfigAuthenticateRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/config/authenticate", params, ); @@ -715,7 +691,7 @@ export class GooseExtClient { async providersSecretsList_unstable( params: ProviderSecretsListRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/secrets/list", params, ); @@ -727,16 +703,13 @@ export class GooseExtClient { async providersSecretsDelete_unstable( params: ProviderSecretDeleteRequest_unstable, ): Promise { - await this.conn.extMethod( - "_goose/unstable/providers/secrets/delete", - params, - ); + await this.conn.request("_goose/unstable/providers/secrets/delete", params); } async providersCanonicalModelInfo_unstable( params: CanonicalModelInfoRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/providers/canonical-model-info", params, ); @@ -748,7 +721,7 @@ export class GooseExtClient { async preferencesRead_unstable( params: PreferencesReadRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/preferences/read", params, ); @@ -760,22 +733,19 @@ export class GooseExtClient { async preferencesSave_unstable( params: PreferencesSaveRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/preferences/save", params); + await this.conn.request("_goose/unstable/preferences/save", params); } async preferencesRemove_unstable( params: PreferencesRemoveRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/preferences/remove", params); + await this.conn.request("_goose/unstable/preferences/remove", params); } async configRead_unstable( params: ConfigReadRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/config/read", - params, - ); + const raw = await this.conn.request("_goose/unstable/config/read", params); return zConfigReadResponse_unstable.parse( raw, ) as ConfigReadResponse_unstable; @@ -784,19 +754,19 @@ export class GooseExtClient { async configUpsert_unstable( params: ConfigUpsertRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/config/upsert", params); + await this.conn.request("_goose/unstable/config/upsert", params); } async configRemove_unstable( params: ConfigRemoveRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/config/remove", params); + await this.conn.request("_goose/unstable/config/remove", params); } async configReadAll_unstable( params: ConfigReadAllRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/config/read-all", params, ); @@ -808,7 +778,7 @@ export class GooseExtClient { async defaultsRead_unstable( params: DefaultsReadRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/defaults/read", params, ); @@ -820,7 +790,7 @@ export class GooseExtClient { async defaultsSave_unstable( params: DefaultsSaveRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/defaults/save", params, ); @@ -832,7 +802,7 @@ export class GooseExtClient { async defaultsClear_unstable( params: DefaultsClearRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/defaults/clear", params, ); @@ -844,7 +814,7 @@ export class GooseExtClient { async onboardingImportScan_unstable( params: OnboardingImportScanRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/onboarding/import/scan", params, ); @@ -856,7 +826,7 @@ export class GooseExtClient { async onboardingImportApply_unstable( params: OnboardingImportApplyRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/onboarding/import/apply", params, ); @@ -868,7 +838,7 @@ export class GooseExtClient { async sessionExport_unstable( params: ExportSessionRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/session/export", params, ); @@ -880,7 +850,7 @@ export class GooseExtClient { async sessionImport_unstable( params: ImportSessionRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/session/import", params, ); @@ -892,7 +862,7 @@ export class GooseExtClient { async sessionShareNostr_unstable( params: ShareSessionNostrRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/session/share/nostr", params, ); @@ -904,7 +874,7 @@ export class GooseExtClient { async recipesEncode_unstable( params: EncodeRecipeRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/recipes/encode", params, ); @@ -916,7 +886,7 @@ export class GooseExtClient { async recipesDecode_unstable( params: DecodeRecipeRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/recipes/decode", params, ); @@ -928,10 +898,7 @@ export class GooseExtClient { async recipesScan_unstable( params: ScanRecipeRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/recipes/scan", - params, - ); + const raw = await this.conn.request("_goose/unstable/recipes/scan", params); return zScanRecipeResponse_unstable.parse( raw, ) as ScanRecipeResponse_unstable; @@ -940,10 +907,7 @@ export class GooseExtClient { async recipesList_unstable( params: ListRecipesRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/recipes/list", - params, - ); + const raw = await this.conn.request("_goose/unstable/recipes/list", params); return zListRecipesResponse_unstable.parse( raw, ) as ListRecipesResponse_unstable; @@ -952,28 +916,25 @@ export class GooseExtClient { async recipesDelete_unstable( params: DeleteRecipeRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/recipes/delete", params); + await this.conn.request("_goose/unstable/recipes/delete", params); } async recipesSchedule_unstable( params: ScheduleRecipeRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/recipes/schedule", params); + await this.conn.request("_goose/unstable/recipes/schedule", params); } async recipesSlashCommand_unstable( params: SetRecipeSlashCommandRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/recipes/slash-command", params); + await this.conn.request("_goose/unstable/recipes/slash-command", params); } async recipesSave_unstable( params: SaveRecipeRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/recipes/save", - params, - ); + const raw = await this.conn.request("_goose/unstable/recipes/save", params); return zSaveRecipeResponse_unstable.parse( raw, ) as SaveRecipeResponse_unstable; @@ -982,7 +943,7 @@ export class GooseExtClient { async recipesParse_unstable( params: ParseRecipeRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/recipes/parse", params, ); @@ -994,7 +955,7 @@ export class GooseExtClient { async recipesToYaml_unstable( params: RecipeToYamlRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/recipes/to-yaml", params, ); @@ -1006,7 +967,7 @@ export class GooseExtClient { async schedulesList_unstable( params: ListSchedulesRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/schedules/list", params, ); @@ -1018,7 +979,7 @@ export class GooseExtClient { async schedulesSessionsList_unstable( params: ListScheduleSessionsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/schedules/sessions/list", params, ); @@ -1030,7 +991,7 @@ export class GooseExtClient { async schedulesCreate_unstable( params: CreateScheduleRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/schedules/create", params, ); @@ -1042,25 +1003,25 @@ export class GooseExtClient { async schedulesDelete_unstable( params: DeleteScheduleRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/schedules/delete", params); + await this.conn.request("_goose/unstable/schedules/delete", params); } async schedulesPause_unstable( params: PauseScheduleRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/schedules/pause", params); + await this.conn.request("_goose/unstable/schedules/pause", params); } async schedulesUnpause_unstable( params: UnpauseScheduleRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/schedules/unpause", params); + await this.conn.request("_goose/unstable/schedules/unpause", params); } async schedulesUpdate_unstable( params: UpdateScheduleRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/schedules/update", params, ); @@ -1072,7 +1033,7 @@ export class GooseExtClient { async schedulesRunNow_unstable( params: RunScheduleNowRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/schedules/run-now", params, ); @@ -1084,7 +1045,7 @@ export class GooseExtClient { async schedulesRunningJobKill_unstable( params: KillRunningJobRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/schedules/running-job/kill", params, ); @@ -1096,7 +1057,7 @@ export class GooseExtClient { async schedulesRunningJobInspect_unstable( params: InspectRunningJobRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/schedules/running-job/inspect", params, ); @@ -1108,10 +1069,7 @@ export class GooseExtClient { async sessionInfo_unstable( params: GetSessionInfoRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/session/info", - params, - ); + const raw = await this.conn.request("_goose/unstable/session/info", params); return zGetSessionInfoResponse_unstable.parse( raw, ) as GetSessionInfoResponse_unstable; @@ -1120,7 +1078,7 @@ export class GooseExtClient { async sessionConversationTruncate_unstable( params: TruncateSessionConversationRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/session/conversation/truncate", params, ); @@ -1129,31 +1087,31 @@ export class GooseExtClient { async sessionProjectUpdate_unstable( params: UpdateSessionProjectRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/session/project/update", params); + await this.conn.request("_goose/unstable/session/project/update", params); } async sessionRename_unstable( params: RenameSessionRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/session/rename", params); + await this.conn.request("_goose/unstable/session/rename", params); } async sessionArchive_unstable( params: ArchiveSessionRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/session/archive", params); + await this.conn.request("_goose/unstable/session/archive", params); } async sessionUnarchive_unstable( params: UnarchiveSessionRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/session/unarchive", params); + await this.conn.request("_goose/unstable/session/unarchive", params); } async sourcesCreate_unstable( params: CreateSourceRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/sources/create", params, ); @@ -1165,10 +1123,7 @@ export class GooseExtClient { async sourcesList_unstable( params: ListSourcesRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( - "_goose/unstable/sources/list", - params, - ); + const raw = await this.conn.request("_goose/unstable/sources/list", params); return zListSourcesResponse_unstable.parse( raw, ) as ListSourcesResponse_unstable; @@ -1177,7 +1132,7 @@ export class GooseExtClient { async agentMentionsList_unstable( params: ListAgentMentionsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/agent-mentions/list", params, ); @@ -1189,7 +1144,7 @@ export class GooseExtClient { async slashCommandsList_unstable( params: ListSlashCommandsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/slash-commands/list", params, ); @@ -1201,7 +1156,7 @@ export class GooseExtClient { async sourcesUpdate_unstable( params: UpdateSourceRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/sources/update", params, ); @@ -1213,13 +1168,13 @@ export class GooseExtClient { async sourcesDelete_unstable( params: DeleteSourceRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/sources/delete", params); + await this.conn.request("_goose/unstable/sources/delete", params); } async sourcesExport_unstable( params: ExportSourceRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/sources/export", params, ); @@ -1231,7 +1186,7 @@ export class GooseExtClient { async sourcesImport_unstable( params: ImportSourcesRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/sources/import", params, ); @@ -1243,7 +1198,7 @@ export class GooseExtClient { async dictationTranscribe_unstable( params: DictationTranscribeRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/dictation/transcribe", params, ); @@ -1255,7 +1210,7 @@ export class GooseExtClient { async dictationConfig_unstable( params: DictationConfigRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/dictation/config", params, ); @@ -1267,22 +1222,19 @@ export class GooseExtClient { async dictationSecretSave_unstable( params: DictationSecretSaveRequest_unstable, ): Promise { - await this.conn.extMethod("_goose/unstable/dictation/secret/save", params); + await this.conn.request("_goose/unstable/dictation/secret/save", params); } async dictationSecretDelete_unstable( params: DictationSecretDeleteRequest_unstable, ): Promise { - await this.conn.extMethod( - "_goose/unstable/dictation/secret/delete", - params, - ); + await this.conn.request("_goose/unstable/dictation/secret/delete", params); } async dictationModelsList_unstable( params: DictationModelsListRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/dictation/models/list", params, ); @@ -1294,7 +1246,7 @@ export class GooseExtClient { async dictationModelsDownload_unstable( params: DictationModelDownloadRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/dictation/models/download", params, ); @@ -1303,7 +1255,7 @@ export class GooseExtClient { async dictationModelsDownloadProgress_unstable( params: DictationModelDownloadProgressRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/dictation/models/download/progress", params, ); @@ -1315,34 +1267,25 @@ export class GooseExtClient { async dictationModelsCancel_unstable( params: DictationModelCancelRequest_unstable, ): Promise { - await this.conn.extMethod( - "_goose/unstable/dictation/models/cancel", - params, - ); + await this.conn.request("_goose/unstable/dictation/models/cancel", params); } async dictationModelsDelete_unstable( params: DictationModelDeleteRequest_unstable, ): Promise { - await this.conn.extMethod( - "_goose/unstable/dictation/models/delete", - params, - ); + await this.conn.request("_goose/unstable/dictation/models/delete", params); } async dictationModelsSelect_unstable( params: DictationModelSelectRequest_unstable, ): Promise { - await this.conn.extMethod( - "_goose/unstable/dictation/models/select", - params, - ); + await this.conn.request("_goose/unstable/dictation/models/select", params); } async localInferenceModelsList_unstable( params: LocalInferenceModelsListRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/local-inference/models/list", params, ); @@ -1354,7 +1297,7 @@ export class GooseExtClient { async localInferenceModelsDownload_unstable( params: LocalInferenceModelDownloadRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/local-inference/models/download", params, ); @@ -1366,7 +1309,7 @@ export class GooseExtClient { async localInferenceModelsDownloadProgress_unstable( params: LocalInferenceModelDownloadProgressRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/local-inference/models/download/progress", params, ); @@ -1378,7 +1321,7 @@ export class GooseExtClient { async localInferenceModelsDownloadCancel_unstable( params: LocalInferenceModelDownloadCancelRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/local-inference/models/download/cancel", params, ); @@ -1387,7 +1330,7 @@ export class GooseExtClient { async localInferenceModelsDelete_unstable( params: LocalInferenceModelDeleteRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/local-inference/models/delete", params, ); @@ -1396,7 +1339,7 @@ export class GooseExtClient { async localInferenceModelsEvict_unstable( params: LocalInferenceModelEvictRequest_unstable, ): Promise { - await this.conn.extMethod( + await this.conn.request( "_goose/unstable/local-inference/models/evict", params, ); @@ -1405,7 +1348,7 @@ export class GooseExtClient { async localInferenceModelsSettingsRead_unstable( params: LocalInferenceModelSettingsReadRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/local-inference/models/settings/read", params, ); @@ -1417,7 +1360,7 @@ export class GooseExtClient { async localInferenceModelsSettingsUpdate_unstable( params: LocalInferenceModelSettingsUpdateRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/local-inference/models/settings/update", params, ); @@ -1429,7 +1372,7 @@ export class GooseExtClient { async localInferenceHuggingfaceSearch_unstable( params: LocalInferenceHuggingFaceSearchRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/local-inference/huggingface/search", params, ); @@ -1441,7 +1384,7 @@ export class GooseExtClient { async localInferenceHuggingfaceRepoVariants_unstable( params: LocalInferenceHuggingFaceRepoVariantsRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/local-inference/huggingface/repo/variants", params, ); @@ -1453,7 +1396,7 @@ export class GooseExtClient { async localInferenceChatTemplatesBuiltinList_unstable( params: LocalInferenceBuiltinChatTemplatesListRequest_unstable, ): Promise { - const raw = await this.conn.extMethod( + const raw = await this.conn.request( "_goose/unstable/local-inference/chat-templates/builtin/list", params, ); @@ -1462,92 +1405,3 @@ export class GooseExtClient { ) as LocalInferenceBuiltinChatTemplatesListResponse_unstable; } } - -export interface GooseExtNotifications { - unstable_sessionUpdate?: ( - notification: GooseSessionNotification_unstable, - ) => Promise; -} - -export interface GooseExtAgentRequests { - unstable_sessionRecipeRequestParams?: ( - request: RequestRecipeParams_unstable, - ) => Promise; -} - -export type GooseClientCallbacks = Omit< - Client, - "extNotification" | "extMethod" -> & - Partial> & - GooseExtNotifications & - GooseExtAgentRequests; - -export function installGooseExtNotificationDispatcher( - callbacks: GooseClientCallbacks, -): Client { - const dispatcher: Pick = { - extNotification: async (method, params) => { - switch (method) { - case "_goose/unstable/session/update": { - const parsed = zGooseSessionNotification_unstable.parse( - params, - ) as GooseSessionNotification_unstable; - await callbacks.unstable_sessionUpdate?.(parsed); - return; - } - default: - await callbacks.extNotification?.(method, params); - return; - } - }, - }; - return new Proxy(callbacks, { - get(target, property) { - if (property === "extNotification") { - return dispatcher.extNotification; - } - - const value = Reflect.get(target, property, target); - return typeof value === "function" ? value.bind(target) : value; - }, - }) as Client; -} - -export function installGooseExtAgentRequestDispatcher( - callbacks: GooseClientCallbacks, -): Client { - const dispatcher: Pick = { - extMethod: async (method, params) => { - switch (method) { - case "_goose/unstable/session/recipe/request-params": { - if (callbacks.unstable_sessionRecipeRequestParams) { - const parsed = zRequestRecipeParams_unstable.parse( - params, - ) as RequestRecipeParams_unstable; - return await callbacks.unstable_sessionRecipeRequestParams(parsed); - } - if (callbacks.extMethod) { - return await callbacks.extMethod(method, params); - } - throw new Error(`unhandled ext method: ${method}`); - } - default: - if (callbacks.extMethod) { - return await callbacks.extMethod(method, params); - } - throw new Error(`unhandled ext method: ${method}`); - } - }, - }; - return new Proxy(callbacks, { - get(target, property) { - if (property === "extMethod") { - return dispatcher.extMethod; - } - - const value = Reflect.get(target, property, target); - return typeof value === "function" ? value.bind(target) : value; - }, - }) as Client; -} diff --git a/ui/sdk/src/goose-client.ts b/ui/sdk/src/goose-client.ts deleted file mode 100644 index b8a0a6874..000000000 --- a/ui/sdk/src/goose-client.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - ClientSideConnection, - type Stream, - type InitializeRequest, - type InitializeResponse, - type NewSessionRequest, - type NewSessionResponse, - type LoadSessionRequest, - type LoadSessionResponse, - type PromptRequest, - type PromptResponse, - type CancelNotification, - type AuthenticateRequest, - type AuthenticateResponse, - type SetSessionModeRequest, - type SetSessionModeResponse, - type SetSessionConfigOptionRequest, - type SetSessionConfigOptionResponse, - type ForkSessionRequest, - type ForkSessionResponse, - type ListSessionsRequest, - type ListSessionsResponse, - type ResumeSessionRequest, - type ResumeSessionResponse, - type CloseSessionRequest, - type CloseSessionResponse, - type SetSessionModelRequest, - type SetSessionModelResponse, -} from "@agentclientprotocol/sdk"; -import { - GooseExtClient, - installGooseExtAgentRequestDispatcher, - installGooseExtNotificationDispatcher, - type GooseClientCallbacks, -} from "./generated/client.gen.js"; -import { createHttpStream } from "./http-stream.js"; - -export class GooseClient { - private conn: ClientSideConnection; - private ext: GooseExtClient; - - constructor( - toClient: () => GooseClientCallbacks, - streamOrUrl: Stream | string, - ) { - const stream = - typeof streamOrUrl === "string" - ? createHttpStream(streamOrUrl) - : streamOrUrl; - const toAcpClient = () => - installGooseExtAgentRequestDispatcher( - installGooseExtNotificationDispatcher(toClient()), - ); - this.conn = new ClientSideConnection(toAcpClient, stream); - this.ext = new GooseExtClient(this.conn); - } - - get signal(): AbortSignal { - return this.conn.signal; - } - - get closed(): Promise { - return this.conn.closed; - } - - initialize(params: InitializeRequest): Promise { - return this.conn.initialize(params); - } - - newSession(params: NewSessionRequest): Promise { - return this.conn.newSession(params); - } - - loadSession(params: LoadSessionRequest): Promise { - return this.conn.loadSession(params); - } - - prompt(params: PromptRequest): Promise { - return this.conn.prompt(params); - } - - cancel(params: CancelNotification): Promise { - return this.conn.cancel(params); - } - - authenticate(params: AuthenticateRequest): Promise { - return this.conn.authenticate(params); - } - - setSessionMode( - params: SetSessionModeRequest, - ): Promise { - return this.conn.setSessionMode(params); - } - - setSessionConfigOption( - params: SetSessionConfigOptionRequest, - ): Promise { - return this.conn.setSessionConfigOption(params); - } - - unstable_forkSession( - params: ForkSessionRequest, - ): Promise { - return this.conn.unstable_forkSession(params); - } - - listSessions(params: ListSessionsRequest): Promise { - return this.conn.listSessions(params); - } - - unstable_resumeSession( - params: ResumeSessionRequest, - ): Promise { - return this.conn.unstable_resumeSession(params); - } - - unstable_closeSession( - params: CloseSessionRequest, - ): Promise { - return this.conn.unstable_closeSession(params); - } - - unstable_setSessionModel( - params: SetSessionModelRequest, - ): Promise { - return this.conn.unstable_setSessionModel(params); - } - - extMethod( - method: string, - params: Record, - ): Promise> { - return this.conn.extMethod(method, params); - } - - get goose(): GooseExtClient { - return this.ext; - } -} diff --git a/ui/sdk/src/http-stream.ts b/ui/sdk/src/http-stream.ts deleted file mode 100644 index efec232e8..000000000 --- a/ui/sdk/src/http-stream.ts +++ /dev/null @@ -1,407 +0,0 @@ -import type { AnyMessage, Stream } from "@agentclientprotocol/sdk"; - -const ACP_CONNECTION_HEADER = "Acp-Connection-Id"; -const ACP_SESSION_HEADER = "Acp-Session-Id"; - -function acpDebug(label: string, payload: unknown): void { - const g = globalThis as { - ACP_DEBUG?: unknown; - localStorage?: { getItem?: (k: string) => string | null }; - process?: { env?: Record }; - }; - const on = - g.ACP_DEBUG === true || - g.ACP_DEBUG === "1" || - !!g.localStorage?.getItem?.("ACP_DEBUG") || - !!g.process?.env?.ACP_DEBUG; - if (!on) return; - // eslint-disable-next-line no-console - console.debug(`[acp] ${label}`, payload); -} - -const SESSION_SCOPED_METHODS = new Set([ - "session/prompt", - "session/cancel", - "session/load", - "session/set_mode", - "session/set_model", -]); - -function messageMethod(msg: AnyMessage): string | null { - const m = msg as { method?: unknown }; - return typeof m.method === "string" ? m.method : null; -} - -function messageParams(msg: AnyMessage): unknown { - return (msg as { params?: unknown }).params; -} - -function messageResult(msg: AnyMessage): unknown { - return (msg as { result?: unknown }).result; -} - -function isRequest(msg: AnyMessage): boolean { - const m = msg as { method?: unknown; id?: unknown }; - return typeof m.method === "string" && m.id !== undefined && m.id !== null; -} - -function isNotification(msg: AnyMessage): boolean { - const m = msg as { method?: unknown; id?: unknown }; - return typeof m.method === "string" && (m.id === undefined || m.id === null); -} - -function isResponse(msg: AnyMessage): boolean { - const m = msg as { method?: unknown; id?: unknown; result?: unknown; error?: unknown }; - return ( - m.method === undefined && - m.id !== undefined && - m.id !== null && - (m.result !== undefined || m.error !== undefined) - ); -} - -function extractSessionId(value: unknown): string | null { - if (value && typeof value === "object" && "sessionId" in value) { - const sid = (value as { sessionId?: unknown }).sessionId; - if (typeof sid === "string") return sid; - } - return null; -} - -/** - * Stream that speaks the ACP Streamable HTTP transport: a connection-scoped - * GET SSE stream plus a session-scoped stream per active `sessionId`. - */ -export function createHttpStream(serverUrl: string): Stream { - const base = serverUrl.replace(/\/+$/, ""); - const endpoint = `${base}/acp`; - - let connectionId: string | null = null; - let connectionStreamAbort: AbortController | null = null; - const sessionStreamAborts = new Map(); - const openSessionStreams = new Set(); - let closed = false; - - const inbox: AnyMessage[] = []; - let pullResolve: (() => void) | null = null; - - function deliver(msg: AnyMessage) { - inbox.push(msg); - if (pullResolve) { - const r = pullResolve; - pullResolve = null; - r(); - } - } - - function waitForInbox(): Promise { - if (inbox.length > 0) return Promise.resolve(); - return new Promise((r) => { - pullResolve = r; - }); - } - - async function openConnectionGetStream() { - if (!connectionId) return; - connectionStreamAbort = new AbortController(); - - const response = await fetch(endpoint, { - method: "GET", - headers: { - Accept: "text/event-stream", - [ACP_CONNECTION_HEADER]: connectionId, - }, - signal: connectionStreamAbort.signal, - }); - - if (!response.ok || !response.body) { - throw new Error( - `Failed to open ACP connection-scoped GET stream: ${response.status} ${response.statusText}`, - ); - } - - void consumeSSE(response.body, "connection").catch((err) => { - if (closed) return; - // eslint-disable-next-line no-console - console.error("ACP connection-scoped GET stream error:", err); - }); - } - - async function ensureSessionGetStream(sessionId: string): Promise { - if (!connectionId) return; - if (openSessionStreams.has(sessionId)) return; - openSessionStreams.add(sessionId); - - const abort = new AbortController(); - sessionStreamAborts.set(sessionId, abort); - - let response: Response; - try { - response = await fetch(endpoint, { - method: "GET", - headers: { - Accept: "text/event-stream", - [ACP_CONNECTION_HEADER]: connectionId, - [ACP_SESSION_HEADER]: sessionId, - }, - signal: abort.signal, - }); - } catch (e) { - openSessionStreams.delete(sessionId); - sessionStreamAborts.delete(sessionId); - throw e; - } - - if (!response.ok || !response.body) { - openSessionStreams.delete(sessionId); - sessionStreamAborts.delete(sessionId); - throw new Error( - `Failed to open ACP session-scoped GET stream for ${sessionId}: ${response.status} ${response.statusText}`, - ); - } - - acpDebug("session GET stream open", { sessionId }); - void consumeSSE(response.body, `session:${sessionId}`) - .catch((err) => { - if (closed) return; - // eslint-disable-next-line no-console - console.error( - `ACP session-scoped GET stream error (${sessionId}):`, - err, - ); - }) - .finally(() => { - if (sessionStreamAborts.get(sessionId) === abort) { - sessionStreamAborts.delete(sessionId); - openSessionStreams.delete(sessionId); - acpDebug("session GET stream closed", { sessionId }); - } - }); - } - - async function consumeSSE(body: ReadableStream, label: string) { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - let idx: number; - while ((idx = buffer.indexOf("\n\n")) >= 0) { - const event = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); - handleSseEvent(event, label); - } - } - if (buffer.length > 0) handleSseEvent(buffer, label); - } catch (e: unknown) { - if (e instanceof DOMException && e.name === "AbortError") return; - throw e; - } - } - - function handleSseEvent(event: string, label: string) { - const dataLines: string[] = []; - for (const line of event.split("\n")) { - if (line.startsWith("data:")) { - dataLines.push(line.slice(5).replace(/^ /, "")); - } - } - if (dataLines.length === 0) return; - const data = dataLines.join("\n"); - let msg: AnyMessage; - try { - msg = JSON.parse(data) as AnyMessage; - } catch { - return; - } - - acpDebug(`SSE → client (${label})`, msg); - handleInbound(msg); - } - - function handleInbound(msg: AnyMessage) { - if (isResponse(msg)) { - const sid = extractSessionId(messageResult(msg)); - if (sid && !openSessionStreams.has(sid)) { - ensureSessionGetStream(sid).catch((err) => { - if (closed) return; - // eslint-disable-next-line no-console - console.error("Failed to open session GET stream:", err); - }); - } - } - - deliver(msg); - } - - async function sendInitialize(msg: AnyMessage) { - acpDebug("initialize → agent", msg); - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(msg), - }); - - if (!response.ok) { - throw new Error( - `ACP initialize failed: ${response.status} ${response.statusText}`, - ); - } - - const connId = response.headers.get(ACP_CONNECTION_HEADER); - if (!connId) { - throw new Error( - `ACP initialize response missing ${ACP_CONNECTION_HEADER} header`, - ); - } - connectionId = connId; - - const body = (await response.json()) as AnyMessage; - acpDebug("initialize response", body); - // Open the connection-scoped GET stream before delivering the initialize - // response so we don't miss any immediate server-initiated messages. - await openConnectionGetStream(); - deliver(body); - } - - async function sendPost(msg: AnyMessage) { - if (!connectionId) { - throw new Error("ACP POST attempted before initialize"); - } - - const headers: Record = { - "Content-Type": "application/json", - Accept: "application/json", - [ACP_CONNECTION_HEADER]: connectionId, - }; - - let outboundSessionId: string | null = null; - if (isRequest(msg) || isNotification(msg)) { - outboundSessionId = extractSessionId(messageParams(msg)); - if (outboundSessionId) { - headers[ACP_SESSION_HEADER] = outboundSessionId; - } else if (isRequest(msg)) { - const method = messageMethod(msg); - if (method && SESSION_SCOPED_METHODS.has(method)) { - throw new Error(`ACP method ${method} requires sessionId in params`); - } - } - } - - if (outboundSessionId && messageMethod(msg) !== "session/load") { - try { - await ensureSessionGetStream(outboundSessionId); - } catch (err) { - // eslint-disable-next-line no-console - console.error("Failed to ensure session GET stream:", err); - } - } - - acpDebug("POST → agent", msg); - const response = await fetch(endpoint, { - method: "POST", - headers, - body: JSON.stringify(msg), - }); - - if (response.status !== 202 && !response.ok) { - throw new Error( - `ACP POST failed: ${response.status} ${response.statusText}`, - ); - } - await response.arrayBuffer().catch(() => undefined); - } - - async function sendDelete() { - if (!connectionId) return; - try { - await fetch(endpoint, { - method: "DELETE", - headers: { [ACP_CONNECTION_HEADER]: connectionId }, - }); - } catch { - // best-effort - } - } - - function abortAllStreams() { - connectionStreamAbort?.abort(); - connectionStreamAbort = null; - for (const a of sessionStreamAborts.values()) { - a.abort(); - } - sessionStreamAborts.clear(); - openSessionStreams.clear(); - } - - const readable = new ReadableStream({ - async pull(controller) { - await waitForInbox(); - while (inbox.length > 0) { - controller.enqueue(inbox.shift()!); - } - if (closed && inbox.length === 0) { - controller.close(); - } - }, - async cancel() { - closed = true; - await sendDelete(); - abortAllStreams(); - if (pullResolve) { - const r = pullResolve; - pullResolve = null; - r(); - } - }, - }); - - const writable = new WritableStream({ - async write(msg) { - if ( - !connectionId && - isRequest(msg) && - messageMethod(msg) === "initialize" - ) { - await sendInitialize(msg); - return; - } - if (!connectionId) { - throw new Error( - "ACP transport: first outgoing message must be `initialize`", - ); - } - await sendPost(msg); - }, - async close() { - closed = true; - await sendDelete(); - abortAllStreams(); - if (pullResolve) { - const r = pullResolve; - pullResolve = null; - r(); - } - }, - async abort() { - closed = true; - await sendDelete(); - abortAllStreams(); - if (pullResolve) { - const r = pullResolve; - pullResolve = null; - r(); - } - }, - }); - - return { readable, writable }; -} diff --git a/ui/sdk/src/index.ts b/ui/sdk/src/index.ts index 2d6815adf..7250d45e8 100644 --- a/ui/sdk/src/index.ts +++ b/ui/sdk/src/index.ts @@ -1,16 +1,9 @@ export * from "./generated/types.gen.js"; export * from "./generated/zod.gen.js"; export { - type GooseClientCallbacks, - type GooseExtNotifications, -} from "./generated/client.gen.js"; -export { GooseClient } from "./goose-client.js"; -export { createHttpStream } from "./http-stream.js"; + GOOSE_EXT_AGENT_REQUESTS, + GOOSE_EXT_NOTIFICATIONS, +} from "./generated/index.js"; +export { GooseExtClient } from "./generated/client.gen.js"; export * from "./client-capabilities.js"; export * from "./mcp-apps.js"; - -export { - ClientSideConnection, - type Client, - type Stream, -} from "@agentclientprotocol/sdk"; diff --git a/ui/sdk/tests/client-callbacks.test.ts b/ui/sdk/tests/client-callbacks.test.ts deleted file mode 100644 index c9dbfa6c1..000000000 --- a/ui/sdk/tests/client-callbacks.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { - installGooseExtAgentRequestDispatcher, - installGooseExtNotificationDispatcher, -} from "../src/generated/client.gen.ts"; -import type { - GooseSessionNotification_unstable, - RecipeParamsResponse_unstable, - RequestRecipeParams_unstable, -} from "../src/generated/types.gen.ts"; -import type { - RequestPermissionRequest, - RequestPermissionResponse, - SessionNotification, -} from "@agentclientprotocol/sdk"; - -class ClassBackedCallbacks { - #events: string[] = []; - - get events(): string[] { - return this.#events; - } - - async requestPermission( - _params: RequestPermissionRequest, - ): Promise { - this.#events.push("requestPermission"); - return { outcome: { outcome: "cancelled" } }; - } - - async sessionUpdate(_params: SessionNotification): Promise { - this.#events.push("sessionUpdate"); - } - - async extNotification( - method: string, - _params: Record, - ): Promise { - this.#events.push(`extNotification:${method}`); - } - - async unstable_sessionUpdate( - notification: GooseSessionNotification_unstable, - ): Promise { - this.#events.push( - `unstable_sessionUpdate:${notification.update.sessionUpdate}`, - ); - } -} - -class MinimalCallbacks { - async requestPermission( - _params: RequestPermissionRequest, - ): Promise { - return { outcome: { outcome: "cancelled" } }; - } - - async sessionUpdate(_params: SessionNotification): Promise {} -} - -class AgentRequestCallbacks extends MinimalCallbacks { - events: string[] = []; - - async unstable_sessionRecipeRequestParams( - request: RequestRecipeParams_unstable, - ): Promise { - this.events.push(`typed:${request.sessionId}`); - return { action: "submit", values: { name: "Ada" } }; - } - - async extMethod( - method: string, - _params: Record, - ): Promise> { - this.events.push(`extMethod:${method}`); - return { action: "cancel" }; - } -} - -class GenericAgentRequestCallbacks extends MinimalCallbacks { - events: string[] = []; - - async extMethod( - method: string, - _params: Record, - ): Promise> { - this.events.push(`extMethod:${method}`); - return { action: "cancel" }; - } -} - -const recipeParamRequest: RequestRecipeParams_unstable = { - sessionId: "session-1", - parameters: [ - { - key: "name", - input_type: "string", - requirement: "user_prompt", - description: "Name", - }, - ], -}; - -const recipeParamRequestParams = recipeParamRequest as unknown as Record< - string, - unknown ->; - -test("dispatcher preserves class-backed callback receivers", async () => { - const callbacks = new ClassBackedCallbacks(); - const client = installGooseExtNotificationDispatcher(callbacks); - - await client.requestPermission({} as RequestPermissionRequest); - await client.sessionUpdate({} as SessionNotification); - await client.extNotification!("_goose/unstable/session/update", { - sessionId: "session-1", - update: { - sessionUpdate: "status_message", - status: { - type: "notice", - message: "ready", - }, - }, - }); - await client.extNotification!("example/unknown", {}); - - assert.deepEqual(callbacks.events, [ - "requestPermission", - "sessionUpdate", - "unstable_sessionUpdate:status_message", - "extNotification:example/unknown", - ]); -}); - -test("raw extNotification is optional", async () => { - const client = installGooseExtNotificationDispatcher(new MinimalCallbacks()); - - await client.extNotification!("example/unknown", {}); -}); - -test("agent request dispatcher prefers typed callbacks", async () => { - const callbacks = new AgentRequestCallbacks(); - const client = installGooseExtAgentRequestDispatcher(callbacks); - - const response = await client.extMethod!( - "_goose/unstable/session/recipe/request-params", - recipeParamRequestParams, - ); - - assert.deepEqual(response, { action: "submit", values: { name: "Ada" } }); - assert.deepEqual(callbacks.events, ["typed:session-1"]); -}); - -test("agent request dispatcher falls back to raw extMethod", async () => { - const callbacks = new GenericAgentRequestCallbacks(); - const client = installGooseExtAgentRequestDispatcher(callbacks); - - const response = await client.extMethod!( - "_goose/unstable/session/recipe/request-params", - recipeParamRequestParams, - ); - - assert.deepEqual(response, { action: "cancel" }); - assert.deepEqual(callbacks.events, [ - "extMethod:_goose/unstable/session/recipe/request-params", - ]); -}); - -test("agent request dispatcher throws when a request is unhandled", async () => { - const client = installGooseExtAgentRequestDispatcher(new MinimalCallbacks()); - - await assert.rejects( - () => - client.extMethod!( - "_goose/unstable/session/recipe/request-params", - recipeParamRequestParams, - ), - /unhandled ext method: _goose\/unstable\/session\/recipe\/request-params/, - ); -});