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
+30 -13
View File
@@ -3,13 +3,14 @@
TypeScript client library for the Goose Agent Client Protocol (ACP).
This package provides:
- TypeScript types and Zod validators for Goose ACP extension methods
- A client for communicating with the Goose ACP server
## Installation
```bash
npm install @aaif/goose-sdk
npm install @aaif/goose-sdk @agentclientprotocol/sdk
```
The native `goose` binaries are distributed as optional dependencies
@@ -76,13 +77,13 @@ npm run build:schema
Platform-specific npm packages for the `goose` binary are located in
`ui/goose-binary/`:
| Package | Platform |
|---------|----------|
| Package | Platform |
| --------------------------------- | ------------------- |
| `@aaif/goose-binary-darwin-arm64` | macOS Apple Silicon |
| `@aaif/goose-binary-darwin-x64` | macOS Intel |
| `@aaif/goose-binary-linux-arm64` | Linux ARM64 |
| `@aaif/goose-binary-linux-x64` | Linux x64 |
| `@aaif/goose-binary-win32-x64` | Windows x64 |
| `@aaif/goose-binary-darwin-x64` | macOS Intel |
| `@aaif/goose-binary-linux-arm64` | Linux ARM64 |
| `@aaif/goose-binary-linux-x64` | Linux x64 |
| `@aaif/goose-binary-win32-x64` | Windows x64 |
These are published separately from `@aaif/goose-sdk`.
@@ -111,20 +112,36 @@ For manual publishing:
```
This will:
1. Build and publish `@aaif/goose-sdk`
2. Publish all native binary packages
## Usage
```typescript
import { GooseClient } from "@aaif/goose-sdk";
Compose the ACP client with the standard ACP SDK, then use `GooseExtClient` for
typed Goose extension methods:
const client = new GooseClient({
// ... configuration
```typescript
import {
client as createAcpClient,
methods,
PROTOCOL_VERSION,
} from "@agentclientprotocol/sdk";
import { createWebSocketStream } from "@agentclientprotocol/sdk/experimental/ws-client";
import { GooseExtClient } from "@aaif/goose-sdk";
const app = createAcpClient({ name: "my-client" });
const stream = createWebSocketStream("ws://localhost:3000/acp");
const connection = app.connect(stream);
const goose = new GooseExtClient(connection.agent);
await connection.agent.request(methods.agent.initialize, {
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: "my-client", version: "1.0.0" },
clientCapabilities: {},
});
// Use the client
const result = await client.someMethod({ ... });
const providers = await goose.providersList_unstable({ providerIds: [] });
```
See the [main documentation](../../README.md) for more details.
+6 -191
View File
@@ -7,7 +7,6 @@
*/
import { createClient } from "@hey-api/openapi-ts";
import { execSync } from "child_process";
import * as fs from "fs/promises";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
@@ -138,38 +137,6 @@ interface MethodMeta {
responseType: string | null;
}
interface NotificationMeta {
method: string;
paramsType: string | null;
}
interface AgentRequestMeta {
method: string;
requestType: string | null;
responseType: string | null;
}
function methodToHandlerName(method: string): string {
let methodParts = method.split(/[/_]/).filter((part) => part.length > 0);
let prefix = "";
if (methodParts[0] == "goose" && methodParts[1] == "unstable") {
methodParts.shift();
methodParts.shift();
prefix = "unstable_";
} else if (methodParts[0] == "goose") {
methodParts.shift();
}
const body = methodParts
.map((part) =>
part.replace(/[^a-zA-Z0-9]+(.)/g, (_, chr: string) => chr.toUpperCase()),
)
.map((part, i) =>
i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1),
)
.join("");
return `${prefix}${body}`;
}
function methodToCamelCase(method: string): string {
let methodParts = method.split(/[/_]/).filter((part) => part.length > 0);
@@ -194,14 +161,10 @@ function methodToCamelCase(method: string): string {
return `${prefix}${suffix}`;
}
async function generateClient(meta: {
methods: MethodMeta[];
notifications?: NotificationMeta[];
agentRequests?: AgentRequestMeta[];
}) {
async function generateClient(meta: { methods: MethodMeta[] }) {
const typeImports = new Set<string>();
const zodImports = new Set<string>();
const upstreamTypeImports = new Set<string>(["Client"]);
const upstreamTypeImports = new Set<string>(["ClientContext"]);
const methodDefs: string[] = [];
@@ -228,18 +191,16 @@ async function generateClient(meta: {
zodImports.add(zodName);
returnType = m.responseType;
bodyLines = [
`const raw = await this.conn.extMethod("${fullMethod}", ${callParams});`,
`const raw = await this.conn.request("${fullMethod}", ${callParams});`,
`return ${zodName}.parse(raw) as ${returnType};`,
];
} else if (m.responseType === "EmptyResponse") {
returnType = "void";
bodyLines = [
`await this.conn.extMethod("${fullMethod}", ${callParams});`,
];
bodyLines = [`await this.conn.request("${fullMethod}", ${callParams});`];
} else {
returnType = "Record<string, unknown>";
bodyLines = [
`return await this.conn.extMethod("${fullMethod}", ${callParams ? callParams : "{}"});`,
`return await this.conn.request<Record<string, unknown>>("${fullMethod}", ${callParams ? callParams : "{}"});`,
];
}
@@ -249,134 +210,6 @@ async function generateClient(meta: {
}`);
}
const handlerFields: string[] = [];
const dispatchCases: string[] = [];
for (const n of meta.notifications ?? []) {
const handlerName = methodToHandlerName(n.method);
if (!n.paramsType) {
handlerFields.push(
` ${handlerName}?: (params: Record<string, unknown>) => Promise<void>;`,
);
dispatchCases.push(
` case "${n.method}": {
await callbacks.${handlerName}?.(params);
return;
}`,
);
continue;
}
typeImports.add(n.paramsType);
const zodName = `z${n.paramsType}`;
zodImports.add(zodName);
handlerFields.push(
` ${handlerName}?: (notification: ${n.paramsType}) => Promise<void>;`,
);
dispatchCases.push(
` case "${n.method}": {
const parsed = ${zodName}.parse(params) as ${n.paramsType};
await callbacks.${handlerName}?.(parsed);
return;
}`,
);
}
const agentRequestHandlerFields: string[] = [];
const agentRequestDispatchCases: string[] = [];
for (const r of meta.agentRequests ?? []) {
const handlerName = methodToHandlerName(r.method);
const argType = r.requestType ?? "Record<string, unknown>";
const retType = r.responseType ?? "Record<string, unknown>";
if (r.requestType) typeImports.add(r.requestType);
if (r.responseType) typeImports.add(r.responseType);
agentRequestHandlerFields.push(
` ${handlerName}?: (request: ${argType}) => Promise<${retType}>;`,
);
const parseLine = r.requestType
? (() => {
zodImports.add(`z${r.requestType}`);
return `const parsed = z${r.requestType}.parse(params) as ${r.requestType};`;
})()
: `const parsed = params as Record<string, unknown>;`;
agentRequestDispatchCases.push(
` case "${r.method}": {
if (callbacks.${handlerName}) {
${parseLine}
return await callbacks.${handlerName}(parsed);
}
if (callbacks.extMethod) {
return await callbacks.extMethod(method, params);
}
throw new Error(\`unhandled ext method: \${method}\`);
}`,
);
}
const handlersInterface = `export interface GooseExtNotifications {
${handlerFields.join("\n")}
}`;
const agentRequestsInterface = `export interface GooseExtAgentRequests {
${agentRequestHandlerFields.join("\n")}
}`;
const agentRequestDispatcherFn = `export function installGooseExtAgentRequestDispatcher(
callbacks: GooseClientCallbacks,
): Client {
const dispatcher: Pick<Client, "extMethod"> = {
extMethod: async (method, params) => {
switch (method) {
${agentRequestDispatchCases.join("\n")}
default:
if (callbacks.extMethod) {
return await callbacks.extMethod(method, params);
}
throw new Error(\`unhandled ext method: \${method}\`);
}
},
};
return new Proxy(callbacks, {
get(target, property) {
if (property === "extMethod") {
return dispatcher.extMethod;
}
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as Client;
}`;
const dispatcherFn = `export function installGooseExtNotificationDispatcher(
callbacks: GooseClientCallbacks,
): Client {
const dispatcher: Pick<Client, "extNotification"> = {
extNotification: async (method, params) => {
switch (method) {
${dispatchCases.join("\n")}
default:
await callbacks.extNotification?.(method, params);
return;
}
},
};
return new Proxy(callbacks, {
get(target, property) {
if (property === "extNotification") {
return dispatcher.extNotification;
}
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as Client;
}`;
const upstreamImportLine = `import type { ${[...upstreamTypeImports].sort().join(", ")} } from "@agentclientprotocol/sdk";`;
const typeImportLine = typeImports.size
? `import type { ${[...typeImports].sort().join(", ")} } from "./types.gen.js";`
@@ -387,32 +220,14 @@ ${dispatchCases.join("\n")}
let src = `// This file is auto-generated — do not edit manually.
export interface ExtMethodProvider {
extMethod(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
}
${upstreamImportLine}
${typeImportLine}
${zodImportLine}
export class GooseExtClient {
constructor(private conn: ExtMethodProvider) {}
constructor(private conn: Pick<ClientContext, "request">) {}
${methodDefs.join("\n")}
}
${handlersInterface}
${agentRequestsInterface}
export type GooseClientCallbacks =
Omit<Client, "extNotification" | "extMethod"> &
Partial<Pick<Client, "extNotification" | "extMethod">> &
GooseExtNotifications &
GooseExtAgentRequests;
${dispatcherFn}
${agentRequestDispatcherFn}
`;
src = await prettier.format(src, { parser: "typescript" });
+2 -4
View File
@@ -37,8 +37,6 @@
"build:native:all": "tsx scripts/build-native.ts --all",
"generate": "tsx generate-schema.ts",
"lint": "tsc --noEmit",
"test": "node --import tsx --test tests/*.test.ts",
"typecheck:test": "tsc -p tsconfig.test.json --noEmit",
"format": "prettier --write src/",
"check:compat": "node scripts/check-binary-compat.mjs"
},
@@ -48,7 +46,7 @@
"zod": "^3.25.76"
},
"peerDependencies": {
"@agentclientprotocol/sdk": "^0.19.0"
"@agentclientprotocol/sdk": "^1.3.0"
},
"optionalDependencies": {
"@aaif/goose-binary-darwin-arm64": "workspace:*",
@@ -58,7 +56,7 @@
"@aaif/goose-binary-win32-x64": "workspace:*"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@agentclientprotocol/sdk": "^1.3.0",
"@hey-api/openapi-ts": "^0.92.3",
"@types/node": "^26.1.2",
"prettier": "^3.8.1",
+21 -15
View File
@@ -35,10 +35,13 @@ if (!GOOSE_BINARY || !existsSync(GOOSE_BINARY)) {
process.exit(1);
}
const { GooseClient } = await import(join(SDK_DIST, "goose-client.js"));
const { PROTOCOL_VERSION, ndJsonStream } = await import(
"@agentclientprotocol/sdk"
);
const { GooseExtClient } = await import(join(SDK_DIST, "index.js"));
const {
client: createAcpClient,
methods,
PROTOCOL_VERSION,
ndJsonStream,
} = await import("@agentclientprotocol/sdk");
// Each entry is a read-only ACP method we expect to succeed against a fresh,
// unconfigured goose install. Platform-specific skips keep hardware-sensitive
@@ -123,15 +126,16 @@ const stream = ndJsonStream(
Readable.toWeb(child.stdout),
);
const client = new GooseClient(
() => ({
requestPermission: async () => ({
outcome: { outcome: "cancelled" },
}),
sessionUpdate: async () => {},
}),
stream,
);
const app = createAcpClient({ name: "publish-npm-compat" })
.onRequest(methods.client.session.requestPermission, async () => ({
outcome: { outcome: "cancelled" },
}))
.onNotification(methods.client.session.update, async () => {});
const connection = app.connect(stream);
const client = {
connection,
goose: new GooseExtClient(connection.agent),
};
let failed = 0;
let passed = 0;
@@ -143,7 +147,7 @@ const timeout = (ms, label) =>
try {
await Promise.race([
client.initialize({
client.connection.agent.request(methods.agent.initialize, {
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: "publish-npm-compat", version: "0.0.0" },
clientCapabilities: {},
@@ -164,13 +168,15 @@ try {
passed += 1;
} catch (err) {
failed += 1;
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
const msg =
err instanceof Error ? (err.stack ?? err.message) : String(err);
console.error(`[compat] ❌ ${check.name}`);
console.error(indent(msg, " "));
}
}
} finally {
exitedEarly = true;
connection.close();
child.kill("SIGTERM");
try {
rmSync(sandbox, { recursive: true, force: true });
File diff suppressed because it is too large Load Diff
-140
View File
@@ -1,140 +0,0 @@
import {
ClientSideConnection,
type Stream,
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type PromptRequest,
type PromptResponse,
type CancelNotification,
type AuthenticateRequest,
type AuthenticateResponse,
type SetSessionModeRequest,
type SetSessionModeResponse,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type ForkSessionRequest,
type ForkSessionResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
type CloseSessionRequest,
type CloseSessionResponse,
type SetSessionModelRequest,
type SetSessionModelResponse,
} from "@agentclientprotocol/sdk";
import {
GooseExtClient,
installGooseExtAgentRequestDispatcher,
installGooseExtNotificationDispatcher,
type GooseClientCallbacks,
} from "./generated/client.gen.js";
import { createHttpStream } from "./http-stream.js";
export class GooseClient {
private conn: ClientSideConnection;
private ext: GooseExtClient;
constructor(
toClient: () => GooseClientCallbacks,
streamOrUrl: Stream | string,
) {
const stream =
typeof streamOrUrl === "string"
? createHttpStream(streamOrUrl)
: streamOrUrl;
const toAcpClient = () =>
installGooseExtAgentRequestDispatcher(
installGooseExtNotificationDispatcher(toClient()),
);
this.conn = new ClientSideConnection(toAcpClient, stream);
this.ext = new GooseExtClient(this.conn);
}
get signal(): AbortSignal {
return this.conn.signal;
}
get closed(): Promise<void> {
return this.conn.closed;
}
initialize(params: InitializeRequest): Promise<InitializeResponse> {
return this.conn.initialize(params);
}
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
return this.conn.newSession(params);
}
loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
return this.conn.loadSession(params);
}
prompt(params: PromptRequest): Promise<PromptResponse> {
return this.conn.prompt(params);
}
cancel(params: CancelNotification): Promise<void> {
return this.conn.cancel(params);
}
authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse> {
return this.conn.authenticate(params);
}
setSessionMode(
params: SetSessionModeRequest,
): Promise<SetSessionModeResponse> {
return this.conn.setSessionMode(params);
}
setSessionConfigOption(
params: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
return this.conn.setSessionConfigOption(params);
}
unstable_forkSession(
params: ForkSessionRequest,
): Promise<ForkSessionResponse> {
return this.conn.unstable_forkSession(params);
}
listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
return this.conn.listSessions(params);
}
unstable_resumeSession(
params: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
return this.conn.unstable_resumeSession(params);
}
unstable_closeSession(
params: CloseSessionRequest,
): Promise<CloseSessionResponse> {
return this.conn.unstable_closeSession(params);
}
unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse> {
return this.conn.unstable_setSessionModel(params);
}
extMethod(
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return this.conn.extMethod(method, params);
}
get goose(): GooseExtClient {
return this.ext;
}
}
-407
View File
@@ -1,407 +0,0 @@
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
const ACP_CONNECTION_HEADER = "Acp-Connection-Id";
const ACP_SESSION_HEADER = "Acp-Session-Id";
function acpDebug(label: string, payload: unknown): void {
const g = globalThis as {
ACP_DEBUG?: unknown;
localStorage?: { getItem?: (k: string) => string | null };
process?: { env?: Record<string, string | undefined> };
};
const on =
g.ACP_DEBUG === true ||
g.ACP_DEBUG === "1" ||
!!g.localStorage?.getItem?.("ACP_DEBUG") ||
!!g.process?.env?.ACP_DEBUG;
if (!on) return;
// eslint-disable-next-line no-console
console.debug(`[acp] ${label}`, payload);
}
const SESSION_SCOPED_METHODS = new Set<string>([
"session/prompt",
"session/cancel",
"session/load",
"session/set_mode",
"session/set_model",
]);
function messageMethod(msg: AnyMessage): string | null {
const m = msg as { method?: unknown };
return typeof m.method === "string" ? m.method : null;
}
function messageParams(msg: AnyMessage): unknown {
return (msg as { params?: unknown }).params;
}
function messageResult(msg: AnyMessage): unknown {
return (msg as { result?: unknown }).result;
}
function isRequest(msg: AnyMessage): boolean {
const m = msg as { method?: unknown; id?: unknown };
return typeof m.method === "string" && m.id !== undefined && m.id !== null;
}
function isNotification(msg: AnyMessage): boolean {
const m = msg as { method?: unknown; id?: unknown };
return typeof m.method === "string" && (m.id === undefined || m.id === null);
}
function isResponse(msg: AnyMessage): boolean {
const m = msg as { method?: unknown; id?: unknown; result?: unknown; error?: unknown };
return (
m.method === undefined &&
m.id !== undefined &&
m.id !== null &&
(m.result !== undefined || m.error !== undefined)
);
}
function extractSessionId(value: unknown): string | null {
if (value && typeof value === "object" && "sessionId" in value) {
const sid = (value as { sessionId?: unknown }).sessionId;
if (typeof sid === "string") return sid;
}
return null;
}
/**
* Stream that speaks the ACP Streamable HTTP transport: a connection-scoped
* GET SSE stream plus a session-scoped stream per active `sessionId`.
*/
export function createHttpStream(serverUrl: string): Stream {
const base = serverUrl.replace(/\/+$/, "");
const endpoint = `${base}/acp`;
let connectionId: string | null = null;
let connectionStreamAbort: AbortController | null = null;
const sessionStreamAborts = new Map<string, AbortController>();
const openSessionStreams = new Set<string>();
let closed = false;
const inbox: AnyMessage[] = [];
let pullResolve: (() => void) | null = null;
function deliver(msg: AnyMessage) {
inbox.push(msg);
if (pullResolve) {
const r = pullResolve;
pullResolve = null;
r();
}
}
function waitForInbox(): Promise<void> {
if (inbox.length > 0) return Promise.resolve();
return new Promise<void>((r) => {
pullResolve = r;
});
}
async function openConnectionGetStream() {
if (!connectionId) return;
connectionStreamAbort = new AbortController();
const response = await fetch(endpoint, {
method: "GET",
headers: {
Accept: "text/event-stream",
[ACP_CONNECTION_HEADER]: connectionId,
},
signal: connectionStreamAbort.signal,
});
if (!response.ok || !response.body) {
throw new Error(
`Failed to open ACP connection-scoped GET stream: ${response.status} ${response.statusText}`,
);
}
void consumeSSE(response.body, "connection").catch((err) => {
if (closed) return;
// eslint-disable-next-line no-console
console.error("ACP connection-scoped GET stream error:", err);
});
}
async function ensureSessionGetStream(sessionId: string): Promise<void> {
if (!connectionId) return;
if (openSessionStreams.has(sessionId)) return;
openSessionStreams.add(sessionId);
const abort = new AbortController();
sessionStreamAborts.set(sessionId, abort);
let response: Response;
try {
response = await fetch(endpoint, {
method: "GET",
headers: {
Accept: "text/event-stream",
[ACP_CONNECTION_HEADER]: connectionId,
[ACP_SESSION_HEADER]: sessionId,
},
signal: abort.signal,
});
} catch (e) {
openSessionStreams.delete(sessionId);
sessionStreamAborts.delete(sessionId);
throw e;
}
if (!response.ok || !response.body) {
openSessionStreams.delete(sessionId);
sessionStreamAborts.delete(sessionId);
throw new Error(
`Failed to open ACP session-scoped GET stream for ${sessionId}: ${response.status} ${response.statusText}`,
);
}
acpDebug("session GET stream open", { sessionId });
void consumeSSE(response.body, `session:${sessionId}`)
.catch((err) => {
if (closed) return;
// eslint-disable-next-line no-console
console.error(
`ACP session-scoped GET stream error (${sessionId}):`,
err,
);
})
.finally(() => {
if (sessionStreamAborts.get(sessionId) === abort) {
sessionStreamAborts.delete(sessionId);
openSessionStreams.delete(sessionId);
acpDebug("session GET stream closed", { sessionId });
}
});
}
async function consumeSSE(body: ReadableStream<Uint8Array>, label: string) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const event = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
handleSseEvent(event, label);
}
}
if (buffer.length > 0) handleSseEvent(buffer, label);
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") return;
throw e;
}
}
function handleSseEvent(event: string, label: string) {
const dataLines: string[] = [];
for (const line of event.split("\n")) {
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).replace(/^ /, ""));
}
}
if (dataLines.length === 0) return;
const data = dataLines.join("\n");
let msg: AnyMessage;
try {
msg = JSON.parse(data) as AnyMessage;
} catch {
return;
}
acpDebug(`SSE → client (${label})`, msg);
handleInbound(msg);
}
function handleInbound(msg: AnyMessage) {
if (isResponse(msg)) {
const sid = extractSessionId(messageResult(msg));
if (sid && !openSessionStreams.has(sid)) {
ensureSessionGetStream(sid).catch((err) => {
if (closed) return;
// eslint-disable-next-line no-console
console.error("Failed to open session GET stream:", err);
});
}
}
deliver(msg);
}
async function sendInitialize(msg: AnyMessage) {
acpDebug("initialize → agent", msg);
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(msg),
});
if (!response.ok) {
throw new Error(
`ACP initialize failed: ${response.status} ${response.statusText}`,
);
}
const connId = response.headers.get(ACP_CONNECTION_HEADER);
if (!connId) {
throw new Error(
`ACP initialize response missing ${ACP_CONNECTION_HEADER} header`,
);
}
connectionId = connId;
const body = (await response.json()) as AnyMessage;
acpDebug("initialize response", body);
// Open the connection-scoped GET stream before delivering the initialize
// response so we don't miss any immediate server-initiated messages.
await openConnectionGetStream();
deliver(body);
}
async function sendPost(msg: AnyMessage) {
if (!connectionId) {
throw new Error("ACP POST attempted before initialize");
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
[ACP_CONNECTION_HEADER]: connectionId,
};
let outboundSessionId: string | null = null;
if (isRequest(msg) || isNotification(msg)) {
outboundSessionId = extractSessionId(messageParams(msg));
if (outboundSessionId) {
headers[ACP_SESSION_HEADER] = outboundSessionId;
} else if (isRequest(msg)) {
const method = messageMethod(msg);
if (method && SESSION_SCOPED_METHODS.has(method)) {
throw new Error(`ACP method ${method} requires sessionId in params`);
}
}
}
if (outboundSessionId && messageMethod(msg) !== "session/load") {
try {
await ensureSessionGetStream(outboundSessionId);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to ensure session GET stream:", err);
}
}
acpDebug("POST → agent", msg);
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(msg),
});
if (response.status !== 202 && !response.ok) {
throw new Error(
`ACP POST failed: ${response.status} ${response.statusText}`,
);
}
await response.arrayBuffer().catch(() => undefined);
}
async function sendDelete() {
if (!connectionId) return;
try {
await fetch(endpoint, {
method: "DELETE",
headers: { [ACP_CONNECTION_HEADER]: connectionId },
});
} catch {
// best-effort
}
}
function abortAllStreams() {
connectionStreamAbort?.abort();
connectionStreamAbort = null;
for (const a of sessionStreamAborts.values()) {
a.abort();
}
sessionStreamAborts.clear();
openSessionStreams.clear();
}
const readable = new ReadableStream<AnyMessage>({
async pull(controller) {
await waitForInbox();
while (inbox.length > 0) {
controller.enqueue(inbox.shift()!);
}
if (closed && inbox.length === 0) {
controller.close();
}
},
async cancel() {
closed = true;
await sendDelete();
abortAllStreams();
if (pullResolve) {
const r = pullResolve;
pullResolve = null;
r();
}
},
});
const writable = new WritableStream<AnyMessage>({
async write(msg) {
if (
!connectionId &&
isRequest(msg) &&
messageMethod(msg) === "initialize"
) {
await sendInitialize(msg);
return;
}
if (!connectionId) {
throw new Error(
"ACP transport: first outgoing message must be `initialize`",
);
}
await sendPost(msg);
},
async close() {
closed = true;
await sendDelete();
abortAllStreams();
if (pullResolve) {
const r = pullResolve;
pullResolve = null;
r();
}
},
async abort() {
closed = true;
await sendDelete();
abortAllStreams();
if (pullResolve) {
const r = pullResolve;
pullResolve = null;
r();
}
},
});
return { readable, writable };
}
+4 -11
View File
@@ -1,16 +1,9 @@
export * from "./generated/types.gen.js";
export * from "./generated/zod.gen.js";
export {
type GooseClientCallbacks,
type GooseExtNotifications,
} from "./generated/client.gen.js";
export { GooseClient } from "./goose-client.js";
export { createHttpStream } from "./http-stream.js";
GOOSE_EXT_AGENT_REQUESTS,
GOOSE_EXT_NOTIFICATIONS,
} from "./generated/index.js";
export { GooseExtClient } from "./generated/client.gen.js";
export * from "./client-capabilities.js";
export * from "./mcp-apps.js";
export {
ClientSideConnection,
type Client,
type Stream,
} from "@agentclientprotocol/sdk";
-181
View File
@@ -1,181 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
installGooseExtAgentRequestDispatcher,
installGooseExtNotificationDispatcher,
} from "../src/generated/client.gen.ts";
import type {
GooseSessionNotification_unstable,
RecipeParamsResponse_unstable,
RequestRecipeParams_unstable,
} from "../src/generated/types.gen.ts";
import type {
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
} from "@agentclientprotocol/sdk";
class ClassBackedCallbacks {
#events: string[] = [];
get events(): string[] {
return this.#events;
}
async requestPermission(
_params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
this.#events.push("requestPermission");
return { outcome: { outcome: "cancelled" } };
}
async sessionUpdate(_params: SessionNotification): Promise<void> {
this.#events.push("sessionUpdate");
}
async extNotification(
method: string,
_params: Record<string, unknown>,
): Promise<void> {
this.#events.push(`extNotification:${method}`);
}
async unstable_sessionUpdate(
notification: GooseSessionNotification_unstable,
): Promise<void> {
this.#events.push(
`unstable_sessionUpdate:${notification.update.sessionUpdate}`,
);
}
}
class MinimalCallbacks {
async requestPermission(
_params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
return { outcome: { outcome: "cancelled" } };
}
async sessionUpdate(_params: SessionNotification): Promise<void> {}
}
class AgentRequestCallbacks extends MinimalCallbacks {
events: string[] = [];
async unstable_sessionRecipeRequestParams(
request: RequestRecipeParams_unstable,
): Promise<RecipeParamsResponse_unstable> {
this.events.push(`typed:${request.sessionId}`);
return { action: "submit", values: { name: "Ada" } };
}
async extMethod(
method: string,
_params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
this.events.push(`extMethod:${method}`);
return { action: "cancel" };
}
}
class GenericAgentRequestCallbacks extends MinimalCallbacks {
events: string[] = [];
async extMethod(
method: string,
_params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
this.events.push(`extMethod:${method}`);
return { action: "cancel" };
}
}
const recipeParamRequest: RequestRecipeParams_unstable = {
sessionId: "session-1",
parameters: [
{
key: "name",
input_type: "string",
requirement: "user_prompt",
description: "Name",
},
],
};
const recipeParamRequestParams = recipeParamRequest as unknown as Record<
string,
unknown
>;
test("dispatcher preserves class-backed callback receivers", async () => {
const callbacks = new ClassBackedCallbacks();
const client = installGooseExtNotificationDispatcher(callbacks);
await client.requestPermission({} as RequestPermissionRequest);
await client.sessionUpdate({} as SessionNotification);
await client.extNotification!("_goose/unstable/session/update", {
sessionId: "session-1",
update: {
sessionUpdate: "status_message",
status: {
type: "notice",
message: "ready",
},
},
});
await client.extNotification!("example/unknown", {});
assert.deepEqual(callbacks.events, [
"requestPermission",
"sessionUpdate",
"unstable_sessionUpdate:status_message",
"extNotification:example/unknown",
]);
});
test("raw extNotification is optional", async () => {
const client = installGooseExtNotificationDispatcher(new MinimalCallbacks());
await client.extNotification!("example/unknown", {});
});
test("agent request dispatcher prefers typed callbacks", async () => {
const callbacks = new AgentRequestCallbacks();
const client = installGooseExtAgentRequestDispatcher(callbacks);
const response = await client.extMethod!(
"_goose/unstable/session/recipe/request-params",
recipeParamRequestParams,
);
assert.deepEqual(response, { action: "submit", values: { name: "Ada" } });
assert.deepEqual(callbacks.events, ["typed:session-1"]);
});
test("agent request dispatcher falls back to raw extMethod", async () => {
const callbacks = new GenericAgentRequestCallbacks();
const client = installGooseExtAgentRequestDispatcher(callbacks);
const response = await client.extMethod!(
"_goose/unstable/session/recipe/request-params",
recipeParamRequestParams,
);
assert.deepEqual(response, { action: "cancel" });
assert.deepEqual(callbacks.events, [
"extMethod:_goose/unstable/session/recipe/request-params",
]);
});
test("agent request dispatcher throws when a request is unhandled", async () => {
const client = installGooseExtAgentRequestDispatcher(new MinimalCallbacks());
await assert.rejects(
() =>
client.extMethod!(
"_goose/unstable/session/recipe/request-params",
recipeParamRequestParams,
),
/unhandled ext method: _goose\/unstable\/session\/recipe\/request-params/,
);
});