upgrade acp sdk to 1.3.0 (#11218)

This commit is contained in:
Lifei Zhou
2026-08-14 03:48:34 +00:00
committed by GitHub
parent 09177caf51
commit 705f30df47
26 changed files with 563 additions and 1602 deletions
+2 -3
View File
@@ -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
+2
View File
@@ -87,6 +87,8 @@ module.exports = [
URLSearchParams: 'readonly',
Response: 'readonly',
ReadableStream: 'readonly',
ReadableStreamDefaultController: 'readonly',
WritableStream: 'readonly',
AbortController: 'readonly',
RequestCredentials: 'readonly',
HeadersInit: 'readonly',
+1 -1
View File
@@ -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",
@@ -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<void>;
close: ReturnType<typeof vi.fn>;
};
goose: Record<string, never>;
};
const instances: Array<{ client: MockClient; resolveClosed: () => void }> = [];
const connectGooseAcpClient = vi.fn((_stream: MockStream): MockClient => {
let resolveClosed: () => void = () => undefined;
const closed = new Promise<void>((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<void>;
resolveClosed: () => void = () => undefined;
constructor() {
this.closed = new Promise<void>((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);
@@ -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,
@@ -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');
});
});
@@ -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<AnyMessage> | undefined;
const writes: AnyMessage[] = [];
return {
readable: new ReadableStream<AnyMessage>({
start(nextController) {
controller = nextController;
},
}),
writable: new WritableStream<AnyMessage>({
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<void> {
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;
});
});
+54 -37
View File
@@ -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<ReturnType<typeof getAcpClient>>
@@ -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',
+13 -5
View File
@@ -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<ReturnType<typeof getAcpClient>>
@@ -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<ReturnType<typeof getAcpClient>>
@@ -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: {
+21 -20
View File
@@ -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<typeof createWebSocketStream>;
client: GooseAcpClient;
initializeResponse: InitializeResponse;
};
@@ -33,7 +33,7 @@ let connectionGeneration = 0;
let recovering = false;
const recoveryListeners = new Set<AcpRecoveryListener>();
export async function getAcpClient(): Promise<GooseClient> {
export async function getAcpClient(): Promise<GooseAcpClient> {
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<AcpConnection> {
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<AcpConnection> {
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<void> {
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<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
@@ -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<void> {
if (incoming.length > 0 || closed) {
return Promise.resolve();
}
return new Promise<void>((resolve) => waiters.push(resolve));
}
const openPromise = new Promise<void>((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;
}
+61
View File
@@ -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<Client, 'requestPermission' | 'sessionUpdate' | 'unstable_createElicitation'>
> & {
unstable_sessionRecipeRequestParams: (
request: RequestRecipeParams_unstable
) => Promise<RecipeParamsResponse_unstable>;
unstable_sessionUpdate: (notification: GooseSessionNotification_unstable) => Promise<void>;
};
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 };
}
+3 -3
View File
@@ -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<PromptResponse> {
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<void> {
const client = await getAcpClient();
await client.cancel({ sessionId });
await client.connection.agent.notify(methods.agent.session.cancel, { sessionId });
}
export async function acpSteerSession(
+9 -4
View File
@@ -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<AppliedSessionProviderModel> {
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,
+16 -13
View File
@@ -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<Sessio
}
const client = await getAcpClient();
const response = await client.listSessions({ _meta: { types: SESSION_LIST_TYPES } });
const response = await client.connection.agent.request(methods.agent.session.list, {
_meta: { types: SESSION_LIST_TYPES },
});
return response.sessions.slice(0, maxSessions).map(sessionInfoToListItem);
}
@@ -199,7 +202,7 @@ async function loadAcpSession(sessionId: string): Promise<AcpLoadSessionResult>
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<void> {
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<void> {
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<void> {
@@ -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);
}
+7 -7
View File
@@ -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
+1 -1
View File
@@ -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
+30 -13
View File
@@ -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.
+6 -191
View File
@@ -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<string>();
const zodImports = new Set<string>();
const upstreamTypeImports = new Set<string>(["Client"]);
const upstreamTypeImports = new Set<string>(["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<string, unknown>";
bodyLines = [
`return await this.conn.extMethod("${fullMethod}", ${callParams ? callParams : "{}"});`,
`return await this.conn.request<Record<string, unknown>>("${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<string, unknown>) => Promise<void>;`,
);
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<void>;`,
);
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<string, unknown>";
const retType = r.responseType ?? "Record<string, unknown>";
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<string, unknown>;`;
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<Client, "extMethod"> = {
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<Client, "extNotification"> = {
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<string, unknown>): Promise<Record<string, unknown>>;
}
${upstreamImportLine}
${typeImportLine}
${zodImportLine}
export class GooseExtClient {
constructor(private conn: ExtMethodProvider) {}
constructor(private conn: Pick<ClientContext, "request">) {}
${methodDefs.join("\n")}
}
${handlersInterface}
${agentRequestsInterface}
export type GooseClientCallbacks =
Omit<Client, "extNotification" | "extMethod"> &
Partial<Pick<Client, "extNotification" | "extMethod">> &
GooseExtNotifications &
GooseExtAgentRequests;
${dispatcherFn}
${agentRequestDispatcherFn}
`;
src = await prettier.format(src, { parser: "typescript" });
+2 -4
View File
@@ -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",
+21 -15
View File
@@ -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 });
File diff suppressed because it is too large Load Diff
-140
View File
@@ -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<void> {
return this.conn.closed;
}
initialize(params: InitializeRequest): Promise<InitializeResponse> {
return this.conn.initialize(params);
}
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
return this.conn.newSession(params);
}
loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
return this.conn.loadSession(params);
}
prompt(params: PromptRequest): Promise<PromptResponse> {
return this.conn.prompt(params);
}
cancel(params: CancelNotification): Promise<void> {
return this.conn.cancel(params);
}
authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse> {
return this.conn.authenticate(params);
}
setSessionMode(
params: SetSessionModeRequest,
): Promise<SetSessionModeResponse> {
return this.conn.setSessionMode(params);
}
setSessionConfigOption(
params: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
return this.conn.setSessionConfigOption(params);
}
unstable_forkSession(
params: ForkSessionRequest,
): Promise<ForkSessionResponse> {
return this.conn.unstable_forkSession(params);
}
listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
return this.conn.listSessions(params);
}
unstable_resumeSession(
params: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
return this.conn.unstable_resumeSession(params);
}
unstable_closeSession(
params: CloseSessionRequest,
): Promise<CloseSessionResponse> {
return this.conn.unstable_closeSession(params);
}
unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse> {
return this.conn.unstable_setSessionModel(params);
}
extMethod(
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return this.conn.extMethod(method, params);
}
get goose(): GooseExtClient {
return this.ext;
}
}
-407
View File
@@ -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<string, string | undefined> };
};
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<string>([
"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<string, AbortController>();
const openSessionStreams = new Set<string>();
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<void> {
if (inbox.length > 0) return Promise.resolve();
return new Promise<void>((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<void> {
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<Uint8Array>, 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<string, string> = {
"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<AnyMessage>({
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<AnyMessage>({
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 };
}
+4 -11
View File
@@ -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";
-181
View File
@@ -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<RequestPermissionResponse> {
this.#events.push("requestPermission");
return { outcome: { outcome: "cancelled" } };
}
async sessionUpdate(_params: SessionNotification): Promise<void> {
this.#events.push("sessionUpdate");
}
async extNotification(
method: string,
_params: Record<string, unknown>,
): Promise<void> {
this.#events.push(`extNotification:${method}`);
}
async unstable_sessionUpdate(
notification: GooseSessionNotification_unstable,
): Promise<void> {
this.#events.push(
`unstable_sessionUpdate:${notification.update.sessionUpdate}`,
);
}
}
class MinimalCallbacks {
async requestPermission(
_params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
return { outcome: { outcome: "cancelled" } };
}
async sessionUpdate(_params: SessionNotification): Promise<void> {}
}
class AgentRequestCallbacks extends MinimalCallbacks {
events: string[] = [];
async unstable_sessionRecipeRequestParams(
request: RequestRecipeParams_unstable,
): Promise<RecipeParamsResponse_unstable> {
this.events.push(`typed:${request.sessionId}`);
return { action: "submit", values: { name: "Ada" } };
}
async extMethod(
method: string,
_params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
this.events.push(`extMethod:${method}`);
return { action: "cancel" };
}
}
class GenericAgentRequestCallbacks extends MinimalCallbacks {
events: string[] = [];
async extMethod(
method: string,
_params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
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/,
);
});