upgrade acp sdk to 1.3.0 (#11218)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -87,6 +87,8 @@ module.exports = [
|
||||
URLSearchParams: 'readonly',
|
||||
Response: 'readonly',
|
||||
ReadableStream: 'readonly',
|
||||
ReadableStreamDefaultController: 'readonly',
|
||||
WritableStream: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
RequestCredentials: 'readonly',
|
||||
HeadersInit: 'readonly',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Generated
+7
-7
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+115
-261
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user