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
@@ -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);
}