feat(acp): promote next implementation (#29929)

This commit is contained in:
Shoubhit Dash 2026-05-30 01:34:44 +05:30 committed by GitHub
commit 4cc166a400
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 506 additions and 4224 deletions

View file

@ -1,298 +0,0 @@
import { describe, expect } from "bun:test"
import type {
CloseSessionResponse,
InitializeResponse,
NewSessionResponse,
ResumeSessionResponse,
SessionNotification,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { cliIt } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import {
createAcpClient,
expectOk,
firstAlternateValue,
flattenSelectOptions,
selectConfigOption,
} from "./acp-test-client"
describe("opencode acp verifier compatibility baseline", () => {
cliIt.live(
"initialize advertises close and resume capabilities",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
const initialized = expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
}),
)
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
cliIt.live(
"first session returns model options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: {},
clientInfo: { name: "opencode-local-acp-baseline", version: "0.1.0" },
})
const session = expectOk(
yield* acp.request<NewSessionResponse>("session/new", {
cwd: home,
mcpServers: [],
}),
)
const model = selectConfigOption(session.configOptions, "model")
expect(model?.category).toBe("model")
expect(model?.currentValue).toBe("test/test-model")
expect(model ? flattenSelectOptions(model).length : 0).toBeGreaterThanOrEqual(2)
}),
60_000,
)
cliIt.live(
"newSession can be called repeatedly",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] })
const session = expectOk(
yield* acp.request<NewSessionResponse>("session/new", {
cwd: home,
mcpServers: [],
}),
)
expect(session.sessionId).toBeTruthy()
}),
60_000,
)
cliIt.live(
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const model = selectConfigOption(session.configOptions, "model")
expect(model).toBeDefined()
const nextModel = model
? flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
: undefined
expect(nextModel).toBe("test/second-model")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
)
cliIt.live(
"effort option is listed for variant-capable models and can switch",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const effort = selectConfigOption(session.configOptions, "effort")
expect(effort?.category).toBe("thought_level")
const nextEffort = effort ? firstAlternateValue(effort) : undefined
expect(nextEffort).toBe("high")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
cliIt.live(
"default test provider documents missing effort option when the model has no variants",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(noVariantConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expect(selectConfigOption(session.configOptions, "model")?.currentValue).toBe("test/test-model")
expect(selectConfigOption(session.configOptions, "effort")).toBeUndefined()
}),
60_000,
)
cliIt.live(
"skill slash command appears through available_commands_update",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const skills = path.join(home, "skills")
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some((command) => command.name === "verifier-skill"),
)
expect(update.params?.sessionId).toBe(session.sessionId)
}),
60_000,
)
cliIt.live(
"close request succeeds for a live session",
({ home, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"resume request succeeds for a created session",
({ home, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
sessionId: session.sessionId,
cwd: home,
mcpServers: [],
}),
)
expect(resumed.configOptions?.length).toBeGreaterThan(0)
}),
60_000,
)
})
function verifierConfig(llmUrl: string, skills?: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
...(skills ? { skills: { paths: [skills] } } : {}),
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
},
},
},
},
}
}
function noVariantConfig(llmUrl: string) {
const config = verifierConfig(llmUrl)
return {
...config,
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: undefined,
},
"second-model": config.provider.test.models["second-model"],
},
},
},
}
}
const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`

View file

@ -1,70 +0,0 @@
// Subprocess integration tests for `opencode acp`. ACP is a JSON-RPC
// protocol spoken over stdin/stdout (not HTTP) — see src/acp/README.md.
// This is the only test tier that exercises the full pipe of bun startup →
// server boot → ACP agent init → stdio framing → graceful shutdown.
import { describe, expect } from "bun:test"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
describe("opencode acp (subprocess)", () => {
// Smoke test: send the `initialize` request from src/acp/README.md and
// assert the response advertises the same protocol version and a non-empty
// capabilities block. If this fails, every other ACP test will too — start
// debugging here.
cliIt.live(
"responds to initialize with protocolVersion 1 and capabilities",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* opencode.acp()
yield* acp.send({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: 1 },
})
// Tight deadline — the response should arrive within a few seconds
// once startup completes. A hang means the agent never finished init,
// which is a real regression and not a tuning issue.
const response = (yield* acp.receive.pipe(Effect.timeout(Duration.seconds(10)))) as {
jsonrpc: string
id: number
result?: { protocolVersion: number; agentCapabilities: Record<string, unknown> }
error?: unknown
}
expect(response.jsonrpc).toBe("2.0")
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
expect(response.result?.protocolVersion).toBe(1)
expect(response.result?.agentCapabilities).toBeDefined()
}),
60_000,
)
// Lock in the scope-close kill path. ACP's clean shutdown is "EOF on stdin"
// — if a future refactor breaks the stdin-end branch in the handler, the
// process would only exit on SIGTERM fallback (2s in the harness). This
// test passing within the inner-scope assertion proves the EOF path works.
cliIt.live(
"exits cleanly when stdin is closed (scope close)",
({ opencode }) =>
Effect.gen(function* () {
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp()
// Capture the Promise — scope-close fires the finalizer which
// ends stdin, and ACP should exit gracefully.
return acp.exited
}),
)
const code = yield* Effect.promise(() => exitedPromise)
// Bun returns a number for normal exit. Anything goes for SIGTERM,
// but we still require resolution within the test timeout.
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
})

View file

@ -2,9 +2,9 @@ import { describe, expect } from "bun:test"
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client"
import { expectOk, flattenSelectOptions, selectConfigOption } from "./acp-test-client"
import {
createAcpNextClient,
createAcpClient,
expectAlternateValue,
expectSelectOption,
initialize,
@ -12,12 +12,12 @@ import {
verifierConfig,
} from "./helpers"
describe("opencode acp-next config option subprocess", () => {
describe("opencode acp config option subprocess", () => {
cliIt.live(
'model option is listed with category "model"',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@ -35,7 +35,7 @@ describe("opencode acp-next config option subprocess", () => {
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@ -62,7 +62,7 @@ describe("opencode acp-next config option subprocess", () => {
'effort option is listed with category "thought_level" when selected model supports variants',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@ -80,7 +80,7 @@ describe("opencode acp-next config option subprocess", () => {
"effort switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)

View file

@ -4,23 +4,16 @@ import { Effect } from "effect"
import type { CliFixture } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import {
createAcpClient,
createAcpClient as createJsonRpcAcpClient,
expectOk,
flattenSelectOptions,
selectConfigOption,
type AcpClient,
} from "../acp/acp-test-client"
} from "./acp-test-client"
export function createAcpNextClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
export function createAcpClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
return Effect.gen(function* () {
return createAcpClient(
yield* input.opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
...env,
},
}),
)
return createJsonRpcAcpClient(yield* input.opencode.acp(env ? { env } : undefined))
})
}
@ -30,7 +23,7 @@ export function initialize(acp: AcpClient) {
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" },
clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
}),
)
})

View file

@ -2,14 +2,14 @@ import { describe, expect } from "bun:test"
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { createAcpNextClient, expectErrorCode, initialize } from "./helpers"
import { createAcpClient, expectErrorCode, initialize } from "./helpers"
describe("opencode acp-next initialize/auth subprocess", () => {
describe("opencode acp initialize/auth subprocess", () => {
cliIt.live(
"initialize responds with capabilities",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
const initialized = yield* initialize(yield* createAcpClient({ opencode }))
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
@ -30,7 +30,7 @@ describe("opencode acp-next initialize/auth subprocess", () => {
"auth negotiation is explicit and safe",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode })
const acp = yield* createAcpClient({ opencode })
const initialized = yield* initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
@ -50,7 +50,7 @@ describe("opencode acp-next initialize/auth subprocess", () => {
"initialize without terminal-auth metadata keeps auth command implicit",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode })
const acp = yield* createAcpClient({ opencode })
const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")

View file

@ -7,15 +7,15 @@ import type {
} from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, selectConfigOption } from "../acp/acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
import { expectOk, selectConfigOption } from "./acp-test-client"
import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
describe("opencode acp-next lifecycle subprocess", () => {
describe("opencode acp lifecycle subprocess", () => {
cliIt.live(
"stdin EOF exits cleanly",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
const acp = yield* opencode.acp()
acp.close()
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
@ -28,7 +28,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"close capability and close request",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@ -45,7 +45,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"loadSession capability and load request return session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@ -69,7 +69,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"list request includes a live ACP-created session",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
@ -86,7 +86,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"resume capability advertisement",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
const initialized = yield* initialize(yield* createAcpClient({ opencode }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
@ -97,7 +97,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"resume request returns session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)

View file

@ -5,18 +5,18 @@ import { writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { cliIt } from "../../lib/cli-process"
import { expectOk } from "../acp/acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
import { expectOk } from "./acp-test-client"
import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("opencode acp-next prompt content subprocess", () => {
describe("opencode acp prompt content subprocess", () => {
cliIt.live(
"accepts embedded text resource image and file resource link prompt content",
({ home, llm, opencode }) =>
Effect.gen(function* () {
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
)

View file

@ -4,9 +4,9 @@ import { Effect } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { cliIt } from "../../lib/cli-process"
import { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
import { createAcpClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
describe("opencode acp-next skills subprocess", () => {
describe("opencode acp skills subprocess", () => {
cliIt.live(
"skill slash command appears through available_commands_update",
({ home, llm, opencode }) =>
@ -14,7 +14,7 @@ describe("opencode acp-next skills subprocess", () => {
const skills = path.join(home, "skills")
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
const acp = yield* createAcpNextClient(
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
)