Compare commits

..
47 changed files with 709 additions and 1587 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")"
+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)
})
})
+101 -40
View File
@@ -1,52 +1,113 @@
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 } from "effect"
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)
// 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<never>())
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)))
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) })
return connection
}
})
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))
}
}
const spanName = (method: string) => `cli.acp.${method.replaceAll("/", ".")}`
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 promise = <A>(evaluate: () => Promise<A>) =>
Effect.tryPromise({ try: evaluate, catch: (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"
+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
+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)),
)
+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,
)
+8 -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, Logger, Option, Schema } from "effect"
import { ACP } from "../../src/acp/agent"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
@@ -377,9 +377,12 @@ 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 agentConnection = await Effect.runPromise(
ACP.connect(
OpenCode.make({ baseUrl: server.url }),
ndJsonStream(agentToClient.writable, clientToAgent.readable),
).pipe(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 +459,7 @@ export async function startWire(options: WireOptions = {}) {
return {
server,
logs,
received,
updates,
permissions,
+6 -5
View File
@@ -76,6 +76,10 @@ export type AgentListInput = { readonly location?: { readonly directory?: string
export type AgentListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Agent.Info> }
export type AgentListOperation<E = never> = (input?: AgentListInput) => Effect.Effect<AgentListOutput, E>
export type AgentDefaultInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type AgentDefaultOutput = { readonly location: Location.PublicRef; readonly data: Agent.Info | undefined }
export type AgentDefaultOperation<E = never> = (input?: AgentDefaultInput) => Effect.Effect<AgentDefaultOutput, E>
export type AgentGetInput = {
readonly agentID: Agent.ID
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -85,6 +89,7 @@ export type AgentGetOperation<E = never> = (input: AgentGetInput) => Effect.Effe
export interface AgentApi<E = never> {
readonly list: AgentListOperation<E>
readonly default: AgentDefaultOperation<E>
readonly get: AgentGetOperation<E>
}
@@ -412,11 +417,7 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: SessionInstructionsEntryRemoveInput,
) => Effect.Effect<SessionInstructionsEntryRemoveOutput, E>
export type SessionGenerateInput = {
readonly sessionID: Session.ID
readonly prompt: string
readonly model?: Model.Ref | undefined
}
export type SessionGenerateInput = { readonly sessionID: Session.ID; readonly prompt: string }
export type SessionGenerateOutput = { readonly text: string }
export type SessionGenerateOperation<E = never> = (
input: SessionGenerateInput,
@@ -14,6 +14,8 @@ import type {
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentDefaultInput,
AgentDefaultOutput,
AgentGetInput,
AgentGetOutput,
PluginListInput,
@@ -319,6 +321,11 @@ const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentList
raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointAgentDefault = (raw: RawClient["server.agent"]) => (input?: AgentDefaultInput) =>
preserveEffect<AgentDefaultOutput>()(
raw["agent.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointAgentGet = (raw: RawClient["server.agent"]) => (input: AgentGetInput) =>
preserveEffect<AgentGetOutput>()(
raw["agent.get"]({ params: { agentID: input["agentID"] }, query: { location: input["location"] } }).pipe(
@@ -328,6 +335,7 @@ const EndpointAgentGet = (raw: RawClient["server.agent"]) => (input: AgentGetInp
const adaptGroupAgent = (raw: RawClient["server.agent"]) => ({
list: EndpointAgentList(raw),
default: EndpointAgentDefault(raw),
get: EndpointAgentGet(raw),
})
@@ -660,10 +668,7 @@ const EndpointSessionInstructionsEntryRemove =
const EndpointSessionGenerate = (raw: RawClient["server.session"]) => (input: SessionGenerateInput) =>
preserveEffect<SessionGenerateOutput>()(
raw["session.generate"]({
params: { sessionID: input["sessionID"] },
payload: { prompt: input["prompt"], model: input["model"] },
}).pipe(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -8,6 +8,8 @@ import type {
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentDefaultInput,
AgentDefaultOutput,
AgentGetInput,
AgentGetOutput,
PluginListInput,
@@ -477,6 +479,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
default: (input?: AgentDefaultInput, requestOptions?: RequestOptions) =>
request<AgentDefaultOutput>(
{
method: "GET",
path: `/api/agent/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
get: (input: AgentGetInput, requestOptions?: RequestOptions) =>
request<AgentGetOutput>(
{
@@ -952,7 +966,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/generate`,
body: { prompt: input["prompt"], model: input["model"] },
body: { prompt: input["prompt"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
empty: false,
@@ -2759,6 +2759,12 @@ export type AgentListInput = {
export type AgentListOutput = { location: LocationPublicRef; data: Array<AgentInfo> }
export type AgentDefaultInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
export type AgentDefaultOutput = { location: LocationPublicRef; data: AgentInfo | null }
export type AgentGetInput = {
readonly agentID: { readonly agentID: string }["agentID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
@@ -4579,14 +4585,7 @@ export type SessionInstructionsEntryRemoveOutput = void
export type SessionGenerateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly prompt: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
}["prompt"]
readonly model?: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
}["model"]
readonly prompt: { readonly prompt: string }["prompt"]
}
export type SessionGenerateOutput = SessionGenerateResponse["data"]
+3 -11
View File
@@ -955,11 +955,7 @@ test("session methods use the public HTTP contract", async () => {
text: "Hello",
resume: false,
})
const generated = await client.session.generate({
sessionID: "ses_test",
prompt: "Summarize this session",
model: { providerID: "openai", id: "gpt-5-mini", variant: "low" },
})
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Summarize this session" })
const synthetic = await client.session.synthetic({
sessionID: "ses_test",
text: "Completed",
@@ -971,7 +967,7 @@ test("session methods use the public HTTP contract", async () => {
const context = await client.session.context({ sessionID: "ses_test" })
const log = []
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
const interrupted = await client.session.interrupt({ sessionID: "ses_test", resume: true })
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
const message = await client.session.message.get({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
@@ -980,10 +976,6 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(generated.text).toBe("A transient answer")
expect(JSON.parse(String(requests.find((request) => request.url.endsWith("/generate"))?.init?.body))).toEqual({
prompt: "Summarize this session",
model: { providerID: "openai", id: "gpt-5-mini", variant: "low" },
})
expect(interrupted).toEqual({ interrupted: true })
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
expect(context).toEqual([])
@@ -1003,7 +995,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/experimental/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt?resume=true"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
])
const viewBody = requests.find((request) => request.url.endsWith("/api/session/ses_test/view"))?.init?.body
+4
View File
@@ -46,6 +46,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
readonly select: (id?: ID | string) => Effect.Effect<Selection>
readonly default: () => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -121,6 +122,9 @@ const layer = Layer.effect(
const info = selectedDefault()
return { id: info?.id ?? defaultID, info }
}),
default: Effect.fn("Agent.default")(function* () {
return selectedDefault()
}),
list: Effect.fn("Agent.list")(function* () {
const agents = Array.fromIterable(state.get().agents.values())
const selected = selectedDefault()
+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
+16 -25
View File
@@ -96,20 +96,25 @@ export const make = Effect.fn("PluginHost.make")(function* (
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) =>
Effect.gen(function* () {
const atAgentLocation = <A>(
input: Parameters<typeof locationRef>[0],
use: (agents: Agent.Interface) => Effect.Effect<A>,
) => {
const ref = locationRef(input)
if (!ref || isCurrentLocation(ref)) return response(use(agents))
return Effect.gen(function* () {
const location = yield* Location.Service
const agents = yield* Agent.Service
const remote = yield* Agent.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* agents.list(),
data: yield* use(remote),
}
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
)
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie, Effect.withSpan("PluginHost.atAgentLocation"))
}
// Keep the instance graph's inferred types independent of Session handles.
const context: Plugin.Context = {
@@ -118,30 +123,16 @@ export const make = Effect.fn("PluginHost.make")(function* (
options: {},
rpc: Object.assign(rpc.client, { register: rpc.register }),
agent: {
get: (input) => {
const ref = locationRef(input)
const output =
ref && !isCurrentLocation(ref)
? listAgents(ref).pipe(
Effect.map((result) => ({
...result,
data: result.data.find((agent) => agent.id === input.agentID),
})),
)
: response(agents.get(input.agentID))
return output.pipe(
get: (input) =>
atAgentLocation(input, (agents) => agents.get(input.agentID)).pipe(
Effect.flatMap((result) =>
result.data
? Effect.succeed({ ...result, data: result.data })
: Effect.fail(new Error(`Agent not found: ${input.agentID}`)),
),
)
},
list: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return listAgents(ref)
return response(agents.list())
},
),
list: (input) => atAgentLocation(input, (agents) => agents.list()),
default: (input) => atAgentLocation(input, (agents) => agents.default()),
reload: agents.reload,
transform: (callback) =>
agents.transform((editor) => {
+1 -2
View File
@@ -186,7 +186,6 @@ export interface Interface {
readonly generate: (input: {
sessionID: SessionSchema.ID
prompt: string
model?: Model.Ref
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
sessionID: SessionSchema.ID
@@ -405,7 +404,7 @@ const layer = Layer.effect(
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionGenerate.generate({ session, prompt: input.prompt, model: input.model }).pipe(
return yield* SessionGenerate.generate({ session, prompt: input.prompt }).pipe(
Effect.provideService(Instance.Service, instances),
Effect.provideService(Database.Service, database),
Effect.provideService(LLMClient.Service, llm),
+1 -5
View File
@@ -5,7 +5,6 @@ import { Effect } from "effect"
import { Database } from "../database/database.js"
import { Instance } from "../instance/service.js"
import { Plugin } from "../plugin/service.js"
import type { Model } from "../model.js"
import type { Instructions } from "../instructions/index.js"
import { SessionContext } from "./context.js"
import type { AgentNotFoundError } from "./error.js"
@@ -21,7 +20,6 @@ export type Error = AgentNotFoundError | Instructions.InitializationBlocked | Se
export const generate = Effect.fn("SessionGenerate.generate")(function* (input: {
session: SessionSchema.Info
prompt: string
model?: Model.Ref
}) {
const instances = yield* Instance.Service
const database = yield* Database.Service
@@ -31,9 +29,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
yield* Plugin.awaitActivation
const context = yield* SessionContext.Service
const selection = yield* context.select(input.session.id)
const model = yield* context.resolveModel(
input.model === undefined ? selection.session : { ...selection.session, model: input.model },
)
const model = yield* context.resolveModel(selection.session)
const history = yield* SessionHistory.preview(
database.db,
selection.session.id,
+67
View File
@@ -87,6 +87,73 @@ describe("Agent", () => {
}),
)
it.effect("resolves the default agent the same way sessions select one", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* agent.transform((editor) => {
editor.update(Agent.ID.make("plan"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("build"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("reviewer"), (info) => {
info.mode = "primary"
})
editor.update(Agent.ID.make("secret"), (info) => {
info.hidden = true
})
editor.update(Agent.ID.make("explore"), (info) => {
info.mode = "subagent"
})
})
const resolved = Effect.gen(function* () {
const info = yield* agent.default()
expect((yield* agent.select()).info).toEqual(info)
return info?.id
})
expect(yield* resolved).toBe(Agent.ID.make("build"))
yield* Effect.forEach(
[
["reviewer", "reviewer"],
["missing", "build"],
["secret", "build"],
["explore", "build"],
] as const,
([id, expected]) =>
Effect.gen(function* () {
yield* agent.transform((editor) => editor.default(Agent.ID.make(id)))
expect(yield* resolved).toBe(Agent.ID.make(expected))
}),
)
}),
)
it.effect("falls back to the first selectable agent without build", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
yield* agent.transform((editor) => {
editor.update(Agent.ID.make("explore"), (info) => {
info.mode = "subagent"
})
editor.update(Agent.ID.make("plan"), (info) => {
info.mode = "primary"
})
})
expect((yield* agent.default())?.id).toBe(Agent.ID.make("plan"))
yield* agent.transform((editor) =>
editor.update(Agent.ID.make("plan"), (info) => {
info.mode = "subagent"
}),
)
expect(yield* agent.default()).toBeUndefined()
expect(yield* agent.select()).toEqual({ id: Agent.defaultID, info: undefined })
}),
)
it.effect("rebuilds state when a transform is replaced", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
+2
View File
@@ -39,6 +39,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
default: () => Effect.die("unused agent.default"),
transform: () => Effect.die("unused agent.transform"),
reload: () => Effect.die("unused agent.reload"),
},
@@ -204,6 +205,7 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
),
),
list: () => Effect.die("unused agent.list"),
default: () => Effect.die("unused agent.default"),
reload: agent.reload,
transform: (callback) =>
agent.transform((editor) =>
+1
View File
@@ -56,6 +56,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
agent: {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
default: () => Effect.die("unused agent.default"),
reload: () => Effect.die("unused agent.reload"),
transform: (callback) => {
callback({
+3 -38
View File
@@ -24,7 +24,7 @@ import { Instructions } from "@opencode/core/instructions/index"
import { InstructionBuiltIns } from "@opencode/core/instructions/builtins"
import { Location } from "@opencode/core/location"
import { McpInstructions } from "@opencode/core/mcp/instructions"
import { ID, Model } from "@opencode/core/model"
import { ID } from "@opencode/core/model"
import { Project } from "@opencode/core/project"
import { Provider } from "@opencode/core/provider"
import { ReferenceInstructions } from "@opencode/core/reference/instructions"
@@ -60,7 +60,6 @@ let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const alternate = LanguageModel.make({ id: "cheap-model", provider: "other", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
stream: () => Stream.die(new Error("unused")),
generate: (request, requestOptions) =>
@@ -84,11 +83,10 @@ const client = Layer.mock(LLMClient.Service)({
}),
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: (session) =>
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(session.model?.id === "cheap-model" ? alternate : model, {
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
variant: session.model?.variant,
cost: [],
limit: { context: 200_000, output: 32_000 },
}),
@@ -355,39 +353,6 @@ it.effect(
expect(requests[0]?.toolChoice).toBeUndefined()
expect(options[0]?.webSocket).toBeUndefined()
expect(yield* durableState(db, sessionID)).toEqual(before)
const hooks = yield* PluginHooks.Service
const override = Model.Ref.make({
providerID: Provider.ID.make("other"),
id: ID.make("cheap-model"),
variant: Model.VariantID.make("low"),
})
let generates = 0
yield* hooks.register(
"session",
"generate",
(event) =>
Effect.sync(() => {
generates++
expect(event.model).toEqual(override)
}),
{ providerID: override.providerID },
)
yield* SessionGenerate.generate({ session, prompt: "Summarize cheaply", model: override }).pipe(
Effect.provideService(Instance.Service, instances),
)
expect(generates).toBe(1)
expect(requests[1]?.model).toBe(alternate)
expect(requests[1]?.promptCacheKey).toBe(sessionID)
expect(userTexts(requests[1])).toEqual(["Existing durable context", "Summarize cheaply"])
expect(yield* durableState(db, sessionID)).toEqual(before)
yield* SessionGenerate.generate({ session, prompt: "Use the session model again" }).pipe(
Effect.provideService(Instance.Service, instances),
)
expect(requests[2]?.model).toBe(model)
expect(generates).toBe(1)
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
{ timeout: 15_000 },
)
+1
View File
@@ -295,6 +295,7 @@ export function fromPromise(plugin: Plugin) {
agent: {
get: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
list: adaptApiMethod(AgentEndpoints["agent.list"], host.agent.list),
default: adaptApiMethod(AgentEndpoints["agent.default"], host.agent.default),
transform: transform(host.agent),
reload: () => run(host.agent.reload()),
},
+17 -437
View File
@@ -48,106 +48,6 @@
"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"],
@@ -986,19 +886,9 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"description": "Create a session at the requested location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -1017,17 +907,6 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -3918,7 +3797,7 @@
}
}
},
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
"description": "Generate transient text from the current session context without mutating session history.",
"summary": "Generate text from session context",
"requestBody": {
"content": {
@@ -3928,16 +3807,6 @@
"properties": {
"prompt": {
"type": "string"
},
"model": {
"anyOf": [
{
"$ref": "#/components/schemas/Model.Ref"
},
{
"type": "null"
}
]
}
},
"required": ["prompt"],
@@ -4639,21 +4508,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4737,6 +4591,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7328,125 +7197,6 @@
"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"],
@@ -13881,9 +13631,6 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13898,9 +13645,6 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13916,128 +13660,6 @@
}
]
},
"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": {
@@ -14124,12 +13746,6 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -16093,29 +15709,6 @@
"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"]
@@ -18641,16 +18234,6 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -19070,9 +18653,6 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
+15
View File
@@ -20,6 +20,21 @@ export const AgentGroup = HttpApiGroup.make("server.agent")
}),
),
)
.add(
HttpApiEndpoint.get("agent.default", "/api/agent/default", {
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(Agent.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "agent.default",
summary: "Get default agent",
description:
"Retrieve the agent used when a session has no explicit agent selection. The result may precede initial plugin and config settlement.",
}),
),
)
.add(
HttpApiEndpoint.get("agent.get", "/api/agent/:agentID", {
params: { agentID: Agent.ID },
+2 -3
View File
@@ -708,7 +708,7 @@ export const makeSessionGroup = <
.add(
HttpApiEndpoint.post("session.generate", "/api/session/:sessionID/generate", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ prompt: Schema.String, model: Model.Ref.pipe(Schema.optional) }),
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({
data: Schema.Struct({ text: Schema.String }),
}).annotate({ identifier: "SessionGenerateResponse" }),
@@ -719,8 +719,7 @@ export const makeSessionGroup = <
OpenApi.annotations({
identifier: "session.generate",
summary: "Generate text from session context",
description:
"Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
description: "Generate transient text from the current session context without mutating session history.",
}),
),
)
+7
View File
@@ -12,6 +12,13 @@ export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers)
return yield* response(Agent.Service.use((agent) => agent.list()))
}),
)
.handle(
"agent.default",
Effect.fn(function* () {
const agents = yield* Agent.Service
return yield* response(agents.default())
}),
)
.handle(
"agent.get",
Effect.fn(function* (ctx) {
+1 -1
View File
@@ -589,7 +589,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.generate",
Effect.fn(function* (ctx) {
const text = yield* session
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt, model: ctx.payload.model })
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })
.pipe(
Effect.mapError((error) =>
error._tag === "Session.NotFoundError"
+50
View File
@@ -0,0 +1,50 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Agent } from "@opencode/schema/agent"
import { Effect, Schedule, Schema } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
const DefaultResponse = Schema.Struct({
location: Schema.Struct({ directory: Schema.String }),
data: Schema.NullOr(Agent.Info),
})
it.live("returns the default agent for each location", () =>
Effect.gen(function* () {
const global = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-global-")))
const configured = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-")))
const unconfigured = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-agent-default-")))
yield* Effect.promise(() =>
fs.writeFile(
path.join(configured.path, "opencode.json"),
JSON.stringify({ default_agent: "reviewer", agents: { reviewer: { mode: "primary" } } }),
),
)
const server = yield* startServer(global.path)
yield* Effect.forEach(
[
{ directory: configured.path, expected: "reviewer" },
{ directory: unconfigured.path, expected: "build" },
],
(project) =>
Effect.gen(function* () {
const url = new URL("/api/agent/default", server.base)
url.searchParams.set("location[directory]", project.directory)
const response = yield* Effect.promise(() => fetch(url, { headers: server.headers }))
expect(response.status).toBe(200)
const body = Schema.decodeUnknownSync(DefaultResponse)(yield* Effect.promise(() => response.json()))
expect(body.location.directory).toBe(project.directory)
return body.data?.id
}).pipe(
// Agents register during plugin activation, after the location is served.
Effect.filterOrFail((id) => id === project.expected),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
),
)
}),
)
+1 -22
View File
@@ -51,14 +51,6 @@ it.live(
limit: { context: 200_000, output: 8_192 },
},
)
const alternate = SessionRunnerModel.resolved(
LanguageModel.make({ id: "cheap-model", provider: "other", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 8_192 },
},
)
// Host and private instances must reuse the same global layer identities.
const replacements: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
@@ -69,9 +61,7 @@ it.live(
Watcher.node.replace(Watcher.configured({ enabled: false })),
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
SessionRunnerModel.node.replace(
Layer.succeed(SessionRunnerModel.Service, {
resolve: (session) => Effect.succeed(session.model?.id === alternate.ref.id ? alternate : model),
}),
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
),
Instance.node.replace(
makeGlobalNode({
@@ -245,17 +235,6 @@ it.live(
})),
)
const selected = yield* sessions.get(first.id)
yield* llm.push(TestLLM.text("A cheaper answer", "cheap-generation"))
const generated = yield* request(`/api/session/${first.id}/generate`, {
prompt: "Summarize cheaply",
model: alternate.ref,
})
expect(generated.status).toBe(200)
expect(yield* Effect.promise(() => generated.json())).toEqual({ data: { text: "A cheaper answer" } })
expect((yield* llm.requests()).at(-1)?.model).toBe(alternate.model)
expect((yield* sessions.get(first.id)).model).toEqual(selected.model)
// Seed through Core, then use HTTP to reach those exact private instances.
const pending = yield* Effect.forEach(configs, (config) =>
Effect.gen(function* () {
+17 -437
View File
@@ -48,106 +48,6 @@
"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"],
@@ -986,19 +886,9 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"description": "Create a session at the requested location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -1017,17 +907,6 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -3918,7 +3797,7 @@
}
}
},
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
"description": "Generate transient text from the current session context without mutating session history.",
"summary": "Generate text from session context",
"requestBody": {
"content": {
@@ -3928,16 +3807,6 @@
"properties": {
"prompt": {
"type": "string"
},
"model": {
"anyOf": [
{
"$ref": "#/components/schemas/Model.Ref"
},
{
"type": "null"
}
]
}
},
"required": ["prompt"],
@@ -4639,21 +4508,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4737,6 +4591,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7328,125 +7197,6 @@
"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"],
@@ -13881,9 +13631,6 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13898,9 +13645,6 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13916,128 +13660,6 @@
}
]
},
"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": {
@@ -14124,12 +13746,6 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -16093,29 +15709,6 @@
"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"]
@@ -18641,16 +18234,6 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -19070,9 +18653,6 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
+17 -437
View File
@@ -48,106 +48,6 @@
"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"],
@@ -986,19 +886,9 @@
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
}
},
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"description": "Create a session at the requested location.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -1017,17 +907,6 @@
}
]
},
"parentID": {
"anyOf": [
{
"type": "string",
"pattern": "^ses"
},
{
"type": "null"
}
]
},
"title": {
"anyOf": [
{
@@ -3918,7 +3797,7 @@
}
}
},
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
"description": "Generate transient text from the current session context without mutating session history.",
"summary": "Generate text from session context",
"requestBody": {
"content": {
@@ -3928,16 +3807,6 @@
"properties": {
"prompt": {
"type": "string"
},
"model": {
"anyOf": [
{
"$ref": "#/components/schemas/Model.Ref"
},
{
"type": "null"
}
]
}
},
"required": ["prompt"],
@@ -4639,21 +4508,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4737,6 +4591,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7328,125 +7197,6 @@
"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"],
@@ -13881,9 +13631,6 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13898,9 +13645,6 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13916,128 +13660,6 @@
}
]
},
"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": {
@@ -14124,12 +13746,6 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -16093,29 +15709,6 @@
"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"]
@@ -18641,16 +18234,6 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -19070,9 +18653,6 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
@@ -821,22 +821,6 @@ effect: (ctx) =>
}),
```
`session.generate` uses the session's model by default, retaining the opportunity to reuse its prompt cache.
Supply `model` to override it for one request without changing the session; switching models generally cannot reuse
the existing model's cache.
```ts
effect: (ctx) =>
Effect.gen(function* () {
const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
const generated = yield* ctx.session.generate({
sessionID: created.id,
prompt: "Summarize this project",
model: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5-mini") },
}).pipe(Effect.orDie)
}),
```
Remove a temporary session with `remove`. Removal stops active execution and recursively deletes child sessions.
```ts
@@ -766,18 +766,6 @@ const command = await ctx.session.command({ sessionID, command: "review", argume
const synthetic = await ctx.session.synthetic({ sessionID, text: "Deployment completed" })
```
`session.generate` uses the session's model by default, retaining the opportunity to reuse its prompt cache.
Supply `model` to override it for one request without changing the session; switching models generally cannot reuse
the existing model's cache.
```ts
const generated = await ctx.session.generate({
sessionID,
prompt: "Summarize this project",
model: { providerID: "openai", id: "gpt-5-mini" },
})
```
Rename, interrupt, or wait for a session.
```ts