Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 6d7238b22e chore(protocol): regenerate the OpenAPI document and check it in CI 2026-10-01 17:09:53 +05:30
Shoubhit Dash 0a6f380db7 fix(acp): report locations for native file tools (#52469) 2026-10-01 16:59:46 +05:30
Shoubhit Dash 44336e8b84 refactor(acp): run the catalog as an effect service (#52471) 2026-10-01 16:53:10 +05:30
Shoubhit Dash 20cf09752d fix(acp): report usage for the whole turn (#52467) 2026-10-01 16:21:26 +05:30
Shoubhit Dash 966e718b31 fix(acp): stop echoing edited files to the client (#52466) 2026-10-01 16:20:16 +05:30
opencode-agent[bot]andnexxeln cf0c9fb914 docs(plugin): use path in skill transform examples (#52470)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-10-01 16:09:33 +05:30
Shoubhit Dash bebc3640f4 refactor(acp): run ACP requests as effects and exit when the server dies (#52336) 2026-10-01 15:44:35 +05:30
Aiden Cline aa6a4f93bd fix(ai): keep system updates after pending tool results (#52426) 2026-10-01 02:46:37 -05:00
opencode-agent[bot] 8433dd732f chore: update nix node_modules hashes 2026-10-01 07:06:42 +00:00
Jérôme BenoitandTest User c34d09fe97 fix(nix): drop x86_64-darwin target and unblock flake eval on nixpkgs 26.11 (#52129)
Co-authored-by: Test User <test@test.com>
2026-10-01 01:55:00 -05:00
Aiden Cline 2fb7985bb7 fix(core): make MCP errors self-describing (#52418) 2026-10-01 01:46:55 -05:00
Aiden Cline a931e8a9fd fix(ai): classify Z.ai provider errors (#52104) 2026-10-01 01:30:53 -05:00
41 changed files with 2618 additions and 587 deletions

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
SYSTEMS="x86_64-linux aarch64-linux aarch64-darwin"
PACKAGES="opencode"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
+1 -3
View File
@@ -34,8 +34,6 @@ jobs:
runner: blacksmith-4vcpu-ubuntu-2404
- system: aarch64-linux
runner: blacksmith-4vcpu-ubuntu-2404-arm
- system: x86_64-darwin
runner: macos-15-intel
- system: aarch64-darwin
runner: macos-latest
runs-on: ${{ matrix.runner }}
@@ -126,7 +124,7 @@ jobs:
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
for SYSTEM in x86_64-linux aarch64-linux aarch64-darwin; do
FILE="hashes/hash-${SYSTEM}/hash.txt"
if [ -f "$FILE" ]; then
HASH="$(tr -d '[:space:]' < "$FILE")"
+5
View File
@@ -167,6 +167,11 @@ jobs:
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
- name: Check generated OpenAPI document
if: runner.os == 'Linux'
working-directory: packages/protocol
run: bun run check:generated
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
+1 -2
View File
@@ -12,7 +12,6 @@
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
rev = self.shortRev or self.dirtyShortRev or "dirty";
@@ -22,7 +21,7 @@
default = pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
nodejs
pkg-config
openssl
git
-1
View File
@@ -7,7 +7,6 @@ in
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
# fetchzip hashes the unpacked headers, not the release tarball.
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
}
+3 -4
View File
@@ -1,8 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-7DgxTpKv6ITKTom0mhlJNPQfdEjtEHK5P8uyCr26HZw=",
"aarch64-linux": "sha256-PJxW1Ibfx6oS1neWPSHqzP1Pm1HT9my1TrrHmOb6/no=",
"aarch64-darwin": "sha256-VIme5VHfM8JxNiDSOykkr5FytghDLI0FxkhiOXUSyQw=",
"x86_64-darwin": "sha256-rQ/j0QkR1vxAq4jgUbr0nY4RDyiLTJqN8q1AfoiEqVQ="
"x86_64-linux": "sha256-yCdtDQsXERjfL9bJg7YXNloMBOPwFkPMFVNAzXlarBM=",
"aarch64-linux": "sha256-iQ1bLIszETcFoD4CENpoaDZJZF/KBKZFeTp9r7ogJ9Y=",
"aarch64-darwin": "sha256-G7oIrTXEFQ5iEF8KSpA4xJPw9xwqRDpbIR472iIoqC4="
}
}
-1
View File
@@ -81,6 +81,5 @@ stdenvNoCC.mkDerivation {
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
}
+6 -3
View File
@@ -40,7 +40,8 @@ const patterns = [
/context[_ ]length[_ ]exceeded/i,
/context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
// Z.ai code 1261 arrives as `Prompt too long` or `Prompt 超长`.
/prompt (?:too long|超长)/i,
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
@@ -156,13 +157,15 @@ const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const AUTH_TEXT = /incorrect api key provided/i
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
// Z.ai reports balance, plan expiry, plan limits, and plan model access on 429.
const QUOTA_TEXT =
/insufficient[-_\s]?(?:quota|balance)|quota[-_\s]?exceeded|budget exceeded|usage limit|limit exhausted|package has expired|plan does not yet include/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
// under `invalid_request_error`.
const CONTENT_POLICY_TEXT =
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system/i
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system|detected potentially unsafe or sensitive content/i
const SERVER_ERROR_TEXT =
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
+15 -2
View File
@@ -6,10 +6,17 @@ const MISSING_TOOL_RESULT = "Tool result missing"
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
const normalized: Message[] = []
const pending = new Map<string, ToolCallPart>()
// System updates cannot sit between a tool call and its results, so they wait until every pending call is answered.
const held: Message[] = []
const releaseHeld = () => {
if (pending.size > 0) return
normalized.push(...held)
held.length = 0
}
const appendMissingResults = () => {
if (pending.size === 0) return
normalized.push(missingToolResults(pending.values()))
if (pending.size > 0) normalized.push(missingToolResults(pending.values()))
pending.clear()
releaseHeld()
}
for (const message of messages) {
@@ -18,6 +25,12 @@ export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
if (message.role === "tool") {
const tool = normalizeToolMessage(message, pending)
if (tool) normalized.push(tool)
releaseHeld()
continue
}
if (message.role === "system" && pending.size > 0) {
held.push(message)
continue
}
+2 -2
View File
@@ -147,7 +147,7 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("accepts a marker between a tool call and its result", () =>
it.effect("moves a marker between a tool call and its result after the result", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -166,8 +166,8 @@ describe("Anthropic Messages effort updates", () => {
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
{ role: "system", content: [], output_config: { effort: "low" } },
])
}),
)
+76
View File
@@ -284,6 +284,82 @@ describe("provider error classification", () => {
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("classifies Z.ai plan and balance limits as quota rather than throttling", () => {
const zai = (code: string, message: string) => ({ error: { code, message } })
const cases = [
zai("1113", "Insufficient balance or no resource package. Please recharge."),
zai("1308", "Usage limit reached for 5 hours. Your limit will reset at 2026-10-01 00:00:00"),
zai(
"1309",
"Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.",
),
zai("1310", "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-10-01 00:00:00"),
zai("1311", "Your current subscription plan does not yet include access to glm-5"),
zai("1314", "Your enterprise package has expired. Please contact your enterprise administrator."),
// Z.ai's Anthropic-compatible endpoint wraps the code and request ID into the message.
{
type: "error",
error: {
type: "rate_limit_error",
code: "1309",
message:
"[1309][Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.][20260929132151e73af01340d54b58]",
},
},
]
expect(
cases.map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(Array(cases.length).fill("QuotaExceeded"))
})
test("classifies Z.ai prompt length rejections as context overflow", () => {
const cases = [
{ error: { code: "1261", message: "Prompt 超长" } },
{ error: { code: "1261", message: "Prompt too long" } },
{
type: "error",
error: { type: "invalid_request_error", code: "1261", message: "[1261][Prompt too long][2026092913]" },
},
]
expect(
cases.map((body) => {
const reason = classifyProviderFailure({
message: body.error.message,
status: 400,
rawBody: JSON.stringify(body),
})
return reason._tag === "InvalidRequest" ? reason.classification : reason._tag
}),
).toEqual(["context-overflow", "context-overflow", "context-overflow"])
})
test("classifies Z.ai sensitive content rejections as content policy", () => {
const message =
"System detected potentially unsafe or sensitive content in input or generation. Please avoid using prompts that may generate sensitive content. Thank you for your cooperation."
expect(
classifyProviderFailure({
message,
status: 400,
rawBody: JSON.stringify({ error: { code: "1301", message } }),
})._tag,
).toBe("ContentPolicy")
})
test("keeps Z.ai throttling and overload retryable", () => {
expect(
[
{ error: { code: "1302", message: "Rate limit reached for requests" } },
{ error: { code: "1305", message: "The service may be temporarily overloaded, please try again later" } },
].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["RateLimit", "RateLimit"])
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
@@ -588,9 +588,9 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("rejects a system update between a local tool call and its result", () =>
it.effect("moves a system update between a local tool call and its result after the result", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const prepared = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
@@ -601,9 +601,13 @@ describe("Anthropic Messages route", () => {
],
cache: "none",
}),
).pipe(Effect.flip)
)
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
expect(prepared.body.messages.slice(1)).toEqual([
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '"Done."' }] },
{ role: "system", content: [{ type: "text", text: "Too early.", cache_control: undefined }] },
])
}),
)
+47
View File
@@ -112,4 +112,51 @@ describe("tool history normalization", () => {
expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined })
})
test("moves system updates after the results of pending calls", () => {
const calls = Message.assistant([toolCall("first"), toolCall("second")])
const first = toolResult("first", "one", "first", "text")
const second = toolResult("second", "two", "second", "text")
const update = Message.system("First update.")
const later = Message.system("Second update.")
const user = Message.user("Continue.")
const missing = (id: string) =>
Message.tool(ToolResultPart.make({ id, name: id, result: "Tool result missing", resultType: "error" }))
expect(normalizeToolHistory([calls, first, update, later, second, user])).toEqual([
calls,
first,
second,
update,
later,
user,
])
expect(normalizeToolHistory([Message.assistant(toolCall("first")), update, user])).toEqual([
Message.assistant(toolCall("first")),
missing("first"),
update,
user,
])
expect(normalizeToolHistory([Message.assistant(toolCall("first")), update])).toEqual([
Message.assistant(toolCall("first")),
missing("first"),
update,
])
})
test("moves effort updates after the results of pending calls", () => {
const call = Message.assistant(toolCall("first"))
const result = toolResult("first", "one", "first", "text")
const effort = Message.effort({ effort: "low", previous: "high" })
expect(normalizeToolHistory([call, effort, result])).toEqual([call, result, effort])
})
test("keeps system updates in place when no call is pending", () => {
const history = [Message.assistant(toolCall("first")), toolResult("first", "one", "first", "text")]
const update = Message.system("Update.")
const input = [Message.user("Start."), update, ...history, update, Message.user("Continue.")]
expect(normalizeToolHistory(input)).toBe(input)
})
})
+110 -43
View File
@@ -1,52 +1,119 @@
import { agent, RequestError, type Stream } from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/promise"
import {
agent,
RequestError,
type AgentHandlerContext,
type AgentNotificationHandlersByMethod,
type AgentNotificationMethod,
type AgentRequestHandlersByMethod,
type AgentRequestMethod,
type Stream,
} from "@agentclientprotocol/sdk"
import { ClientError, type OpenCodeClient } from "@opencode/client/promise"
import { Cause, Effect, type Scope } from "effect"
import { ACPCatalog } from "./catalog"
import { ACPConnection } from "./connection"
import { ACPError } from "./error"
import { ACPService } from "./service"
export function connect(client: OpenCodeClient, stream: Stream) {
const connection = agent({ name: "opencode" })
.onRequest("initialize", (ctx) => run(service.initialize(ctx.params)))
.onRequest("authenticate", (ctx) => run(service.authenticate(ctx.params)))
.onRequest("session/new", (ctx) => run(service.newSession(ctx.params)))
.onRequest("session/load", (ctx) => run(service.loadSession(ctx.params)))
.onRequest("session/list", (ctx) => run(service.listSessions(ctx.params)))
.onRequest("session/delete", (ctx) => run(service.deleteSession(ctx.params)))
.onRequest("session/resume", (ctx) => run(service.resumeSession(ctx.params)))
.onRequest("session/close", (ctx) => run(service.closeSession(ctx.params)))
.onRequest("session/fork", (ctx) => run(service.forkSession(ctx.params)))
.onRequest("session/set_config_option", (ctx) => run(service.setSessionConfigOption(ctx.params)))
.onRequest("session/set_mode", (ctx) => run(service.setSessionMode(ctx.params)))
.onRequest("session/prompt", (ctx) => run(service.prompt(ctx.params, ctx.signal)))
.onNotification("session/cancel", (ctx) => run(service.cancel(ctx.params)))
.connect(stream)
// Inbound dispatch starts after the stream's async read loop yields, so handlers never observe this before assignment.
const service = ACPService.make({ client, connection: ACPConnection.make(connection) })
return connection
}
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
const run = Effect.runPromiseWith(yield* Effect.context<Scope.Scope>())
const catalog = yield* ACPCatalog.make(client)
const handle =
<Params, A>(call: (ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Error | RequestError>) =>
(name: string) => {
const handler = Effect.fn(name)(
call,
Effect.mapError((error) => (error instanceof RequestError ? error : ACPError.toRequestError(error))),
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
Effect.catchDefect((defect) => Effect.fail(ACPError.toRequestError(ACPError.fromUnknown(defect)))),
)
return (ctx: AgentHandlerContext<Params>) => run(handler(ctx))
}
const app = agent({ name: "opencode" })
const request = <Method extends AgentRequestMethod>(
method: Method,
make: (name: string) => AgentRequestHandlersByMethod[Method],
) => app.onRequest(method, make(spanName(method)))
const notification = <Method extends AgentNotificationMethod>(
method: Method,
make: (name: string) => AgentNotificationHandlersByMethod[Method],
) => app.onNotification(method, make(spanName(method)))
async function run<A>(promise: Promise<A>) {
try {
return await promise
} catch (error) {
if (error instanceof RequestError) throw error
if (isACPError(error)) throw ACPError.toRequestError(error)
throw ACPError.toRequestError(ACPError.fromUnknown(error))
}
}
request(
"initialize",
handle((ctx) => promise(() => service.initialize(ctx.params))),
)
request(
"authenticate",
handle((ctx) => promise(() => service.authenticate(ctx.params))),
)
request(
"session/new",
handle((ctx) => promise(() => service.newSession(ctx.params))),
)
request(
"session/load",
handle((ctx) => promise(() => service.loadSession(ctx.params))),
)
request(
"session/list",
handle((ctx) => promise(() => service.listSessions(ctx.params))),
)
request(
"session/delete",
handle((ctx) => promise(() => service.deleteSession(ctx.params))),
)
request(
"session/resume",
handle((ctx) => promise(() => service.resumeSession(ctx.params))),
)
request(
"session/close",
handle((ctx) => promise(() => service.closeSession(ctx.params))),
)
request(
"session/fork",
handle((ctx) => promise(() => service.forkSession(ctx.params))),
)
request(
"session/set_config_option",
handle((ctx) => promise(() => service.setSessionConfigOption(ctx.params))),
)
request(
"session/set_mode",
handle((ctx) => promise(() => service.setSessionMode(ctx.params))),
)
// The SDK signal is passed through rather than interrupting the fiber: a cancelled turn still resolves with
// `stopReason: "cancelled"`.
request(
"session/prompt",
handle((ctx) => promise(() => service.prompt(ctx.params, ctx.signal))),
)
notification(
"session/cancel",
handle((ctx) => promise(() => service.cancel(ctx.params))),
)
const connection = app.connect(stream)
// Inbound dispatch starts after the stream's async read loop yields, so handlers never observe this before assignment.
const service = ACPService.make({ client, connection: ACPConnection.make(connection), catalog, run })
return connection
})
function isACPError(error: unknown): error is ACPError.Error {
return (
error instanceof ACPError.SessionNotFoundError ||
error instanceof ACPError.SessionDirectoryMismatchError ||
error instanceof ACPError.InvalidConfigOptionError ||
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.AuthRequiredError ||
error instanceof ACPError.UnknownAuthMethodError ||
error instanceof ACPError.ServiceFailureError
const spanName = (method: string) => `cli.acp.${method.replaceAll("/", ".")}`
const promise = <A>(evaluate: () => Promise<A>) =>
Effect.tryPromise({
try: evaluate,
// A catalog load failure is classified by the client error that caused it.
catch: (cause) => (cause instanceof ACPCatalog.LoadError ? cause.cause : cause),
}).pipe(
Effect.catch((cause) => {
if (cause instanceof RequestError || ACPError.is(cause)) return Effect.fail(cause)
if (cause instanceof ClientError && cause.reason === "Transport")
return Effect.fail(new ACPError.ServerUnavailableError())
return Effect.die(cause)
}),
)
}
export * as ACP from "./agent"
+202 -100
View File
@@ -1,5 +1,6 @@
import type { CommandInfo, ModelInfo, ModelRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import { Context, Deferred, Effect, Exit, Schedule, Schema, Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import type { ConfigOptionProvider } from "./config-option"
export type Catalog = {
@@ -11,118 +12,219 @@ export type Catalog = {
readonly commands: CommandInfo[]
}
export type Live = {
export class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
reason: Schema.Literals(["models", "agents"]),
}) {
override get message() {
return this.reason === "models" ? "No models are available" : "No primary agents are available"
}
}
export class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
cause: Schema.Defect(),
}) {}
export type Error = NotReadyError | LoadError
export interface Interface {
/** Loads a directory's catalog once. Concurrent callers share the load, and a failed load is not cached. */
readonly get: (cwd: string) => Effect.Effect<Catalog, Error>
/** Resolves after a reload that started after the call. A failed reload keeps the previous catalog. */
readonly reload: (cwd: string) => Effect.Effect<void, Error>
/** Emits the current catalog, then each reloaded one. */
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Catalog") {}
type Entry = {
readonly cwd: string
current: Catalog
readonly catalog: SubscriptionRef.SubscriptionRef<Catalog>
readonly lock: Semaphore.Semaphore
requested: number
loaded: number
}
// Provider, integration, and credential changes reach the catalog through model.updated.
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
export function make(input: {
readonly client: OpenCodeClient
readonly signal?: AbortSignal
export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
const scope = yield* Effect.scope
const entries = new Map<string, Deferred.Deferred<Entry, Error>>()
const connected = yield* Deferred.make<void>()
// A reload covers every request made before it starts, so requests queued behind a running reload share
// one more load. Typed load failures keep the previous catalog and still settle the requests they covered.
const reload = (entry: Entry) =>
Effect.suspend(() => {
const target = ++entry.requested
return entry.lock.withPermit(
Effect.suspend(() => {
if (entry.loaded >= target) return Effect.void
const generation = entry.requested
return load(client, entry.cwd).pipe(
Effect.flatMap((next) => SubscriptionRef.set(entry.catalog, next)),
Effect.ignore,
Effect.andThen(
Effect.sync(() => {
entry.loaded = generation
}),
),
)
}),
)
})
// Subscribe before the first read so an update between the read and the subscription is not lost.
yield* Stream.fromAsyncIterable(client.event.subscribe(), (cause) => cause).pipe(
Stream.runForEach((event) => {
if (event.type === "server.connected") return Deferred.succeed(connected, undefined)
if (!reloadOn.has(event.type)) return Effect.void
const directory = event.location?.directory
const targets = directory === undefined ? [...entries.values()] : [entries.get(FSUtil.resolve(directory))]
return Effect.forEach(
targets.filter((entry) => entry !== undefined),
(entry) => Deferred.await(entry).pipe(Effect.flatMap(reload), Effect.ignore, Effect.forkIn(scope)),
{ discard: true },
)
}),
Effect.ignore,
Effect.ensuring(Deferred.succeed(connected, undefined)),
Effect.forkScoped,
)
const create = Effect.fnUntraced(function* (cwd: string) {
yield* Deferred.await(connected)
return {
cwd,
catalog: yield* SubscriptionRef.make<Catalog>(yield* load(client, cwd)),
lock: Semaphore.makeUnsafe(1),
requested: 0,
loaded: 0,
} satisfies Entry
})
const entry = (cwd: string) =>
Effect.suspend(() => {
const key = FSUtil.resolve(cwd)
const cached = entries.get(key)
if (cached) return Deferred.await(cached)
const loading = Deferred.makeUnsafe<Entry, Error>()
entries.set(key, loading)
return create(cwd).pipe(
Effect.onExit((exit) => {
if (Exit.isFailure(exit)) entries.delete(key)
return Deferred.done(loading, exit)
}),
Effect.forkIn(scope),
Effect.andThen(Deferred.await(loading)),
)
})
return Service.of({
get: Effect.fn("cli.acp.catalog.get")(function* (cwd) {
const loaded = yield* entry(cwd)
return yield* SubscriptionRef.get(loaded.catalog)
}),
reload: Effect.fn("cli.acp.catalog.reload")(function* (cwd) {
yield* reload(yield* entry(cwd))
}),
changes: (cwd) => Stream.unwrap(entry(cwd).pipe(Effect.map((loaded) => SubscriptionRef.changes(loaded.catalog)))),
})
})
export type Live = {
readonly cwd: string
readonly current: Catalog
}
/** Temporary adapter for the promise-based `ACPService` until sessions consume the service directly. */
export function promise(input: {
readonly catalog: Interface
readonly run: <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) => Promise<A>
readonly changed: (live: Live, previous: Catalog) => Promise<unknown>
}) {
const entries = new Map<string, Promise<Live>>()
const running = new Map<Live, Promise<void>>()
const queued = new Set<Live>()
let subscribed: Promise<void> | undefined
const get = (cwd: string) => {
const key = FSUtil.resolve(cwd)
const cached = entries.get(key)
if (cached) return cached
// Subscribe before the first read so an update between the read and the subscription is not lost.
const loaded = subscribe()
.then(() => load(input.client, cwd))
.then((current): Live => ({ cwd, current }))
.catch((error) => {
entries.delete(key)
throw error
})
entries.set(key, loaded)
return loaded
const lives = new Map<string, Live>()
return {
get: (cwd: string) =>
input.run(
Effect.gen(function* () {
const current = yield* input.catalog.get(cwd)
const key = FSUtil.resolve(cwd)
const existing = lives.get(key)
if (existing) return existing
const live: Live = {
cwd,
// A loaded entry's read never suspends.
get current() {
return Effect.runSync(input.catalog.get(cwd))
},
}
lives.set(key, live)
yield* input.catalog.changes(cwd).pipe(
Stream.runFoldEffect(
() => current,
(previous, next) =>
next === previous
? Effect.succeed(previous)
: Effect.promise(() => input.changed(live, previous).catch(() => {})).pipe(Effect.as(next)),
),
Effect.ignore,
Effect.forkScoped({ startImmediately: true }),
)
return live
}),
),
reload: (live: Live) => input.run(input.catalog.reload(live.cwd)),
}
const reload = (live: Live) => {
const current = running.get(live)
if (current) {
queued.add(live)
return current
}
const run = (async () => {
do {
queued.delete(live)
const next = await load(input.client, live.cwd).catch(() => undefined)
if (!next) break
const previous = live.current
live.current = next
await input.changed(live, previous).catch(() => {})
} while (queued.has(live))
running.delete(live)
})()
running.set(live, run)
return run
}
const subscribe = () =>
(subscribed ??= new Promise<void>((ready) => {
void (async () => {
for await (const event of input.client.event.subscribe({ signal: input.signal })) {
if (event.type === "server.connected") ready()
if (!reloadOn.has(event.type)) continue
const directory = event.location?.directory
const targets = directory === undefined ? [...entries.values()] : [entries.get(FSUtil.resolve(directory))]
targets.forEach((entry) => void entry?.then(reload, () => {}))
}
})()
.catch(() => {})
.finally(ready)
}))
return { get, reload }
}
async function load(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const load = (client: OpenCodeClient, cwd: string) =>
read(client, cwd).pipe(
// Some providers discover models in the background after plugin startup begins.
Effect.retry({
while: (error) => error._tag === "ACPCatalogNotReadyError",
schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })),
}),
Effect.withSpan("cli.acp.catalog.load"),
)
const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
const location = { directory: cwd }
// Some providers discover models in the background after plugin startup begins.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// Parallel reads can straddle initialization; select only from this model list.
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
if (defaultModel && defaultAgent) {
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
}
}
missing = defaultModel ? "No primary agents are available" : "No models are available"
await Bun.sleep(25)
}
throw new Error(missing)
}
const [modelResult, defaultResult, agentResult, commandResult] = yield* Effect.tryPromise({
try: (signal) =>
Promise.all([
client.model.list({ location }, { signal }),
client.model.default({ location }, { signal }),
client.agent.list({ location }, { signal }),
client.command.list({ location }, { signal }),
]),
catch: (cause) => new LoadError({ cause }),
})
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// Parallel reads can straddle initialization; select only from this model list.
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
if (!defaultModel) return yield* new NotReadyError({ reason: "models" })
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
// Core lists its resolved default agent first, the same one a new session runs.
const defaultAgent = agents[0]
if (!defaultAgent) return yield* new NotReadyError({ reason: "agents" })
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
} satisfies Catalog
})
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
return Array.from(new Set(models.map((model) => model.providerID)))
-4
View File
@@ -5,15 +5,12 @@ import {
type RequestPermissionResponse,
type SendRequestOptions,
type SessionNotification,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from "@agentclientprotocol/sdk"
export type Connection = {
readonly signal?: AbortSignal
sessionUpdate(params: SessionNotification): Promise<void>
requestPermission(params: RequestPermissionRequest, options?: SendRequestOptions): Promise<RequestPermissionResponse>
writeTextFile?(params: WriteTextFileRequest, options?: SendRequestOptions): Promise<WriteTextFileResponse>
extNotification?(method: string, params: Record<string, unknown>): Promise<void>
}
@@ -23,7 +20,6 @@ export function make(connection: AgentConnection): Connection {
sessionUpdate: (params) => connection.client.notify(methods.client.session.update, params),
requestPermission: (params, options) =>
connection.client.request(methods.client.session.requestPermission, params, options),
writeTextFile: (params, options) => connection.client.request(methods.client.fs.writeTextFile, params, options),
extNotification: (method, params) => connection.client.notify(method, params),
}
}
+23 -10
View File
@@ -40,16 +40,27 @@ export class ServiceFailureError extends Schema.TaggedError<ServiceFailureError>
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
| SessionNotFoundError
| SessionDirectoryMismatchError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| AuthRequiredError
| UnknownAuthMethodError
| ServiceFailureError
export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailableError>()(
"ACPServerUnavailableError",
{},
) {}
const Errors = Schema.Union([
SessionNotFoundError,
SessionDirectoryMismatchError,
InvalidConfigOptionError,
InvalidModelError,
InvalidEffortError,
InvalidModeError,
AuthRequiredError,
UnknownAuthMethodError,
ServiceFailureError,
ServerUnavailableError,
])
export type Error = typeof Errors.Type
export const is = Schema.is(Errors)
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
@@ -83,6 +94,8 @@ export function toRequestError(error: Error): RequestError {
},
error.safeMessage,
)
case "ACPServerUnavailableError":
return RequestError.internalError({ errorName: "ServerUnavailable" }, "OpenCode server is unavailable")
}
const exhaustive: never = error
return exhaustive
+52 -41
View File
@@ -5,11 +5,13 @@ import type {
SessionMessageAssistant,
SessionMessageInfo,
SessionStructuredError,
TokenUsageInfo,
} from "@opencode/client/promise"
import { TokenUsage } from "@opencode/schema/token-usage"
import type { ACPConnection } from "./connection"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import { replyPermission, syncEditedFiles } from "./permission"
import { replyPermission } from "./permission"
import {
completedToolUpdate,
errorToolUpdate,
@@ -19,7 +21,7 @@ import {
type ToolInput,
} from "./tool"
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission">
export type TurnControl = {
cancelled: boolean
@@ -84,14 +86,13 @@ export async function streamTurn(input: {
readonly sessionID: string
readonly cwd: string
readonly start: TurnStart
readonly writeTextFile: boolean
readonly action?: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
readonly connectionSignal?: AbortSignal
readonly sessionSignal?: AbortSignal
}): Promise<PromptResponse> {
}): Promise<{ readonly response: PromptResponse; readonly contextTokens: number | undefined }> {
const streamController = new AbortController()
const connectionAbort = () => streamController.abort()
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
@@ -101,15 +102,31 @@ export async function streamTurn(input: {
const control = input.control
let started = false
let assistantMessageID: string | undefined
let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined
let stepError: SessionStructuredError | undefined
let usage: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo } | undefined
const tools = new Map<string, ToolState>()
const retries = new Map<string, RetryStatus>()
const children = new Map<string, ChildSession>()
const openChildren = new Set<string>()
let handedOff = false
const recordStep = (tokens: TokenUsageInfo) => {
const turn = usage?.turn
usage = {
turn: turn
? {
input: turn.input + tokens.input,
output: turn.output + tokens.output,
reasoning: turn.reasoning + tokens.reasoning,
cache: { read: turn.cache.read + tokens.cache.read, write: turn.cache.write + tokens.cache.write },
}
: tokens,
last: tokens,
}
}
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
if (!input.childSessionUpdate) return
await input
@@ -196,7 +213,7 @@ export async function streamTurn(input: {
}
if (event.type === "session.step.started") {
if (!child) assistantMessageID = event.data.assistantMessageID
if (!child) stepError = undefined
if (retries.delete(eventSessionID))
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } })
continue
@@ -212,7 +229,6 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.text.delta") {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID,
@@ -221,7 +237,6 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.reasoning.delta") {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_thought_chunk",
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
@@ -230,7 +245,6 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.input.started") {
if (!child) assistantMessageID = event.data.assistantMessageID
tools.set(toolKey(event.data.sessionID, event.data.id), {
name: event.data.name,
input: {},
@@ -249,7 +263,6 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.called") {
if (!child) assistantMessageID = event.data.assistantMessageID
const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
current.input = event.data.input
@@ -284,16 +297,6 @@ export async function streamTurn(input: {
const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
tools.delete(key)
await syncEditedFiles({
connection: input.connection,
writeTextFile: input.writeTextFile,
sessionID: input.sessionID,
cwd: input.cwd,
toolName: current.name,
toolInput: current.input,
metadata: event.data.metadata ?? {},
signal: control.admission.signal,
}).catch(() => {})
await send({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
@@ -302,6 +305,7 @@ export async function streamTurn(input: {
input: current.input,
metadata: event.data.metadata,
content: event.data.content,
cwd: input.cwd,
}),
})
continue
@@ -326,8 +330,15 @@ export async function streamTurn(input: {
}
if (event.type === "session.step.ended") {
if (!child) {
assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
recordStep(event.data.tokens)
}
continue
}
if (event.type === "session.step.failed") {
if (!child) {
stepError = event.data.error
if (event.data.tokens) recordStep(event.data.tokens)
}
continue
}
@@ -373,14 +384,17 @@ export async function streamTurn(input: {
if (input.action) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
return {
response: response(undefined, undefined, "succeeded", control.cancelled, undefined),
contextTokens: undefined,
}
}
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "interrupted", true, undefined)
return { response: response(undefined, undefined, "interrupted", true, undefined), contextTokens: undefined }
}
}
const terminal = await completed
@@ -391,19 +405,17 @@ export async function streamTurn(input: {
.catch(() => {})
.finally(closeStream)
}
const assistant = assistantMessageID
? await input.client.session
.message.get({ sessionID: input.sessionID, messageID: assistantMessageID })
.catch(() => undefined)
: undefined
return response(
assistant?.type === "assistant" ? assistant : undefined,
executionError,
terminal,
control.cancelled,
finish,
retries.get(input.sessionID),
)
return {
response: response(
usage?.turn,
stepError ?? executionError,
terminal,
control.cancelled,
finish,
retries.get(input.sessionID),
),
contextTokens: usage ? TokenUsage.total(usage.last) : undefined,
}
} catch (error) {
streamController.abort()
await completed.catch(() => {})
@@ -529,6 +541,7 @@ async function replayMessage(
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
cwd,
}),
},
})
@@ -577,14 +590,13 @@ function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
}
function response(
assistant: SessionMessageAssistant | undefined,
executionError: { readonly type: string; readonly message: string } | undefined,
tokens: TokenUsageInfo | undefined,
error: { readonly type: string; readonly message: string } | undefined,
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
retry?: RetryStatus,
): PromptResponse {
const error = assistant?.error ?? executionError
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
throw new ACPError.ServiceFailureError({
@@ -593,12 +605,11 @@ function response(
errorName: error.type,
})
}
const tokens = assistant?.tokens
const usage = tokens
? {
inputTokens: tokens.input,
outputTokens: tokens.output,
totalTokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write,
totalTokens: TokenUsage.total(tokens),
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
+37 -80
View File
@@ -1,13 +1,19 @@
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode/client/promise"
import { Patch } from "@opencode/util/patch"
import { Result } from "effect"
import { isAbsolute, resolve } from "node:path"
import type { ACPConnection } from "./connection"
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
import {
absolutePath,
filePath,
patchHunks,
pendingToolCall,
stringValue,
toLocations,
type ToolInput,
} from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type Connection = Pick<ACPConnection.Connection, "requestPermission" | "writeTextFile">
type Connection = Pick<ACPConnection.Connection, "requestPermission">
type Tool = { readonly name: string; readonly input: ToolInput }
const options: PermissionOption[] = [
@@ -45,7 +51,7 @@ export async function replyPermission(input: {
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
@@ -69,45 +75,13 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
return `${prefix}: ${title}`
}
export async function syncEditedFiles(input: {
readonly connection: Pick<ACPConnection.Connection, "writeTextFile">
readonly writeTextFile: boolean
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly metadata: Readonly<Record<string, unknown>>
readonly signal?: AbortSignal
}) {
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.metadata.files)
? input.metadata.files.flatMap((file): string[] => {
if (!file || typeof file !== "object") return []
const path = "file" in file ? file.file : undefined
return typeof path === "string" ? [path] : []
})
: []
const path = filePath(input.toolInput)
const paths = [...new Set([...files, ...(path ? [path] : [])])]
await Promise.all(
paths.map(async (path) => {
const target = resolvePath(path, input.cwd)
const file = Bun.file(target)
if (!(await file.exists())) return
await input.connection.writeTextFile?.(
{ sessionId: input.sessionID, path: target, content: await file.text() },
{ cancellationSignal: input.signal },
)
}),
)
}
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
const path = filePath(input)
if (!path) return []
const oldText = await readText(path, cwd)
const file = filePath(input)
if (!file) return []
const path = absolutePath(file, cwd)
const oldText = await readText(path)
if (tool === "write") {
const content = stringValue(input.content)
return content === undefined ? [] : [{ type: "diff", path, oldText, newText: content }]
@@ -121,31 +95,25 @@ async function permissionPreviews(toolName: string, input: ToolInput, cwd: strin
return [{ type: "diff", path, oldText, newText }]
}
async function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const patchText = stringValue(input.patchText)
if (!patchText) return []
try {
const parsed = Patch.parse(patchText)
if (Result.isFailure(parsed)) return []
return await Promise.all(
parsed.success.map(async (hunk): Promise<ToolCallContent> => {
const oldText = hunk.type === "add" ? "" : await readText(hunk.path, cwd)
if (hunk.type === "add") {
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
return { type: "diff", path: hunk.path, oldText, newText }
}
if (hunk.type === "delete") return { type: "diff", path: hunk.path, oldText, newText: "" }
return {
type: "diff",
path: hunk.movePath ?? hunk.path,
oldText,
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
}
}),
)
} catch {
return []
}
function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
// Patch.derive throws when a hunk does not match the current file.
return Promise.all(
patchHunks(input).map(async (hunk): Promise<ToolCallContent> => {
const path = absolutePath(hunk.path, cwd)
if (hunk.type === "add") {
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
return { type: "diff", path, oldText: "", newText }
}
const oldText = await readText(path)
if (hunk.type === "delete") return { type: "diff", path, oldText, newText: "" }
return {
type: "diff",
path: hunk.movePath ? absolutePath(hunk.movePath, cwd) : path,
oldText,
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
}
}),
).catch(() => [])
}
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
@@ -176,27 +144,16 @@ function permissionLocations(
input: ToolInput,
resources: ReadonlyArray<string>,
cwd: string,
previews: ReadonlyArray<ToolCallContent>,
): ToolCallLocation[] {
const paths = previews.flatMap((preview) => (preview.type === "diff" ? [preview.path] : []))
if (paths.length > 0) return [...new Set(paths)].map((path) => ({ path }))
const locations = toLocations(toolName, input, cwd)
if (locations.length > 0) return locations
return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
}
function readText(path: string, cwd: string) {
return Bun.file(resolvePath(path, cwd))
function readText(path: string) {
return Bun.file(path)
.text()
.catch(() => "")
}
function filePath(input: ToolInput) {
return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
}
function resolvePath(path: string, cwd: string) {
return isAbsolute(path) ? path : resolve(cwd, path)
}
export * as ACPPermission from "./permission"
+22 -15
View File
@@ -8,6 +8,7 @@ import {
type SessionMessageInfo,
} from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import type { Effect, Scope } from "effect"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import type {
AuthenticateRequest,
@@ -57,6 +58,9 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
// ACP runs these itself; they take precedence over server commands with the same name.
const builtinCommands = new Map([["compact", { description: "Compact the session", start: "compaction" as const }]])
// Model and mode are unset while the session follows the server defaults.
type Attached = {
readonly id: string
@@ -95,15 +99,17 @@ export interface Interface {
export function make(input: {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Connection
readonly catalog: ACPCatalog.Interface
readonly run: <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) => Promise<A>
}): Interface {
const sessions = new Map<string, Attached>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
const capabilities = { writeTextFile: false, childSessionUpdates: false }
const capabilities = { childSessionUpdates: false }
const catalogs = ACPCatalog.make({
client: input.client,
signal: input.connection.signal,
const catalogs = ACPCatalog.promise({
catalog: input.catalog,
run: input.run,
changed: (live, previous) =>
Promise.all(
Array.from(sessions.values())
@@ -126,10 +132,12 @@ export function make(input: {
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: state.catalog.current.commands.map((command) => ({
name: command.name,
description: command.description ?? "",
})),
availableCommands: [
...state.catalog.current.commands
.filter((command) => !builtinCommands.has(command.name))
.map((command) => ({ name: command.name, description: command.description ?? "" })),
...Array.from(builtinCommands, ([name, command]) => ({ name, description: command.description })),
],
},
})
@@ -202,7 +210,6 @@ export function make(input: {
return {
initialize: async (params) => {
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
@@ -351,7 +358,6 @@ export function make(input: {
sessionID: state.id,
cwd: state.cwd,
start: prepared.start,
writeTextFile: capabilities.writeTextFile,
action: prepared.command !== undefined,
control,
connectionSignal: input.connection.signal,
@@ -359,9 +365,9 @@ export function make(input: {
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
...(childSessionUpdate ? { childSessionUpdate } : {}),
})
.then(async (response) => {
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
.then(async (result) => {
await sendUsageUpdate(input.client, input.connection, state, result.contextTokens).catch(() => {})
return result.response
})
.finally(() => {
signal?.removeEventListener("abort", cancel)
@@ -386,7 +392,8 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
const slash = detectSlashCommand(text)
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
const command =
slash && !builtinCommands.has(slash.name) ? catalog.commands.find((item) => item.name === slash.name) : undefined
const start = turnStart(messageID, slash)
return { start, text, files, synthetic, slash, command }
}
@@ -421,7 +428,7 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
if (slash?.name === "compact") return { type: "compaction", id: messageID }
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
return { type: "input", id: messageID }
}
+40 -39
View File
@@ -1,6 +1,8 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
import { readDisplayText } from "@opencode/tui/mini/tool"
import { Patch } from "@opencode/util/patch"
import { Result } from "effect"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
@@ -36,28 +38,29 @@ export function toToolKind(toolName: string): ToolKind {
}
}
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
export function toLocations(toolName: string, input: ToolInput, cwd: string): ToolCallLocation[] {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "shell":
return locationFrom(cwd, stringValue(input.workdir) ?? stringValue(input.cwd) ?? cwd)
case "read":
return locationFrom(input.path)
case "edit":
case "write":
return locationFrom(cwd, filePath(input))
case "patch":
case "apply_patch":
return locationFrom(input.filePath ?? input.filepath)
return locationFrom(
cwd,
...patchHunks(input).flatMap((hunk) => [hunk.path, hunk.type === "update" ? hunk.movePath : undefined]),
)
case "external_directory":
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
return locationFrom(cwd, input.filepath)
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
return locationFrom(cwd, input.path)
default:
return []
}
@@ -67,7 +70,7 @@ export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly cwd?: string
readonly cwd: string
}): ToolCall {
return {
toolCallId: input.toolCallId,
@@ -84,7 +87,7 @@ export function runningToolUpdate(input: {
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly content?: ToolContent
readonly cwd?: string
readonly cwd: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
@@ -103,6 +106,7 @@ export function completedToolUpdate(input: {
readonly input: ToolInput
readonly content: ToolContent
readonly metadata?: Readonly<Record<string, unknown>>
readonly cwd: string
}): ToolCallUpdate {
const normalized = toolContent(input.content)
// Read's model content is a JSON page envelope; show the clean text instead.
@@ -115,20 +119,15 @@ export function completedToolUpdate(input: {
: [{ type: "content" as const, content: { type: "text" as const, text: read } }]
const oldText = stringValue(input.input.oldString)
const newText = stringValue(input.input.newString)
const path = filePath(input.input)
const diff: ToolCallContent[] =
oldText === undefined || newText === undefined
oldText === undefined || newText === undefined || path === undefined
? []
: [
{
type: "diff",
path: stringValue(input.input.path) ?? stringValue(input.input.filePath) ?? "",
oldText,
newText,
},
]
: [{ type: "diff", path: absolutePath(path, input.cwd), oldText, newText }]
return {
toolCallId: input.toolCallId,
status: "completed",
locations: toLocations(input.toolName, input.input, input.cwd),
content: [...primary, ...diff, ...images],
rawOutput: {
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
@@ -143,7 +142,7 @@ export function errorToolUpdate(input: {
readonly content?: ToolContent
readonly metadata?: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
readonly cwd: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
@@ -174,16 +173,9 @@ function toolTitle(toolName: string, input: ToolInput, fallback: string | undefi
return fallback || toolName
}
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
function rawInput(toolName: string, input: ToolInput, cwd: string): ToolInput {
if (!isShell(toolName) || input.cwd || input.workdir) return input
const workdir = shellWorkdir(input, cwd)
return workdir ? { ...input, cwd: workdir } : input
}
function shellWorkdir(input: ToolInput, cwd?: string) {
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
if (!explicit) return cwd
return isAbsolute(explicit) ? explicit : resolve(cwd ?? process.cwd(), explicit)
return { ...input, cwd }
}
function isShell(toolName: string) {
@@ -191,20 +183,29 @@ function isShell(toolName: string) {
return tool === "bash" || tool === "shell"
}
function locationFrom(...values: unknown[]): ToolCallLocation[] {
function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
return Array.from(
new Set(
values.flatMap((value): string[] => {
if (Array.isArray(value))
return value.filter((item): item is string => typeof item === "string" && item.length > 0)
const path = stringValue(value)
return path ? [path] : []
}),
),
new Set(values.flatMap((value) => (typeof value === "string" && value ? [absolutePath(value, cwd)] : []))),
(path) => ({ path }),
)
}
// Sessions migrated from V1 keep their original `filePath` tool inputs.
export function filePath(input: ToolInput) {
return stringValue(input.path) ?? stringValue(input.filePath)
}
export function patchHunks(input: ToolInput) {
const patchText = stringValue(input.patchText)
if (!patchText) return []
const parsed = Patch.parse(patchText)
return Result.isSuccess(parsed) ? parsed.success : []
}
export function absolutePath(path: string, cwd: string) {
return isAbsolute(path) ? path : resolve(cwd, path)
}
export function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
+22 -17
View File
@@ -1,7 +1,9 @@
import { ndJsonStream } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode/client/promise"
import { Service } from "@opencode/client/effect/service"
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
import { Effect } from "effect"
import { Writable } from "node:stream"
import { ACP } from "../../acp/agent"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -13,23 +15,26 @@ export default Runtime.handler(
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 connection = yield* ACP.connect(client, ndJsonStream(Writable.toWeb(process.stdout), Bun.stdin.stream()))
const code = yield* Effect.raceFirst(
Effect.promise(() => connection.closed).pipe(Effect.as(0)),
endpoint.exited.pipe(
Effect.match({
onSuccess: (code) => `code ${code}`,
onFailure: (error) =>
error.cause instanceof CrossSpawnSpawner.KilledBySignal ? `signal ${error.cause.signal}` : error.message,
}),
})
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 connection = ACP.connect(client, ndJsonStream(input, output))
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))
// stdout carries ACP, so the diagnostic goes to stderr.
Effect.flatMap((reason) =>
Effect.sync(() => {
process.stderr.write(`opencode acp: server exited unexpectedly (${reason})\n`)
return 1
}),
),
),
)
// Closing the handler scope would wait for the private server's graceful shutdown; its lease pipe already
// ends the server once this process exits.
yield* Effect.sync(() => process.exit(code))
}),
)
+3 -2
View File
@@ -1,4 +1,4 @@
import { Service, type Endpoint } from "@opencode/client/effect/service"
import { Service } from "@opencode/client/effect/service"
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Deferred, Effect, Schema, Stream } from "effect"
@@ -53,7 +53,8 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
url: ready.url,
auth: { type: "basic" as const, username: "opencode", password },
pid: proc.pid,
} satisfies Endpoint & { readonly pid: number }
exited: proc.exitCode,
}
},
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
)
@@ -0,0 +1,128 @@
import { describe, expect } from "bun:test"
import { OpenCode } from "@opencode/client/promise"
import { Clock, Duration, Effect, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { it } from "../../../core/test/lib/effect"
import { ACPCatalog } from "../../src/acp/catalog"
import { buildAgent, planAgent, startWire, testModel, type Wire, type WireOptions } from "./wire-fixture"
describe("acp catalog service", () => {
it.effect("coalesces reloads requested during a reload into one more load", () => {
const gate = { held: false, release: Promise.withResolvers<void>() }
return withCatalog(
{
fetch: (request) =>
request.path === "/api/agent" && gate.held ? gate.release.promise.then(() => undefined) : undefined,
},
(acp) =>
Effect.gen(function* () {
const catalog = yield* ACPCatalog.Service
yield* catalog.get("/workspace")
gate.held = true
const running = yield* catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.promise(() => acp.until(() => requests(acp, "/api/agent") === 2, "the held reload"))
const queued = yield* Effect.all(
[0, 1].map(() => catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))),
)
acp.server.catalog.agents = [planAgent, buildAgent]
gate.held = false
gate.release.resolve()
yield* Fiber.join(running)
yield* Fiber.joinAll(queued)
expect(reads(acp, "agent")).toBe(3)
expect((yield* catalog.get("/workspace")).defaultModeID).toBe("plan")
}),
)
})
it.effect("keeps the previous catalog when a reload fails", () => {
const failing = { model: false }
return withCatalog(
{
fetch: (request) =>
failing.model && request.path === "/api/model"
? Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
: undefined,
},
() =>
Effect.gen(function* () {
const catalog = yield* ACPCatalog.Service
const before = yield* catalog.get("/workspace")
failing.model = true
yield* catalog.reload("/workspace")
expect(yield* catalog.get("/workspace")).toBe(before)
}),
)
})
it.effect("waits 25ms between readiness reads", () =>
withCatalog({}, (acp) =>
Effect.gen(function* () {
const catalog = yield* ACPCatalog.Service
acp.server.catalog.models = []
const loading = yield* catalog.get("/workspace").pipe(Effect.forkChild)
yield* Effect.promise(() => acp.until(() => reads(acp, "model") === 1, "the first model read"))
acp.server.catalog.models = [testModel]
yield* advance("5 millis", () => reads(acp, "model") === 2)
const retriedAt = yield* Clock.currentTimeMillis
const loaded = yield* Fiber.join(loading)
expect(retriedAt).toBeGreaterThanOrEqual(25)
expect(loaded.defaultModel).toEqual({ providerID: "test", id: "test-model", variant: "default" })
}),
),
)
it.effect("gives up with the last readiness failure after 5 seconds", () =>
withCatalog({}, (acp) =>
Effect.gen(function* () {
const catalog = yield* ACPCatalog.Service
acp.server.catalog.agents = []
const loading = yield* catalog.get("/workspace").pipe(Effect.flip, Effect.timed, Effect.forkChild)
yield* advance("25 millis", () => loading.pollUnsafe() !== undefined)
const [elapsed, error] = yield* Fiber.join(loading)
expect(error).toEqual(new ACPCatalog.NotReadyError({ reason: "agents" }))
expect(error.message).toBe("No primary agents are available")
// Reads in flight while the clock steps push the last attempt slightly past the deadline.
expect(Duration.toMillis(elapsed)).toBeGreaterThanOrEqual(5_000)
expect(Duration.toMillis(elapsed)).toBeLessThan(6_000)
}),
),
)
})
function withCatalog<A, E>(options: WireOptions, body: (acp: Wire) => Effect.Effect<A, E, ACPCatalog.Service>) {
return Effect.acquireRelease(
Effect.promise(() => startWire(options)),
(acp) => Effect.promise(() => acp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((acp) =>
body(acp).pipe(
Effect.provideServiceEffect(ACPCatalog.Service, ACPCatalog.make(OpenCode.make({ baseUrl: acp.server.url }))),
),
),
)
}
// Catalog reads are real HTTP that settles between sleeps, so the clock moves in steps until the reads catch up.
function advance(step: Duration.Input, done: () => boolean) {
return TestClock.adjust(step).pipe(
Effect.andThen(TestClock.withLive(Effect.sleep("1 millis"))),
Effect.repeat({ until: done }),
)
}
function reads(acp: Wire, kind: "model" | "agent") {
return acp.server.catalogReads.filter((read) => read.kind === kind).length
}
function requests(acp: Wire, path: string) {
return acp.server.requests.filter((request) => request.path === path).length
}
+39 -12
View File
@@ -37,7 +37,7 @@ describe("acp catalog and config options over the wire", () => {
[other.sessionId]: "/other",
})
await acp.until(() => acp.updates.filter((item) => commandNames(item)).length === 3, "commands for each session")
expect(acp.updates.map(commandNames)).toEqual([["review"], ["review"], ["review"]])
expect(acp.updates.map(commandNames)).toEqual(Array.from({ length: 3 }, () => ["review", "compact"]))
})
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
@@ -57,14 +57,28 @@ describe("acp catalog and config options over the wire", () => {
currentValue: "copilot-build",
options: ["copilot-build", "build", "plan"],
})
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
expect(commandNames(commands)).toEqual(["review", "ship"])
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 3)
expect(commandNames(commands)).toEqual(["review", "ship", "compact"])
expect(agentReads(acp)).toBe(reads + 1)
const second = await acp.newSession()
expect(currentValue(second, "mode")).toBe("copilot-build")
})
test("defaults the mode to the first selectable agent the server lists", async () => {
const configured = { ...buildAgent, id: "review", name: "Review", mode: "all" as const }
await using acp = await startWire()
acp.server.catalog.agents = [configured, buildAgent, planAgent]
await acp.initialize()
const session = await acp.newSession()
expect(modeOption(session.configOptions ?? [])).toEqual({
currentValue: "review",
options: ["review", "build", "plan"],
})
})
test("pushes config options on model.updated and commands on command.updated", async () => {
await using acp = await startWire()
acp.server.catalog.models = [testModel]
@@ -81,7 +95,7 @@ describe("acp catalog and config options over the wire", () => {
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 3)
expect(commands).toEqual({
sessionId: session.sessionId,
update: {
@@ -89,6 +103,7 @@ describe("acp catalog and config options over the wire", () => {
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "ship", description: "Ship it" },
{ name: "compact", description: "Compact the session" },
],
},
})
@@ -193,15 +208,27 @@ describe("acp catalog and config options over the wire", () => {
})
})
test.todo(
"advertises the built-in compact command (https://github.com/anomalyco/opencode/issues/37229)",
async () => {
await using acp = await startSession()
test("advertises and runs the built-in compact over a server command (https://github.com/anomalyco/opencode/issues/37229)", async () => {
await using acp = await startSession()
const advertised = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
const commands = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
expect(commandNames(commands)).toContain("compact")
},
)
acp.server.catalog.commands = [reviewCommand, { name: "compact", description: "Server compact" }]
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
const replaced = await acp.waitForUpdate((item) => item !== advertised && commandNames(item) !== undefined)
const compacted = await acp.prompt(acp.sessionId, "/compact")
expect([advertised, replaced].map((item) => item.update)).toEqual(
Array.from({ length: 2 }, () => ({
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "compact", description: "Compact the session" },
],
})),
)
expect(compacted.stopReason).toBe("end_turn")
expect(acp.server.submissions.map((item) => item.kind)).toEqual(["compact"])
})
})
function commandNames(item: SessionNotification) {
+33
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import { Cause } from "effect"
import { ACPError } from "../../src/acp/error"
import { rpcError, startSession, startWire } from "./wire-fixture"
describe("acp errors", () => {
test("maps validation failures to invalid params", () => {
@@ -58,3 +60,34 @@ describe("acp errors", () => {
expect(serialized).not.toContain("stack")
})
})
describe("acp error boundary over the wire", () => {
test("maps unexpected server failures to the generic internal error", async () => {
await using acp = await startWire({
fetch: (request) =>
request.method === "POST" && request.path === "/api/session" ? new Response(null, { status: 500 }) : undefined,
})
await acp.initialize()
expect(await rpcError(acp.newSession())).toEqual({
code: -32603,
message: "Internal error: Internal service failure",
data: { errorName: "ClientError" },
})
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
{ message: ["ACP request failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
])
})
test("reports an unavailable server once the server stops", async () => {
await using acp = await startSession()
await acp.server.stop()
expect(await rpcError(acp.request("session/list", {}))).toEqual({
code: -32603,
message: "Internal error: OpenCode server is unavailable",
data: { errorName: "ServerUnavailable" },
})
expect(acp.logs).toEqual([])
})
})
@@ -101,8 +101,8 @@ describe("acp lifecycle subprocess", () => {
}, 60_000)
// The private server is found with `pgrep`, which Windows lacks.
const todoOutsideWindows = process.platform === "win32" ? test.skip : test.todo
todoOutsideWindows(
const testOutsideWindows = process.platform === "win32" ? test.skip : test
testOutsideWindows(
"exits when the private server process dies (https://github.com/anomalyco/opencode/issues/51716)",
async () => {
await using fixture = await createAcpFixture()
@@ -121,7 +121,9 @@ describe("acp lifecycle subprocess", () => {
const timeout = Promise.withResolvers<"running">()
const timer = setTimeout(() => timeout.resolve("running"), 10_000)
const exited = await Promise.race([acp.exited, timeout.promise]).finally(() => clearTimeout(timer))
expect(exited).not.toBe("running")
expect(exited).toBe(1)
await acp[Symbol.asyncDispose]()
expect(acp.stderr()).toContain("opencode acp: server exited unexpectedly (signal SIGKILL)")
},
60_000,
)
+82 -33
View File
@@ -89,12 +89,7 @@ describe("acp permissions over the wire", () => {
id,
permissionAsked(sessionID, "perm_external", {
action: "external_directory",
metadata: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
metadata: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
}),
),
permission: allowOnce,
@@ -103,14 +98,9 @@ describe("acp permissions over the wire", () => {
await acp.prompt(acp.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "Create external directory",
locations: [{ path: "/tmp/outside" }],
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
directories: ["/tmp/outside"],
patterns: ["/tmp/outside/*"],
},
title: "/tmp/outside",
locations: [{ path: "/tmp/outside/a.ts" }],
rawInput: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
})
})
@@ -263,8 +253,8 @@ describe("acp permissions over the wire", () => {
})
})
describe("acp edit previews and client file sync over the wire", () => {
test("previews edits during approval and syncs the completed file", async () => {
describe("acp edit previews over the wire", () => {
test("previews edits during approval", async () => {
await using dir = await tmpdir()
const file = path.join(dir.path, "file.ts")
await fs.writeFile(file, "before")
@@ -287,7 +277,7 @@ describe("acp edit previews and client file sync over the wire", () => {
},
permission: allowOnce,
})
await acp.initialize({ writeTextFile: true })
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
@@ -295,13 +285,12 @@ describe("acp edit previews and client file sync over the wire", () => {
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "file.ts",
kind: "edit",
locations: [{ path: "file.ts" }],
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
locations: [{ path: file }],
content: [{ type: "diff", path: file, oldText: "before", newText: "after" }],
})
expect(acp.writes).toEqual([{ sessionId: session.sessionId, path: file, content: "after" }])
})
test("previews and syncs each file in a patch", async () => {
test("previews each file in a patch", async () => {
await using dir = await tmpdir()
await Promise.all([
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
@@ -341,7 +330,7 @@ describe("acp edit previews and client file sync over the wire", () => {
},
permission: allowOnce,
})
await acp.initialize({ writeTextFile: true })
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
@@ -349,36 +338,96 @@ describe("acp edit previews and client file sync over the wire", () => {
expect(acp.permissions[0]?.toolCall).toMatchObject({
title: "2 files",
kind: "edit",
locations: [{ path: "first.ts" }, { path: "second.ts" }],
locations: [{ path: path.join(dir.path, "first.ts") }, { path: path.join(dir.path, "second.ts") }],
content: [
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
{ type: "diff", path: path.join(dir.path, "first.ts"), oldText: "one\n", newText: "two\n" },
{ type: "diff", path: path.join(dir.path, "second.ts"), oldText: "alpha\n", newText: "beta\n" },
],
})
expect(acp.writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
{ sessionId: session.sessionId, path: path.join(dir.path, "first.ts"), content: "two\n" },
{ sessionId: session.sessionId, path: path.join(dir.path, "second.ts"), content: "beta\n" },
})
test("reports the same absolute locations for a moved file in the permission and tool updates", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "old.ts"), "one\n")
const patchText = [
"*** Begin Patch",
"*** Update File: old.ts",
"*** Move to: new.ts",
"@@",
"-one",
"+two",
"*** End Patch",
].join("\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_move", "patch"),
toolCalled(sessionID, "call_move", { patchText }),
permissionAsked(sessionID, "perm_move", {
action: "edit",
source: { type: "tool", messageID: "msg_move", id: "call_move" },
}),
],
onPermissionReply: ({ sessionID }) => [
toolSucceeded(sessionID, "call_move", {}, "patched"),
succeeded(sessionID),
],
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
const locations = [{ path: path.join(dir.path, "old.ts") }, { path: path.join(dir.path, "new.ts") }]
expect(acp.permissions[0]?.toolCall).toMatchObject({
locations,
content: [{ type: "diff", path: path.join(dir.path, "new.ts"), oldText: "one\n", newText: "two\n" }],
})
expect(
acp.updates.flatMap((item) =>
item.update.sessionUpdate === "tool_call_update" && item.update.toolCallId === "call_move"
? [[item.update.status, item.update.locations]]
: [],
),
).toEqual([
["in_progress", locations],
["completed", locations],
])
})
test("does not sync edits when the client did not advertise writeTextFile", async () => {
test("does not echo completed edits to a client that advertises writeTextFile", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "file.ts"), "after")
const file = path.join(dir.path, "file.ts")
await fs.writeFile(file, "after")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { filePath: path.join(dir.path, "file.ts") }),
toolSucceeded(sessionID, "call_edit", {}, "edited"),
toolCalled(sessionID, "call_edit", { filePath: file, oldString: "before", newString: "after" }),
toolSucceeded(sessionID, "call_edit", { files: [{ file }] }, "edited"),
),
})
await acp.initialize()
await acp.initialize({ writeTextFile: true })
const session = await acp.newSession(dir.path)
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(acp.writes).toEqual([])
expect(
acp.updates.flatMap((item) =>
item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed" ? [item.update] : [],
),
).toMatchObject([
{
toolCallId: "call_edit",
content: [
{ type: "content", content: { type: "text", text: "edited" } },
{ type: "diff", path: file, oldText: "before", newText: "after" },
],
},
])
})
})
+113 -27
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { StopReason } from "@agentclientprotocol/sdk"
import type { OpenCodeEvent } from "@opencode/client/promise"
import {
assistantMessage,
childCreated,
delivered,
durableEvent,
failed,
@@ -32,7 +32,6 @@ describe("acp prompt turns over the wire", () => {
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, textDelta(sessionID, "msg_assistant", "hello"), stepEnded(sessionID, "msg_assistant")),
})
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
const response = await acp.prompt(acp.sessionId, "hi")
@@ -114,7 +113,6 @@ describe("acp prompt turns over the wire", () => {
value: "test/second-model",
})
acp.server.sessions.set(acp.sessionId, makeSession(acp.sessionId, { cost: 3.5 }))
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant", { tokens: assistantTokens })])
const response = await acp.prompt(acp.sessionId, "hello")
@@ -145,7 +143,6 @@ describe("acp prompt turns over the wire", () => {
return turn(sessionID, id, stepEnded(sessionID, "msg_assistant"))
},
})
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
})
@@ -191,14 +188,20 @@ describe("acp prompt turns over the wire", () => {
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
})
test("maps an assistant message auth error to auth required", async () => {
test("maps an assistant step auth error to auth required", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, textDelta(sessionID, "msg_auth", "partial"), stepEnded(sessionID, "msg_auth")),
turn(
sessionID,
id,
textDelta(sessionID, "msg_auth", "partial"),
durableEvent("session.step.failed", {
sessionID,
assistantMessageID: "msg_auth",
error: { type: "provider.auth", message: "expired" },
}),
),
})
acp.server.messages.set(acp.sessionId, [
assistantMessage("msg_auth", { error: { type: "provider.auth", message: "expired" } }),
])
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
})
@@ -353,27 +356,110 @@ describe("acp prompt turns over the wire", () => {
expect((await first).stopReason).toBe("cancelled")
})
test.todo(
"reports usage summed across every step of the turn (https://github.com/anomalyco/opencode/issues/41660)",
async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
test("reports usage summed across every step of the turn", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
})
test("publishes the last step's context usage rather than the turn sum", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
stepEnded(sessionID, "msg_step_1", {
finish: "tool-calls",
tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 50 } },
}),
stepEnded(sessionID, "msg_step_2", {
tokens: { input: 20, output: 5, reasoning: 3, cache: { read: 150, write: 0 } },
}),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(response.usage).toEqual({
inputTokens: 120,
outputTokens: 15,
thoughtTokens: 3,
cachedReadTokens: 150,
cachedWriteTokens: 50,
totalTokens: 338,
})
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
sessionId: acp.sessionId,
update: { sessionUpdate: "usage_update", used: 178, size: 100_000, cost: { amount: 0, currency: "USD" } },
})
})
test("counts a failed step's tokens and clears its error when the next step starts", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.step.failed", {
sessionID,
id,
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
),
})
acp.server.messages.set(acp.sessionId, [
assistantMessage("msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
])
assistantMessageID: "msg_1",
error: { type: "provider.stream", message: "stream interrupted" },
cost: 0,
tokens: { ...tokens(), input: 40, output: 4 },
}),
durableEvent("session.step.started", {
sessionID,
assistantMessageID: "msg_2",
agent: "build",
model: { providerID: "test", id: "test-model" },
started: 0,
}),
stepEnded(sessionID, "msg_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
const response = await acp.prompt(acp.sessionId, "hello")
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
},
)
expect(response).toEqual({
stopReason: "end_turn",
usage: { inputTokens: 60, outputTokens: 11, totalTokens: 71 },
_meta: {},
})
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
update: { used: 27 },
})
})
test("excludes child session steps from the turn usage", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
stepEnded("ses_child", "msg_child", { tokens: { ...tokens(), input: 500, output: 50 } }),
succeeded("ses_child"),
stepEnded(sessionID, "msg_root", { tokens: { ...tokens(), input: 20, output: 7 } }),
),
})
const response = await acp.prompt(acp.sessionId, "hello")
expect(response.usage).toEqual({ inputTokens: 20, outputTokens: 7, totalTokens: 27 })
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
update: { used: 27 },
})
})
})
// The server answered admission before streaming the chunk, and this request round-trips through the server after it,
+7 -1
View File
@@ -54,7 +54,13 @@ describe("acp session lifecycle over the wire", () => {
])
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).toEqual({
sessionId: result.sessionId,
update: { sessionUpdate: "available_commands_update", availableCommands: [{ name: "review", description: "" }] },
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "" },
{ name: "compact", description: "Compact the session" },
],
},
})
})
+110 -15
View File
@@ -28,22 +28,105 @@ describe("acp tools", () => {
})
test("extracts file locations from tool input", () => {
expect(toLocations("read", { path: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
{ path: "/tmp/outside" },
expect(toLocations("read", { path: "/tmp/a.ts" }, "/workspace")).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { path: "/tmp/b.ts", oldString: "a", newString: "b" }, "/workspace")).toEqual([
{ path: "/tmp/b.ts" },
])
expect(toLocations("write", { path: "/tmp/c.ts", content: "c" }, "/workspace")).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("read", { filePath: "/tmp/v1.ts" }, "/workspace")).toEqual([{ path: "/tmp/v1.ts" }])
expect(toLocations("edit", { path: "src/b.ts" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "src/b.ts") },
])
expect(toLocations("grep", { path: "/repo/src" }, "/workspace")).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" }, "/workspace")).toEqual([{ path: "/repo/test" }])
expect(toLocations("grep", { pattern: "x", path: "src" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "src") },
])
expect(toLocations("glob", { pattern: "*.ts" }, "/workspace")).toEqual([])
expect(toLocations("context7_get_library_docs", { path: "/docs" }, "/workspace")).toEqual([{ path: "/docs" }])
expect(
toLocations("external_directory", { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" }, "/workspace"),
).toEqual([{ path: "/tmp/outside/a.ts" }])
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
{ path: resolve("/workspace", "subdir") },
])
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([{ path: "/tmp/missing-file-path.ts" }])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" }, "/workspace")).toEqual([
{ path: "/tmp/missing-file-path.ts" },
])
})
test("extracts patch locations from every hunk in the patch body", () => {
const patchText = [
"*** Begin Patch",
"*** Add File: src/new.ts",
"+created",
"*** Update File: /abs/old.ts",
"*** Move to: src/moved.ts",
"@@",
"-one",
"+two",
"*** Update File: src/same.ts",
"@@",
"-a",
"+b",
"*** Delete File: src/gone.ts",
"*** Update File: src/new.ts",
"@@",
"-created",
"+changed",
"*** End Patch",
].join("\n")
expect(toLocations("patch", { patchText }, "/workspace")).toEqual([
{ path: resolve("/workspace", "src/new.ts") },
{ path: "/abs/old.ts" },
{ path: resolve("/workspace", "src/moved.ts") },
{ path: resolve("/workspace", "src/same.ts") },
{ path: resolve("/workspace", "src/gone.ts") },
])
expect(toLocations("apply_patch", { patchText }, "/workspace")).toHaveLength(5)
})
test("returns no patch locations when the patch body cannot be parsed", () => {
expect(toLocations("patch", { patchText: "*** Update File: src/a.ts\n-a\n+b" }, "/workspace")).toEqual([])
expect(toLocations("patch", { patchText: "*** Begin Patch\nnot a hunk\n*** End Patch" }, "/workspace")).toEqual([])
expect(toLocations("patch", {}, "/workspace")).toEqual([])
})
test("reports resolved locations on completed and failed updates", () => {
const patchText = ["*** Begin Patch", "*** Add File: src/a.ts", "+a", "*** End Patch"].join("\n")
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "patch",
input: { patchText },
content: [{ type: "text", text: "patched" }],
cwd: "/workspace",
}).locations,
).toEqual([{ path: resolve("/workspace", "src/a.ts") }])
expect(
completedToolUpdate({
toolCallId: "call",
toolName: "edit",
input: { path: "src/b.ts", oldString: "before", newString: "after" },
content: [],
cwd: "/workspace",
}),
).toMatchObject({
locations: [{ path: resolve("/workspace", "src/b.ts") }],
content: [{ type: "diff", path: resolve("/workspace", "src/b.ts"), oldText: "before", newText: "after" }],
})
expect(
errorToolUpdate({
toolCallId: "call",
toolName: "write",
input: { path: "src/c.ts", content: "c" },
error: "denied",
cwd: "/workspace",
}).locations,
).toEqual([{ path: resolve("/workspace", "src/c.ts") }])
})
test("builds completed content with text and image attachments", () => {
@@ -52,9 +135,10 @@ describe("acp tools", () => {
expect(
completedToolUpdate({
toolCallId: "tool-1",
cwd: "/workspace",
toolName: "edit",
input: {
filePath: "/tmp/file.ts",
path: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
@@ -87,9 +171,10 @@ describe("acp tools", () => {
expect(
completedToolUpdate({
toolCallId: "tool-1",
cwd: "/workspace",
toolName: "write",
input: {
filePath: "/tmp/file.ts",
path: "/tmp/file.ts",
content: "created",
},
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
@@ -107,6 +192,7 @@ describe("acp tools", () => {
expect(
completedToolUpdate({
toolCallId: "tool-read",
cwd: "/workspace",
toolName: "read",
input: { path: "/tmp/file.ts" },
content: [
@@ -125,6 +211,7 @@ describe("acp tools", () => {
expect(
completedToolUpdate({
toolCallId: "tool-list",
cwd: "/workspace",
toolName: "read",
input: { path: "/tmp" },
content: [
@@ -146,10 +233,11 @@ describe("acp tools", () => {
expect(
pendingToolCall({
toolCallId: "tool-1",
cwd: "/workspace",
toolName: "edit",
state: {
input: {
filePath: "/tmp/file.ts",
path: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
@@ -161,7 +249,7 @@ describe("acp tools", () => {
kind: "edit",
locations: [{ path: "/tmp/file.ts" }],
rawInput: {
filePath: "/tmp/file.ts",
path: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
@@ -170,9 +258,10 @@ describe("acp tools", () => {
expect(
completedToolUpdate({
toolCallId: "tool-1",
cwd: "/workspace",
toolName: "edit",
input: {
filePath: "/tmp/file.ts",
path: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
@@ -182,6 +271,7 @@ describe("acp tools", () => {
).toEqual({
toolCallId: "tool-1",
status: "completed",
locations: [{ path: "/tmp/file.ts" }],
content: [
{
type: "content",
@@ -204,6 +294,7 @@ describe("acp tools", () => {
expect(
runningToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
state: { input: { path: "/tmp/a" } },
content: [{ type: "text", text: "done" }],
@@ -229,6 +320,7 @@ describe("acp tools", () => {
expect(
completedToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
input: {},
content: [],
@@ -241,6 +333,7 @@ describe("acp tools", () => {
expect(
completedToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
input: {},
content: [],
@@ -252,6 +345,7 @@ describe("acp tools", () => {
expect(
completedToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
input: {},
content: [
@@ -273,6 +367,7 @@ describe("acp tools", () => {
expect(
errorToolUpdate({
toolCallId: "call",
cwd: "/workspace",
toolName: "read",
input: { path: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
+41 -44
View File
@@ -317,53 +317,50 @@ describe("acp turn events over the wire", () => {
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
})
test.todo(
"reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)",
async () => {
const patchText = [
"*** Begin Patch",
"*** Update File: /workspace/src/c.ts",
"@@",
"-one",
"+two",
"*** End Patch",
].join("\n")
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "/workspace/src/a.ts", oldString: "a", newString: "b" }),
toolSucceeded(sessionID, "call_edit", {}, "edited"),
toolStarted(sessionID, "call_write", "write"),
toolCalled(sessionID, "call_write", { path: "/workspace/src/b.ts", content: "b" }),
toolSucceeded(sessionID, "call_write", {}, "written"),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
toolSucceeded(sessionID, "call_patch", {}, "patched"),
),
})
test("reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)", async () => {
const patchText = [
"*** Begin Patch",
"*** Update File: /workspace/src/c.ts",
"@@",
"-one",
"+two",
"*** End Patch",
].join("\n")
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
toolStarted(sessionID, "call_edit", "edit"),
toolCalled(sessionID, "call_edit", { path: "/workspace/src/a.ts", oldString: "a", newString: "b" }),
toolSucceeded(sessionID, "call_edit", {}, "edited"),
toolStarted(sessionID, "call_write", "write"),
toolCalled(sessionID, "call_write", { path: "/workspace/src/b.ts", content: "b" }),
toolSucceeded(sessionID, "call_write", {}, "written"),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
toolSucceeded(sessionID, "call_patch", {}, "patched"),
),
})
await acp.prompt(acp.sessionId, "hello")
await acp.prompt(acp.sessionId, "hello")
const locations = turnUpdates(acp.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update")
.map((item) => [
toolCallID(item),
toolStatus(item),
"locations" in item.update ? item.update.locations : undefined,
])
expect(locations).toEqual([
["call_edit", "in_progress", [{ path: "/workspace/src/a.ts" }]],
["call_edit", "completed", [{ path: "/workspace/src/a.ts" }]],
["call_write", "in_progress", [{ path: "/workspace/src/b.ts" }]],
["call_write", "completed", [{ path: "/workspace/src/b.ts" }]],
["call_patch", "in_progress", [{ path: "/workspace/src/c.ts" }]],
["call_patch", "completed", [{ path: "/workspace/src/c.ts" }]],
const locations = turnUpdates(acp.updates)
.filter((item) => item.update.sessionUpdate === "tool_call_update")
.map((item) => [
toolCallID(item),
toolStatus(item),
"locations" in item.update ? item.update.locations : undefined,
])
},
)
expect(locations).toEqual([
["call_edit", "in_progress", [{ path: "/workspace/src/a.ts" }]],
["call_edit", "completed", [{ path: "/workspace/src/a.ts" }]],
["call_write", "in_progress", [{ path: "/workspace/src/b.ts" }]],
["call_write", "completed", [{ path: "/workspace/src/b.ts" }]],
["call_patch", "in_progress", [{ path: "/workspace/src/c.ts" }]],
["call_patch", "completed", [{ path: "/workspace/src/c.ts" }]],
])
})
})
function turnUpdates(updates: readonly SessionNotification[]) {
+13 -4
View File
@@ -28,7 +28,7 @@ import {
type TokenUsageInfo,
} from "@opencode/client/promise"
import type { BunRequest } from "bun"
import { Option, Schema } from "effect"
import { Effect, Exit, Logger, Option, Schema, Scope } from "effect"
import { ACP } from "../../src/acp/agent"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
@@ -377,9 +377,16 @@ export async function startWire(options: WireOptions = {}) {
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
const agentConnection = ACP.connect(
OpenCode.make({ baseUrl: server.url }),
ndJsonStream(agentToClient.writable, clientToAgent.readable),
const logs: Array<Pick<Logger.Options<unknown>, "message" | "cause">> = []
const agentScope = Scope.makeUnsafe()
const agentConnection = await Effect.runPromise(
ACP.connect(
OpenCode.make({ baseUrl: server.url }),
ndJsonStream(agentToClient.writable, clientToAgent.readable),
).pipe(
Scope.provide(agentScope),
Effect.provide(Logger.layer([Logger.make((log) => logs.push({ message: log.message, cause: log.cause }))])),
),
)
const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable)
const connection = client({ name: "test" })
@@ -456,6 +463,7 @@ export async function startWire(options: WireOptions = {}) {
return {
server,
logs,
received,
updates,
permissions,
@@ -478,6 +486,7 @@ export async function startWire(options: WireOptions = {}) {
async [Symbol.asyncDispose]() {
connection.close()
agentConnection.close()
await Effect.runPromise(Scope.close(agentScope, Exit.void))
await server.stop()
},
}
+35 -5
View File
@@ -4,6 +4,8 @@ import path from "node:path"
import { pathToFileURL } from "node:url"
import {
Client,
SdkError,
SdkErrorCode,
SdkHttpError,
StreamableHTTPClientTransport,
UnauthorizedError,
@@ -35,6 +37,18 @@ const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
const TERMINATE_TIMEOUT = 1_000
const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))
// HTTP statuses and network error codes live on error properties and are lost once flattened to a message.
const describe = (error: unknown) => {
if (!(error instanceof Error)) return String(error)
const detail =
error instanceof SdkHttpError
? `HTTP ${error.status}`
: !(error instanceof SdkError) && "code" in error && typeof error.code === "string"
? error.code
: undefined
return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message
}
export type { GetPromptResult, Prompt, ReadResourceResult, Resource, Tool }
export type ResourceTemplate = ResourceTemplateType
@@ -108,7 +122,7 @@ export interface Connection {
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
readonly onClose: (callback: (reason: string) => void) => void
readonly onSessionExpired: (callback: () => void) => void
readonly onToolsChanged: (callback: () => void) => void
readonly onPromptsChanged: (callback: () => void) => void
@@ -138,7 +152,15 @@ export const connect = Effect.fnUntraced(function* (
onChanged: () => changed[key](),
})
// The SDK fails pending requests with a bare "Connection closed"; the transport error before it says why.
let lastError: string | undefined
const explain = (error: unknown) =>
error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed && lastError
? `${describe(error)}: ${lastError}`
: describe(error)
const initialize = Effect.fnUntraced(function* (transport: Transport) {
const runFork = Effect.runForkWith(yield* Effect.context())
const client = new Client(clientInfo, {
capabilities: {
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
@@ -153,6 +175,12 @@ export const connect = Effect.fnUntraced(function* (
resources: listChanged("resources"),
},
})
// Background work such as the standalone SSE stream reports failures only here; aborts come from close.
client.onerror = (error) => {
if (error.name === "AbortError") return
lastError = describe(error)
runFork(Effect.logWarning("mcp transport error", { server, error: lastError }))
}
client.setRequestHandler("roots/list", () => ({ roots: [{ uri: pathToFileURL(directory).href }] }))
if (elicitation) {
client.setRequestHandler("elicitation/create", (request, ctx) =>
@@ -179,13 +207,13 @@ export const connect = Effect.fnUntraced(function* (
reported: false,
}
const failure = (error: unknown) => {
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return toError(error)
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return new Error(explain(error))
const expired =
error.status === 404 ||
(error.status === 400 &&
typeof error.data.text === "string" &&
error.data.text.includes("Bad Request: Server not initialized"))
if (!expired) return toError(error)
if (!expired) return new Error(explain(error))
if (!session.reported) {
session.reported = true
session.expired?.()
@@ -242,6 +270,8 @@ export const connect = Effect.fnUntraced(function* (
// Close only aborts streams; the legacy session lives on until the server expires it unless
// terminated explicitly. Terminate first: close aborts the signal the DELETE shares.
const transport = session.transport
// Termination failures are logged below; onerror would report them a second time.
client.onerror = undefined
if (transport?.sessionId !== undefined && !session.reported)
yield* Effect.tryPromise({ try: () => transport.terminateSession(), catch: toError }).pipe(
Effect.timeoutOrElse({
@@ -301,7 +331,7 @@ export const connect = Effect.fnUntraced(function* (
),
).pipe(Effect.map(toCallToolResult)),
onClose: (callback) => {
client.onclose = callback
client.onclose = () => callback(lastError ? `Connection closed: ${lastError}` : "Connection closed")
},
onSessionExpired: (callback) => {
session.expired = callback
@@ -325,7 +355,7 @@ export const connect = Effect.fnUntraced(function* (
server,
message: `${error.message}; the server supports ${error.supported.join(", ")}. Set "protocol" for this server to one of those or to "legacy".`,
})
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
return yield* new ConnectError({ server, message: explain(error) })
})
// Absent config is legacy: the SDK sends the plain initialize handshake with no discover probe.
+22 -4
View File
@@ -62,6 +62,19 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
message: Schema.String,
}) {}
const unavailable = (server: ServerName, status: Status) => {
switch (status.status) {
case "failed":
return `MCP server "${server}" is not connected: ${status.error}. Reconnect it from /mcps.`
case "needs_auth":
return `MCP server "${server}" needs authentication: ${status.error}. Sign in from /mcps.`
case "disabled":
return `MCP server "${server}" is disabled.`
default:
return `MCP server "${server}" is not connected.`
}
}
type ServerEntry = {
readonly config: Mcp.ServerConfig
status: Status
@@ -388,10 +401,10 @@ export const layer = (options?: Options) =>
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
const live = whenLive(name, entry, connection)
connection.onClose(() =>
connection.onClose((reason) =>
live(
Effect.gen(function* () {
entry.status = { status: "failed", error: "Connection closed" }
entry.status = { status: "failed", error: reason }
yield* stopServer(name, entry)
yield* bus.publish(McpEvent.StatusChanged, { server: name })
}),
@@ -660,13 +673,18 @@ export const layer = (options?: Options) =>
return yield* new ToolCallError({
server: target.name,
tool: input.name,
message: "MCP server is not connected",
message: unavailable(target.name, target.entry.status),
})
const result = yield* recovering(target.name, target.entry, target.entry.client, (connection) =>
connection.callTool({ name: input.name, args: input.args, sessionID: input.sessionID }),
).pipe(
Effect.mapError(
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
(error) =>
new ToolCallError({
server: target.name,
tool: input.name,
message: `MCP tool "${input.name}" on server "${target.name}" failed: ${error.message}`,
}),
),
)
return { ...result, server: target.name, tool: input.name }
+34 -15
View File
@@ -1,7 +1,7 @@
export * as McpStdio from "./stdio.js"
import { ReadBuffer, serializeMessage, type JSONRPCMessage, type Transport } from "@modelcontextprotocol/client"
import { Cause, Duration, Effect, Queue, Scope, Stream } from "effect"
import { Cause, Duration, Effect, Fiber, Option, Queue, Scope, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import { Environment } from "../environment/index.js"
@@ -13,6 +13,9 @@ const CLOSE_GRACE = Duration.seconds(2)
const FORCE_KILL_AFTER = Duration.seconds(2)
const OUTGOING_CAPACITY = 64
const MAX_FRAME_BYTES = 16 * 1024 * 1024
// An unexpected exit is reported with its exit code and the end of stderr, which usually names the cause.
const EXIT_WAIT = Duration.seconds(1)
const STDERR_TAIL = 1_000
export interface Options {
/** Server name; only used to attribute logs. */
@@ -50,6 +53,7 @@ export const make = Effect.fnUntraced(function* (options: Options) {
let startup: Promise<void> | undefined
let closing: Promise<void> | undefined
let trailingBytes = 0
let stderr = ""
const stop = Effect.fnUntraced(function* (handle: ChildProcessHandle) {
// Exit completion can precede descendant cleanup after the capture deadline.
@@ -139,6 +143,20 @@ export const make = Effect.fnUntraced(function* (options: Options) {
const startOutput = (handle: ChildProcessHandle) =>
Effect.gen(function* () {
// Drain stderr into the debug log so chatty servers cannot stall on a full pipe.
const drain = yield* Effect.forkScoped(
handle.stderr.pipe(
Stream.decodeText(),
Stream.tap((output) => Effect.sync(() => (stderr = (stderr + output).slice(-STDERR_TAIL)))),
Stream.runForEach((output) =>
output.trim() === ""
? Effect.void
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
),
Effect.ignore,
),
)
yield* Effect.forkScoped(
Stream.runForEach(handle.stdout, deliver).pipe(
Effect.tapCause((cause) =>
@@ -152,25 +170,26 @@ export const make = Effect.fnUntraced(function* (options: Options) {
Effect.ensuring(
Effect.gen(function* () {
const unexpected = state.phase !== "closed"
if (unexpected) yield* Effect.promise(close)
if (unexpected) {
// stdout can end before the final stderr output has been read.
const code = yield* Fiber.await(drain).pipe(
Effect.andThen(handle.exitCode),
Effect.timeoutOption(EXIT_WAIT),
Effect.orElseSucceed(() => Option.none()),
)
const exited = Option.match(code, {
onNone: () => "MCP server process exited",
onSome: (code) => `MCP server process exited with code ${code}`,
})
const output = stderr.trim()
transport.onerror?.(new Error(output ? `${exited}: ${output}` : exited))
yield* Effect.promise(close)
}
transport.onclose?.()
}),
),
),
)
// Drain stderr into the debug log so chatty servers cannot stall on a full pipe.
yield* Effect.forkScoped(
handle.stderr.pipe(
Stream.decodeText(),
Stream.runForEach((output) =>
output.trim() === ""
? Effect.void
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
),
Effect.ignore,
),
)
})
return transport
+426 -16
View File
@@ -48,6 +48,106 @@
"summary": "Get server info"
}
},
"/api/pair": {
"post": {
"tags": ["server"],
"operationId": "server.pair",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "PairingCode",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingCode"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
"summary": "Create pairing code"
}
},
"/auth/connect/{code}": {
"get": {
"tags": ["server"],
"operationId": "server.connect",
"parameters": [
{
"name": "code",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "PairingSession",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingSession"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
},
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
]
}
}
}
}
},
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
"summary": "Redeem pairing code"
}
},
"/api/location": {
"get": {
"tags": ["location"],
@@ -886,9 +986,19 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location.",
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -907,6 +1017,17 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -4508,6 +4629,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4591,21 +4727,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7197,6 +7318,125 @@
"summary": "List MCP resources"
}
},
"/api/credential": {
"get": {
"tags": ["credential"],
"operationId": "credential.list",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Credential.Entry"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "List every stored integration credential, including its secret value.",
"summary": "List credentials"
},
"post": {
"tags": ["credential"],
"operationId": "credential.create",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Credential.Entry"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
"summary": "Create credential",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Credential.CreateInput"
}
}
},
"required": true
}
}
},
"/api/credential/{credentialID}": {
"patch": {
"tags": ["credential"],
@@ -13631,6 +13871,9 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13645,6 +13888,9 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13660,6 +13906,128 @@
}
]
},
"Connection.Status": {
"type": "object",
"properties": {
"status": {
"anyOf": [
{
"type": "string",
"enum": ["needs_auth"]
}
]
},
"message": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["status", "message"],
"additionalProperties": false
},
"Credential.CreateInput": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
},
"activate": {
"type": "boolean"
}
},
"required": ["integrationID", "value"],
"additionalProperties": false
},
"Credential.Entry": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"active": {
"type": "boolean"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
}
},
"required": ["id", "integrationID", "label", "active", "value"],
"additionalProperties": false
},
"Credential.Key": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["key"]
},
"key": {
"type": "string"
},
"metadata": {
"type": "object"
},
"configuration": {
"$ref": "#/components/schemas/Form.Answer_1"
}
},
"required": ["type", "key"],
"additionalProperties": false
},
"Credential.OAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["oauth"]
},
"methodID": {
"type": "string"
},
"refresh": {
"type": "string"
},
"access": {
"type": "string"
},
"expires": {
"type": "integer",
"minimum": 0
},
"metadata": {
"type": "object"
}
},
"required": ["type", "methodID", "refresh", "access", "expires"],
"additionalProperties": false
},
"Credential.Value": {
"anyOf": [
{
"$ref": "#/components/schemas/Credential.OAuth"
},
{
"$ref": "#/components/schemas/Credential.Key"
}
]
},
"FileDiff.Info": {
"type": "object",
"properties": {
@@ -13746,6 +14114,12 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -15709,6 +16083,29 @@
"Money.USDPerMillionTokens": {
"type": "number"
},
"PairingCode": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"expires_in": {
"type": "integer"
}
},
"required": ["code", "expires_in"],
"additionalProperties": false
},
"PairingSession": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
},
"required": ["token"],
"additionalProperties": false
},
"Permission.Effect": {
"type": "string",
"enum": ["allow", "deny", "ask"]
@@ -18234,6 +18631,16 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -18653,6 +19060,9 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
+426 -16
View File
@@ -48,6 +48,106 @@
"summary": "Get server info"
}
},
"/api/pair": {
"post": {
"tags": ["server"],
"operationId": "server.pair",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "PairingCode",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingCode"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
"summary": "Create pairing code"
}
},
"/auth/connect/{code}": {
"get": {
"tags": ["server"],
"operationId": "server.connect",
"parameters": [
{
"name": "code",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "PairingSession",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingSession"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
},
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
]
}
}
}
}
},
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
"summary": "Redeem pairing code"
}
},
"/api/location": {
"get": {
"tags": ["location"],
@@ -886,9 +986,19 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location.",
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -907,6 +1017,17 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -4508,6 +4629,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4591,21 +4727,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7197,6 +7318,125 @@
"summary": "List MCP resources"
}
},
"/api/credential": {
"get": {
"tags": ["credential"],
"operationId": "credential.list",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Credential.Entry"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "List every stored integration credential, including its secret value.",
"summary": "List credentials"
},
"post": {
"tags": ["credential"],
"operationId": "credential.create",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Credential.Entry"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
"summary": "Create credential",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Credential.CreateInput"
}
}
},
"required": true
}
}
},
"/api/credential/{credentialID}": {
"patch": {
"tags": ["credential"],
@@ -13631,6 +13871,9 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13645,6 +13888,9 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13660,6 +13906,128 @@
}
]
},
"Connection.Status": {
"type": "object",
"properties": {
"status": {
"anyOf": [
{
"type": "string",
"enum": ["needs_auth"]
}
]
},
"message": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["status", "message"],
"additionalProperties": false
},
"Credential.CreateInput": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
},
"activate": {
"type": "boolean"
}
},
"required": ["integrationID", "value"],
"additionalProperties": false
},
"Credential.Entry": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"active": {
"type": "boolean"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
}
},
"required": ["id", "integrationID", "label", "active", "value"],
"additionalProperties": false
},
"Credential.Key": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["key"]
},
"key": {
"type": "string"
},
"metadata": {
"type": "object"
},
"configuration": {
"$ref": "#/components/schemas/Form.Answer_1"
}
},
"required": ["type", "key"],
"additionalProperties": false
},
"Credential.OAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["oauth"]
},
"methodID": {
"type": "string"
},
"refresh": {
"type": "string"
},
"access": {
"type": "string"
},
"expires": {
"type": "integer",
"minimum": 0
},
"metadata": {
"type": "object"
}
},
"required": ["type", "methodID", "refresh", "access", "expires"],
"additionalProperties": false
},
"Credential.Value": {
"anyOf": [
{
"$ref": "#/components/schemas/Credential.OAuth"
},
{
"$ref": "#/components/schemas/Credential.Key"
}
]
},
"FileDiff.Info": {
"type": "object",
"properties": {
@@ -13746,6 +14114,12 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -15709,6 +16083,29 @@
"Money.USDPerMillionTokens": {
"type": "number"
},
"PairingCode": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"expires_in": {
"type": "integer"
}
},
"required": ["code", "expires_in"],
"additionalProperties": false
},
"PairingSession": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
},
"required": ["token"],
"additionalProperties": false
},
"Permission.Effect": {
"type": "string",
"enum": ["allow", "deny", "ask"]
@@ -18234,6 +18631,16 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -18653,6 +19060,9 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
+426 -16
View File
@@ -48,6 +48,106 @@
"summary": "Get server info"
}
},
"/api/pair": {
"post": {
"tags": ["server"],
"operationId": "server.pair",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "PairingCode",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingCode"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
"summary": "Create pairing code"
}
},
"/auth/connect/{code}": {
"get": {
"tags": ["server"],
"operationId": "server.connect",
"parameters": [
{
"name": "code",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "PairingSession",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairingSession"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
},
{
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
]
}
}
}
}
},
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
"summary": "Redeem pairing code"
}
},
"/api/location": {
"get": {
"tags": ["location"],
@@ -886,9 +986,19 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location.",
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -907,6 +1017,17 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -4508,6 +4629,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4591,21 +4727,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7197,6 +7318,125 @@
"summary": "List MCP resources"
}
},
"/api/credential": {
"get": {
"tags": ["credential"],
"operationId": "credential.list",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Credential.Entry"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "List every stored integration credential, including its secret value.",
"summary": "List credentials"
},
"post": {
"tags": ["credential"],
"operationId": "credential.create",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Credential.Entry"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"409": {
"description": "ConflictError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
}
}
}
},
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
"summary": "Create credential",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Credential.CreateInput"
}
}
},
"required": true
}
}
},
"/api/credential/{credentialID}": {
"patch": {
"tags": ["credential"],
@@ -13631,6 +13871,9 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13645,6 +13888,9 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13660,6 +13906,128 @@
}
]
},
"Connection.Status": {
"type": "object",
"properties": {
"status": {
"anyOf": [
{
"type": "string",
"enum": ["needs_auth"]
}
]
},
"message": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["status", "message"],
"additionalProperties": false
},
"Credential.CreateInput": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
},
"activate": {
"type": "boolean"
}
},
"required": ["integrationID", "value"],
"additionalProperties": false
},
"Credential.Entry": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"integrationID": {
"type": "string"
},
"label": {
"type": "string"
},
"active": {
"type": "boolean"
},
"value": {
"$ref": "#/components/schemas/Credential.Value"
}
},
"required": ["id", "integrationID", "label", "active", "value"],
"additionalProperties": false
},
"Credential.Key": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["key"]
},
"key": {
"type": "string"
},
"metadata": {
"type": "object"
},
"configuration": {
"$ref": "#/components/schemas/Form.Answer_1"
}
},
"required": ["type", "key"],
"additionalProperties": false
},
"Credential.OAuth": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["oauth"]
},
"methodID": {
"type": "string"
},
"refresh": {
"type": "string"
},
"access": {
"type": "string"
},
"expires": {
"type": "integer",
"minimum": 0
},
"metadata": {
"type": "object"
}
},
"required": ["type", "methodID", "refresh", "access", "expires"],
"additionalProperties": false
},
"Credential.Value": {
"anyOf": [
{
"$ref": "#/components/schemas/Credential.OAuth"
},
{
"$ref": "#/components/schemas/Credential.Key"
}
]
},
"FileDiff.Info": {
"type": "object",
"properties": {
@@ -13746,6 +14114,12 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -15709,6 +16083,29 @@
"Money.USDPerMillionTokens": {
"type": "number"
},
"PairingCode": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"expires_in": {
"type": "integer"
}
},
"required": ["code", "expires_in"],
"additionalProperties": false
},
"PairingSession": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
},
"required": ["token"],
"additionalProperties": false
},
"Permission.Effect": {
"type": "string",
"enum": ["allow", "deny", "ask"]
@@ -18234,6 +18631,16 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -18653,6 +19060,9 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
@@ -890,7 +890,7 @@ effect: (ctx) =>
id: Skill.ID.make("review"),
name: Skill.Name.make("Review"),
description: "Review the current changes",
location: "/workspace/.opencode/skills/review.md",
path: "/workspace/.opencode/skills/review/SKILL.md",
content: "Review the current changes for correctness and missing tests.",
}))
const review = editor.get("review")
@@ -829,7 +829,7 @@ await ctx.skill.transform((editor) => {
id: "review",
name: "Review",
description: "Review the current changes",
location: "/workspace/.opencode/skills/review.md",
path: "/workspace/.opencode/skills/review/SKILL.md",
content: "Review the current changes for correctness and missing tests.",
})
const review = editor.get("review")