feat(cli): register acp command
This commit is contained in:
@@ -34,6 +34,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect } from "effect"
|
||||
import { ACP } from "../../acp/agent"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Standalone } from "../../services/standalone"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.acp,
|
||||
Effect.fn("cli.acp")(function* () {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const endpoint = yield* Standalone.start()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write: (chunk) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
|
||||
}),
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)))
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (error) => controller.error(error))
|
||||
},
|
||||
})
|
||||
const stream = ndJsonStream(input, output)
|
||||
const connection = new AgentSideConnection((connection) => ACP.create(client, connection), stream)
|
||||
process.stdin.resume()
|
||||
yield* Effect.promise(() => connection.closed)
|
||||
// EOF owns this stdio process; exiting also closes the private server's lease pipe.
|
||||
yield* Effect.sync(() => process.exit(0))
|
||||
}),
|
||||
)
|
||||
@@ -15,6 +15,7 @@ import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
acp: () => import("./commands/handlers/acp"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
auth: {
|
||||
connect: () => import("./commands/handlers/auth/connect"),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
|
||||
type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown }
|
||||
const children: Bun.Subprocess[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
children.splice(0).map(async (child) => {
|
||||
child.kill("SIGKILL")
|
||||
await child.exited
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("acp command", () => {
|
||||
test("is registered", async () => {
|
||||
const result = await cli(["--help"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
|
||||
})
|
||||
|
||||
test("initializes over ndjson and exits on stdin eof", async () => {
|
||||
const child = spawn()
|
||||
const stderr = new Response(child.stderr).text()
|
||||
child.stdin.write(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
clientInfo: { name: "test", version: "1.0.0" },
|
||||
},
|
||||
}) + "\n",
|
||||
),
|
||||
)
|
||||
child.stdin.flush()
|
||||
const response = await readMessage(child.stdout)
|
||||
expect(response.id).toBe(1)
|
||||
expect(response.error).toBeUndefined()
|
||||
expect(response.result).toMatchObject({
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: { loadSession: true },
|
||||
agentInfo: { name: "OpenCode" },
|
||||
})
|
||||
|
||||
child.stdin.end()
|
||||
const exitCode = await child.exited
|
||||
const errorOutput = await stderr
|
||||
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
|
||||
children.splice(children.indexOf(child), 1)
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
function spawn() {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
|
||||
cwd: path.join(import.meta.dir, "../.."),
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
children.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
async function readMessage(stream: ReadableStream<Uint8Array>) {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let output = ""
|
||||
while (true) {
|
||||
const result = await Promise.race([
|
||||
reader.read(),
|
||||
Bun.sleep(20_000).then(() => {
|
||||
throw new Error("timed out waiting for ACP response")
|
||||
}),
|
||||
])
|
||||
if (result.done) throw new Error(`ACP exited before responding: ${output}`)
|
||||
output += decoder.decode(result.value, { stream: true })
|
||||
const newline = output.indexOf("\n")
|
||||
if (newline === -1) continue
|
||||
reader.releaseLock()
|
||||
return JSON.parse(output.slice(0, newline)) as Message
|
||||
}
|
||||
}
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, "../.."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
Reference in New Issue
Block a user