Compare commits

..
Author SHA1 Message Date
rekram1-node 5fbdf3ee60 docs: restore security scope wording 2026-10-02 01:24:18 +00:00
rekram1-node 4827ef1f27 docs: restore no-sandbox paragraph 2026-10-02 01:00:17 +00:00
rekram1-node 2dedc5e274 docs: describe v2 permissions and server auth 2026-10-02 00:42:49 +00:00
8a3185b66c docs(websearch): include TinyFish provider (#52604)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-10-01 19:34:42 -05:00
opencode-agent[bot]andrekram1-node 37f3e239c6 docs(cli): fix command examples (#52603)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-01 19:34:08 -05:00
opencode-agent[bot]andrekram1-node 2deb0e250f docs(mcp): correct session metadata key (#52602)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-01 19:33:37 -05:00
Aiden Cline e41756cfc4 fix(ai): isolate Groq and Vertex metadata keys (#52598) 2026-10-01 19:24:08 -05:00
Shoubhit Dash 8b24fc8c2a feat(app): show last turn changes in review panel (#51640) 2026-10-02 06:39:33 +08:00
Filip 0f9f0a9ae3 fix(core): ignore directories named AGENTS.md during instruction discovery (#52576) 2026-10-01 23:12:26 +02:00
Aiden Cline f9e61fd9a3 fix(core): drop legacy catalog context pricing (#52582) 2026-10-01 16:09:38 -05:00
James Long 5256112bac feat(cli): check for updates every 10 minutes (#52552) 2026-10-01 16:29:04 -04:00
Filip afc5d1e21f test(core): test Azure plugin behavior instead of fakes (#52570) 2026-10-01 21:42:39 +02:00
opencode-agent[bot] 80ecb3fc89 chore: update nix node_modules hashes 2026-10-01 19:19:55 +00:00
Shoubhit Dash 31a683f5ca chore(acp): bump @agentclientprotocol/sdk to 1.6.0 (#52559) 2026-10-02 00:38:00 +05:30
Aiden Cline 5d707f1a2b fix(ai): classify Together input token rejections as context overflow (#52133) 2026-10-01 14:07:15 -05:00
Aiden Cline e2948f5a51 fix(core): skip automatic copies of directly read instructions (#52382) 2026-10-01 13:59:02 -05:00
Filip fbe8a9f7b4 feat(core): discover Azure deployments (#50053) 2026-10-01 20:42:12 +02:00
Aiden Cline 033b9ea560 fix(ai): make Claude capability defaults forward-compatible (#52535) 2026-10-01 13:20:54 -05:00
Shoubhit Dash 7bb3fbf095 feat(acp): support additional workspace directories (#52524) 2026-10-01 23:47:00 +05:30
Shoubhit Dash 507e117466 chore(ci): remove unused ffmpeg install (#52547) 2026-10-01 23:38:06 +05:30
Shoubhit Dash 1549712761 fix(acp): fall back to a reference for unreadable resource links (#52520) 2026-10-01 23:03:39 +05:30
opencode-agent[bot]andrekram1-node 4f37be6265 fix(ai): place Bedrock tool images by model family (#52528)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-01 11:58:36 -05:00
Rémy SanchezandAiden Cline 7b04288099 fix(ai): support prompt caching for DigitalOcean inference (#51559)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-01 11:56:04 -05:00
Shoubhit Dash 96339544bf feat(acp): answer V2 forms through ACP elicitation (#52511) 2026-10-01 22:17:50 +05:30
Adam b03d760f43 feat(core): explain why the provider blocked a response (#52518) 2026-10-01 11:23:40 -05:00
Shoubhit Dash b73e75fb6a test(tui): isolate app fixture state (#52512) 2026-10-01 21:49:07 +05:30
Shoubhit Dash 08c15ec1cd fix(acp): send available commands after the session response (#52522) 2026-10-01 21:42:35 +05:30
Shoubhit Dash 48f3c2e923 fix(acp): map invalid requests to invalid params (#52521) 2026-10-01 21:34:24 +05:30
Shoubhit Dash 3d94a90978 refactor(acp): run permission requests as effects (#52510) 2026-10-01 20:59:05 +05:30
Shoubhit Dash 2c33922412 refactor(acp): run ACP turns as interruptible effects (#52503) 2026-10-01 19:31:40 +05:30
Shoubhit Dash 7e42a897bc feat(acp): follow model and agent changes from other clients (#52499) 2026-10-01 18:45:52 +05:30
Shoubhit Dash d8663e1fe8 chore(ci): cancel superseded check runs (#52498) 2026-10-01 18:43:55 +05:30
Shoubhit Dash a6b10e3e87 fix(ci): stabilize bun cache keys (#52495) 2026-10-01 18:36:05 +05:30
Shoubhit Dash 3400375fb3 refactor(acp): split the promise turn from the session lifecycle (#52493) 2026-10-01 18:06:09 +05:30
opencode-agent[bot] ffb51318f6 chore(core): refresh bundled models.dev snapshot 2026-10-01 12:22:44 +00:00
Shoubhit Dash b1157ebb04 feat(acp): mark compactions for ACP clients (#52485) 2026-10-01 17:33:45 +05:30
Shoubhit Dash 3e4cdcd180 refactor(acp): move ACP sessions into a scoped effect registry (#52488) 2026-10-01 17:33:08 +05:30
98 changed files with 6473 additions and 3357 deletions

No files matched your search

+5 -3
View File
@@ -45,14 +45,16 @@ runs:
- name: Get cache directory
id: cache
shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
run: |
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
- name: Restore Bun dependencies
id: bun-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }}
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
@@ -75,4 +77,4 @@ runs:
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
+4
View File
@@ -7,6 +7,10 @@ on:
branches: [dev, v2]
workflow_dispatch:
concurrency:
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
cancel-in-progress: true
jobs:
check:
name: typecheck
-11
View File
@@ -94,12 +94,6 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Install ffmpeg
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -167,11 +161,6 @@ 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
+4 -1
View File
@@ -20,7 +20,10 @@ If you need true isolation, run OpenCode inside a Docker container or VM.
### Server Mode
Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability.
OpenCode clients normally discover or start a local background HTTP service. The CLI binds to loopback by default and
supplies a generated HTTP Basic Auth password when one is not configured; its server process refuses to start without a
password. If you expose the service beyond your machine, use appropriate network access controls as well as authentication.
An application embedding the fetch handler without a password must provide its own access control.
### Out of Scope
+2 -2
View File
@@ -118,7 +118,7 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@agentclientprotocol/sdk": "1.6.0",
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
@@ -1230,7 +1230,7 @@
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.6.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-XxXrmX7aZkDgOB0Rg9cu+ZFyiUUc5lF2n9seO3Gc4OR+MTdfZOwIqF6m3LvsmmM8K3qgPmXkDH8/IFM2u9vdcQ=="],
"@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yCdtDQsXERjfL9bJg7YXNloMBOPwFkPMFVNAzXlarBM=",
"aarch64-linux": "sha256-iQ1bLIszETcFoD4CENpoaDZJZF/KBKZFeTp9r7ogJ9Y=",
"aarch64-darwin": "sha256-G7oIrTXEFQ5iEF8KSpA4xJPw9xwqRDpbIR472iIoqC4="
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
}
}
+1
View File
@@ -49,6 +49,7 @@ const RESPECTS_INLINE_HINTS = new Set([
"zai-coding-messages",
"bedrock-converse",
"openrouter",
"digitalocean",
])
// OpenRouter upstreams other than Anthropic and Alibaba Qwen cache without breakpoints. Gemini uses only the last
+14 -10
View File
@@ -451,6 +451,9 @@ const AnthropicStreamDelta = Schema.Struct({
signature: Schema.optional(Schema.String),
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
stop_details: optionalNull(
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
),
})
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
@@ -804,15 +807,12 @@ const requireThinkingSignature = (request: LLMRequest) => {
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
// Opus 4.8 and every Claude 5 model accept mid-conversation system messages; later versions inherit support.
const supportsNativeSystemUpdates = (request: LLMRequest) => {
const match = /(?:^|[./])claude-(fable|haiku|mythos|opus|sonnet)-(\d+)(?:[.-](\d+))?/.exec(
String(request.model.id).toLowerCase(),
)
if (!match) return false
const major = Number(match[2])
if (match[1] !== "opus") return major >= 5
if (major !== 4) return major >= 5
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
const version = claudeVersion(String(request.model.id))
if (version === undefined) return false
if (version.family === "opus" && version.major === 4) return version.minor >= 8
return version.major >= 5
}
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
@@ -989,13 +989,13 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(model.id)
if (version === undefined) return false
if (version.family === "opus") return version.major >= 5
if (version.family !== "fable" && version.family !== "mythos") return false
if (version.family === "opus" && version.major >= 5) return true
return version.major > 5 || (version.major === 5 && version.minor >= 1)
}
@@ -1417,10 +1417,14 @@ const onMessageDelta = (
stopSequence === null || stopSequence === undefined
? state.pendingFinish?.providerMetadata
: providerMetadata(state.providerMetadataKey, { stopSequence })
const category = event.delta?.stop_details?.category
const explanation = event.delta?.stop_details?.explanation
return {
reason: {
normalized: mapFinishReason(stopReason),
raw: stopReason,
...(category ? { category } : {}),
...(explanation ? { explanation } : {}),
},
providerMetadata: finishMetadata,
}
+31 -1
View File
@@ -319,6 +319,9 @@ const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
} satisfies BedrockToolResultBlock
})
// Keep Claude and Nova tool-result images inline; put other models' images beside the result.
const keepToolImagesInline = (id: string) => id.includes("anthropic.claude-") || id.includes("amazon.nova-")
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
request: LLMRequest,
breakpoints: BedrockCache.Breakpoints,
@@ -328,8 +331,19 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// Mistral can reject replay IDs even when they satisfy Converse's broader ID syntax.
const normalizeID = request.model.id.includes("mistral.") ? MistralToolID.normalizer(request) : (id: string) => id
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
const hoistImages = !keepToolImagesInline(request.model.id)
// Bedrock expects parallel tool results before any images hoisted beside them.
const pendingImages: BedrockMedia.ImageBlock[] = []
const flushImages = () => {
if (pendingImages.length === 0) return
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...pendingImages] }
pendingImages.length = 0
}
for (const message of request.messages) {
if (message.role !== "tool") flushImages()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message)
const content = textWithCache(breakpoints, part.text, part.cache)
@@ -403,7 +417,22 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(yield* lowerToolResult(part, documentNames, normalizeID))
const result = yield* lowerToolResult(part, documentNames, normalizeID)
const images: BedrockMedia.ImageBlock[] = hoistImages
? result.toolResult.content.filter((item) => "image" in item)
: []
const nonImageContent = result.toolResult.content.filter((item) => !("image" in item))
content.push(
images.length === 0
? result
: {
toolResult: {
...result.toolResult,
content: nonImageContent.length > 0 ? nonImageContent : [{ text: "See attached image." }],
},
},
)
pendingImages.push(...images)
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
@@ -413,6 +442,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
else messages.push({ role: "user", content })
}
flushImages()
return messages
})
+12 -7
View File
@@ -212,9 +212,11 @@ const OpenAIChatUsage = Schema.StructWithRest(
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
// Provider-specific cache accounting fields.
cached_tokens: optionalNull(Schema.Number),
prompt_cache_hit_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
cache_created_input_tokens: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
@@ -903,16 +905,19 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
// satisfied on both sides.
// Providers differ on cache-hit location: OpenAI uses
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
// `prompt_cache_hit_tokens`, Zai uses top-level `cached_tokens`, and
// DigitalOcean uses top-level `cache_read_input_tokens` / `cache_created_input_tokens`.
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
const cached = (usage.prompt_tokens_details?.cached_tokens ??
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
(usage as { cached_tokens?: number | null }).cached_tokens ??
undefined) as number | undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
const cached =
usage.prompt_tokens_details?.cached_tokens ??
usage.prompt_cache_hit_tokens ??
usage.cached_tokens ??
usage.cache_read_input_tokens ??
undefined
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? usage.cache_created_input_tokens ?? undefined
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({
+10
View File
@@ -1,4 +1,5 @@
// Shared counter and TTL mapping for provider cache-marker lowering.
import type { CacheHint } from "../../schema/index.js"
export interface Breakpoints {
remaining: number
@@ -11,3 +12,12 @@ export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, d
// requests omit the wire TTL and use the provider default.
export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined
export const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return { type: "ephemeral" as const, ttl: ttlBucket(cache.ttlSeconds) }
}
}
+2
View File
@@ -35,6 +35,8 @@ const patterns = [
/exceeds the limit of \d+/i,
/exceeds the available context size/i,
/greater than the context length/i,
// Hugging Face Text Generation Inference, e.g. Together
/`inputs` tokens \+ `max_new_tokens` must be <= \d+/i,
/context window exceeds limit/i,
/exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i,
+76
View File
@@ -0,0 +1,76 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { cacheControl } from "../protocols/utils/cache.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("digitalocean")
const baseURL = "https://inference.do-ai.run/v1"
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings & OpenAIProviderOptionsInput & { readonly apiKey?: string }
export const protocol = Protocol.make({
id: "digitalocean-chat",
body: {
schema: OpenAIChat.protocol.body.schema,
from: (request) => OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }),
},
stream: OpenAIChat.protocol.stream,
})
export const route = Route.make({
id: "digitalocean",
provider: id,
providerMetadataKey: "digitalocean",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
auth: AuthOptions.bearer(input, ["DIGITALOCEAN_ACCESS_TOKEN", "DIGITALOCEAN_API_KEY", "DO_INFERENCE_API_KEY"]),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: {
supportsPromptCacheKey: true,
},
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey,
baseURL,
headers,
http: { body },
providerOptions,
}).model(modelID)
export * as DigitalOcean from "./digitalocean.js"
@@ -37,7 +37,7 @@ export type Settings = ProviderPackage.Settings &
const route = Route.make({
id: "google-vertex-messages",
provider: id,
providerMetadataKey: "anthropic",
providerMetadataKey: "vertex",
protocol: Protocol.make({
id: AnthropicMessages.protocol.id,
body: {
+1 -1
View File
@@ -71,7 +71,7 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: "groq-chat",
provider: id,
providerMetadataKey: "openai",
providerMetadataKey: "groq",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
+1
View File
@@ -14,6 +14,7 @@ export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
export * as DeepInfra from "./deepinfra.js"
export * as Deepgram from "./deepgram.js"
export * as DeepSeek from "./deepseek.js"
export * as DigitalOcean from "./digitalocean.js"
export * as ElevenLabs from "./elevenlabs.js"
export * as Fal from "./fal.js"
export * as Fireworks from "./fireworks.js"
+2 -14
View File
@@ -3,11 +3,11 @@ import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type CacheHint, type ModelID, type OpenString } from "../schema/index.js"
import { HttpOptions, ProviderID, type ModelID, type OpenString } from "../schema/index.js"
import type { ProviderPackage } from "../provider-package.js"
import { SystemOne } from "../experimental/system-one.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { cacheControl } from "../protocols/utils/cache.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
export const id = ProviderID.make("openrouter")
@@ -131,18 +131,6 @@ export const protocol = Protocol.make({
stream: OpenAIChat.protocol.stream,
})
const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return {
type: "ephemeral" as const,
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
}
}
}
// OpenRouter forwards `reasoning.max_tokens` as the upstream thinking budget. Upstreams such as Anthropic and Alibaba
// reject one that is not below the output limit; 1,024 is Anthropic's minimum budget.
const fitReasoning = (reasoning: Record<string, unknown>, maxTokens: number | undefined) =>
+4
View File
@@ -264,6 +264,10 @@ export type ToolError = Schema.Schema.Type<typeof ToolError>
export const FinishReasonDetails = Schema.Struct({
normalized: FinishReason,
raw: Schema.optional(Schema.String),
/** The provider's policy area for a content-filter finish, such as `cyber`. */
category: Schema.optional(Schema.String),
/** The provider's human-readable reason for a content-filter finish. */
explanation: Schema.optional(Schema.String),
}).annotate({ identifier: "LLM.FinishReasonDetails" })
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
+28
View File
@@ -8,6 +8,7 @@ import {
AmazonBedrock,
AnthropicCompatible,
CloudflareAIGateway,
DigitalOcean,
GoogleVertexMessages,
Meta,
MiniMax,
@@ -117,6 +118,33 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' emits cache_control markers on DigitalOcean", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: DigitalOcean.configure({ apiKey: "test" }).model("anthropic-claude-fable-5.1"),
system: "You are concise.",
tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ type: "function", function: { name: "lookup" }, cache_control: { type: "ephemeral" } }],
messages: [
{
role: "system",
content: [{ text: "You are concise.", cache_control: { type: "ephemeral" } }],
},
{
role: "user",
content: [{ text: "hi", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+6
View File
@@ -197,9 +197,15 @@ describe("Anthropic Messages effort updates", () => {
["anthropic/claude-opus-5", true],
["claude-fable-5-1", true],
["claude-mythos-5-1", true],
["claude-opus-5-5", true],
["claude-sonnet-5-5", true],
["anthropic/claude-sonnet-5-5", true],
["claude-sonnet-6", true],
["claude-haiku-6", true],
["claude-fable-5", false],
["claude-opus-4-8", false],
["claude-sonnet-5", false],
["claude-sonnet-5-20260801", false],
["kimi-k2.5", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
@@ -0,0 +1,54 @@
{
"version": 1,
"metadata": {
"model": "openai-gpt-5-nano",
"tags": [
"prefix:digitalocean-chat",
"provider:digitalocean",
"protocol:digitalocean-chat",
"cache",
"tool",
"tool-loop"
],
"name": "digitalocean-chat/gpt-nano-continues-a-tool-call-with-cache-markers",
"recordedAt": "2026-10-01T07:36:41.717Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://inference.do-ai.run/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai-gpt-5-nano\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"minimal\",\"max_completion_tokens\":1024}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\",\"name\":\"get_weather\"},\"id\":\"call_zPwN7xhG4uXQINkufgtjVJ7L\",\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"city\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\":\\\"\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"Paris\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"}\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":23,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":155,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":178}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://inference.do-ai.run/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai-gpt-5-nano\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_zPwN7xhG4uXQINkufgtjVJ7L\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_zPwN7xhG4uXQINkufgtjVJ7L\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"minimal\",\"max_completion_tokens\":1024}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"Paris\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" is\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" sunny\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\".\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":13,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":193,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":206}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -0,0 +1,53 @@
{
"version": 1,
"metadata": {
"model": "openai-gpt-5-nano",
"tags": [
"prefix:digitalocean-chat",
"provider:digitalocean",
"protocol:digitalocean-chat",
"cache",
"usage"
],
"name": "digitalocean-chat/gpt-nano-reuses-a-cached-prompt",
"recordedAt": "2026-10-01T07:36:40.074Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://inference.do-ai.run/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai-gpt-5-nano\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid fillLine truncated
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840199,\"id\":\"chatcmpl-EU5dHjjHh6g7s4uSL7kMOOpLEXVki\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"OK\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840199,\"id\":\"chatcmpl-EU5dHjjHh6g7s4uSL7kMOOpLEXVki\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840199,\"id\":\"chatcmpl-EU5dHjjHh6g7s4uSL7kMOOpLEXVki\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840199,\"id\":\"chatcmpl-EU5dHjjHh6g7s4uSL7kMOOpLEXVki\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":10,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":4765,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":4775}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://inference.do-ai.run/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai-gpt-5-nano\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid fillLine truncated
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dHYe8HWjEAEIIvsJ6Hk0EUxL9r\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"OK\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dHYe8HWjEAEIIvsJ6Hk0EUxL9r\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dHYe8HWjEAEIIvsJ6Hk0EUxL9r\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840200,\"id\":\"chatcmpl-EU5dHYe8HWjEAEIIvsJ6Hk0EUxL9r\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":4608,\"completion_tokens\":10,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":4765,\"prompt_tokens_details\":{\"cached_tokens\":4608},\"speed\":null,\"total_tokens\":4775}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -0,0 +1,54 @@
{
"version": 1,
"metadata": {
"model": "anthropic-claude-haiku-4.5",
"tags": [
"prefix:digitalocean-chat",
"provider:digitalocean",
"protocol:digitalocean-chat",
"cache",
"tool",
"tool-loop"
],
"name": "digitalocean-chat/haiku-continues-a-tool-call-with-cache-markers",
"recordedAt": "2026-10-01T07:36:38.471Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://inference.do-ai.run/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic-claude-haiku-4.5\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_completion_tokens\":128}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\",\"name\":\"get_weather\"},\"id\":\"toolu_014LEa2F66CGw2SoLmWBeH5C\",\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\"\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\": \\\"Pari\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"s\\\"}\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":54,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":593,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":647}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://inference.do-ai.run/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic-claude-haiku-4.5\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_014LEa2F66CGw2SoLmWBeH5C\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_014LEa2F66CGw2SoLmWBeH5C\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_completion_tokens\":128}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840198,\"id\":\"chatcmpl-3914ee28-f256-4a49-994f-49365922a7a6\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" is sunny.\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840198,\"id\":\"chatcmpl-3914ee28-f256-4a49-994f-49365922a7a6\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840198,\"id\":\"chatcmpl-3914ee28-f256-4a49-994f-49365922a7a6\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840198,\"id\":\"chatcmpl-3914ee28-f256-4a49-994f-49365922a7a6\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":7,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":668,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":675}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -0,0 +1,53 @@
{
"version": 1,
"metadata": {
"model": "anthropic-claude-haiku-4.5",
"tags": [
"prefix:digitalocean-chat",
"provider:digitalocean",
"protocol:digitalocean-chat",
"cache",
"usage"
],
"name": "digitalocean-chat/haiku-reuses-a-cached-prompt",
"recordedAt": "2026-10-01T07:36:36.689Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://inference.do-ai.run/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic-claude-haiku-4.5\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and aLine truncated
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"OK\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840195,\"id\":\"chatcmpl-94f208e5-82fa-4ec6-b014-1b4ea9fbb98c\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840195,\"id\":\"chatcmpl-94f208e5-82fa-4ec6-b014-1b4ea9fbb98c\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840195,\"id\":\"chatcmpl-94f208e5-82fa-4ec6-b014-1b4ea9fbb98c\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":5759,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":5759},\"cache_read_input_tokens\":0,\"completion_tokens\":4,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":5762,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":5766}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://inference.do-ai.run/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic-claude-haiku-4.5\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and aLine truncated
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"OK\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840196,\"id\":\"chatcmpl-eee2d578-6fab-4fc1-8117-043193225bed\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840196,\"id\":\"chatcmpl-eee2d578-6fab-4fc1-8117-043193225bed\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840196,\"id\":\"chatcmpl-eee2d578-6fab-4fc1-8117-043193225bed\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":5759,\"completion_tokens\":4,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":5762,\"prompt_tokens_details\":{\"cached_tokens\":5759},\"speed\":null,\"total_tokens\":5766}}\n\ndata: [DONE]\n\n"
}
}
]
}
+1
View File
@@ -15,6 +15,7 @@ describe("provider error classification", () => {
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Range of input length should be [1, 129024]",
"Too many tokens",
"Input validation error: `inputs` tokens + `max_new_tokens` must be <= 131073. Given: 600035 `inputs` tokens and 16 `max_new_tokens`",
"Token limit exceeded",
]
@@ -314,6 +314,9 @@ describe("Anthropic Messages route", () => {
"claude-haiku-5-1",
"claude-fable-6",
"anthropic/claude-mythos-7.2",
"claude-sonnet-5-5",
"claude-opus-4-8@20260101",
"claude-nova-6",
]
const prepared = yield* Effect.forEach(ids, (id) =>
@@ -1673,6 +1676,40 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("carries a refusal's category and explanation on the content-filter finish", () =>
Effect.gen(function* () {
const refusal = (stop_details: unknown) =>
LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "message_delta", delta: { stop_reason: "refusal", stop_details }, usage: { output_tokens: 0 } },
{ type: "message_stop" },
),
),
),
)
expect(
(yield* refusal({
type: "refusal",
category: "cyber",
explanation: "This request was declined because it could enable cyber harm.",
})).finishReason,
).toEqual({
normalized: "content-filter",
raw: "refusal",
category: "cyber",
explanation: "This request was declined because it could enable cyber harm.",
})
expect((yield* refusal({ type: "refusal", category: null, explanation: null })).finishReason).toEqual({
normalized: "content-filter",
raw: "refusal",
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -648,6 +648,177 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("hoists tool-result images beside the result for Bedrock GPT models", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model(
"global.openai.gpt-6-sol",
),
messages: [
Message.user("What is in this image?"),
Message.assistant([ToolCallPart.make({ id: "tool_1", name: "read", input: {} })]),
Message.tool({
id: "tool_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image loaded." },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
{ type: "file", uri: "data:application/pdf;base64,QkI=", mime: "application/pdf", name: "note.pdf" },
],
},
}),
],
cache: "none",
}),
)
expect(prepared.body.messages[2]).toEqual({
role: "user",
content: [
{
toolResult: {
toolUseId: "tool_1",
content: [
{ text: "Image loaded." },
{ text: 'Attached file "note.pdf" has document label "note".' },
{ document: { format: "pdf", name: "note", source: { bytes: "QkI=" } } },
],
status: "success",
},
},
{ image: { format: "png", source: { bytes: "AAAA" } } },
],
})
}),
)
it.effect("hoists tool-result images for other Bedrock model families", () =>
Effect.gen(function* () {
for (const id of [
"qwen.qwen3-vl-235b-a22b",
"global.xai.grok-4.7",
"global.moonshotai.kimi-k3",
"us.meta.llama4-scout-17b-instruct-v1:0",
]) {
const prepared = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model(
id,
),
messages: [
Message.assistant([ToolCallPart.make({ id: "tool_1", name: "read", input: {} })]),
Message.tool({
id: "tool_1",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
},
}),
],
cache: "none",
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "user",
content: [
{ toolResult: { toolUseId: "tool_1", content: [{ text: "See attached image." }], status: "success" } },
{ image: { format: "png", source: { bytes: "AAAA" } } },
],
})
}
}),
)
;["global.anthropic.claude-sonnet-4-5-20250929-v1:0", "us.amazon.nova-pro-v1:0"].forEach((id) => {
it.effect(`keeps ${id} tool images inside the result`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model(
id,
),
messages: [
Message.assistant([ToolCallPart.make({ id: "tool_1", name: "read", input: {} })]),
Message.tool({
id: "tool_1",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
},
}),
],
cache: "none",
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "user",
content: [
{
toolResult: {
toolUseId: "tool_1",
content: [{ image: { format: "png", source: { bytes: "AAAA" } } }],
status: "success",
},
},
],
})
}),
)
})
it.effect("keeps parallel tool results before hoisted images and gives image-only results text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model(
"global.openai.gpt-6-sol",
),
messages: [
Message.assistant([
ToolCallPart.make({ id: "tool_1", name: "first", input: {} }),
ToolCallPart.make({ id: "tool_2", name: "second", input: {} }),
]),
Message.tool({
id: "tool_1",
name: "first",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
},
}),
Message.tool({
id: "tool_2",
name: "second",
result: {
type: "content",
value: [
{ type: "text", text: "Second image." },
{ type: "file", uri: "data:image/jpeg;base64,BBBB", mime: "image/jpeg" },
],
},
}),
],
cache: "none",
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "user",
content: [
{ toolResult: { toolUseId: "tool_1", content: [{ text: "See attached image." }], status: "success" } },
{ toolResult: { toolUseId: "tool_2", content: [{ text: "Second image." }], status: "success" } },
{ image: { format: "png", source: { bytes: "AAAA" } } },
{ image: { format: "jpeg", source: { bytes: "BBBB" } } },
],
})
}),
)
it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -0,0 +1,78 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest } from "../../src/index.js"
import { DigitalOcean } from "../../src/providers/digitalocean.js"
import { LLMClient } from "../../src/route.js"
import {
LARGE_CACHEABLE_SYSTEM,
expectWeatherToolLoop,
goldenWeatherToolLoopRequest,
runWeatherToolLoop,
} from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const recorded = recordedTests({
prefix: "digitalocean-chat",
provider: "digitalocean",
protocol: "digitalocean-chat",
requires: ["DIGITAL_OCEAN_OFFICIAL_API_KEY"],
})
for (const item of [
{ id: "anthropic-claude-haiku-4.5", name: "Haiku", maxTokens: 128, providerOptions: undefined },
{ id: "openai-gpt-5-nano", name: "GPT Nano", maxTokens: 1024, providerOptions: { reasoningEffort: "minimal" } },
] as const) {
const model = DigitalOcean.configure({
apiKey: process.env.DIGITAL_OCEAN_OFFICIAL_API_KEY ?? "fixture",
providerOptions: item.providerOptions,
}).model(item.id)
describe(`DigitalOcean ${item.name} recorded`, () => {
recorded.effect.with(
`${item.name} reuses a cached prompt`,
{ tags: ["cache", "usage"], metadata: { model: item.id } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model,
system: LARGE_CACHEABLE_SYSTEM,
prompt: "Reply exactly: OK",
promptCacheKey: `digitalocean-recorded-${item.id}`,
generation: { maxTokens: item.maxTokens },
})
const first = yield* LLMClient.generate(request)
const second = yield* LLMClient.generate(request)
expect(first.text.trim()).toMatch(/^OK\.?$/)
expect(second.text.trim()).toMatch(/^OK\.?$/)
expect(second.usage.cacheReadInputTokens).toBeGreaterThan(0)
for (const response of [first, second]) {
expect(response.usage.inputTokens).toBeGreaterThan(4096)
expect(response.usage.inputTokens).toBe(
(response.usage.nonCachedInputTokens ?? 0) +
(response.usage.cacheReadInputTokens ?? 0) +
(response.usage.cacheWriteInputTokens ?? 0),
)
}
}),
60_000,
)
recorded.effect.with(
`${item.name} continues a tool call with cache markers`,
{ tags: ["cache", "tool", "tool-loop"], metadata: { model: item.id } },
() =>
Effect.gen(function* () {
const request = goldenWeatherToolLoopRequest({
id: `digitalocean-${item.id}-tool-loop`,
model,
maxTokens: item.maxTokens,
temperature: false,
})
const events = yield* runWeatherToolLoop(LLMRequest.update(request, { cache: "auto" }))
expectWeatherToolLoop(events)
}),
60_000,
)
})
}
@@ -0,0 +1,123 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../../src/index.js"
import { compileRequest } from "../../src/route/client.js"
import { DigitalOcean } from "../../src/providers/digitalocean.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
import { LLMClient } from "../../src/route.js"
describe("DigitalOcean", () => {
test("preserves package entrypoint headers and body overrides", () => {
const model = DigitalOcean.model("anthropic-claude-fable-5.1", {
apiKey: "test-key",
headers: { "X-Test": "fixture" },
body: { temperature: 0 },
})
expect(model.route.defaults?.http).toMatchObject({
headers: { "X-Test": "fixture" },
body: { temperature: 0 },
})
})
it.effect("prepares DigitalOcean models with default endpoint and auth", () =>
Effect.gen(function* () {
const model = DigitalOcean.configure({ apiKey: "test-key" }).model("anthropic-claude-fable-5.1")
expect(model).toMatchObject({
id: "anthropic-claude-fable-5.1",
provider: "digitalocean",
route: { id: "digitalocean" },
})
expect(model.route.endpoint.baseURL).toBe("https://inference.do-ai.run/v1")
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
expect(prepared.route).toBe("digitalocean")
expect(prepared.body).toMatchObject({
model: "anthropic-claude-fable-5.1",
messages: [{ role: "user", content: "Say hello." }],
stream: true,
})
}),
)
it.effect("lowers the native cache policy to DigitalOcean cache_control markers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: DigitalOcean.configure({ apiKey: "test-key" }).model("anthropic-claude-fable-5.1"),
system: [
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
cache: { tools: true, system: true, messages: { tail: 1 } },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ cache_control: { type: "ephemeral" } }],
messages: [
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{
role: "user",
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("parses DigitalOcean cache usage fields into AI.Usage", () =>
Effect.gen(function* () {
const model = DigitalOcean.configure({ apiKey: "test-key" }).model("anthropic-claude-fable-5.1")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say OK" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
created: 1,
model: "anthropic-claude-fable-5.1",
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: null }],
},
{
id: "chatcmpl-1",
object: "chat.completion.chunk",
created: 1,
model: "anthropic-claude-fable-5.1",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 4491,
completion_tokens: 8,
total_tokens: 4499,
cache_read_input_tokens: 4483,
cache_created_input_tokens: 0,
},
},
"[DONE]",
),
),
),
)
expect(response.usage).toMatchObject({
inputTokens: 4491,
outputTokens: 8,
nonCachedInputTokens: 8,
cacheReadInputTokens: 4483,
totalTokens: 4499,
})
}),
)
})
@@ -302,6 +302,7 @@ describe("Google Vertex providers", () => {
expect(model.provider).toBe("google-vertex")
expect(response.text).toBe("Hello.")
expect(response.usage?.providerMetadata).toHaveProperty("vertex")
}),
)
@@ -171,6 +171,7 @@ describe("Groq recorded", () => {
expect(response.text).not.toContain("<think>")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expect(response.usage?.providerMetadata).toHaveProperty("groq")
expectUsage(response)
}),
60_000,
@@ -48,7 +48,7 @@ describe("native OpenAI-compatible providers", () => {
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[GoogleVertexMessages.configure(vertex).model("model"), "vertex"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
@@ -64,6 +64,7 @@ describe("native OpenAI-compatible providers", () => {
[DeepSeek.configure({ apiKey: "test" }).model("model"), "deepseek"],
[Fireworks.configure({ apiKey: "test" }).model("model"), "fireworks"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[Groq.configure({ apiKey: "test" }).model("model"), "groq"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
@@ -532,6 +532,29 @@ test("restores review state and the side-panel tab per session", async ({ page }
await expect(review).toHaveAttribute("aria-selected", "true")
})
test("shows and restores last turn changes from the session diff", async ({ page }) => {
const sessionID = "ses_reviewturn"
await openSession(page, { name: "ReviewTurn", vcsDiff: [fileDiff("src/alpha.ts")] })
await page.route(`**/api/session/${sessionID}/diff**`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [fileDiff("src/delta.ts")] }),
}),
)
const panel = page.locator("#review-panel")
await page.getByRole("button", { name: "Toggle review" }).click()
await page.getByRole("button", { name: "Git changes" }).click()
await page.getByRole("option", { name: "Last turn changes" }).click()
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
await expect(panel.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("delta.ts")
await page.reload()
await expectSessionTitle(page, "ReviewTurn")
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
await expect(panel.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("delta.ts")
})
test("keeps the review state a session stored before extensions", async ({ page }) => {
const directory = "C:/OpenCode/ReviewLegacy"
await openSession(page, {
+1 -1
View File
@@ -24,7 +24,7 @@
"typecheck": "tsgo -b"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@agentclientprotocol/sdk": "1.6.0",
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
+43 -36
View File
@@ -6,29 +6,47 @@ import {
type AgentNotificationMethod,
type AgentRequestHandlersByMethod,
type AgentRequestMethod,
type JsonRpcId,
type Stream,
} from "@agentclientprotocol/sdk"
import { ClientError, type OpenCodeClient } from "@opencode/client/promise"
import { Cause, Effect, type Scope } from "effect"
import type { OpenCodeClient } from "@opencode/client/promise"
import { Cause, Deferred, Effect, Ref, type Scope } from "effect"
import { ACPCatalog } from "./catalog"
import { ACPConnection } from "./connection"
import { ACPError } from "./error"
import { ACPPromise } from "./promise"
import { ACPService } from "./service"
import { ACPSessions } from "./sessions"
import { ACPTurn } from "./turn"
// 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)
// Requests can dispatch once the stream's read loop yields, which may be before the service below is built.
const ready = yield* Deferred.make<ACPService.Interface>()
const handle =
<Params, A>(call: (ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Error | RequestError>) =>
<Params, A>(
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPService.Failure>,
) =>
(name: string) => {
const handler = Effect.fn(name)(
call,
(ctx: AgentHandlerContext<Params>) =>
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
Effect.catchTags({
ACPCatalogLoadError: (error) => ACPPromise.classify(error.cause),
ACPCatalogNotReadyError: (error) => Effect.die(error),
}),
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))
return (ctx: AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }) => {
if (ctx.requestId === undefined) return run(handler(ctx))
return run(
handler(ctx).pipe(Effect.provideService(ACPConnection.Responded, acp.connection.responded(ctx.requestId))),
)
}
}
const app = agent({ name: "opencode" })
const request = <Method extends AgentRequestMethod>(
@@ -42,78 +60,67 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
request(
"initialize",
handle((ctx) => promise(() => service.initialize(ctx.params))),
handle((service, ctx) => service.initialize(ctx.params)),
)
request(
"authenticate",
handle((ctx) => promise(() => service.authenticate(ctx.params))),
handle((service, ctx) => service.authenticate(ctx.params)),
)
request(
"session/new",
handle((ctx) => promise(() => service.newSession(ctx.params))),
handle((service, ctx) => service.newSession(ctx.params)),
)
request(
"session/load",
handle((ctx) => promise(() => service.loadSession(ctx.params))),
handle((service, ctx) => service.loadSession(ctx.params)),
)
request(
"session/list",
handle((ctx) => promise(() => service.listSessions(ctx.params))),
handle((service, ctx) => service.listSessions(ctx.params)),
)
request(
"session/delete",
handle((ctx) => promise(() => service.deleteSession(ctx.params))),
handle((service, ctx) => service.deleteSession(ctx.params)),
)
request(
"session/resume",
handle((ctx) => promise(() => service.resumeSession(ctx.params))),
handle((service, ctx) => service.resumeSession(ctx.params)),
)
request(
"session/close",
handle((ctx) => promise(() => service.closeSession(ctx.params))),
handle((service, ctx) => service.closeSession(ctx.params)),
)
request(
"session/fork",
handle((ctx) => promise(() => service.forkSession(ctx.params))),
handle((service, ctx) => service.forkSession(ctx.params)),
)
request(
"session/set_config_option",
handle((ctx) => promise(() => service.setSessionConfigOption(ctx.params))),
handle((service, ctx) => service.setSessionConfigOption(ctx.params)),
)
request(
"session/set_mode",
handle((ctx) => promise(() => service.setSessionMode(ctx.params))),
handle((service, ctx) => 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))),
handle((service, ctx) => service.prompt(ctx.params, ctx.signal)),
)
notification(
"session/cancel",
handle((ctx) => promise(() => service.cancel(ctx.params))),
handle((service, ctx) => 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
const acp = ACPConnection.make(app, stream)
const connection = acp.connection
const sessions = yield* ACPSessions.make({ client, connection, catalog })
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false })
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
return acp.agent
})
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"
+8 -48
View File
@@ -1,14 +1,20 @@
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 { Context, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
import type { ConfigOptionProvider } from "./config-option"
// ACP runs these itself; they take precedence over server commands with the same name.
export const builtinCommands = new Map([
["compact", { description: "Compact the session", start: "compaction" as const }],
])
export type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
readonly defaultModel: ModelRef
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
/** Server commands, without those shadowed by a built-in. */
readonly commands: CommandInfo[]
}
@@ -133,52 +139,6 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
})
})
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 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 load = (client: OpenCodeClient, cwd: string) =>
read(client, cwd).pipe(
// Some providers discover models in the background after plugin startup begins.
@@ -222,7 +182,7 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
commands: commandResult.data.filter((command) => !builtinCommands.has(command.name)),
} satisfies Catalog
})
+30
View File
@@ -1,4 +1,6 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import type { ModelRef } from "@opencode/client/promise"
import { builtinCommands, type Catalog } from "./catalog"
export const DEFAULT_VARIANT_VALUE = "default"
@@ -25,6 +27,34 @@ export type ModelSelection = {
variant?: string
}
/** A session's model and mode. Unset fields follow the catalog defaults. */
export type Selection = {
readonly model?: ModelRef
readonly modeID?: string
}
export function currentModel(catalog: Catalog, selection: Selection) {
return selection.model ?? catalog.defaultModel
}
export function configOptions(catalog: Catalog, selection: Selection) {
const model = currentModel(catalog, selection)
return buildConfigOptions({
providers: catalog.providers,
currentModel: { providerID: model.providerID, modelID: model.id },
currentVariant: model.variant,
modes: catalog.modes,
currentModeId: selection.modeID ?? catalog.defaultModeID,
})
}
export function availableCommands(catalog: Catalog) {
return [
...catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
...Array.from(builtinCommands, ([name, command]) => ({ name, description: command.description })),
]
}
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
+73 -13
View File
@@ -1,26 +1,86 @@
import {
methods,
type AgentConnection,
type AgentApp,
type AnyMessage,
type CreateElicitationRequest,
type CreateElicitationResponse,
type JsonRpcId,
type RequestError,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SendRequestOptions,
type SessionNotification,
type Stream,
} from "@agentclientprotocol/sdk"
import { Context, Deferred, Effect } from "effect"
import type { ACPError } from "./error"
import { ACPPromise } from "./promise"
export type Connection = {
readonly signal?: AbortSignal
sessionUpdate(params: SessionNotification): Promise<void>
requestPermission(params: RequestPermissionRequest, options?: SendRequestOptions): Promise<RequestPermissionResponse>
extNotification?(method: string, params: Record<string, unknown>): Promise<void>
/**
* Completes once the response to the request being handled is written, so messages sent afterwards follow it.
* Interrupts when the request fails. Outside a request it completes immediately.
*/
export const Responded = Context.Reference<Effect.Effect<void>>("@opencode/cli/acp/Connection/Responded", {
defaultValue: () => Effect.void,
})
export interface Interface {
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, ACPError.Error | RequestError>
/** Interruption cancels the client's request. */
readonly requestPermission: (
params: RequestPermissionRequest,
) => Effect.Effect<RequestPermissionResponse, ACPError.Error | RequestError>
readonly extNotification: (
method: string,
params: Record<string, unknown>,
) => Effect.Effect<void, ACPError.Error | RequestError>
/** Interruption cancels the client's request. */
readonly createElicitation: (
params: CreateElicitationRequest,
) => Effect.Effect<CreateElicitationResponse, ACPError.Error | RequestError>
/** Tracks an incoming request from now on and returns its `Responded`. */
readonly responded: (requestId: JsonRpcId) => Effect.Effect<void>
}
export function make(connection: AgentConnection): Connection {
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Connection") {}
export function make(app: AgentApp, stream: Stream) {
// Settled as each response is written to the stream, which serializes every outgoing message.
const responses = new Map<JsonRpcId, Deferred.Deferred<void>>()
const writer = stream.writable.getWriter()
const agent = app.connect({
readable: stream.readable,
writable: new WritableStream<AnyMessage>({
write: async (message) => {
await writer.write(message)
if ("method" in message) return
const responded = responses.get(message.id)
if (!responded) return
responses.delete(message.id)
Deferred.doneUnsafe(responded, "result" in message ? Effect.void : Effect.interrupt)
},
close: () => writer.close(),
abort: (reason) => writer.abort(reason),
}),
})
return {
signal: connection.signal,
sessionUpdate: (params) => connection.client.notify(methods.client.session.update, params),
requestPermission: (params, options) =>
connection.client.request(methods.client.session.requestPermission, params, options),
extNotification: (method, params) => connection.client.notify(method, params),
agent,
connection: Service.of({
sessionUpdate: (params) => ACPPromise.promise(() => agent.client.notify(methods.client.session.update, params)),
requestPermission: (params) =>
ACPPromise.promise((signal) =>
agent.client.request(methods.client.session.requestPermission, params, { cancellationSignal: signal }),
),
extNotification: (method, params) => ACPPromise.promise(() => agent.client.notify(method, params)),
createElicitation: (params) =>
ACPPromise.promise((signal) =>
agent.client.request(methods.client.elicitation.create, params, { cancellationSignal: signal }),
),
responded: (requestId) => {
const responded = Deferred.makeUnsafe<void>()
responses.set(requestId, responded)
return Deferred.await(responded)
},
}),
}
}
+5 -1
View File
@@ -171,7 +171,11 @@ function resourceLinkToPart(link: ResourceLink): PromptPart {
mime: link.mimeType ?? "text/plain",
}
}
return { type: "text", text: link.uri }
return linkReference(link.name, link.uri)
}
export function linkReference(name: string | undefined, uri: string): PromptPart {
return { type: "text", text: name ? `[${name}](${uri})` : uri }
}
function filenameFromUri(uri: string | undefined): string | undefined {
+83
View File
@@ -0,0 +1,83 @@
import { isAbsolute, join, resolve } from "node:path"
import { isDeepStrictEqual } from "node:util"
import type { OpenCodeClient, PermissionRule, SessionInfo, SessionMetadata } from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import { Effect, Option, Schema } from "effect"
import { ACPError } from "./error"
import { ACPPromise } from "./promise"
const key = "opencode.acp.additionalDirectories"
const decodeStored = Schema.decodeUnknownOption(Schema.Array(Schema.String))
/**
* Normalizes additional workspace roots without following symlinks, keeping the spelling tools will see, and
* drops duplicates and roots that resolve to cwd. Glob characters are rejected because permission resources
* would treat them as wildcards. The ACP SDK has already dropped entries that are not strings and replaced a
* value that is not an array with `[]`.
*/
export const parse = Effect.fnUntraced(function* (cwd: string, directories: readonly string[] = []) {
const invalid = directories.find((directory) => !isAbsolute(directory) || /[*?]/.test(directory))
if (invalid !== undefined) return yield* new ACPError.InvalidAdditionalDirectoryError({ directory: invalid })
const root = FSUtil.resolve(cwd)
return [...new Set(directories.map((directory) => resolve(FSUtil.windowsPath(directory))))].filter(
(directory) => FSUtil.resolve(directory) !== root,
)
})
/** Session create fields that grant these directories. */
export function grant(directories: readonly string[]) {
if (directories.length === 0) return {}
return { permissions: rules(directories), metadata: { [key]: [...directories] } }
}
/** The additional directories ACP last activated for the session, in request order. */
export function list(session: Pick<SessionInfo, "metadata">) {
return [...Option.getOrElse(decodeStored(session.metadata?.[key]), () => [])]
}
/**
* Replaces the rules granted for the previously activated directories with grants for these directories.
* Grants persist on the server session, so they also apply when it is used from other clients, and child
* sessions copy them when they are created; a child created earlier keeps roots its parent later dropped.
* Session rules are evaluated after agent and config rules, so a grant overrides config `external_directory`
* rules inside the root, while read, edit, and shell rules still apply.
*/
export const activate = Effect.fnUntraced(function* (
client: OpenCodeClient,
session: SessionInfo,
directories: readonly string[],
) {
const previous = list(session)
const owned = rules(previous)
const current = session.permissions ?? []
// ACP rules lead so the session's other rules keep precedence for the same paths.
const permissions = [
...rules(directories),
...current.filter((rule) => !owned.some((item) => isDeepStrictEqual(item, rule))),
]
const metadata: SessionMetadata = {
...Object.fromEntries(Object.entries(session.metadata ?? {}).filter(([name]) => name !== key)),
...(directories.length > 0 ? { [key]: [...directories] } : {}),
}
const permissionsChanged = !isDeepStrictEqual(permissions, current)
const metadataChanged = !isDeepStrictEqual(previous, directories)
if (!permissionsChanged && !metadataChanged) return
yield* ACPPromise.promise(() =>
client.session.update({
sessionID: session.id,
...(permissionsChanged ? { permissions } : {}),
...(metadataChanged ? { metadata } : {}),
}),
)
})
// Tools compare the written path without following symlinks, so both spellings of a root are granted.
function rules(directories: readonly string[]): PermissionRule[] {
return [...new Set(directories.flatMap((directory) => [directory, FSUtil.resolve(directory)]))].map((directory) => ({
action: "external_directory",
resource: join(directory, "*"),
effect: "allow",
}))
}
export * as ACPDirectories from "./directories"
+238
View File
@@ -0,0 +1,238 @@
import type {
CreateElicitationResponse,
ElicitationPropertySchema,
ElicitationSchema,
EnumOption,
} from "@agentclientprotocol/sdk"
import { isFormAlreadySettledError, isFormNotFoundError, type OpenCodeClient } from "@opencode/client/promise"
import { Form } from "@opencode/schema/form"
import { Cause, Effect, Option, Schema } from "effect"
import type { ACPConnection } from "./connection"
import type { ACPService } from "./service"
/** A form as the event stream carries it, with an unbranded ID. */
export type AskedForm = Omit<Form.Info, "id"> & { readonly id: string }
type InputField = Exclude<Form.Field, Form.ExternalField>
type SelectField = Form.StringField | Form.MultiselectField
// Form mode must not collect secrets, so only forms from flows known not to ask for credentials are elicited.
const ElicitedKind = Schema.Struct({ kind: Schema.Literals(["question", "websearch.provider"]) })
const Credential = /password|passphrase|secret|token|api[_-]?key|credential|private[_-]?key/i
const ToolSource = Schema.Struct({ tool: Schema.Struct({ id: Schema.String }) })
type Input = {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Interface
readonly form: AskedForm
readonly requestedSchema: ElicitationSchema
readonly clientSessionID: string
readonly child?: { readonly id: string; readonly title?: string }
/** Whether the asking tool call reached the client as a `session/update` tool call. */
readonly toolCallSent: boolean
/** Completes once the form is answered or cancelled elsewhere. */
readonly settled: Effect.Effect<void>
}
type Outcome = Form.Answer | "cancel" | "settled"
/**
* Asks the client, then resolves the form on the server. Once `cancelled` completes, the client's request is
* cancelled or never sent, and the form is cancelled. Once `settled` completes, the client's request is cancelled and
* the server is left alone. Resolving on the server is uninterruptible.
*/
export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
yield* Effect.uninterruptibleMask((restore) =>
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
restore(
cancelled.pipe(
Effect.as("cancel" as const),
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
Effect.raceFirst(ask(input)),
),
).pipe(
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP elicitation failed", cause)),
Effect.catchCause(() => Effect.succeed("cancel" as const)),
Effect.flatMap((outcome) => respond(input, outcome)),
),
)
})
/** Cancels a form, interrupting its session when the server can't cancel it. */
export function cancel(client: OpenCodeClient, form: AskedForm) {
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id })).pipe(
Effect.catch(() =>
Effect.tryPromise(() => client.session.interrupt({ sessionID: form.sessionID })).pipe(Effect.ignore),
),
)
}
/**
* The form-mode schema for a form, or undefined when the form is cancelled instead: the client lacks form
* elicitation, the form is not from an allowed flow or has a field that looks like a credential, or ACP can't
* represent it faithfully. Unrepresentable forms have `external` fields, `when` conditions, a hidden required field
* without a default, a default outside a field's options, or a free-text answer alongside options that must also
* satisfy `required` or item bounds across both inputs. Hidden fields are not asked and answer with their default.
*/
export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabilities): ElicitationSchema | undefined {
if (!capabilities.formElicitation) return undefined
if (Option.isNone(Schema.decodeUnknownOption(ElicitedKind)(form.metadata))) return undefined
if (form.fields.some((field) => Credential.test(field.key) || Credential.test(field.title ?? ""))) return undefined
const fields = form.fields.filter((field): field is InputField => field.type !== "external")
if (fields.length !== form.fields.length || fields.some((field) => field.when?.length)) return undefined
if (fields.some((field) => field.hidden && field.required && field.default === undefined)) return undefined
const keys = new Set(fields.map((field) => field.key))
const visible = fields.filter((field) => !field.hidden)
if (!visible.every((field) => representable(field, keys))) return undefined
return {
type: "object",
properties: Object.fromEntries(visible.flatMap(properties)),
required: visible.filter((field) => field.required).map((field) => field.key),
}
}
/** The answer for an accepted response, or undefined when the user declined, cancelled, or sent invalid content. */
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
if (response.action !== "accept") return undefined
const content = Schema.decodeUnknownOption(Form.Answer)(response.content ?? {})
if (Option.isNone(content)) return undefined
return Object.fromEntries(
form.fields.flatMap((field) => {
const value = fieldAnswer(field, content.value)
return value === undefined ? [] : [[field.key, value]]
}),
)
}
const ask = Effect.fnUntraced(function* (input: Input) {
const source = input.toolCallSent ? Schema.decodeUnknownOption(ToolSource)(input.form.metadata) : Option.none()
const toolCallID = Option.getOrUndefined(Option.map(source, (metadata) => metadata.tool.id))
const response = yield* input.connection.createElicitation({
mode: "form",
sessionId: input.clientSessionID,
...(toolCallID ? { toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID } : {}),
message: input.child?.title ? `${input.child.title}: ${input.form.title}` : input.form.title,
requestedSchema: input.requestedSchema,
})
return answer(input.form, response) ?? "cancel"
})
function respond(input: Input, outcome: Outcome) {
if (outcome === "settled") return Effect.void
if (outcome === "cancel") return cancel(input.client, input.form)
return settle(() =>
input.client.session.form.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome }),
).pipe(
Effect.catch((cause) =>
Effect.logWarning("ACP form reply failed", cause).pipe(Effect.andThen(cancel(input.client, input.form))),
),
)
}
// A form already answered or cancelled elsewhere needs nothing more.
function settle(evaluate: () => Promise<void>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
Effect.catch((cause) =>
isFormAlreadySettledError(cause) || isFormNotFoundError(cause) ? Effect.void : Effect.fail(cause),
),
)
}
function representable(field: InputField, keys: ReadonlySet<string>) {
if (field.type !== "string" && field.type !== "multiselect") return true
if (!hasOptions(field)) return true
const values = new Set(field.options?.map((option) => option.value))
const defaults =
field.default === undefined ? [] : typeof field.default === "string" ? [field.default] : field.default
if (defaults.some((value) => !values.has(value))) return false
if (!field.custom) return true
if (field.required || keys.has(customKey(field))) return false
return field.type === "string" || (field.minItems === undefined && field.maxItems === undefined)
}
function properties(field: InputField): Array<[string, ElicitationPropertySchema]> {
const base = { title: field.title, description: field.description }
switch (field.type) {
case "string": {
if (!hasOptions(field)) return [[field.key, { type: "string", ...base, ...text(field), default: field.default }]]
const select: ElicitationPropertySchema = {
type: "string",
...base,
oneOf: options(field),
default: field.default,
}
return field.custom ? [[field.key, select], other(field, "Type your own answer")] : [[field.key, select]]
}
case "multiselect": {
const select: ElicitationPropertySchema = {
type: "array",
...base,
items: { anyOf: options(field) },
minItems: field.required ? Math.max(field.minItems ?? 0, 1) : field.minItems,
maxItems: field.maxItems,
default: field.default,
}
return field.custom ? [[field.key, select], other(field, "Add your own answer")] : [[field.key, select]]
}
case "number":
case "integer":
return [
[
field.key,
{ type: field.type, ...base, minimum: field.minimum, maximum: field.maximum, default: field.default },
],
]
case "boolean":
return [[field.key, { type: "boolean", ...base, default: field.default }]]
}
}
// A free-text answer next to a field's options is a separate optional property that wins over the selection.
function other(field: SelectField, description: string): [string, ElicitationPropertySchema] {
return [
customKey(field),
{
type: "string",
title: `${field.title ?? field.key} (other)`,
description,
...(field.type === "string" ? text(field) : {}),
},
]
}
// Core rejects an empty string for a required field, so the client is told it needs at least one character.
function text(field: Form.StringField) {
return {
format: field.format,
minLength: field.required ? Math.max(field.minLength ?? 0, 1) : field.minLength,
maxLength: field.maxLength,
pattern: field.pattern,
}
}
function options(field: SelectField): EnumOption[] {
return (field.options ?? []).map((option) => ({
const: option.value,
title: option.label,
description: option.description,
}))
}
function fieldAnswer(field: Form.Field, content: Form.Answer) {
if (field.type !== "external" && field.hidden) return field.default
const value = content[field.key]
if ((field.type !== "string" && field.type !== "multiselect") || !field.custom || !hasOptions(field)) return value
const custom = content[customKey(field)]
if (typeof custom !== "string" || custom.trim() === "") return value
if (field.type === "string") return custom
return Array.isArray(value) ? [...value, custom] : [custom]
}
function hasOptions(field: SelectField) {
return field.type === "multiselect" || field.options !== undefined
}
function customKey(field: SelectField) {
return `${field.key}_custom`
}
export * as ACPElicitation from "./elicitation"
+19
View File
@@ -28,12 +28,22 @@ export class InvalidModeError extends Schema.TaggedError<InvalidModeError>()("AC
mode: Schema.String,
}) {}
export class InvalidAdditionalDirectoryError extends Schema.TaggedError<InvalidAdditionalDirectoryError>()(
"ACPInvalidAdditionalDirectoryError",
{ directory: Schema.String },
) {}
export class AuthRequiredError extends Schema.TaggedError<AuthRequiredError>()("ACPAuthRequiredError", {}) {}
export class UnknownAuthMethodError extends Schema.TaggedError<UnknownAuthMethodError>()("ACPUnknownAuthMethodError", {
methodId: Schema.String,
}) {}
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()("ACPInvalidRequestError", {
message: Schema.String,
field: Schema.optional(Schema.String),
}) {}
export class ServiceFailureError extends Schema.TaggedError<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
@@ -52,8 +62,10 @@ const Errors = Schema.Union([
InvalidModelError,
InvalidEffortError,
InvalidModeError,
InvalidAdditionalDirectoryError,
AuthRequiredError,
UnknownAuthMethodError,
InvalidRequestError,
ServiceFailureError,
ServerUnavailableError,
])
@@ -82,10 +94,17 @@ export function toRequestError(error: Error): RequestError {
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
case "ACPInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPInvalidAdditionalDirectoryError":
return RequestError.invalidParams(
{ additionalDirectory: error.directory },
`additional directory must be an absolute path without glob characters: ${error.directory}`,
)
case "ACPAuthRequiredError":
return RequestError.authRequired({}, "provider authentication required")
case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPInvalidRequestError":
return RequestError.invalidParams(error.field ? { field: error.field } : {}, error.message)
case "ACPServiceFailureError":
return RequestError.internalError(
{
-635
View File
@@ -1,635 +0,0 @@
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
OpenCodeClient,
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 } from "./permission"
import {
completedToolUpdate,
errorToolUpdate,
pendingToolCall,
runningToolUpdate,
type ToolContent,
type ToolInput,
} from "./tool"
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission">
export type TurnControl = {
cancelled: boolean
// Aborted whenever the turn is cancelled or closed, so it also cancels the turn's outbound client requests.
readonly admission: AbortController
}
type ToolState = {
readonly name: string
input: ToolInput
metadata: Record<string, unknown>
content: ToolContent
}
export type TurnStart =
| { readonly type: "input"; readonly id: string }
| { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string }
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
const RetryMeta = "opencode/retry"
type RetryStatus = {
readonly attempt: number
readonly nextRetryAt: string
readonly error: SessionStructuredError
}
type ChildSessionUpdateBase = {
readonly rootSessionId: string
readonly childSessionId: string
readonly parentSessionId: string
readonly depth: number
readonly title?: string
}
type ChildSessionEvent =
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
}
export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent
type ChildSession = {
readonly id: string
readonly parentID: string
readonly depth: number
readonly title?: string
}
function emptyToolState(): ToolState {
return { name: "tool", input: {}, metadata: {}, content: [] }
}
export async function streamTurn(input: {
readonly client: OpenCodeClient
readonly connection: Connection
readonly sessionID: string
readonly cwd: string
readonly start: TurnStart
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<{ readonly response: PromptResponse; readonly contextTokens: number | undefined }> {
const streamController = new AbortController()
const connectionAbort = () => streamController.abort()
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission")
const control = input.control
let started = false
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
.childSessionUpdate({
rootSessionId: input.sessionID,
childSessionId: child.id,
parentSessionId: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
...value,
})
.catch(() => {})
}
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
const projected = child ? projectChildUpdate(value, child) : value
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
}
if (child) await notifyChild(child, { type: "update", update: projected })
}
const consume = async (mode: "turn" | "background") => {
while (!streamController.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "session.created") {
const parentID = event.data.parentID
if (!parentID) continue
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
if ((mode === "turn" && parentID === input.sessionID) || parent) {
const child = {
id: event.data.sessionID,
parentID,
depth: parent ? parent.depth + 1 : 1,
title: event.data.title,
}
children.set(child.id, child)
openChildren.add(child.id)
await notifyChild(child, { type: "status", status: "created" })
}
continue
}
const eventSessionID = sessionIDFromEvent(event)
const child = eventSessionID ? children.get(eventSessionID) : undefined
const send = (update: SessionUpdate) => updateSession(update, child, mode)
if (mode === "background" && !child) continue
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
event,
sessionID: event.data.sessionID,
clientSessionID: input.sessionID,
cwd: input.cwd,
tool,
signal: control.admission.signal,
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
})
continue
}
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
await input.client.session.form
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
continue
}
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
if (matchesStart(event, input.start)) {
started = true
continue
}
if (!started) continue
if (event.type === "session.execution.started") {
if (child) {
await notifyChild(child, { type: "status", status: "running" })
}
continue
}
if (event.type === "session.step.started") {
if (!child) stepError = undefined
if (retries.delete(eventSessionID))
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } })
continue
}
if (event.type === "session.retry.scheduled") {
const retry = {
attempt: event.data.attempt,
nextRetryAt: new Date(event.data.at).toISOString(),
error: event.data.error,
}
retries.set(eventSessionID, retry)
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } })
continue
}
if (event.type === "session.text.delta") {
await send({
sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
}
if (event.type === "session.reasoning.delta") {
await send({
sessionUpdate: "agent_thought_chunk",
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
content: { type: "text", text: event.data.delta },
})
continue
}
if (event.type === "session.tool.input.started") {
tools.set(toolKey(event.data.sessionID, event.data.id), {
name: event.data.name,
input: {},
metadata: {},
content: [],
})
await send({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.id,
toolName: event.data.name,
state: { input: {} },
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.called") {
const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
current.input = event.data.input
tools.set(key, current)
await send({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.id,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.sessionID, event.data.id))
if (!current) continue
current.metadata = event.data.metadata
await send({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.id,
toolName: current.name,
state: { input: current.input },
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.success") {
const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
tools.delete(key)
await send({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.id,
toolName: current.name,
input: current.input,
metadata: event.data.metadata,
content: event.data.content,
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.tool.failed") {
const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
tools.delete(key)
await send({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.id,
toolName: current.name,
input: current.input,
metadata: event.data.metadata ?? current.metadata,
content: event.data.content ?? current.content,
error: event.data.error.message,
cwd: input.cwd,
}),
})
continue
}
if (event.type === "session.step.ended") {
if (!child) {
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
}
if (event.type === "session.execution.succeeded") {
if (!child) return "succeeded" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "completed" })
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
continue
}
if (event.type === "session.execution.interrupted") {
if (!child) return "interrupted" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "interrupted" })
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
continue
}
if (event.type === "session.execution.failed") {
if (child) {
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
if (mode === "background" && openChildren.size === 0) return "failed" as const
continue
}
executionError = event.data.error
return "failed" as const
}
}
return "interrupted" as const
}
const completed = consume("turn")
const closeStream = async () => {
streamController.abort()
input.connectionSignal?.removeEventListener("abort", connectionAbort)
input.sessionSignal?.removeEventListener("abort", connectionAbort)
await stream.return?.(undefined).catch(() => {})
}
try {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
})
if (input.action) {
streamController.abort()
await completed.catch(() => {})
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: response(undefined, undefined, "interrupted", true, undefined), contextTokens: undefined }
}
}
const terminal = await completed
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
handedOff = true
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
void consume("background")
.catch(() => {})
.finally(closeStream)
}
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(() => {})
throw error
} finally {
if (!handedOff) await closeStream()
}
}
function sessionIDFromEvent(event: EventSubscribeOutput) {
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
if (event.type === "form.created") return event.data.form.sessionID
return undefined
}
function toolKey(sessionID: string, id: string) {
return `${sessionID}:${id}`
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = {
...projected._meta,
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
export async function replayMessages(
connection: Pick<Connection, "sessionUpdate">,
sessionID: string,
cwd: string,
messages: readonly SessionMessageInfo[],
) {
for (const message of messages) await replayMessage(connection, sessionID, cwd, message).catch(() => {})
}
async function replayMessage(
connection: Pick<Connection, "sessionUpdate">,
sessionID: string,
cwd: string,
message: SessionMessageInfo,
) {
if (message.type === "user") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "user_message_chunk",
messageId: message.id,
content: { type: "text", text: message.text },
},
})
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
type: "file",
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
filename: file.name,
mime: file.mime,
}))
for (const chunk of partsToContentChunks(files)) {
await connection.sessionUpdate({
sessionId: sessionID,
update: { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk },
})
}
return
}
if (message.type !== "assistant") return
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
let reasoningOrdinal = 0
for (const part of message.content) {
if (part.type === "text") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "agent_message_chunk",
messageId: message.id,
content: { type: "text", text: part.text },
},
})
continue
}
if (part.type === "reasoning") {
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
content: { type: "text", text: part.text },
},
})
continue
}
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.status === "streaming" ? {} : part.state.input },
cwd,
}),
},
})
switch (part.state.status) {
case "completed":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
cwd,
}),
},
})
break
case "running":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.input },
cwd,
}),
},
})
break
case "error":
await connection.sessionUpdate({
sessionId: sessionID,
update: {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
error: part.state.error.message,
cwd,
}),
},
})
break
case "streaming":
break
}
}
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
if (start.type === "input") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
if (start.type === "compaction") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
}
function response(
tokens: TokenUsageInfo | undefined,
error: { readonly type: string; readonly message: string } | undefined,
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
retry?: RetryStatus,
): PromptResponse {
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
throw new ACPError.ServiceFailureError({
safeMessage: error.message || "OpenCode prompt failed",
service: "session",
errorName: error.type,
})
}
const usage = tokens
? {
inputTokens: tokens.input,
outputTokens: tokens.output,
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 } : {}),
}
: undefined
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
}
function resolveStopReason(input: {
readonly terminal: "succeeded" | "failed" | "interrupted"
readonly cancelled: boolean
readonly finish: SessionMessageAssistant["finish"]
readonly error?: string
}): PromptResponse["stopReason"] {
if (input.cancelled || input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
if (input.finish === "length") return "max_tokens"
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
return "end_turn"
}
export * as ACPEvent from "./event"
+76 -66
View File
@@ -1,53 +1,62 @@
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode/client/promise"
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
import { Patch } from "@opencode/util/patch"
import { Cause, Effect } from "effect"
import type { ACPConnection } from "./connection"
import {
absolutePath,
filePath,
patchHunks,
pendingToolCall,
stringValue,
toLocations,
type ToolInput,
} from "./tool"
import { ACPPromise } from "./promise"
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type Connection = Pick<ACPConnection.Connection, "requestPermission">
type Tool = { readonly name: string; readonly input: ToolInput }
type Input = {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Interface
readonly event: PermissionEvent
readonly sessionID: string
readonly clientSessionID: string
readonly cwd: string
readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
}
const options: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
export async function replyPermission(input: {
readonly client: OpenCodeClient
readonly connection: Connection
readonly event: PermissionEvent
readonly sessionID: string
readonly clientSessionID?: string
readonly cwd: string
readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
readonly signal?: AbortSignal
}) {
/**
* Asks the client, then replies to the server. Once `cancelled` completes, the client's request is cancelled or never
* sent, and the server gets `reject`. The server reply is uninterruptible, so a server that is alive but stuck can
* hold a cancel past `CancelDrainTimeout`; a dead server fails fast.
*/
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
yield* Effect.uninterruptibleMask((restore) =>
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
restore(cancelled.pipe(Effect.as("reject" as const), Effect.raceFirst(ask(input)))).pipe(
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
Effect.catchCause(() => Effect.succeed("reject" as const)),
Effect.flatMap((decision) => respond(input, decision)),
),
)
})
const ask = Effect.fnUntraced(function* (input: Input) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
const toolCallID = input.event.data.source?.id ?? input.event.data.id
const title = permissionTitle(toolName, toolInput, previews)
const request = {
sessionId: input.clientSessionID ?? input.sessionID,
const result = yield* input.connection.requestPermission({
sessionId: input.clientSessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, title),
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
},
cwd: input.cwd,
}),
@@ -55,18 +64,15 @@ export async function replyPermission(input: {
...(previews.length > 0 ? { content: previews } : {}),
},
options,
}
// An already-cancelled turn skips the round-trip; the SDK would still send the request and then cancel it.
const result = input.signal?.aborted
? undefined
: await input.connection.requestPermission(request, { cancellationSignal: input.signal }).catch(() => undefined)
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
const reply = selected === "once" || selected === "always" ? selected : "reject"
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
decision: reply,
})
const selected = result.outcome.outcome === "selected" ? result.outcome.optionId : undefined
return selected === "once" || selected === "always" ? selected : "reject"
})
function respond(input: Input, decision: PermissionReplyInput["decision"]) {
return ACPPromise.promise(() =>
input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }),
)
}
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
@@ -75,45 +81,50 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
return `${prefix}: ${title}`
}
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, 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 }]
if (content === undefined) return []
const oldText = yield* readText(path)
return [diff(path, oldText, content)]
}
if (tool !== "edit") return []
const oldString = stringValue(input.oldString)
const newString = stringValue(input.newString)
if (oldString === undefined || newString === undefined) return []
const oldText = yield* readText(path)
const newText =
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
return [{ type: "diff", path, oldText, newText }]
return [diff(path, oldText, newText)]
})
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
function patchPreviews(input: ToolInput, cwd: string) {
return Effect.forEach(
patchHunks(input),
(hunk) =>
Effect.gen(function* () {
const path = absolutePath(hunk.path, cwd)
if (hunk.type === "add") {
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
return diff(path, "", newText)
}
const oldText = yield* readText(path)
if (hunk.type === "delete") return diff(path, oldText, "")
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
}),
{ concurrency: "unbounded" },
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
}
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 diff(path: string, oldText: string, newText: string): ToolCallContent {
return { type: "diff", path, oldText, newText }
}
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
@@ -150,10 +161,9 @@ function permissionLocations(
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
}
// A missing file previews as empty.
function readText(path: string) {
return Bun.file(path)
.text()
.catch(() => "")
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
}
export * as ACPPermission from "./permission"
+21
View File
@@ -0,0 +1,21 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { ClientError, isInvalidRequestError, isSessionNotFoundError } from "@opencode/client/promise"
import { Effect } from "effect"
import { ACPError } from "./error"
/** Runs a promise, keeping ACP failures typed. Any other rejection is a defect. */
export const promise = <A>(evaluate: (signal: AbortSignal) => Promise<A>) =>
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(Effect.catch(classify))
export function classify(cause: unknown): Effect.Effect<never, ACPError.Error | RequestError> {
if (cause instanceof RequestError || ACPError.is(cause)) return Effect.fail(cause)
if (cause instanceof ClientError && cause.reason === "Transport")
return Effect.fail(new ACPError.ServerUnavailableError())
if (isSessionNotFoundError(cause))
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: cause.sessionID }))
if (isInvalidRequestError(cause))
return Effect.fail(new ACPError.InvalidRequestError({ message: cause.message, field: cause.field }))
return Effect.die(cause)
}
export * as ACPPromise from "./promise"
+203 -462
View File
@@ -1,14 +1,12 @@
import { isDeepStrictEqual } from "node:util"
import {
isInvalidRequestError,
isSessionNotFoundError,
type CommandInfo,
type ModelRef,
type OpenCodeClient,
type SessionInfo,
type SessionMessageInfo,
} from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import type { Effect, Scope } from "effect"
import { Effect, Option, Ref, Result, Stream } from "effect"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import type {
AuthenticateRequest,
@@ -27,11 +25,11 @@ import type {
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
McpServer,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
RequestError,
ResumeSessionRequest,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
@@ -40,177 +38,124 @@ import type {
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode/schema/session-message"
import { ACPCatalog, type Catalog } from "./catalog"
import { buildConfigOptions, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
import type { ACPCatalog, Catalog } from "./catalog"
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
import type { ACPConnection } from "./connection"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
ChildSessionUpdatesCapability,
replayMessages,
streamTurn,
type ChildSessionUpdate,
type TurnControl,
type TurnStart,
} from "./event"
import { ACPDirectories } from "./directories"
import { ACPError } from "./error"
import { ACPPromise } from "./promise"
import type { ACPSessions, Attached } from "./sessions"
import { ACPTranslate } from "./translate"
import type { ACPTurn } from "./turn"
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 }]])
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
// Model and mode are unset while the session follows the server defaults.
type Attached = {
readonly id: string
readonly cwd: string
readonly abort: AbortController
readonly catalog: ACPCatalog.Live
model?: ModelRef
modeID?: string
}
type PreparedPrompt = {
readonly start: TurnStart
readonly text: string
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: CommandInfo
}
/** What the client advertised in `initialize`. */
export type Capabilities = { readonly childSessionUpdates: boolean; readonly formElicitation: boolean }
export interface Interface {
initialize(input: InitializeRequest): Promise<InitializeResponse>
authenticate(input: AuthenticateRequest): Promise<AuthenticateResponse>
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
deleteSession(input: DeleteSessionRequest): Promise<DeleteSessionResponse>
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
prompt(input: PromptRequest, signal?: AbortSignal): Promise<PromptResponse>
cancel(input: CancelNotification): Promise<void>
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse>
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Failure>
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Failure>
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Failure>
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Failure>
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, Failure>
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Failure>
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Failure>
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Failure>
readonly setSessionConfigOption: (
input: SetSessionConfigOptionRequest,
) => Effect.Effect<SetSessionConfigOptionResponse, Failure>
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Failure>
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
}
export function make(input: {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Connection
readonly connection: ACPConnection.Interface
readonly catalog: ACPCatalog.Interface
readonly run: <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) => Promise<A>
readonly sessions: ACPSessions.Interface
readonly capabilities: Ref.Ref<Capabilities>
readonly turn: ACPTurn.Interface
}): 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 = { childSessionUpdates: false }
const catalogs = ACPCatalog.promise({
catalog: input.catalog,
run: input.run,
changed: (live, previous) =>
Promise.all(
Array.from(sessions.values())
.filter((state) => state.catalog === live)
.map(async (state) => {
const options = configOptions(state)
if (!isDeepStrictEqual(options, configOptions(state, previous))) {
await input.connection.sessionUpdate({
sessionId: state.id,
update: { sessionUpdate: "config_option_update", configOptions: options },
})
}
if (!isDeepStrictEqual(live.current.commands, previous.commands)) await sendCommands(state)
}),
),
const currentOptions = Effect.fnUntraced(function* (attached: Attached) {
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
})
const sendCommands = (state: Attached) =>
input.connection.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
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 })),
],
},
})
const withReload = <A>(state: Attached, select: () => Promise<A>) =>
select().catch(async (error: unknown) => {
if (
!(
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.InvalidEffortError
)
)
throw error
await catalogs.reload(state.catalog)
return select()
})
const requireSession = async (sessionID: string) => {
const current = sessions.get(sessionID)
if (current) return current
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
// A selection the catalog has not seen may be new on the server, so reload once before rejecting it.
const withReload = <A>(attached: Attached, select: Effect.Effect<A, Failure>) => {
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(select))
return select.pipe(
Effect.catchTags({ ACPInvalidModelError: retry, ACPInvalidModeError: retry, ACPInvalidEffortError: retry }),
)
}
const detach = (sessionID: string) => {
sessions.get(sessionID)?.abort.abort()
sessions.delete(sessionID)
registeredMcp.delete(sessionID)
}
const cancelTurn = (sessionID: string) => {
const turn = active.get(sessionID)
if (turn) {
turn.control.cancelled = true
turn.control.admission.abort()
const selectOption = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
const catalog = yield* input.catalog.get(attached.cwd)
const current = currentModel(catalog, yield* Ref.get(attached.selection))
switch (configId) {
case "model":
return yield* selectModel(attached, yield* requireModel(catalog, value, current))
case "effort":
return yield* selectModel(attached, yield* requireEffort(catalog, value, current))
case "mode":
return yield* selectMode(attached, value)
default:
return yield* new ACPError.InvalidConfigOptionError({ configId })
}
return input.client.session.interrupt({ sessionID })
}
})
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const catalog = await catalogs.get(cwd)
sessions.get(session.id)?.abort.abort()
const state: Attached = {
id: session.id,
cwd,
abort: new AbortController(),
catalog,
model: session.model,
modeID: session.agent,
// Both selectors update the selection before switching on the server, so the echoed event diffs to no change.
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: ModelRef) {
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
yield* ACPPromise.promise(() => input.client.session.switchModel({ sessionID: attached.id, model }))
})
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
const catalog = yield* input.catalog.get(attached.cwd)
if (!catalog.modes.some((mode) => mode.id === modeID)) return yield* new ACPError.InvalidModeError({ mode: modeID })
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID }))
yield* ACPPromise.promise(() => input.client.session.switchAgent({ sessionID: attached.id, agent: modeID }))
})
const getSession = Effect.fnUntraced(function* (sessionID: string, cwd: string) {
const session = yield* ACPPromise.promise(() => input.client.session.get({ sessionID }))
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory))
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
return session
})
const replay = (attached: Attached) =>
Stream.paginate(undefined, (cursor: string | undefined) =>
ACPPromise.promise(() =>
cursor
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" }),
).pipe(Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const)),
).pipe(Stream.runForEach((message) => replayMessage(attached, message)))
// A message that fails to translate keeps the updates before the failure and does not stop the replay.
const replayMessage = Effect.fnUntraced(function* (attached: Attached, message: SessionMessageInfo) {
const updates = ACPTranslate.replayMessage(message, attached.cwd)
while (true) {
const next = yield* Effect.result(Effect.try(() => updates.next()))
if (Result.isFailure(next))
return yield* Effect.logWarning("ACP replay skipped the rest of a message", message.id, next.failure.cause)
if (next.success.done) return
yield* input.connection.sessionUpdate({ sessionId: attached.id, update: next.success.value })
}
sessions.set(session.id, state)
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
await sendCommands(state)
return state
}
const replay = async (state: Attached) => {
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
}
const configOptions = (state: Attached, catalog = state.catalog.current) => {
const model = currentModel(state, catalog)
return buildConfigOptions({
providers: catalog.providers,
currentModel: { providerID: model.providerID, modelID: model.id },
currentVariant: model.variant,
modes: catalog.modes,
currentModeId: state.modeID ?? catalog.defaultModeID,
})
}
})
return {
initialize: async (params) => {
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
initialize: Effect.fnUntraced(function* (params) {
const elicitation = params.clientCapabilities?.elicitation
yield* Ref.set(input.capabilities, {
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
})
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -227,219 +172,125 @@ export function make(input: {
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { [ChildSessionUpdatesCapability]: true },
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { [ACPTranslate.ChildSessionUpdatesCapability]: true },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
}
},
authenticate: async (params) => {
if (params.methodId !== AuthMethodID) throw new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
}),
authenticate: Effect.fnUntraced(function* (params) {
if (params.methodId !== AuthMethodID)
return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
return {}
},
newSession: async (params) => {
}),
newSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
// Load before creating so a catalog failure leaves no session behind. Agent and model stay unset
// so the server resolves its defaults after plugins activate.
await catalogs.get(params.cwd)
const created = await input.client.session.create({ location: { directory: params.cwd } })
const state = await attach(created, params.cwd, params.mcpServers)
return { sessionId: state.id, configOptions: configOptions(state) }
},
loadSession: async (params) => {
const session = await getSession(input.client, params.sessionId, params.cwd)
const state = await attach(session, session.location.directory, params.mcpServers)
await replay(state)
return { configOptions: configOptions(state) }
},
listSessions: async (params) => {
const page = await input.client.session.list({
...(params.cwd ? { directory: params.cwd } : {}),
order: "desc",
limit: 100,
...(params.cursor ? { cursor: params.cursor } : {}),
})
yield* input.catalog.get(params.cwd)
const created = yield* ACPPromise.promise(() =>
input.client.session.create({
location: { directory: params.cwd },
...ACPDirectories.grant(directories),
}),
)
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
return { sessionId: attached.id, configOptions: yield* currentOptions(attached) }
}),
loadSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
const session = yield* getSession(params.sessionId, params.cwd)
yield* ACPDirectories.activate(input.client, session, directories)
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers)
return yield* replay(attached).pipe(
Effect.andThen(currentOptions(attached)),
Effect.map((configOptions) => ({ configOptions })),
Effect.onError(() => input.sessions.detach(attached.id)),
)
}),
listSessions: Effect.fnUntraced(function* (params) {
const page = yield* ACPPromise.promise(() =>
input.client.session.list({
...(params.cwd ? { directory: params.cwd } : {}),
order: "desc",
limit: 100,
...(params.cursor ? { cursor: params.cursor } : {}),
}),
)
return {
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: withTimestampedFallback(session),
updatedAt: new Date(session.time.updated).toISOString(),
})),
sessions: page.data.map((session) => {
const additionalDirectories = ACPDirectories.list(session)
return {
sessionId: session.id,
cwd: session.location.directory,
...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
title: withTimestampedFallback(session),
updatedAt: new Date(session.time.updated).toISOString(),
}
}),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
},
deleteSession: async (params) => {
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
detach(params.sessionId)
}),
deleteSession: Effect.fnUntraced(function* (params) {
// A malformed ID fails the server's path decode, and the session ID is the only path param.
yield* ACPPromise.promise(() =>
input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (isSessionNotFoundError(error) || (isInvalidRequestError(error) && error.kind === "Params")) return
throw error
}),
)
yield* input.sessions.detach(params.sessionId)
return {}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId, params.cwd)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
const turn = active.get(params.sessionId)
await cancelTurn(params.sessionId).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
await turn?.turn.catch(() => {})
detach(params.sessionId)
}),
resumeSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
const session = yield* getSession(params.sessionId, params.cwd)
yield* ACPDirectories.activate(input.client, session, directories)
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: yield* currentOptions(attached) }
}),
closeSession: Effect.fnUntraced(function* (params) {
yield* input.turn.close(params.sessionId)
yield* input.sessions.detach(params.sessionId)
return {}
},
forkSession: async (params) => {
const forked = await input.client.session.fork({
sessionID: params.sessionId,
})
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
return { sessionId: state.id, configOptions: configOptions(state) }
},
setSessionConfigOption: async (params) => {
const state = await requireSession(params.sessionId)
}),
forkSession: Effect.fnUntraced(function* (params) {
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
const forked = yield* ACPPromise.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
// Forks copy the source session's rules, so the request list replaces any inherited grants.
yield* ACPDirectories.activate(input.client, forked, directories)
const attached = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
return yield* currentOptions(attached).pipe(
Effect.map((configOptions) => ({ sessionId: attached.id, configOptions })),
Effect.onError(() => input.sessions.detach(attached.id)),
)
}),
setSessionConfigOption: Effect.fnUntraced(function* (params) {
const attached = yield* input.sessions.require(params.sessionId)
const value = params.value
if (typeof value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
await withReload(state, async () => {
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog.current, value, currentModel(state))
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
return
}
case "effort": {
const current = currentModel(state)
const model = state.catalog.current.models.find(
(item) => item.providerID === current.providerID && item.id === current.id,
)
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
throw new ACPError.InvalidEffortError({ effort: value })
state.model = { ...current, variant: value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
return
}
case "mode":
return selectMode(input.client, state, value)
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
})
return { configOptions: configOptions(state) }
},
setSessionMode: async (params) => {
const state = await requireSession(params.sessionId)
await withReload(state, () => selectMode(input.client, state, params.modeId))
if (typeof value !== "string") return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
yield* withReload(attached, selectOption(attached, params.configId, value))
return { configOptions: yield* currentOptions(attached) }
}),
setSessionMode: Effect.fnUntraced(function* (params) {
const attached = yield* input.sessions.require(params.sessionId)
yield* withReload(attached, selectMode(attached, params.modeId))
return {}
},
prompt: async (params, signal) => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
throw new ACPError.ServiceFailureError({
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
service: "session",
})
}
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog.current, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const extNotification = input.connection.extNotification
const childSessionUpdate =
capabilities.childSessionUpdates && extNotification
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
: undefined
// A `$/cancel_request` for this prompt behaves like `session/cancel` for its turn.
const cancel = () => void cancelTurn(state.id).catch(() => {})
const turn = streamTurn({
client: input.client,
connection: input.connection,
sessionID: state.id,
cwd: state.cwd,
start: prepared.start,
action: prepared.command !== undefined,
control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
...(childSessionUpdate ? { childSessionUpdate } : {}),
})
.then(async (result) => {
await sendUsageUpdate(input.client, input.connection, state, result.contextTokens).catch(() => {})
return result.response
})
.finally(() => {
signal?.removeEventListener("abort", cancel)
if (active.get(state.id)?.control === control) active.delete(state.id)
})
active.set(state.id, { control, turn })
signal?.addEventListener("abort", cancel, { once: true })
// The cancel may already be buffered behind the awaits above.
if (signal?.aborted) cancel()
return turn
},
cancel: async (params) => {
await cancelTurn(params.sessionId).catch(() => {})
},
}),
prompt: input.turn.prompt,
cancel: input.turn.cancel,
}
}
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
const parts = promptContentToParts(prompt)
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
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 && !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 }
}
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
if (prompt.synthetic.length > 0) {
await client.session.synthetic({
sessionID: session.id,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
})
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.command) {
return client.session.command(
{
sessionID: session.id,
name: prompt.command.name,
text: prompt.slash?.args ?? "",
files: prompt.files,
delivery: "steer",
},
{ signal },
)
}
return client.session.prompt(
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
{ signal },
)
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
return { type: "input", id: messageID }
}
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: ModelRef) {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
)
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selected.variant ??
(current.providerID === model.providerID &&
@@ -447,124 +298,14 @@ function requireModel(catalog: Catalog, modelID: string, current: ModelRef): Mod
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant }
}
return { providerID: model.providerID, id: model.id, variant } satisfies ModelRef
})
function currentModel(state: Attached, catalog = state.catalog.current) {
return state.model ?? catalog.defaultModel
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
if (!state.catalog.current.modes.some((mode) => mode.id === modeID))
throw new ACPError.InvalidModeError({ mode: modeID })
state.modeID = modeID
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
}
async function getSession(client: OpenCodeClient, sessionID: string, cwd: string) {
const session = await client.session.get({ sessionID }).catch((error) => {
if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
throw error
})
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory)) {
throw new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
}
return session
}
async function messages(client: OpenCodeClient, sessionID: string) {
const result: SessionMessageInfo[] = []
let cursor: string | undefined
do {
const page = cursor
? await client.message.list({ sessionID, limit: 200, cursor })
: await client.message.list({ sessionID, limit: 200, order: "asc" })
result.push(...page.data)
cursor = page.cursor.next ?? undefined
} while (cursor)
return result
}
async function registerMcpServers(
client: OpenCodeClient,
registered: Map<string, Set<string>>,
session: Attached,
servers: readonly McpServer[],
) {
const current = registered.get(session.id) ?? new Set<string>()
registered.set(session.id, current)
await Promise.all(
servers.flatMap((server) => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (current.has(key)) return []
current.add(key)
return [
client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
current.delete(key)
throw error
}),
]
}),
)
}
function mcpConfig(server: McpServer) {
if ("type" in server) {
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
return {
type: "remote" as const,
url: server.url,
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
oauth: false as const,
}
}
return {
type: "local" as const,
command: [server.command, ...server.args],
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
}
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
if (!value || typeof value !== "object") return JSON.stringify(value)
return `{${Object.entries(value)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`
}
async function sendUsageUpdate(
client: OpenCodeClient,
connection: ACPConnection.Connection,
session: Attached,
used?: number,
) {
if (!used) return
const current = currentModel(session)
const model = session.catalog.current.models.find(
(item) => item.providerID === current.providerID && item.id === current.id,
)
if (!model?.limit.context) return
const info = await client.session.get({ sessionID: session.id })
await connection.sessionUpdate({
sessionId: session.id,
update: {
sessionUpdate: "usage_update",
used,
size: model.limit.context,
cost: { amount: info.cost, currency: "USD" },
},
})
}
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
const value = text.trim()
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: ModelRef) {
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
if (!model || (effort !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === effort)))
return yield* new ACPError.InvalidEffortError({ effort })
return { ...current, variant: effort } satisfies ModelRef
})
export * as ACPService from "./service"
+210
View File
@@ -0,0 +1,210 @@
import { isDeepStrictEqual } from "node:util"
import type { McpServer, RequestError } from "@agentclientprotocol/sdk"
import type { OpenCodeClient, OpenCodeEvent, SessionInfo } from "@opencode/client/promise"
import { Context, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
import type { ACPCatalog, Catalog } from "./catalog"
import { availableCommands, configOptions, type Selection } from "./config-option"
import { ACPConnection } from "./connection"
import { ACPError } from "./error"
import { ACPPromise } from "./promise"
export type Attached = {
readonly id: string
readonly cwd: string
readonly selection: Ref.Ref<Selection>
}
export interface Interface {
/**
* Attaches a session in its own scope, closing any previous attachment of the same ID. Once the attaching request
* has responded, the scope follows the cwd's catalog and pushes config option and command updates while it is
* open. A failed attach leaves the session detached.
*/
readonly attach: (
session: SessionInfo,
cwd: string,
mcpServers: readonly McpServer[],
) => Effect.Effect<Attached, ACPError.Error | RequestError | ACPCatalog.Error>
/** Closes the session scope. No-op when the session is not attached. */
readonly detach: (sessionID: string) => Effect.Effect<void>
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
/** Forks work into this attachment's scope, so it ends on detach or re-attach. Fails once the attachment is gone. */
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Sessions") {}
type Entry = {
readonly attached: Attached
readonly scope: Scope.Closeable
/** Selection changes from other clients, applied by the session's fold. */
readonly selected: Queue.Queue<Selection>
}
type SelectedEvent = Extract<OpenCodeEvent, { type: "session.model.selected" | "session.agent.selected" }>
export const make = Effect.fnUntraced(function* (input: {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Interface
readonly catalog: ACPCatalog.Interface
}) {
const scope = yield* Effect.scope
const sessions = new Map<string, Entry>()
// Kept across re-attachment so resuming with the same servers does not add them again.
const registeredMcp = new Map<string, Set<string>>()
const connected = yield* Deferred.make<void>()
// Subscribe before any attach so a switch right after `sessions.set` reaches the session.
yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
Stream.tap((event) => (event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void)),
Stream.filter(
(event): event is SelectedEvent =>
event.type === "session.model.selected" || event.type === "session.agent.selected",
),
Stream.runForEach((event) => {
const entry = sessions.get(event.data.sessionID)
if (!entry) return Effect.void
return Queue.offer(
entry.selected,
event.type === "session.model.selected" ? { model: event.data.model } : { modeID: event.data.agent },
)
}),
Effect.ignore,
Effect.ensuring(Deferred.succeed(connected, undefined)),
Effect.forkScoped,
)
const sendCommands = (sessionID: string, catalog: Catalog) =>
input.connection.sessionUpdate({
sessionId: sessionID,
update: { sessionUpdate: "available_commands_update", availableCommands: availableCommands(catalog) },
})
const changed = Effect.fnUntraced(function* (attached: Attached, previous: Catalog, next: Catalog, patch: Selection) {
const selection = yield* Ref.getAndUpdate(attached.selection, (current) => ({ ...current, ...patch }))
const options = configOptions(next, { ...selection, ...patch })
if (!isDeepStrictEqual(options, configOptions(previous, selection))) {
yield* input.connection.sessionUpdate({
sessionId: attached.id,
update: { sessionUpdate: "config_option_update", configOptions: options },
})
}
if (!isDeepStrictEqual(next.commands, previous.commands)) yield* sendCommands(attached.id, next)
})
const registerMcp = (attached: Attached, servers: readonly McpServer[]) =>
Effect.suspend(() => {
const registered = registeredMcp.get(attached.id) ?? new Set<string>()
registeredMcp.set(attached.id, registered)
return Effect.forEach(
servers,
(server) =>
Effect.suspend(() => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (registered.has(key)) return Effect.void
registered.add(key)
return ACPPromise.promise(() =>
input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }),
).pipe(
Effect.onError(() => Effect.sync(() => registered.delete(key))),
Effect.uninterruptible,
)
}),
{ concurrency: "unbounded", discard: true },
)
})
const remove = (sessionID: string, entry: Entry) =>
Effect.suspend(() => {
if (sessions.get(sessionID) === entry) {
sessions.delete(sessionID)
registeredMcp.delete(sessionID)
}
return Scope.close(entry.scope, Exit.void)
})
return Service.of({
attach: Effect.fn("cli.acp.sessions.attach")(function* (session, cwd, mcpServers) {
yield* Deferred.await(connected)
const current = yield* input.catalog.get(cwd)
const entry: Entry = {
attached: {
id: session.id,
cwd,
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
},
scope: Scope.forkUnsafe(scope),
selected: yield* Queue.unbounded<Selection>(),
}
// Swap synchronously so concurrent attaches of one ID cannot both keep a scope.
const replaced = sessions.get(session.id)
sessions.set(session.id, entry)
if (replaced) yield* Scope.close(replaced.scope, Exit.void)
yield* registerMcp(entry.attached, mcpServers).pipe(Effect.onError(() => remove(session.id, entry)))
const responded = yield* ACPConnection.Responded
// Updates wait for the response that hands the client this session. `changes` emits the latest catalog
// first, so a reload since `current` is still pushed. One fold applies catalog and selection changes so
// pushes leave the client on the latest pair.
yield* Effect.gen(function* () {
yield* responded
yield* sendCommands(session.id, current)
yield* Stream.merge(
input.catalog.changes(cwd).pipe(Stream.map((catalog) => ({ catalog, patch: {} }))),
Stream.fromQueue(entry.selected).pipe(Stream.map((patch) => ({ catalog: undefined, patch }))),
).pipe(
Stream.runFoldEffect(
() => current,
(previous, step) => {
const next = step.catalog ?? previous
return changed(entry.attached, previous, next, step.patch).pipe(Effect.ignore, Effect.as(next))
},
),
)
}).pipe(Effect.ignore, Effect.forkIn(entry.scope))
return entry.attached
}),
detach: Effect.fn("cli.acp.sessions.detach")(function* (sessionID) {
const entry = sessions.get(sessionID)
if (entry) yield* remove(sessionID, entry)
}),
require: Effect.fn("cli.acp.sessions.require")(function* (sessionID) {
const entry = sessions.get(sessionID)
if (!entry) return yield* new ACPError.SessionNotFoundError({ sessionId: sessionID })
return entry.attached
}),
fork: Effect.fn("cli.acp.sessions.fork")(function* (attached, effect) {
const entry = sessions.get(attached.id)
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
}),
})
})
function mcpConfig(server: McpServer) {
if ("type" in server) {
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
return {
type: "remote" as const,
url: server.url,
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
oauth: false as const,
}
}
return {
type: "local" as const,
command: [server.command, ...server.args],
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
}
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
if (!value || typeof value !== "object") return JSON.stringify(value)
return `{${Object.entries(value)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`
}
export * as ACPSessions from "./sessions"
+633
View File
@@ -0,0 +1,633 @@
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
SessionMessageAssistant,
SessionMessageInfo,
SessionStructuredError,
TokenUsageInfo,
} from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import { SessionMessage } from "@opencode/schema/session-message"
import { TokenUsage } from "@opencode/schema/token-usage"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
const RetryMeta = "opencode/retry"
const CompactionMeta = "opencode/compaction"
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: string }
export type Terminal = "succeeded" | "failed" | "interrupted"
export type Context = {
readonly sessionID: string
readonly cwd: string
readonly start: TurnStart
readonly childUpdates: boolean
/** A background consumer follows open children after the parent turn ends; it never writes `session/update`. */
readonly mode: "turn" | "background"
}
type Tool = {
readonly sessionID: string
readonly id: string
readonly name: string
readonly input: ToolInput
readonly metadata: Record<string, unknown>
}
type RetryStatus = {
readonly attempt: number
readonly nextRetryAt: string
readonly error: SessionStructuredError
}
export type ChildSession = {
readonly id: string
readonly parentID: string
readonly depth: number
readonly title?: string
}
type ChildSessionEvent =
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
}
export type ChildSessionUpdate = {
readonly rootSessionId: string
readonly childSessionId: string
readonly parentSessionId: string
readonly depth: number
readonly title?: string
} & ChildSessionEvent
export type TurnState = {
readonly started: boolean
readonly tools: ReadonlyMap<string, Tool>
readonly retries: ReadonlyMap<string, RetryStatus>
readonly compactions: ReadonlyMap<string, string>
readonly children: ReadonlyMap<string, ChildSession>
readonly openChildren: ReadonlySet<string>
/** Forms asked of the client that the server has not yet answered or cancelled. */
readonly forms: ReadonlySet<string>
readonly finish?: SessionMessageAssistant["finish"]
readonly usage?: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo }
readonly stepError?: SessionStructuredError
readonly executionError?: { readonly type: string; readonly message: string }
}
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type FormEvent = Extract<EventSubscribeOutput, { type: "form.created" }>
export type Output =
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
| { readonly _tag: "ChildUpdate"; readonly update: ChildSessionUpdate }
| {
readonly _tag: "PermissionAsk"
readonly event: PermissionEvent
readonly tool?: Tool
readonly child?: ChildSession
}
| {
readonly _tag: "FormAsk"
readonly form: FormEvent["data"]["form"]
readonly child?: ChildSession
/** Whether the form's session sends its tool calls to the client as `session/update` tool calls. */
readonly toolCallSent: boolean
}
| { readonly _tag: "FormSettled"; readonly formID: string }
export type Step = {
readonly state: TurnState
readonly outputs: ReadonlyArray<Output>
readonly terminal?: Terminal
}
type CompactionEvent = Extract<
EventSubscribeOutput,
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
>
type CompactionMarker = {
readonly status: "started" | "completed" | "failed"
readonly messageId: string
readonly reason: "auto" | "manual"
readonly error?: SessionStructuredError
}
export const initial: TurnState = {
started: false,
tools: new Map(),
retries: new Map(),
compactions: new Map(),
children: new Map(),
openChildren: new Set(),
forms: new Set(),
}
export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context): Step {
if (event.type === "session.created") {
const parentID = event.data.parentID
if (!parentID) return { state, outputs: [] }
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
if (!parent && (ctx.mode === "background" || parentID !== ctx.sessionID)) return { state, outputs: [] }
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
return {
state: {
...state,
children: new Map(state.children).set(child.id, child),
openChildren: new Set(state.openChildren).add(child.id),
},
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
}
}
const eventSessionID = sessionIDFromEvent(event)
const child = eventSessionID ? state.children.get(eventSessionID) : undefined
if (ctx.mode === "background" && !child) return { state, outputs: [] }
const send = (update: SessionUpdate) => route(ctx, child, update)
if (event.type === "permission.asked" && (event.data.sessionID === ctx.sessionID || child)) {
const tool = event.data.source?.id
? state.tools.get(toolKey(event.data.sessionID, event.data.source.id))
: undefined
return { state, outputs: [{ _tag: "PermissionAsk", event, tool, child }] }
}
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
return {
state: { ...state, forms: new Set(state.forms).add(event.data.form.id) },
outputs: [
{
_tag: "FormAsk",
form: event.data.form,
child,
toolCallSent: ctx.mode === "turn" && (!child || !ctx.childUpdates),
},
],
}
}
if ((event.type === "form.replied" || event.type === "form.cancelled") && state.forms.has(event.data.id)) {
const forms = new Set(state.forms)
forms.delete(event.data.id)
return { state: { ...state, forms }, outputs: [{ _tag: "FormSettled", formID: event.data.id }] }
}
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
if (matchesStart(event, ctx.start)) return { state: { ...state, started: true }, outputs: [] }
if (!state.started) return { state, outputs: [] }
switch (event.type) {
case "session.execution.started":
return { state, outputs: child ? childStatus(ctx, child, { type: "status", status: "running" }) : [] }
case "session.step.started": {
const next = child ? state : { ...state, stepError: undefined }
if (!state.retries.has(eventSessionID)) return { state: next, outputs: [] }
return {
state: { ...next, retries: without(state.retries, eventSessionID) },
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } }),
}
}
case "session.retry.scheduled": {
const retry = {
attempt: event.data.attempt,
nextRetryAt: new Date(event.data.at).toISOString(),
error: event.data.error,
}
return {
state: { ...state, retries: new Map(state.retries).set(eventSessionID, retry) },
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } }),
}
}
case "session.compaction.started":
case "session.compaction.ended":
case "session.compaction.failed": {
const compacted = compactionMarker(event, state.compactions)
return {
state: { ...state, compactions: compacted.compactions },
outputs: compacted.marker ? send(compactionUpdate(compacted.marker)) : [],
}
}
case "session.text.delta":
return {
state,
outputs: send({
sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
}),
}
case "session.reasoning.delta":
return {
state,
outputs: send({
sessionUpdate: "agent_thought_chunk",
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
content: { type: "text", text: event.data.delta },
}),
}
case "session.tool.input.started":
return {
state: {
...state,
tools: new Map(state.tools).set(
toolKey(event.data.sessionID, event.data.id),
newTool(event.data.sessionID, event.data.id, event.data.name),
),
},
outputs: send({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.id,
toolName: event.data.name,
state: { input: {} },
cwd: ctx.cwd,
}),
}),
}
case "session.tool.called": {
const key = toolKey(event.data.sessionID, event.data.id)
const tool = {
...(state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)),
input: event.data.input,
}
return {
state: { ...state, tools: new Map(state.tools).set(key, tool) },
outputs: send({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.id,
toolName: tool.name,
state: { input: tool.input },
cwd: ctx.cwd,
}),
}),
}
}
case "session.tool.progress": {
const key = toolKey(event.data.sessionID, event.data.id)
const current = state.tools.get(key)
if (!current) return { state, outputs: [] }
return {
state: { ...state, tools: new Map(state.tools).set(key, { ...current, metadata: event.data.metadata }) },
outputs: send({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.id,
toolName: current.name,
state: { input: current.input },
cwd: ctx.cwd,
}),
}),
}
}
case "session.tool.success": {
const key = toolKey(event.data.sessionID, event.data.id)
const tool = state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)
return {
state: { ...state, tools: without(state.tools, key) },
outputs: send({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.id,
toolName: tool.name,
input: tool.input,
metadata: event.data.metadata,
content: event.data.content,
cwd: ctx.cwd,
}),
}),
}
}
case "session.tool.failed": {
const key = toolKey(event.data.sessionID, event.data.id)
const tool = state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)
return {
state: { ...state, tools: without(state.tools, key) },
outputs: send({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.id,
toolName: tool.name,
input: tool.input,
metadata: event.data.metadata ?? tool.metadata,
content: event.data.content ?? [],
error: event.data.error.message,
cwd: ctx.cwd,
}),
}),
}
}
case "session.step.ended":
if (child) return { state, outputs: [] }
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
case "session.step.failed": {
if (child) return { state, outputs: [] }
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
}
case "session.execution.succeeded":
if (!child) return { state, outputs: [], terminal: "succeeded" }
return childEnded(state, ctx, child, { type: "status", status: "completed" }, "succeeded")
case "session.execution.interrupted":
if (!child) return { state, outputs: [], terminal: "interrupted" }
return childEnded(state, ctx, child, { type: "status", status: "interrupted" }, "interrupted")
case "session.execution.failed":
if (!child) return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error }, "failed")
default:
return { state, outputs: [] }
}
}
/** The ACP failure a settled turn reports instead of a response, if any. */
export function failure(state: TurnState) {
const error = state.stepError ?? state.executionError
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
return new ACPError.ServiceFailureError({
safeMessage: error.message || "OpenCode prompt failed",
service: "session",
errorName: error.type,
})
}
return undefined
}
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
const tokens = state.usage?.turn
const usage = tokens
? {
inputTokens: tokens.input,
outputTokens: tokens.output,
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 } : {}),
}
: undefined
const error = (state.stepError ?? state.executionError)?.type
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
const retry = state.retries.get(sessionID)
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
}
/** Fails the tools a cancelled turn left open, for when the server's wind-down never reports them. */
export function abandonTools(state: TurnState, ctx: Context): Step {
return {
state: { ...state, tools: new Map() },
outputs: [...state.tools.values()].flatMap((tool) =>
route(ctx, state.children.get(tool.sessionID), {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: tool.id,
toolName: tool.name,
input: tool.input,
metadata: tool.metadata,
content: [],
error: "Cancelled",
cwd: ctx.cwd,
}),
}),
),
}
}
/** Lazy, so a message that fails to translate part way still replays the updates before the failure. */
export function* replayMessage(message: SessionMessageInfo, cwd: string): Generator<SessionUpdate> {
if (message.type === "user") {
yield { sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } }
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
type: "file",
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
filename: file.name,
mime: file.mime,
}))
for (const chunk of partsToContentChunks(files))
yield { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk }
return
}
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
if (message.type === "compaction" && message.status !== "running") {
yield compactionUpdate({
status: message.status,
messageId: message.id,
reason: message.reason,
...(message.status === "failed" ? { error: message.error } : {}),
})
return
}
if (message.type !== "assistant") return
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
let reasoningOrdinal = 0
for (const part of message.content) {
if (part.type === "text") {
yield { sessionUpdate: "agent_message_chunk", messageId: message.id, content: { type: "text", text: part.text } }
continue
}
if (part.type === "reasoning") {
yield {
sessionUpdate: "agent_thought_chunk",
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
content: { type: "text", text: part.text },
}
continue
}
yield {
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: part.id,
toolName: part.name,
state: { input: part.state.status === "streaming" ? {} : part.state.input },
cwd,
}),
}
switch (part.state.status) {
case "completed":
yield {
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
cwd,
}),
}
break
case "running":
yield {
sessionUpdate: "tool_call_update",
...runningToolUpdate({ toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, cwd }),
}
break
case "error":
yield {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: part.id,
toolName: part.name,
input: part.state.input,
metadata: part.state.metadata,
content: part.state.content,
error: part.state.error.message,
cwd,
}),
}
break
case "streaming":
break
}
}
}
function newTool(sessionID: string, id: string, name = "tool"): Tool {
return { sessionID, id, name, input: {}, metadata: {} }
}
function route(ctx: Context, child: ChildSession | undefined, update: SessionUpdate): Output[] {
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
const projected = projectChildUpdate(update, child)
if (ctx.childUpdates) return childStatus(ctx, child, { type: "update", update: projected })
return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update: projected }] : []
}
function childStatus(ctx: Context, child: ChildSession, value: ChildSessionEvent): Output[] {
if (!ctx.childUpdates) return []
return [
{
_tag: "ChildUpdate",
update: {
rootSessionId: ctx.sessionID,
childSessionId: child.id,
parentSessionId: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
...value,
},
},
]
}
// A background consumer ends once its last open child settles.
function childEnded(
state: TurnState,
ctx: Context,
child: ChildSession,
status: ChildSessionEvent,
terminal: Terminal,
): Step {
const openChildren = new Set(state.openChildren)
openChildren.delete(child.id)
return {
state: { ...state, openChildren },
outputs: childStatus(ctx, child, status),
...(ctx.mode === "background" && openChildren.size === 0 ? { terminal } : {}),
}
}
function recordStep(state: TurnState, tokens: TokenUsageInfo): TurnState {
const turn = state.usage?.turn
return {
...state,
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,
},
}
}
function without<K, V>(map: ReadonlyMap<K, V>, key: K) {
const next = new Map(map)
next.delete(key)
return next
}
function sessionIDFromEvent(event: EventSubscribeOutput) {
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
if (event.type === "form.created") return event.data.form.sessionID
return undefined
}
function toolKey(sessionID: string, id: string) {
return `${sessionID}:${id}`
}
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
function compactionMarker(event: CompactionEvent, compactions: ReadonlyMap<string, string>) {
const sessionID = event.data.sessionID
if (event.type === "session.compaction.started") {
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
const marker: CompactionMarker = { status: "started", messageId, reason: event.data.reason }
return { marker, compactions: new Map(compactions).set(sessionID, messageId) }
}
const tracked = compactions.get(sessionID)
const remaining = without(compactions, sessionID)
if (event.type === "session.compaction.ended") {
const marker: CompactionMarker | undefined = tracked
? { status: "completed", messageId: tracked, reason: event.data.reason }
: undefined
return { marker, compactions: remaining }
}
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
const marker: CompactionMarker = {
status: "failed",
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
reason: event.data.reason,
error: event.data.error,
}
return { marker, compactions: remaining }
}
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = {
...projected._meta,
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
}
function resolveStopReason(input: {
readonly terminal: Terminal
readonly finish: SessionMessageAssistant["finish"]
readonly error?: string
}): PromptResponse["stopReason"] {
if (input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
if (input.finish === "length") return "max_tokens"
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
return "end_turn"
}
export * as ACPTranslate from "./translate"
+455
View File
@@ -0,0 +1,455 @@
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
import {
isSessionNotFoundError,
type CommandInfo,
type OpenCodeClient,
type OpenCodeEvent,
} from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import { TokenUsage } from "@opencode/schema/token-usage"
import {
Cause,
Context,
Deferred,
Duration,
Effect,
Exit,
Fiber,
FiberMap,
Option,
Queue,
Ref,
Scope,
Stream,
} from "effect"
import { access, constants } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
import { currentModel } from "./config-option"
import type { ACPConnection } from "./connection"
import { linkReference, promptContentToParts, type PromptPart } from "./content"
import { ACPElicitation } from "./elicitation"
import { ACPError } from "./error"
import { ACPPermission } from "./permission"
import { ACPPromise } from "./promise"
import type { ACPService } from "./service"
import type { ACPSessions, Attached } from "./sessions"
import { ACPTranslate } from "./translate"
type Failure = ACPError.Error | RequestError | ACPCatalog.Error
export interface Interface {
/**
* Runs the session's only turn. Cancelling it, including through the request's `$/cancel_request` signal,
* interrupts the turn and still resolves with `stopReason: "cancelled"`.
*/
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
/** Interrupts the session's active turn and waits for it to settle. No-op when the session is idle. */
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
/** Like `cancel`, but an idle session is still interrupted, since server work can outlive its turn. */
readonly close: (sessionID: string) => Effect.Effect<void, ACPError.Error | RequestError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Turn") {}
/**
* How long a cancelled turn keeps forwarding the server's wind-down. Core acknowledges an interrupt before its
* cleanup settles, and its shell tool waits 3 seconds before escalating to SIGKILL.
*/
export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/cli/acp/Turn/CancelDrainTimeout", {
defaultValue: () => "5 seconds",
})
type PreparedPrompt = {
readonly start: ACPTranslate.TurnStart
readonly text: string
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: CommandInfo
}
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
/** A turn's event feed. It moves to the session scope when the turn ends with children still running. */
type Subscription = {
readonly scope: Scope.Closeable
readonly events: Queue.Dequeue<OpenCodeEvent, unknown>
/** Runs permission and form asks one at a time in ask order, without holding back the rest of the stream. */
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
/** Completed when the turn is cancelled; pending and later asks then resolve without the client. */
readonly cancelled: Deferred.Deferred<void>
/** Completed per asked form once the server reports it answered or cancelled. */
readonly forms: Map<string, Deferred.Deferred<void>>
}
export const make = Effect.fnUntraced(function* (input: {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Interface
readonly sessions: ACPSessions.Interface
readonly catalog: ACPCatalog.Interface
readonly capabilities: Ref.Ref<ACPService.Capabilities>
}) {
const scope = yield* Effect.scope
const drainTimeout = yield* CancelDrainTimeout
const turns = yield* FiberMap.make<string, PromptResponse, Failure>()
const subscribe = Effect.fnUntraced(function* () {
// Parented, so it still closes when the session scope it is handed to is already gone.
const subscriptionScope = yield* Scope.fork(scope)
const subscription: Subscription = {
scope: subscriptionScope,
events: yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
Stream.toQueue({ capacity: "unbounded" }),
Scope.provide(subscriptionScope),
),
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
cancelled: yield* Deferred.make<void>(),
forms: new Map(),
}
yield* Queue.take(subscription.asks).pipe(
Effect.flatten,
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP permission reply failed", cause),
),
Effect.forever,
Effect.forkIn(subscriptionScope),
)
return subscription
})
const take = (subscription: Subscription) =>
Queue.take(subscription.events).pipe(
Effect.catch((error) =>
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPPromise.classify(error),
),
)
// A turn settles only after the asks it saw have been resolved.
const asksSettled = Effect.fnUntraced(function* (subscription: Subscription) {
const settled = yield* Deferred.make<void>()
yield* Queue.offer(subscription.asks, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
yield* Deferred.await(settled)
})
const reply = (subscription: Subscription, ctx: ACPTranslate.Context, ask: PermissionAsk) =>
ACPPermission.reply(
{
client: input.client,
connection: input.connection,
event: ask.event,
sessionID: ask.event.data.sessionID,
clientSessionID: ctx.sessionID,
cwd: ctx.cwd,
tool: ask.tool,
...(ask.child ? { toolCallPrefix: ask.child.id, titlePrefix: ask.child.title } : {}),
},
Deferred.await(subscription.cancelled),
)
const interpret = (subscription: Subscription, ctx: ACPTranslate.Context, output: ACPTranslate.Output) => {
switch (output._tag) {
case "SessionUpdate":
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
case "ChildUpdate":
return input.connection
.extNotification(ACPTranslate.ChildSessionUpdateMethod, output.update)
.pipe(Effect.ignoreCause)
case "PermissionAsk":
return Queue.offer(subscription.asks, reply(subscription, ctx, output)).pipe(Effect.asVoid)
case "FormAsk":
return Effect.gen(function* () {
const capabilities = yield* Ref.get(input.capabilities)
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
if (!requestedSchema) return yield* ACPElicitation.cancel(input.client, output.form)
const settled = yield* Deferred.make<void>()
subscription.forms.set(output.form.id, settled)
yield* Queue.offer(
subscription.asks,
ACPElicitation.reply(
{
client: input.client,
connection: input.connection,
form: output.form,
requestedSchema,
clientSessionID: ctx.sessionID,
child: output.child,
toolCallSent: output.toolCallSent,
settled: Deferred.await(settled),
},
Deferred.await(subscription.cancelled),
),
)
})
case "FormSettled":
return Effect.suspend(() => {
const settled = subscription.forms.get(output.formID)
subscription.forms.delete(output.formID)
return settled ? Deferred.succeed(settled, undefined) : Effect.void
})
}
}
const consume = Effect.fnUntraced(function* (
subscription: Subscription,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
) {
while (true) {
const event = yield* take(subscription)
const next = ACPTranslate.step(yield* Ref.get(state), event, ctx)
yield* Ref.set(state, next.state)
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
if (next.terminal) {
yield* asksSettled(subscription)
return next.terminal
}
}
})
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
const sessionID = attached.id
if (prompt.synthetic.length > 0) {
yield* ACPPromise.promise((signal) =>
input.client.session.synthetic(
{
sessionID,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
},
{ signal },
),
)
}
if (prompt.start.type === "compaction") {
yield* ACPPromise.promise((signal) =>
input.client.session.compact({ sessionID, id: prompt.start.id }, { signal }),
)
return
}
const command = prompt.command
if (command) {
yield* ACPPromise.promise((signal) =>
input.client.session.command(
{ sessionID, name: command.name, text: prompt.slash?.args ?? "", files: prompt.files, delivery: "steer" },
{ signal },
),
)
return
}
yield* ACPPromise.promise((signal) =>
input.client.session.prompt(
{ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
{ signal },
),
)
})
const interruptServer = (sessionID: string) =>
ACPPromise.promise(() => input.client.session.interrupt({ sessionID })).pipe(Effect.ignoreCause)
// Rejects pending asks, interrupts the server once, then forwards its wind-down until the terminal event or the
// timeout. Tools still open at the timeout are reported failed so the client never shows them running.
const windDown = Effect.fnUntraced(function* (
subscription: Subscription,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
events: Fiber.Fiber<ACPTranslate.Terminal, Failure>,
) {
yield* Deferred.succeed(subscription.cancelled, undefined)
yield* interruptServer(ctx.sessionID)
if (!(yield* Ref.get(state)).started) return
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
yield* Fiber.interrupt(events)
const abandoned = ACPTranslate.abandonTools(yield* Ref.get(state), ctx)
yield* Ref.set(state, abandoned.state)
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
Effect.ignore,
)
})
const execute = (
attached: Attached,
prompt: PreparedPrompt,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
) =>
Effect.acquireUseRelease(
subscribe(),
(subscription) =>
Effect.gen(function* () {
// The feed opens with `server.connected`, so every event the submission causes comes after it.
const connected = yield* take(subscription)
if (connected.type !== "server.connected")
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
const events = yield* consume(subscription, ctx, state).pipe(Effect.forkScoped)
return yield* Effect.gen(function* () {
yield* submit(attached, prompt)
if (prompt.command) return "succeeded" as const
return yield* Fiber.join(events)
}).pipe(Effect.onInterrupt(() => windDown(subscription, ctx, state, events)))
}).pipe(Effect.scoped),
(subscription, exit) => handoff(attached, subscription, ctx, state, exit),
)
const handoff = Effect.fnUntraced(function* (
attached: Attached,
subscription: Subscription,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
) {
const close = Scope.close(subscription.scope, Exit.void)
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
if ((yield* Ref.get(state)).openChildren.size === 0) return yield* close
// Children that outlive a cancelled turn were not cancelled, so their asks still go to the client.
const cancelled = yield* Deferred.make<void>()
const background = consume({ ...subscription, cancelled }, { ...ctx, mode: "background" }, state).pipe(
Effect.ignore,
Effect.ensuring(close),
Effect.withSpan("cli.acp.turn.background"),
)
yield* input.sessions.fork(attached, background).pipe(Effect.catchTag("ACPSessionNotFoundError", () => close))
})
const settle = Effect.fnUntraced(function* (
attached: Attached,
state: Ref.Ref<ACPTranslate.TurnState>,
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
) {
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
const current = yield* Ref.get(state)
const failure = ACPTranslate.failure(current)
if (failure) return yield* failure
yield* sendUsageUpdate(attached, current)
return ACPTranslate.response(current, attached.id, Exit.isSuccess(exit) ? exit.value : "interrupted")
})
const sendUsageUpdate = Effect.fn("cli.acp.turn.usage")(
function* (attached: Attached, state: ACPTranslate.TurnState) {
const used = state.usage ? TokenUsage.total(state.usage.last) : 0
if (!used) return
const catalog = yield* input.catalog.get(attached.cwd)
const current = currentModel(catalog, yield* Ref.get(attached.selection))
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
if (!model?.limit.context) return
const info = yield* ACPPromise.promise((signal) =>
input.client.session.get({ sessionID: attached.id }, { signal }),
)
yield* input.connection.sessionUpdate({
sessionId: attached.id,
update: {
sessionUpdate: "usage_update",
used,
size: model.limit.context,
cost: { amount: info.cost, currency: "USD" },
},
})
},
(effect) => Effect.ignoreCause(effect),
)
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
const run = Effect.fn("cli.acp.turn.run")(function* (
attached: Attached,
prompt: PreparedPrompt,
childUpdates: boolean,
) {
const state = yield* Ref.make(ACPTranslate.initial)
const ctx: ACPTranslate.Context = {
sessionID: attached.id,
cwd: attached.cwd,
start: prompt.start,
childUpdates,
mode: "turn",
}
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
return yield* settle(attached, state, exit)
})
return Service.of({
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
const attached = yield* input.sessions.require(params.sessionId)
const catalog = yield* input.catalog.get(attached.cwd)
const childUpdates = (yield* Ref.get(input.capabilities)).childSessionUpdates
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
concurrency: "unbounded",
})
const prompt = preparePrompt(catalog, parts, SessionMessage.ID.create())
// Check and register in one synchronous step.
const turn = yield* Effect.withFiber((fiber) => {
if (FiberMap.hasUnsafe(turns, attached.id)) {
return Effect.fail(
new ACPError.ServiceFailureError({
safeMessage: `Session already has an active ACP prompt: ${attached.id}`,
service: "session",
}),
)
}
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt, childUpdates), { uninterruptible: true })
FiberMap.setUnsafe(turns, attached.id, forked)
return Effect.succeed(forked)
})
// A `$/cancel_request` for this prompt cancels its turn like `session/cancel`, rather than failing the request.
yield* aborted(signal).pipe(Effect.andThen(Fiber.interrupt(turn)), Effect.forkChild)
return yield* Fiber.join(turn)
}),
cancel: Effect.fn("cli.acp.turn.cancel")(function* (params) {
yield* FiberMap.remove(turns, params.sessionId)
}),
close: Effect.fn("cli.acp.turn.close")(function* (sessionID) {
if (FiberMap.hasUnsafe(turns, sessionID)) return yield* FiberMap.remove(turns, sessionID)
yield* ACPPromise.promise(() =>
input.client.session.interrupt({ sessionID }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
}),
)
}),
})
})
function aborted(signal: AbortSignal) {
return Effect.callback<void>((resume) => {
if (signal.aborted) return resume(Effect.void)
const abort = () => resume(Effect.void)
signal.addEventListener("abort", abort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", abort))
})
}
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: string): PreparedPrompt {
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
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 start = turnStart(messageID, slash)
return { start, text, files, synthetic, slash, command }
}
// Covers only missing or permission-denied targets; the server still rejects oversized, non-regular, or unlistable ones.
function referenceUnreadableFile(part: PromptPart) {
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
Effect.as(part),
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
)
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): ACPTranslate.TurnStart {
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
return { type: "input", id: messageID }
}
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
const value = text.trim()
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
export * as ACPTurn from "./turn"
+15 -11
View File
@@ -1,4 +1,4 @@
import { ndJsonStream } from "@agentclientprotocol/sdk"
import { MessageTooLargeError, 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"
@@ -16,25 +16,29 @@ export default Runtime.handler(
const endpoint = yield* Standalone.start()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
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)),
const failure = yield* Effect.raceFirst(
Effect.promise(() => connection.closed).pipe(
Effect.map(() =>
connection.signal.reason instanceof MessageTooLargeError
? `incoming message exceeded the ${connection.signal.reason.maxMessageBytes / 1024 / 1024} MiB limit`
: undefined,
),
),
endpoint.exited.pipe(
Effect.match({
onSuccess: (code) => `code ${code}`,
onFailure: (error) =>
error.cause instanceof CrossSpawnSpawner.KilledBySignal ? `signal ${error.cause.signal}` : error.message,
}),
// 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
}),
),
Effect.map((reason) => `server exited unexpectedly (${reason})`),
),
)
// 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))
yield* Effect.sync(() => {
// stdout carries ACP, so the diagnostic goes to stderr.
if (failure) process.stderr.write(`opencode acp: ${failure}\n`)
process.exit(failure ? 1 : 0)
})
}),
)
+30 -16
View File
@@ -4,7 +4,7 @@ import { run } from "@opencode/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
import { Context, Effect, FileSystem, Option, Queue, Schedule, Semaphore } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@@ -61,13 +61,29 @@ export default Runtime.handler(Commands, (input) =>
})) !== undefined
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
const update = yield* updater
let latest: Updater.RunResult | undefined
const installListeners = new Set<(version: string) => void>()
const resultListeners = new Set<(result: Updater.RunResult) => void>()
// Background checks, `/update` lookups, and manual installs take turns so two installs never overlap.
const checking = yield* Semaphore.make(1)
yield* updater
.run((version) => {
installing = version
updateListeners.forEach((notify) => notify(version))
installListeners.forEach((notify) => notify(version))
})
.pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped)
.pipe(
Effect.ensuring(Effect.sync(() => (installing = undefined))),
Effect.tap((result) =>
Effect.sync(() => {
if (!result || (result.type === latest?.type && result.version === latest.version)) return
latest = result
resultListeners.forEach((notify) => notify(result))
}),
),
checking.withPermits(1),
Effect.repeat(Schedule.spaced("10 minutes")),
Effect.forkScoped({ startImmediately: true }),
)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -106,21 +122,19 @@ export default Runtime.handler(Commands, (input) =>
},
updater: {
remote: requestedServer !== undefined,
subscribe: (notify, signal) =>
runPromise(
Fiber.join(update).pipe(
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
),
{ signal },
),
subscribe: (notify) => {
if (latest) notify(latest)
resultListeners.add(notify)
return () => resultListeners.delete(notify)
},
check: (signal, notify) => {
if (installing) notify(installing)
updateListeners.add(notify)
return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() =>
updateListeners.delete(notify),
installListeners.add(notify)
return runPromise(checking.withPermits(1)(updater.check()), { signal }).finally(() =>
installListeners.delete(notify),
)
},
apply: (version) => runPromise(updater.apply(version)),
apply: (version) => runPromise(checking.withPermits(1)(updater.apply(version))),
},
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
+2 -1
View File
@@ -13,6 +13,7 @@ import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { Config } from "./config"
import { Npm } from "@opencode/util/npm"
import { EffectFlock } from "@opencode/util/effect-flock"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
@@ -113,7 +114,7 @@ Effect.gen(function* () {
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node, EffectFlock.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
+5
View File
@@ -1,5 +1,6 @@
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { EffectFlock } from "@opencode/util/effect-flock"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
@@ -127,6 +128,7 @@ const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const flock = yield* EffectFlock.Service
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
@@ -370,6 +372,9 @@ const make = Effect.gen(function* () {
}
yield* Effect.scoped(
Effect.gen(function* () {
// Other OpenCode processes may be installing at the same time. Wait longer than the
// slowest install (curl runs two 5-minute commands).
yield* flock.acquire("cli-upgrade", undefined, { timeoutMs: Duration.toMillis("15 minutes") })
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
@@ -0,0 +1,58 @@
import type { NewSessionResponse, PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { createAcpFixture, expectOk, initialize } from "./subprocess"
// The first completion reads the file outside cwd; the follow-up completion ends the turn.
function readingModel(file: () => string) {
return (request: unknown) => {
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
return new Response(toolCall(file()), { headers: { "content-type": "text/event-stream" } })
}
}
function toolCall(file: string) {
const call = { index: 0, id: "call_read", type: "function", function: { name: "read", arguments: "" } }
const chunks = [
{ choices: [{ delta: { role: "assistant", tool_calls: [call] }, finish_reason: null }], usage: null },
{
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: file }) } }] } }],
usage: null,
},
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
]
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
}
describe("acp additional directories subprocess", () => {
test("tools read files in an additional directory without an external directory ask", async () => {
const target = { file: "" }
await using fixture = await createAcpFixture({ respond: readingModel(() => target.file) })
// Keep the unresolved tmpdir spelling, which differs from the real path on macOS.
const shared = path.join(fixture.root, "shared")
target.file = path.join(shared, "notes.txt")
await fs.mkdir(shared)
await Bun.write(target.file, "shared root content\n")
const acp = fixture.spawn()
await initialize(acp)
const session = expectOk(
await acp.request<NewSessionResponse>("session/new", {
cwd: fixture.home,
additionalDirectories: [shared],
mcpServers: [],
}),
)
const result = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "read the shared notes" }],
}),
)
expect(result.stopReason).toBe("end_turn")
expect(JSON.stringify(fixture.llm.requests.at(-1))).toContain("shared root content")
}, 60_000)
})
@@ -0,0 +1,182 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import type { PermissionRule } from "@opencode/client/promise"
import { tmpdir } from "../fixture/tmpdir"
import { makeSession, rpcError, startWire, type Wire } from "./wire-fixture"
const key = "opencode.acp.additionalDirectories"
const grant = (directory: string): PermissionRule => ({
action: "external_directory",
resource: path.join(directory, "*"),
effect: "allow",
})
const sharedLib = path.resolve("/shared/lib")
const productDocs = path.resolve("/product-docs")
const old = path.resolve("/old")
const userGrant: PermissionRule = { action: "external_directory", resource: "/x/**", effect: "allow" }
const other: PermissionRule[] = [
{ action: "read", resource: "*.secret", effect: "deny" },
{ action: "external_directory", resource: "/shared/lib/private/*", effect: "deny" },
]
const updates = (acp: Wire) =>
acp.server.requests.filter((request) => request.method === "PATCH" && request.path.startsWith("/api/session/"))
describe("acp additional directories over the wire", () => {
test("session/new grants normalized unique directories other than cwd and lists them", async () => {
await using acp = await startWire()
await acp.initialize()
const created = await acp.request("session/new", {
cwd: "/workspace",
additionalDirectories: ["/shared/lib/", "/workspace", "/docs/../product-docs", "/shared/lib", "/workspace/"],
mcpServers: [],
})
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
permissions: [grant(sharedLib), grant(productDocs)],
metadata: { [key]: [sharedLib, productDocs] },
})
expect((await acp.request("session/list", { cwd: "/workspace" })).sessions).toEqual([
expect.objectContaining({
sessionId: created.sessionId,
additionalDirectories: [sharedLib, productDocs],
}),
])
})
test("grants both the written and real spelling of a symlinked root and drops links to cwd", async () => {
await using tmp = await tmpdir()
const root = await fs.realpath(tmp.path)
const cwd = path.join(root, "workspace")
const shared = path.join(root, "shared")
await Promise.all([fs.mkdir(cwd), fs.mkdir(shared)])
await Promise.all([
fs.symlink(shared, path.join(root, "shared-link")),
fs.symlink(cwd, path.join(root, "workspace-link")),
])
await using acp = await startWire()
await acp.initialize()
const created = await acp.request("session/new", {
cwd,
additionalDirectories: [path.join(root, "workspace-link"), path.join(root, "shared-link")],
mcpServers: [],
})
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
permissions: [grant(path.join(root, "shared-link")), grant(shared)],
metadata: { [key]: [path.join(root, "shared-link")] },
})
expect((await acp.request("session/list", { cwd })).sessions[0]?.additionalDirectories).toEqual([
path.join(root, "shared-link"),
])
})
test.each(["shared/lib", "", "/shared/*", "/shared/lib?"])(
"rejects %p before creating a session",
async (directory) => {
await using acp = await startWire()
await acp.initialize()
expect(
await rpcError(
acp.request("session/new", {
cwd: "/workspace",
additionalDirectories: ["/shared/ok", directory],
mcpServers: [],
}),
),
).toMatchObject({ code: -32602, data: { additionalDirectory: directory } })
expect(acp.server.sessions.size).toBe(0)
},
)
test("load and resume replace ACP grants and keep other session rules and metadata", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_saved", {
...makeSession("ses_saved"),
metadata: { host: "tui", [key]: [old] },
permissions: [grant(old), userGrant, ...other],
})
await acp.initialize()
await acp.request("session/load", {
cwd: "/workspace",
sessionId: "ses_saved",
additionalDirectories: ["/shared/lib", "/product-docs"],
mcpServers: [],
})
expect(acp.server.sessions.get("ses_saved")).toMatchObject({
metadata: { host: "tui", [key]: [sharedLib, productDocs] },
permissions: [grant(sharedLib), grant(productDocs), userGrant, ...other],
})
await acp.request("session/resume", {
cwd: "/workspace",
sessionId: "ses_saved",
additionalDirectories: ["/shared/lib", "/product-docs"],
})
expect(updates(acp)).toHaveLength(1)
await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_saved" })
expect(acp.server.sessions.get("ses_saved")?.metadata).toEqual({ host: "tui" })
expect(acp.server.sessions.get("ses_saved")?.permissions).toEqual([userGrant, ...other])
expect((await acp.request("session/list", { cwd: "/workspace" })).sessions[0]).not.toHaveProperty(
"additionalDirectories",
)
})
test("forks replace inherited grants with the requested list", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_source", {
...makeSession("ses_source"),
metadata: { [key]: [old] },
permissions: [grant(old), ...other],
})
await acp.initialize()
const plain = await acp.request("session/fork", { cwd: "/workspace", sessionId: "ses_source" })
const granted = await acp.request("session/fork", {
cwd: "/workspace",
sessionId: "ses_source",
additionalDirectories: ["/shared/lib"],
})
expect(acp.server.sessions.get(plain.sessionId)).toMatchObject({ metadata: {}, permissions: other })
expect(acp.server.sessions.get(granted.sessionId)).toMatchObject({
metadata: { [key]: [sharedLib] },
permissions: [grant(sharedLib), ...other],
})
expect(acp.server.sessions.get("ses_source")?.permissions).toEqual([grant(old), ...other])
})
test("leaves sessions alone without additional directories", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_saved", {
...makeSession("ses_saved"),
metadata: { host: "tui" },
permissions: [userGrant, ...other],
})
await acp.initialize()
const created = await acp.request("session/new", { cwd: "/workspace", mcpServers: [] })
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers: [] })
await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_saved", additionalDirectories: [] })
const create = acp.server.requests.find((request) => request.method === "POST" && request.path === "/api/session")
expect(create?.body).toEqual({ location: { directory: "/workspace" } })
expect(acp.server.sessions.get(created.sessionId)?.permissions).toBeUndefined()
expect(updates(acp)).toEqual([])
expect(
(await acp.request("session/list", { cwd: "/workspace" })).sessions.map(
(session) => session.additionalDirectories,
),
).toEqual([undefined, undefined])
})
})
+97 -13
View File
@@ -118,24 +118,73 @@ describe("acp catalog and config options over the wire", () => {
const session = await acp.newSession()
acp.server.catalog.models = [testModel, secondModel]
acp.server.catalog.agents = [buildAgent, planAgent, configured]
const set = (configId: string, value: string) =>
acp.request("session/set_config_option", { sessionId: session.sessionId, configId, value })
const initialModelReads = modelReads(acp)
const model = await acp.request("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
const model = await set("model", "test/second-model")
const reloadedModelReads = modelReads(acp)
const missingModel = await rpcError(set("model", "test/missing-model"))
const missingModelReads = modelReads(acp)
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
const reads = agentReads(acp)
const missing = await rpcError(
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "mode", value: "missing" }),
)
const missing = await rpcError(set("mode", "missing"))
expect(currentValue(model, "model")).toBe("test/second-model")
expect([reloadedModelReads, missingModelReads]).toEqual([initialModelReads + 1, initialModelReads + 2])
expect(missingModel).toMatchObject({ code: -32602, data: { modelId: "test/missing-model" } })
expect(acp.server.selections).toContainEqual({ sessionID: session.sessionId, agent: "copilot-build" })
expect(missing).toMatchObject({ code: -32602, data: { mode: "missing" } })
expect(agentReads(acp)).toBeGreaterThan(reads)
})
test.each([
[
"a sibling session closes",
async (acp: Wire) => {
const closed = await acp.newSession()
const open = await acp.newSession()
await acp.request("session/close", { sessionId: closed.sessionId })
await initialCommands(acp, open.sessionId, 1)
return open.sessionId
},
],
...(["session/load", "session/resume"] as const).map(
(method) =>
[
`${method} re-attaches the session`,
async (acp: Wire) => {
const session = await acp.newSession()
const params = { cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] }
await acp.request(method, params)
await acp.request(method, params)
await initialCommands(acp, session.sessionId, 3)
return session.sessionId
},
] as const,
),
])("pushes exactly one update per catalog change after %s", async (_, setup) => {
await using acp = await startWire()
acp.server.catalog.models = [testModel]
await acp.initialize()
const sessionId = await setup(acp)
const since = acp.updates.length
await change(acp, sessionId, "config_option_update", () => {
acp.server.catalog.models = [testModel, secondModel]
acp.server.send(ephemeralEvent("model.updated", {}))
})
await change(acp, sessionId, "available_commands_update", () => {
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
})
expect(updateKinds(acp, since)).toEqual([
[sessionId, "config_option_update"],
[sessionId, "available_commands_update"],
])
})
test.each(["empty", "missing the default"])(
"retries when the model list is %s but the default is ready",
async (initial) => {
@@ -212,25 +261,60 @@ describe("acp catalog and config options over the wire", () => {
await using acp = await startSession()
const advertised = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
acp.server.catalog.commands = [reviewCommand, { name: "compact", description: "Server compact" }]
acp.server.catalog.commands = [
reviewCommand,
{ name: "compact", description: "Server compact" },
{ name: "ship", description: "Ship it" },
]
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 }, () => ({
expect([advertised, replaced].map((item) => item.update)).toEqual([
{
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "compact", description: "Compact the session" },
],
})),
)
},
{
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "review", description: "Review changes" },
{ name: "ship", description: "Ship it" },
{ name: "compact", description: "Compact the session" },
],
},
])
expect(compacted.stopReason).toBe("end_turn")
expect(acp.server.submissions.map((item) => item.kind)).toEqual(["compact"])
})
})
// Each change waits on a catalog reload over HTTP, so a stray update for an earlier change lands before the next one.
async function change(acp: Wire, sessionId: string, kind: string, trigger: () => void) {
const seen = acp.updates.filter((item) => item.sessionId === sessionId && item.update.sessionUpdate === kind).length
trigger()
await acp.until(
() =>
acp.updates.filter((item) => item.sessionId === sessionId && item.update.sessionUpdate === kind).length > seen,
kind,
)
}
// Each attach sends its commands after the response, so later counts start once every attach has sent them.
function initialCommands(acp: Wire, sessionId: string, attaches: number) {
return acp.until(
() => acp.updates.filter((item) => item.sessionId === sessionId && commandNames(item)).length === attaches,
"initial commands",
)
}
function updateKinds(acp: Wire, since: number) {
return acp.updates.slice(since).map((item) => [item.sessionId, item.update.sessionUpdate])
}
function commandNames(item: SessionNotification) {
if (item.update.sessionUpdate !== "available_commands_update") return undefined
return item.update.availableCommands.map((command) => command.name)
+271
View File
@@ -0,0 +1,271 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import { SessionMessage } from "@opencode/schema/session-message"
import { Schema } from "effect"
import {
assistantMessage,
childCreated,
durableEvent,
ephemeralEvent,
makeSession,
startSession,
startWire,
stepEnded,
succeeded,
textDelta,
turn,
} from "./wire-fixture"
const summary = "Summary of the earlier conversation"
const providerError = { type: "provider.error", message: "summary request failed", status: 500 }
const decodeCompact = Schema.decodeUnknownSync(Schema.Struct({ id: Schema.String }))
describe("acp compaction markers over the wire", () => {
test("marks a /compact turn without forwarding the summary text", async () => {
const compacted = await compactTurn((sessionID, id) => [
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
])
await using acp = compacted.acp
expect(turnUpdates(acp.updates)).toEqual([
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
marker(acp.sessionId, { status: "completed", messageId: compacted.id, reason: "manual" }),
])
expect(compacted.response.stopReason).toBe("end_turn")
})
test("marks a failed /compact turn with the full compaction error", async () => {
const compacted = await compactTurn((sessionID, id) => [
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
durableEvent("session.compaction.failed", { sessionID, reason: "manual", inputID: id, error: providerError }),
])
await using acp = compacted.acp
expect(turnUpdates(acp.updates)).toEqual([
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
marker(acp.sessionId, { status: "failed", messageId: compacted.id, reason: "manual", error: providerError }),
])
expect(compacted.response.stopReason).toBe("end_turn")
})
test("marks an automatic compaction between steps with the ID its replayed message gets", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
textDelta(sessionID, "msg_before", "before"),
stepEnded(sessionID, "msg_before"),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
textDelta(sessionID, "msg_after", "after"),
stepEnded(sessionID, "msg_after"),
),
})
using events = await watchEvents(acp.server.url)
const response = await acp.prompt(acp.sessionId, "hello")
const messageId = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
chunk(acp.sessionId, "msg_before", "before"),
marker(acp.sessionId, { status: "started", messageId, reason: "auto" }),
marker(acp.sessionId, { status: "completed", messageId, reason: "auto" }),
chunk(acp.sessionId, "msg_after", "after"),
])
expect(response.stopReason).toBe("end_turn")
const live = turnUpdates(acp.updates)
acp.server.messages.set(acp.sessionId, [
{
id: messageId,
type: "compaction",
status: "completed",
reason: "auto",
summary,
recent: "",
time: { created: 1 },
},
])
await acp.request("session/load", { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] })
expect(turnUpdates(acp.updates).slice(live.length)).toEqual([
marker(acp.sessionId, { status: "completed", messageId, reason: "auto" }),
])
})
test("marks automatic compaction failures before and after the compaction starts", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
durableEvent("session.compaction.failed", {
sessionID,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
}),
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const unstarted = await events.messageID("session.compaction.failed")
const started = await events.messageID("session.compaction.started")
expect(turnUpdates(acp.updates)).toEqual([
marker(acp.sessionId, {
status: "failed",
messageId: unstarted,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
}),
marker(acp.sessionId, { status: "started", messageId: started, reason: "auto" }),
marker(acp.sessionId, { status: "failed", messageId: started, reason: "auto", error: providerError }),
])
})
test("projects child session compaction markers onto the parent turn", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore"),
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
durableEvent("session.compaction.ended", {
sessionID: "ses_child",
reason: "auto",
text: summary,
recent: "",
}),
succeeded("ses_child"),
),
})
using events = await watchEvents(acp.server.url)
await acp.prompt(acp.sessionId, "hello")
const messageId = await events.messageID("session.compaction.started")
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
])
})
test("replays settled compactions at their position on session/load", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
acp.server.messages.set("ses_compacted", compactedHistory())
await acp.initialize()
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
expect(turnUpdates(acp.updates)).toEqual([
{
sessionId: "ses_compacted",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg_user",
content: { type: "text", text: "hello" },
},
},
marker("ses_compacted", {
status: "failed",
messageId: "msg_compaction_failed",
reason: "auto",
error: providerError,
}),
marker("ses_compacted", { status: "completed", messageId: "msg_compaction", reason: "manual" }),
chunk("ses_compacted", "msg_after", "after"),
])
})
})
// Holds the compact response so the test can publish the turn's events while the request is in flight.
async function compactTurn(events: (sessionID: string, id: string) => OpenCodeEvent[]) {
const held = Promise.withResolvers<Response>()
const acp = await startSession({ fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined) })
const response = acp.prompt(acp.sessionId, "/compact")
const request = await acp.until(
() => acp.server.requests.find((item) => item.path.endsWith("/compact")),
"compact request",
)
const id = decodeCompact(request.body).id
acp.server.send(...turn(acp.sessionId, id, ...events(acp.sessionId, id)))
held.resolve(Response.json({ data: {} }))
return { acp, id, response: await response }
}
// Core derives an automatic compaction's message ID from the event ID the server stamps on publish.
async function watchEvents(url: string) {
const controller = new AbortController()
const stream = OpenCode.make({ baseUrl: url }).event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
await stream.next()
return {
async messageID(type: OpenCodeEvent["type"]) {
while (true) {
const next = await stream.next()
if (next.done) throw new Error(`event stream ended before ${type}`)
if (next.value.type === type) return SessionMessage.ID.fromEvent(Event.ID.make(next.value.id))
}
},
[Symbol.dispose]: () => controller.abort(),
}
}
function marker(sessionId: string, value: Record<string, unknown>): SessionNotification {
return { sessionId, update: { sessionUpdate: "session_info_update", _meta: { "opencode/compaction": value } } }
}
function chunk(sessionId: string, messageId: string, text: string): SessionNotification {
return { sessionId, update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text } } }
}
function turnUpdates(updates: readonly SessionNotification[]) {
return updates.filter(
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
)
}
function compactedHistory(): SessionMessageInfo[] {
return [
{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } },
{
id: "msg_compaction_failed",
type: "compaction",
status: "failed",
reason: "auto",
error: providerError,
time: { created: 2 },
},
{
id: "msg_compaction",
type: "compaction",
status: "completed",
reason: "manual",
summary,
recent: "",
time: { created: 3 },
},
{
id: "msg_compaction_running",
type: "compaction",
status: "running",
reason: "auto",
summary: "",
recent: "",
time: { created: 4 },
},
assistantMessage("msg_after", { time: { created: 5, completed: 6 }, content: [{ type: "text", text: "after" }] }),
]
}
+6
View File
@@ -80,6 +80,12 @@ describe("acp content conversion", () => {
])
})
test("resource_link to another scheme becomes a markdown link", () => {
expect(contentBlockToParts({ type: "resource_link", uri: "https://example.com/spec", name: "spec" })).toEqual([
{ type: "text", text: "[spec](https://example.com/spec)" },
])
})
test("resource_link zed path becomes a file URL part", () => {
expect(
contentBlockToParts({
+468
View File
@@ -0,0 +1,468 @@
import { describe, expect, test } from "bun:test"
import type { AnyMessage, AnyRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk"
import { ACPElicitation } from "../../src/acp/elicitation"
import {
childCreated,
delivered,
durableEvent,
ephemeralEvent,
interrupted,
startSession,
succeeded,
textDelta,
toolStarted,
toolSucceeded,
} from "./wire-fixture"
const questions = (sessionID: string, id = "frm_question", tool = "call_question") =>
ephemeralEvent("form.created", {
form: {
id,
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "msg_tools", id: tool } },
fields: [
{
key: "q0",
title: "Runtime",
description: "Which runtime?",
type: "string",
options: [
{ value: "Bun", label: "Bun", description: "Fast" },
{ value: "Node", label: "Node", description: "Stable" },
],
custom: true,
},
{
key: "q1",
title: "Goals",
description: "What matters?",
type: "multiselect",
options: [{ value: "Fast", label: "Fast", description: "Speed" }],
custom: true,
},
],
},
})
const capable = { childSessionUpdates: false, formElicitation: true }
const form = (
fields: ACPElicitation.AskedForm["fields"],
metadata: ACPElicitation.AskedForm["metadata"] = { kind: "question" },
) => ({ id: "frm_test", sessionID: "ses_test", title: "Test", metadata, fields })
const accept = (content: Record<string, string | number | boolean | string[]>): CreateElicitationResponse => ({
action: "accept",
content,
})
const pendingUntilAborted = (_request: unknown, signal: AbortSignal) =>
new Promise<CreateElicitationResponse>((resolve) => {
signal.addEventListener("abort", () => resolve({ action: "cancel" }), { once: true })
})
const firstElicitationCancel = (received: readonly AnyMessage[]): AnyMessage => {
const asked = received.find(
(message): message is AnyRequest =>
"method" in message && "id" in message && message.method === "elicitation/create",
)
return { jsonrpc: "2.0", method: "$/cancel_request", params: { requestId: asked?.id } }
}
describe("acp elicitation mapping", () => {
test("maps every representable field type", () => {
expect(
ACPElicitation.requestedSchema(
form([
{
key: "email",
title: "Email",
description: "Work address",
type: "string",
format: "email",
maxLength: 80,
pattern: ".+@.+",
placeholder: "you@example.com",
default: "a@b.co",
required: true,
},
{ key: "name", type: "string", minLength: 2 },
{
key: "plan",
type: "string",
options: [
{ value: "pro", label: "Pro", description: "Paid" },
{ value: "free", label: "Free" },
],
default: "free",
},
{ key: "ratio", type: "number", minimum: 0, maximum: 1, default: 0.5 },
{ key: "count", type: "integer", minimum: 1, required: true },
{ key: "confirm", type: "boolean", default: false },
{
key: "tags",
type: "multiselect",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
maxItems: 2,
default: ["a"],
required: true,
},
{ key: "server", type: "string", format: "uri", hidden: true, default: "https://example.com" },
]),
capable,
),
).toEqual({
type: "object",
properties: {
email: {
type: "string",
title: "Email",
description: "Work address",
format: "email",
minLength: 1,
maxLength: 80,
pattern: ".+@.+",
default: "a@b.co",
},
name: { type: "string", minLength: 2 },
plan: {
type: "string",
oneOf: [
{ const: "pro", title: "Pro", description: "Paid" },
{ const: "free", title: "Free" },
],
default: "free",
},
ratio: { type: "number", minimum: 0, maximum: 1, default: 0.5 },
count: { type: "integer", minimum: 1 },
confirm: { type: "boolean", default: false },
tags: {
type: "array",
items: {
anyOf: [
{ const: "a", title: "A" },
{ const: "b", title: "B" },
],
},
minItems: 1,
maxItems: 2,
default: ["a"],
},
},
required: ["email", "count", "tags"],
})
})
test("adds a free-text property next to options that accept a custom answer", () => {
expect(
ACPElicitation.requestedSchema(
form([
{
key: "q0",
title: "Runtime",
type: "string",
options: [{ value: "Bun", label: "Bun" }],
custom: true,
maxLength: 20,
},
{ key: "q1", type: "multiselect", options: [{ value: "Fast", label: "Fast" }], custom: true },
]),
capable,
)?.properties,
).toEqual({
q0: { type: "string", title: "Runtime", oneOf: [{ const: "Bun", title: "Bun" }] },
q0_custom: { type: "string", title: "Runtime (other)", description: "Type your own answer", maxLength: 20 },
q1: { type: "array", items: { anyOf: [{ const: "Fast", title: "Fast" }] } },
q1_custom: { type: "string", title: "q1 (other)", description: "Add your own answer" },
})
})
test("cancels forms from unsupported clients, unknown flows, and credential-looking fields", () => {
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
expect(ACPElicitation.requestedSchema(form(fields), { ...capable, formElicitation: false })).toBeUndefined()
expect(ACPElicitation.requestedSchema(form(fields, { kind: "mcp-elicitation" }), capable)).toBeUndefined()
expect(ACPElicitation.requestedSchema(form(fields, {}), capable)).toBeUndefined()
expect(ACPElicitation.requestedSchema(form(fields, { kind: "websearch.provider" }), capable)).toBeDefined()
const credentials: Array<ACPElicitation.AskedForm["fields"]> = [
[{ key: "api_key", type: "string" }],
[{ key: "q0", title: "GitHub token", type: "string" }],
[{ key: "q0", title: "Password", type: "string", hidden: true, default: "" }],
]
expect(credentials.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
credentials.map(() => undefined),
)
})
test("cancels forms it cannot represent faithfully", () => {
const options = [{ value: "a", label: "A" }]
const unrepresentable: Array<ACPElicitation.AskedForm["fields"]> = [
[
{ key: "mode", type: "boolean" },
{ key: "detail", type: "string", when: [{ key: "mode", op: "eq", value: true }] },
],
[
{ key: "mode", type: "boolean" },
{ key: "detail", type: "string", hidden: true, default: "x", when: [{ key: "mode", op: "eq", value: true }] },
],
[{ key: "login", type: "external", url: "https://example.com/login" }],
[{ key: "server", type: "string", hidden: true, required: true }],
[{ key: "pick", type: "string", options, custom: true, required: true }],
[{ key: "pick", type: "multiselect", options, custom: true, maxItems: 1 }],
[{ key: "pick", type: "string", options, default: "b" }],
[{ key: "pick", type: "multiselect", options, default: ["a", "b"] }],
[
{ key: "pick", type: "string", options, custom: true },
{ key: "pick_custom", type: "string" },
],
]
expect(unrepresentable.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
unrepresentable.map(() => undefined),
)
expect(
ACPElicitation.requestedSchema(
form([
{ key: "name", type: "string" },
{ key: "pick", type: "string", options, default: "b", hidden: true },
]),
capable,
)?.properties,
).toEqual({ name: { type: "string" } })
})
test("maps an accepted response back to answers", () => {
const options = [{ value: "a", label: "A" }]
const fields: ACPElicitation.AskedForm["fields"] = [
{ key: "single", type: "string", options, custom: true },
{ key: "multi", type: "multiselect", options, custom: true },
{ key: "blank", type: "string", options, custom: true },
{ key: "count", type: "integer" },
{ key: "server", type: "string", hidden: true, default: "https://example.com" },
{ key: "region", type: "string", hidden: true },
]
expect(
ACPElicitation.answer(
form(fields),
accept({
single: "a",
single_custom: "typed",
multi: ["a"],
multi_custom: "extra",
blank: "a",
blank_custom: " ",
count: 3,
server: "https://other.example.com",
unknown: true,
}),
),
).toEqual({
single: "typed",
multi: ["a", "extra"],
blank: "a",
count: 3,
server: "https://example.com",
})
expect(ACPElicitation.answer(form(fields), accept({ multi_custom: "only" }))).toEqual({
multi: ["only"],
server: "https://example.com",
})
expect(ACPElicitation.answer(form(fields), { action: "accept" })).toEqual({ server: "https://example.com" })
})
test("has no answer unless the user accepted valid content", () => {
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
expect(ACPElicitation.answer(form(fields), { action: "decline" })).toBeUndefined()
expect(ACPElicitation.answer(form(fields), { action: "cancel" })).toBeUndefined()
expect(ACPElicitation.answer(form(fields), { action: "_custom" })).toBeUndefined()
expect(
ACPElicitation.answer(form(fields), { action: "accept", content: { name: { nested: true } } }),
).toBeUndefined()
})
})
describe("acp elicitation over the wire", () => {
test("answers a question form through elicitation and continues the turn", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_question", "question"),
questions(sessionID),
],
elicitation: () => accept({ q0: "Bun", q1: ["Fast"], q1_custom: "Small" }),
onFormReply: ({ sessionID, formID }) => [
ephemeralEvent("form.replied", { sessionID, id: formID, answer: {} }),
toolSucceeded(sessionID, "call_question", {}, "answered"),
textDelta(sessionID, "msg_after", "thanks"),
succeeded(sessionID),
],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toMatchObject([
{ mode: "form", sessionId: acp.sessionId, toolCallId: "call_question", message: "Questions" },
])
expect(acp.server.repliedForms).toEqual([
{ sessionID: acp.sessionId, formID: "frm_question", answer: { q0: "Bun", q1: ["Fast", "Small"] } },
])
expect(acp.server.cancelledForms).toEqual([])
expect(acp.updates.some((item) => item.update.sessionUpdate === "agent_message_chunk")).toBe(true)
})
test("cancels the form when the client declines, cancels, fails, or sends a wrong value type", async () => {
const responses: Array<() => CreateElicitationResponse> = [
() => ({ action: "decline" }),
() => ({ action: "cancel" }),
() => {
throw new Error("elicitation UI failed")
},
() => ({ action: "accept", content: { q0: { nested: true } } }),
]
const ids = ["frm_decline", "frm_cancel", "frm_fail", "frm_invalid"]
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...ids.map((form) => questions(sessionID, form))],
elicitation: () => responses[acp.elicitations.length - 1](),
onFormCancel: ({ sessionID }) => (acp.server.cancelledForms.length === ids.length ? [succeeded(sessionID)] : []),
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toHaveLength(ids.length)
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(ids)
expect(acp.server.repliedForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("cancels forms outside the allowed flows without asking", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_plugin",
sessionID,
title: "Plugin",
metadata: { kind: "plugin" },
fields: [{ key: "name", type: "string" }],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
})
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
questions(sessionID, "frm_pending"),
questions(sessionID, "frm_queued"),
],
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
elicitation: pendingUntilAborted,
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
await acp.until(() => acp.server.cancelledForms.length === 2, "form cancellation")
expect(acp.elicitations).toHaveLength(1)
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(["frm_pending", "frm_queued"])
expect(acp.server.repliedForms).toEqual([])
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
})
test("withdraws an elicitation for a form settled elsewhere and moves on to the next ask", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID, "frm_elsewhere")],
elicitation: (request, signal) =>
acp.elicitations.length === 1 ? pendingUntilAborted(request, signal) : accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID)],
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
acp.server.send(
ephemeralEvent("form.replied", { sessionID: acp.sessionId, id: "frm_elsewhere", answer: { q0: "Bun" } }),
questions(acp.sessionId, "frm_next"),
)
expect((await prompt).stopReason).toBe("end_turn")
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
expect(acp.elicitations).toHaveLength(2)
expect(acp.server.repliedForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_next", answer: { q0: "Node" } }])
expect(acp.server.cancelledForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("leaves the session alone when the user's answer arrives after the form settled", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID)],
elicitation: () => accept({ q0: "Bun" }),
fetch: (request) => {
if (!request.path.endsWith("/form/frm_question/reply")) return undefined
acp.server.send(succeeded(acp.sessionId))
return Response.json(
{ _tag: "FormAlreadySettledError", id: "frm_question", message: "Form already settled: frm_question" },
{ status: 409 },
)
},
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.server.requests.filter((request) => request.path.endsWith("/reply"))).toHaveLength(1)
expect(acp.server.cancelledForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("prefixes a foreground child form's tool call and message with the child", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
childCreated("ses_child", sessionID, "Review code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
questions("ses_child", "frm_child", "call_child"),
],
elicitation: () => accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toMatchObject([
{ sessionId: acp.sessionId, toolCallId: "ses_child:call_child", message: "Review code: Questions" },
])
expect(acp.server.repliedForms).toEqual([{ sessionID: "ses_child", formID: "frm_child", answer: { q0: "Node" } }])
})
test("omits the tool call when the child's tool calls only reach the client as child updates", async () => {
await using acp = await startSession({
capabilities: { elicitation: true, childSessionUpdates: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
childCreated("ses_child", sessionID, "Review code"),
questions("ses_child", "frm_child", "call_child"),
],
elicitation: () => accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toHaveLength(1)
expect(acp.elicitations[0]).toMatchObject({ sessionId: acp.sessionId, message: "Review code: Questions" })
expect(acp.elicitations[0]).not.toHaveProperty("toolCallId")
})
})
+23 -1
View File
@@ -12,9 +12,12 @@ describe("acp errors", () => {
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
new ACPError.InvalidEffortError({ effort: "extreme" }),
new ACPError.InvalidModeError({ mode: "turbo" }),
new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" }),
]
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602])
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
-32602, -32602, -32602, -32602, -32602, -32602,
])
})
test("includes safe validation details", () => {
@@ -79,6 +82,25 @@ describe("acp error boundary over the wire", () => {
])
})
test("maps rejected prompt submissions to invalid params with the server's message", async () => {
await using acp = await startSession({
fetch: (request) =>
request.method === "POST" && request.path.endsWith("/prompt")
? Response.json(
{ _tag: "InvalidRequestError", message: "File not readable: missing.png", field: "files" },
{ status: 400 },
)
: undefined,
})
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toEqual({
code: -32602,
message: "Invalid params: File not readable: missing.png",
data: { field: "files" },
})
expect(acp.logs).toEqual([])
})
test("reports an unavailable server once the server stops", async () => {
await using acp = await startSession()
await acp.server.stop()
@@ -17,6 +17,20 @@ describe("acp lifecycle subprocess", () => {
expect(await acp.close()).toBe(0)
}, 60_000)
test("an incoming message over the size limit exits with an error", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
const [code] = await Promise.all([
acp.exited,
// The agent stops reading partway through the line, so the write may fail.
acp.notify("opencode/oversized", { data: "a".repeat(32 * 1024 * 1024) }).catch(() => undefined),
])
await acp[Symbol.asyncDispose]()
expect(code).toBe(1)
expect(acp.stderr()).toContain("opencode acp: incoming message exceeded the 32 MiB limit\n")
}, 60_000)
test("close capability and close request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
+129
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import { Cause } from "effect"
import fs from "node:fs/promises"
import path from "node:path"
import { tmpdir } from "../fixture/tmpdir"
@@ -132,6 +133,33 @@ describe("acp permissions over the wire", () => {
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
})
test("asks for a background child's permission after the parent turn ends without the child capability", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => turn(sessionID, id, childCreated("ses_background", sessionID, "Research")),
permission: allowOnce,
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
acp.server.send(
durableEvent("session.execution.started", { sessionID: "ses_background" }),
permissionAsked("ses_background", "perm_background", {
action: "read",
metadata: { path: "/workspace/notes.md" },
}),
)
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
expect(acp.permissions).toMatchObject([
{
sessionId: acp.sessionId,
toolCall: { toolCallId: "ses_background:perm_background", title: "Research: /workspace/notes.md" },
},
])
expect(acp.server.replies).toEqual([
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
])
})
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
@@ -159,6 +187,23 @@ describe("acp permissions over the wire", () => {
])
})
test("logs a failed server reply and still answers later asks", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(sessionID, id, permissionAsked(sessionID, "perm_failed"), permissionAsked(sessionID, "perm_next")),
fetch: (request) =>
request.path.endsWith("/permission/perm_failed/reply") ? new Response(null, { status: 500 }) : undefined,
permission: allowOnce,
})
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_failed", "perm_next"])
expect(decisions(acp)).toEqual([["perm_next", "once"]])
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
{ message: ["ACP permission reply failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
])
})
test("serializes permission requests and replies within one session", async () => {
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
await using acp = await startSession({
@@ -185,6 +230,34 @@ describe("acp permissions over the wire", () => {
])
})
test("keeps streaming other children while one child's permission is pending", async () => {
const release = Promise.withResolvers<RequestPermissionResponse>()
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
turn(
sessionID,
id,
childCreated("ses_a", sessionID, "A"),
childCreated("ses_b", sessionID, "B"),
permissionAsked("ses_a", "perm_a"),
textDelta("ses_b", "msg_b", "still streaming"),
succeeded("ses_b"),
succeeded("ses_a"),
),
permission: () => release.promise,
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk", "child B's chunk")
await acp.until(() => acp.permissions.length === 1, "child A's permission")
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["ses_a:perm_a"])
expect(acp.server.replies).toEqual([])
release.resolve({ outcome: { outcome: "selected", optionId: "once" } })
expect((await prompt).stopReason).toBe("end_turn")
expect(decisions(acp)).toEqual([["perm_a", "once"]])
})
test("does not let one session's blocked permission stall another session", async () => {
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
await using acp = await startWire({ onPrompt: () => undefined, permission: () => releaseBlocked.promise })
@@ -251,6 +324,32 @@ describe("acp permissions over the wire", () => {
params: { requestId: asked?.id },
})
})
test("rejects asks queued behind a cancelled one without sending them to the client", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
permissionAsked(sessionID, "perm_pending"),
permissionAsked(sessionID, "perm_queued"),
],
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
permission: (_request, signal) =>
new Promise((resolve) => {
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
}),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.permissions.length === 1, "permission request")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_pending"])
expect(decisions(acp)).toEqual([
["perm_pending", "reject"],
["perm_queued", "reject"],
])
})
})
describe("acp edit previews over the wire", () => {
@@ -346,6 +445,36 @@ describe("acp edit previews over the wire", () => {
})
})
test("asks without previews when a patch does not apply to the current file", async () => {
await using dir = await tmpdir()
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
const patchText = ["*** Begin Patch", "*** Update File: first.ts", "@@", "-one", "+two", "*** End Patch"].join("\n")
await using acp = await startWire({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_patch", "patch"),
toolCalled(sessionID, "call_patch", { patchText }),
permissionAsked(sessionID, "perm_patch", {
action: "edit",
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
}),
],
onPermissionReply: ({ sessionID }) => [succeeded(sessionID)],
permission: allowOnce,
})
await acp.initialize()
const session = await acp.newSession(dir.path)
await acp.prompt(session.sessionId, "hello")
expect(acp.permissions[0]?.toolCall).toMatchObject({
kind: "edit",
locations: [{ path: path.join(dir.path, "first.ts") }],
})
expect(acp.permissions[0]?.toolCall.content).toBeUndefined()
expect(decisions(acp)).toEqual([["perm_patch", "once"]])
})
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")
@@ -56,7 +56,22 @@ describe("acp prompt content subprocess", () => {
}),
)
const missing = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this missing file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(fixture.home, "missing.md")).href,
name: "missing.md",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(3)
expect(missing.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(4)
}, 60_000)
})
+156 -2
View File
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { StopReason } from "@agentclientprotocol/sdk"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { Schema } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { tmpdir } from "../fixture/tmpdir"
import {
childCreated,
delivered,
@@ -14,6 +19,9 @@ import {
succeeded,
textDelta,
tokens,
toolCalled,
toolFailed,
toolStarted,
turn,
type Wire,
type WireOptions,
@@ -76,12 +84,15 @@ describe("acp prompt turns over the wire", () => {
})
test("submits assistant-only context as synthetic input before the visible prompt", async () => {
await using dir = await tmpdir()
const readme = pathToFileURL(path.join(dir.path, "README.md")).href
await Bun.write(path.join(dir.path, "README.md"), "# readme\n")
await using acp = await startSession()
await acp.prompt(acp.sessionId, [
{ type: "text", text: "visible" },
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
{ type: "resource_link", uri: "file:///workspace/README.md", name: "README.md", mimeType: "text/markdown" },
{ type: "resource_link", uri: readme, name: "README.md", mimeType: "text/markdown" },
])
expect(acp.server.submissions).toEqual([
@@ -96,7 +107,37 @@ describe("acp prompt turns over the wire", () => {
expect.objectContaining({
kind: "prompt",
text: "visible",
files: [{ uri: "file:///workspace/README.md", name: "README.md" }],
files: [{ uri: readme, name: "README.md" }],
}),
])
})
test("attaches readable file links and references unreadable ones in place", async () => {
await using dir = await tmpdir()
const file = pathToFileURL(path.join(dir.path, "notes.md")).href
const folder = pathToFileURL(path.join(dir.path, "src")).href
const missing = pathToFileURL(path.join(dir.path, "missing.md")).href
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
await mkdir(path.join(dir.path, "src"))
await using acp = await startSession()
const response = await acp.prompt(acp.sessionId, [
{ type: "text", text: "compare" },
{ type: "resource_link", uri: file, name: "notes.md" },
{ type: "resource_link", uri: missing, name: "missing.md" },
{ type: "resource_link", uri: folder, name: "src" },
{ type: "text", text: "please" },
])
expect(response.stopReason).toBe("end_turn")
expect(acp.server.submissions).toEqual([
expect.objectContaining({
kind: "prompt",
text: `compare\n[missing.md](${missing})\nplease`,
files: [
{ uri: file, name: "notes.md" },
{ uri: folder, name: "src" },
],
}),
])
})
@@ -293,6 +334,20 @@ describe("acp prompt turns over the wire", () => {
expect(acp.server.interrupts).toContain(acp.sessionId)
})
test("session/cancel before admission returns interrupts the session exactly once", async () => {
await using acp = await startSession({
onPrompt: ({ signal }) =>
new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.server.submissions.length === 1, "prompt submission")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
expect(acp.server.interrupts).toEqual([acp.sessionId])
})
test("session/cancel mid-turn interrupts the session once, returns cancelled, and keeps it usable", async () => {
await using acp = await startSession(held)
@@ -305,6 +360,91 @@ describe("acp prompt turns over the wire", () => {
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
})
test("session/cancel forwards the server's wind-down before resolving cancelled", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_sleep", "shell"),
toolCalled(sessionID, "call_sleep", { command: "sleep 60" }),
textDelta(sessionID, "msg_held", "working"),
],
onInterrupt: ({ sessionID }) => [
toolFailed(sessionID, "call_sleep", { error: { type: "aborted", message: "interrupted" } }),
durableEvent("session.step.failed", {
sessionID,
assistantMessageID: "msg_held",
error: { type: "aborted", message: "interrupted" },
cost: 0,
tokens: { ...tokens(), input: 30, output: 3 },
}),
interrupted(sessionID),
],
})
const prompt = acp.prompt(acp.sessionId, "hello")
await admitted(acp, acp.sessionId)
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toEqual({
stopReason: "cancelled",
usage: { inputTokens: 30, outputTokens: 3, totalTokens: 33 },
_meta: {},
})
expect(receivedBeforeResponse(acp)).toEqual(
expect.arrayContaining([
expect.objectContaining({ sessionUpdate: "tool_call_update", toolCallId: "call_sleep", status: "failed" }),
expect.objectContaining({ sessionUpdate: "usage_update", used: 33 }),
]),
)
expect(acp.server.interrupts).toEqual([acp.sessionId])
})
test("stops waiting for a wind-down that never ends and fails the tools left running", async () => {
await using acp = await startSession({
cancelDrainTimeout: "50 millis",
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_stuck", "shell"),
toolCalled(sessionID, "call_stuck", { command: "sleep 60" }),
textDelta(sessionID, "msg_held", "working"),
],
})
const prompt = acp.prompt(acp.sessionId, "hello")
await admitted(acp, acp.sessionId)
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toEqual({ stopReason: "cancelled", _meta: {} })
expect(receivedBeforeResponse(acp)).toContainEqual(
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId: "call_stuck",
status: "failed",
rawOutput: expect.objectContaining({ error: "Cancelled" }),
}),
)
})
test("session/close interrupts a slash command still running after its prompt ended", async () => {
await using acp = await startSession()
expect((await acp.prompt(acp.sessionId, "/review now")).stopReason).toBe("end_turn")
expect(acp.server.interrupts).toEqual([])
await acp.request("session/close", { sessionId: acp.sessionId })
expect(acp.server.interrupts).toEqual([acp.sessionId])
})
test("fails the prompt as server unavailable when the event stream ends mid-turn", async () => {
await using acp = await startSession(held)
const prompt = acp.prompt(acp.sessionId, "hold")
await admitted(acp, acp.sessionId)
acp.server.closeEvents()
expect(await rpcError(prompt)).toMatchObject({ code: -32603, data: { errorName: "ServerUnavailable" } })
})
test("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
await using acp = await startSession(held)
const controller = new AbortController()
@@ -469,6 +609,20 @@ async function admitted(acp: Wire, sessionId: string) {
await acp.request("session/set_mode", { sessionId, modeId: "build" })
}
const isCancelledResponse = Schema.is(
Schema.Struct({ result: Schema.Struct({ stopReason: Schema.Literal("cancelled") }) }),
)
// Session updates the client received before the cancelled prompt response.
function receivedBeforeResponse(acp: Wire) {
const response = acp.received.findIndex(isCancelledResponse)
expect(response).toBeGreaterThan(-1)
const count = acp.received
.slice(0, response)
.filter((message) => "method" in message && message.method === "session/update").length
return acp.updates.slice(0, count).map((item) => item.update)
}
function retryScheduled(sessionID: string, attempt: number, at: number) {
return durableEvent("session.retry.scheduled", {
sessionID,
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import { currentValue, selectValues } from "./select-options"
import {
durableEvent,
ephemeralEvent,
secondModel,
startSession,
startWire,
stepEnded,
testModel,
turn,
type Wire,
} from "./wire-fixture"
describe("acp follows model and agent selections from other clients", () => {
test("pushes an external model switch with its variant and uses it for the next prompt", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => turn(sessionID, id, stepEnded(sessionID, "msg_assistant")),
})
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id, variant: "low" },
previous: { providerID: "test", id: "test-model" },
}),
)
const [options] = await optionUpdates(acp, acp.sessionId, 1)
await acp.prompt(acp.sessionId, "hello")
expect(values(options)).toEqual({ model: "test/second-model", effort: "low", mode: "build" })
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
update: { size: secondModel.limit.context },
})
})
test("shows the default effort when the external switch carries no variant", async () => {
await using acp = await startSession()
await acp.request("session/set_config_option", { sessionId: acp.sessionId, configId: "effort", value: "high" })
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id },
}),
)
expect(values((await optionUpdates(acp, acp.sessionId, 1))[0])).toEqual({
model: "test/second-model",
effort: "default",
mode: "build",
})
})
test("pushes an external agent switch as the mode", async () => {
await using acp = await startSession()
acp.server.send(
durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan", previous: "build" }),
)
expect(values((await optionUpdates(acp, acp.sessionId, 1))[0])).toEqual({
model: "test/test-model",
effort: "default",
mode: "plan",
})
})
test("does not echo its own model and mode switches", async () => {
await using acp = await startSession({
// The server publishes the selection event before it answers the switch.
fetch: (request) => {
if (request.method !== "POST") return undefined
if (request.path.endsWith("/model"))
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id, variant: "medium" },
}),
)
if (request.path.endsWith("/agent"))
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan" }))
return undefined
},
})
const set = (configId: string, value: string) =>
acp.request("session/set_config_option", { sessionId: acp.sessionId, configId, value })
await set("model", "test/second-model/medium")
await acp.request("session/set_mode", { sessionId: acp.sessionId, modeId: "plan" })
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "build" }))
const updates = await optionUpdates(acp, acp.sessionId, 1)
expect(updates.map(values)).toEqual([{ model: "test/second-model", effort: "medium", mode: "build" }])
})
test("ignores selections for other sessions", async () => {
await using acp = await startSession()
const other = await acp.newSession()
acp.server.send(durableEvent("session.agent.selected", { sessionID: "ses_unattached", agent: "plan" }))
acp.server.send(durableEvent("session.agent.selected", { sessionID: other.sessionId, agent: "plan" }))
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id },
}),
)
const updates = await optionUpdates(acp, acp.sessionId, 1)
expect(updates.map(values)).toEqual([{ model: "test/second-model", effort: "default", mode: "build" }])
})
test("stops following a closed session and follows it once after re-attaching", async () => {
await using acp = await startSession()
const other = await acp.newSession()
await acp.request("session/close", { sessionId: acp.sessionId })
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "plan" }))
acp.server.send(durableEvent("session.agent.selected", { sessionID: other.sessionId, agent: "plan" }))
await optionUpdates(acp, other.sessionId, 1)
expect(configUpdates(acp, acp.sessionId)).toEqual([])
const stored = acp.server.sessions.get(acp.sessionId)
if (!stored) throw new Error(`missing stored session ${acp.sessionId}`)
acp.server.sessions.set(acp.sessionId, { ...stored, agent: "plan" })
const resume = () => acp.request("session/resume", { sessionId: acp.sessionId, cwd: "/workspace" })
const resumed = await resume()
await resume()
acp.server.send(
durableEvent("session.model.selected", {
sessionID: acp.sessionId,
model: { providerID: "test", id: secondModel.id },
}),
)
await optionUpdates(acp, acp.sessionId, 1)
acp.server.send(durableEvent("session.agent.selected", { sessionID: acp.sessionId, agent: "build" }))
await optionUpdates(acp, acp.sessionId, 2)
expect(currentValue(resumed, "mode")).toBe("plan")
expect(configUpdates(acp, acp.sessionId).map(values)).toEqual([
{ model: "test/second-model", effort: "default", mode: "plan" },
{ model: "test/second-model", effort: "default", mode: "build" },
])
})
test("ends on the latest catalog and selection when both change together", async () => {
await using acp = await startWire()
acp.server.catalog.models = [testModel]
await acp.initialize()
const { sessionId } = await acp.newSession()
acp.server.catalog.models = [testModel, secondModel]
acp.server.send(
ephemeralEvent("model.updated", {}),
durableEvent("session.model.selected", {
sessionID: sessionId,
model: { providerID: "test", id: secondModel.id },
}),
)
await acp.until(
() => configUpdates(acp, sessionId).some((options) => selectValues(options, "model").length === 2),
"the reloaded catalog",
)
acp.server.send(durableEvent("session.agent.selected", { sessionID: sessionId, agent: "plan" }))
const updates = await acp.until(() => {
const updates = configUpdates(acp, sessionId)
return values(updates.at(-1)).mode === "plan" && updates
}, "the sentinel update")
expect(
updates.slice(-2).map((options) => ({ ...values(options), models: selectValues(options, "model") })),
).toEqual(
["build", "plan"].map((mode) => ({
model: "test/second-model",
effort: "default",
mode,
models: ["test/second-model", "test/test-model"],
})),
)
})
})
function configUpdates(acp: Wire, sessionId: string) {
return acp.updates.flatMap((item) =>
item.sessionId === sessionId && item.update.sessionUpdate === "config_option_update"
? [item.update.configOptions]
: [],
)
}
function optionUpdates(acp: Wire, sessionId: string, count: number) {
return acp.until(() => {
const updates = configUpdates(acp, sessionId)
return updates.length >= count && updates
}, `${count} config_option_update for ${sessionId}`)
}
function values(options: SessionConfigOption[] | undefined) {
return Object.fromEntries((options ?? []).map((option) => [option.id, option.currentValue]))
}
+164 -5
View File
@@ -1,7 +1,17 @@
import { describe, expect, test } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk"
import type { McpServer, SessionNotification } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
import { currentValue } from "./select-options"
import { makeSession, rpcError, secondModel, startSession, startWire } from "./wire-fixture"
import {
ephemeralEvent,
makeSession,
rpcError,
secondModel,
startSession,
startWire,
testModel,
type Wire,
} from "./wire-fixture"
describe("acp session lifecycle over the wire", () => {
test("initialize advertises capabilities and terminal auth only when the client asks", async () => {
@@ -16,7 +26,7 @@ describe("acp session lifecycle over the wire", () => {
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { "opencode/child-session-updates": true },
},
agentInfo: { name: "OpenCode" },
@@ -76,7 +86,7 @@ describe("acp session lifecycle over the wire", () => {
expect(acp.server.selections).toEqual([])
})
test("loads and forks with paginated replay while resume does not replay", async () => {
test("loads with paginated replay while resume and fork do not replay", async () => {
await using acp = await startWire()
const history = Array.from({ length: 201 }, (_, index) => ({
id: `msg_${index}`,
@@ -122,7 +132,7 @@ describe("acp session lifecycle over the wire", () => {
: [],
)
expect(replayed("ses_loaded")).toEqual(history.map((message) => message.id))
expect(replayed(forked.sessionId)).toEqual(history.map((message) => message.id))
expect(replayed(forked.sessionId)).toEqual([])
expect(replayed("ses_resume")).toEqual([])
expect(
acp.updates.find((item) => item.sessionId === "ses_loaded" && item.update.sessionUpdate === "user_message_chunk")
@@ -134,6 +144,67 @@ describe("acp session lifecycle over the wire", () => {
})
})
test("publishes a session's commands after the response that attaches it", async () => {
await using acp = await startWire()
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
acp.server.messages.set("ses_loaded", [{ id: "msg_0", type: "user", text: "hello", time: { created: 0 } }])
await acp.initialize()
const params = { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }
expect(await untilCommands(acp, () => acp.newSession())).toEqual(["response", "available_commands_update"])
expect(await untilCommands(acp, () => acp.request("session/load", params))).toEqual([
"user_message_chunk",
"response",
"available_commands_update",
])
expect(await untilCommands(acp, () => acp.request("session/resume", params))).toEqual([
"response",
"available_commands_update",
])
expect(await untilCommands(acp, () => acp.request("session/fork", params))).toEqual([
"response",
"available_commands_update",
])
})
test("does not publish commands for a session closed before its load responds", async () => {
const held = Promise.withResolvers<undefined>()
await using acp = await startWire({
fetch: (request) => (request.path === "/api/session/ses_loaded/message" ? held.promise : undefined),
})
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
await acp.initialize()
const loaded = acp.request("session/load", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })
await acp.until(() => acp.server.requests.some((item) => item.path === "/api/session/ses_loaded/message"))
await acp.request("session/close", { sessionId: "ses_loaded" })
held.resolve(undefined)
await loaded
// A later session's commands follow the load response, giving a stray update time to arrive.
const created = await acp.newSession()
await acp.until(() => acp.updates.some(isCommands), "commands for the later session")
expect(acp.updates.filter(isCommands).map((item) => item.sessionId)).toEqual([created.sessionId])
})
test("detaches a session whose load fails after attaching", async () => {
await using acp = await startWire({
fetch: (request) =>
request.path === "/api/session/ses_loaded/message" ? new Response(null, { status: 500 }) : undefined,
})
acp.server.sessions.set("ses_loaded", makeSession("ses_loaded"))
await acp.initialize()
expect(
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })),
).toMatchObject({ code: -32603 })
expect(
await rpcError(
acp.request("session/set_config_option", { sessionId: "ses_loaded", configId: "mode", value: "plan" }),
),
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded" } })
})
test("lists server-backed pages for the requested cwd", async () => {
await using acp = await startWire()
Array.from({ length: 101 }, (_, index) =>
@@ -180,6 +251,8 @@ describe("acp session lifecycle over the wire", () => {
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
expect(acp.server.sessions.has(acp.sessionId)).toBe(false)
expect(await acp.request("session/delete", { sessionId: acp.sessionId })).toEqual({})
expect(await acp.request("session/delete", { sessionId: "ses_never_created" })).toEqual({})
expect(await acp.request("session/delete", { sessionId: "never-created" })).toEqual({})
expect(
await rpcError(
acp.request("session/set_config_option", { sessionId: acp.sessionId, configId: "effort", value: "high" }),
@@ -187,6 +260,38 @@ describe("acp session lifecycle over the wire", () => {
).toMatchObject({ code: -32602, data: { sessionId: acp.sessionId } })
})
test("rejects malformed session IDs as invalid params", async () => {
await using acp = await startWire()
await acp.initialize()
const params = { cwd: "/workspace", sessionId: "never-created", mcpServers: [] }
expect(await rpcError(acp.request("session/load", params))).toEqual({
code: -32602,
message: 'Invalid params: Expected a string starting with "ses"',
data: {},
})
expect(await rpcError(acp.request("session/fork", params))).toEqual({
code: -32602,
message: "Invalid params: Invalid session ID",
data: { field: "sessionID" },
})
expect(acp.logs).toEqual([])
})
test("rejects forking an unknown session as session not found", async () => {
await using acp = await startWire()
await acp.initialize()
expect(
await rpcError(acp.request("session/fork", { cwd: "/workspace", sessionId: "ses_unknown", mcpServers: [] })),
).toEqual({
code: -32602,
message: "Invalid params: session not found: ses_unknown",
data: { sessionId: "ses_unknown" },
})
expect(acp.logs).toEqual([])
})
test("converts MCP configs and deduplicates registrations per session and config", async () => {
const local: McpServer = {
name: "tools",
@@ -226,4 +331,58 @@ describe("acp session lifecycle over the wire", () => {
config: { type: "remote", url: "https://example.com/mcp", headers: { Authorization: "Bearer x" }, oauth: false },
})
})
test("leaves a session detached when re-attaching it fails", async () => {
const broken: McpServer = { name: "broken", command: "bun", args: [], env: [] }
await using acp = await startWire({
fetch: (request) =>
request.method === "PUT" && request.path === "/api/experimental/mcp/broken"
? new Response(null, { status: 500 })
: undefined,
})
await acp.initialize()
const failed = await acp.newSession()
const other = await acp.newSession()
await acp.until(() => acp.updates.filter(isCommands).length === 2, "initial commands")
expect(
await rpcError(
acp.request("session/resume", { cwd: "/workspace", sessionId: failed.sessionId, mcpServers: [broken] }),
),
).toMatchObject({ code: -32603 })
const since = acp.updates.length
acp.server.catalog.models = [testModel]
acp.server.send(ephemeralEvent("model.updated", {}))
await acp.until(() => acp.updates.length > since, "config options for the attached session")
expect(acp.updates.slice(since).map((item) => item.sessionId)).toEqual([other.sessionId])
expect(
await rpcError(
acp.request("session/set_config_option", { sessionId: failed.sessionId, configId: "mode", value: "plan" }),
),
).toMatchObject({ code: -32602, data: { sessionId: failed.sessionId } })
})
})
const isSessionUpdate = Schema.is(
Schema.Struct({
method: Schema.Literal("session/update"),
params: Schema.Struct({ update: Schema.Struct({ sessionUpdate: Schema.String }) }),
}),
)
function isCommands(item: SessionNotification) {
return item.update.sessionUpdate === "available_commands_update"
}
// Labels what the agent sends from the request until the commands that follow it, in wire order.
async function untilCommands(acp: Wire, send: () => Promise<unknown>) {
const start = acp.received.length
await send()
return acp.until(() => {
const labels = acp.received.slice(start).map((message) => {
if (isSessionUpdate(message)) return message.params.update.sessionUpdate
return "method" in message ? message.method : "response"
})
return labels.includes("available_commands_update") && labels
}, "available commands")
}
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { ACPTranslate } from "../../src/acp/translate"
import {
childCreated,
delivered,
durableEvent,
failed,
interrupted,
stepEnded,
succeeded,
textDelta,
tokens,
toolCalled,
toolProgress,
toolStarted,
} from "./wire-fixture"
const root = "ses_root"
const ctx: ACPTranslate.Context = {
sessionID: root,
cwd: "/workspace",
start: { type: "input", id: "msg_input" },
childUpdates: false,
mode: "turn",
}
function run(events: ReadonlyArray<OpenCodeEvent>, context = ctx, state = ACPTranslate.initial) {
return events.reduce<{ state: ACPTranslate.TurnState; outputs: ACPTranslate.Output[]; terminal?: string }>(
(acc, event, index) => {
const next = ACPTranslate.step(acc.state, { ...event, id: `evt_${index + 1}` }, context)
return {
state: next.state,
outputs: [...acc.outputs, ...next.outputs],
...(next.terminal ? { terminal: next.terminal } : {}),
}
},
{ state, outputs: [] },
)
}
function started(...events: OpenCodeEvent[]) {
return run([delivered(root, "msg_input"), ...events])
}
function updates(outputs: ReadonlyArray<ACPTranslate.Output>) {
return outputs.flatMap((output) => (output._tag === "SessionUpdate" ? [output.update] : []))
}
describe("acp turn translation", () => {
test("ignores a session's events until its own input is delivered, and other sessions' events after", () => {
const result = run([
textDelta(root, "msg_early", "early"),
delivered(root, "msg_other_input"),
delivered("ses_other", "msg_input"),
succeeded(root),
delivered(root, "msg_input"),
textDelta("ses_other", "msg_other", "other session"),
textDelta(root, "msg_ok", "accepted"),
])
expect(result.terminal).toBeUndefined()
expect(updates(result.outputs)).toEqual([
{ sessionUpdate: "agent_message_chunk", messageId: "msg_ok", content: { type: "text", text: "accepted" } },
])
})
test("sums usage across steps, including a failed step, and keeps the last step for context", () => {
const result = started(
durableEvent("session.step.failed", {
sessionID: root,
assistantMessageID: "msg_1",
error: { type: "provider.stream", message: "stream interrupted" },
cost: 0,
tokens: { ...tokens(), input: 40, output: 4 },
}),
durableEvent("session.step.started", {
sessionID: root,
assistantMessageID: "msg_2",
agent: "build",
model: { providerID: "test", id: "test-model" },
started: 0,
}),
stepEnded(root, "msg_2", { finish: "length", tokens: { ...tokens(), input: 20, output: 7, reasoning: 2 } }),
)
expect(result.state.usage).toEqual({
turn: { input: 60, output: 11, reasoning: 2, cache: { read: 0, write: 0 } },
last: { input: 20, output: 7, reasoning: 2, cache: { read: 0, write: 0 } },
})
expect(ACPTranslate.failure(result.state)).toBeUndefined()
expect(ACPTranslate.response(result.state, root, "succeeded")).toEqual({
stopReason: "max_tokens",
usage: { inputTokens: 60, outputTokens: 11, thoughtTokens: 2, totalTokens: 73 },
_meta: {},
})
})
test("marks a compaction that fails before starting, and drops an end with no start", () => {
const result = started(
durableEvent("session.compaction.failed", {
sessionID: root,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
}),
durableEvent("session.compaction.ended", { sessionID: root, reason: "auto", text: "summary", recent: "" }),
)
expect(updates(result.outputs).map((update) => update._meta?.["opencode/compaction"])).toEqual([
{
status: "failed",
messageId: "msg_2",
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
},
])
expect(result.state.compactions.size).toBe(0)
})
test("projects a nested child's updates with its depth and its own parent", () => {
const result = started(
childCreated("ses_child", root, "Explore"),
childCreated("ses_grandchild", "ses_child", "Deeper"),
childCreated("ses_stranger", "ses_unknown", "Unrelated"),
toolStarted("ses_grandchild", "call_1", "read"),
textDelta("ses_stranger", "msg_stranger", "ignored"),
)
expect(updates(result.outputs)).toEqual([
expect.objectContaining({
sessionUpdate: "tool_call",
toolCallId: "ses_grandchild:call_1",
title: "Deeper: read",
_meta: { "opencode/child-session": { id: "ses_grandchild", parentID: "ses_child", depth: 2, title: "Deeper" } },
}),
])
expect([...result.state.openChildren]).toEqual(["ses_child", "ses_grandchild"])
})
test("ends a background consumer when its last open child settles, without session updates", () => {
const turn = started(childCreated("ses_a", root, "A"), childCreated("ses_b", root, "B"), succeeded(root))
const background = { ...ctx, mode: "background" as const }
const first = run(
[textDelta(root, "msg_root", "ignored"), toolStarted("ses_a", "call_1", "read"), succeeded("ses_a")],
background,
turn.state,
)
const last = run([childCreated("ses_later", root, "Later"), interrupted("ses_b")], background, first.state)
expect(turn.terminal).toBe("succeeded")
expect(first.outputs).toEqual([])
expect(first.terminal).toBeUndefined()
expect(last.state.children.has("ses_later")).toBe(false)
expect(last.terminal).toBe("interrupted")
})
test("fails the tools a cancelled turn left open, including a child's", () => {
const result = started(
childCreated("ses_child", root, "Explore"),
toolStarted(root, "call_root", "shell"),
toolCalled(root, "call_root", { command: "sleep 60" }),
toolProgress(root, "call_root", { pid: 1 }),
toolStarted("ses_child", "call_child", "read"),
failed("ses_child", { type: "aborted", message: "interrupted" }),
)
const abandoned = ACPTranslate.abandonTools(result.state, ctx)
expect(abandoned.state.tools.size).toBe(0)
expect(updates(abandoned.outputs)).toEqual([
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId: "call_root",
status: "failed",
rawInput: expect.objectContaining({ command: "sleep 60" }),
rawOutput: { metadata: { pid: 1 }, error: "Cancelled" },
}),
expect.objectContaining({ toolCallId: "ses_child:call_child", status: "failed", title: "Explore: read" }),
])
expect(ACPTranslate.abandonTools(abandoned.state, ctx).outputs).toEqual([])
})
})
+50
View File
@@ -8,6 +8,8 @@ import {
durableEvent,
ephemeralEvent,
failed,
interrupted,
permissionAsked,
reasoningDelta,
startSession,
stepEnded,
@@ -235,6 +237,53 @@ describe("acp turn events over the wire", () => {
expect(acp.childUpdates.some((item) => item.childSessionId === "ses_future")).toBe(false)
})
test("keeps following open children after a cancelled turn", async () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
childCreated("ses_background", sessionID, "Background research"),
textDelta(sessionID, "msg_root", "working"),
],
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
permission: () => ({ outcome: { outcome: "selected", optionId: "once" } }),
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect((await prompt).stopReason).toBe("cancelled")
acp.server.send(permissionAsked("ses_background", "perm_background"), interrupted("ses_background"))
await acp.until(
() => acp.childUpdates.some((item) => item.type === "status" && item.status === "interrupted"),
"background child interruption",
)
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
expect(acp.childUpdates.map(childUpdateKind)).toEqual(["status:created", "status:interrupted"])
expect(acp.server.replies).toEqual([
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
])
})
test("stops following background children once the session closes", async () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: true },
onPrompt: ({ sessionID, id, text }) =>
text === "hello"
? turn(sessionID, id, childCreated("ses_background", sessionID, "Background research"))
: turn(sessionID, id),
})
const other = await acp.newSession()
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
await acp.request("session/close", { sessionId: acp.sessionId })
acp.server.send(textDelta("ses_background", "msg_late", "after close"))
expect((await acp.prompt(other.sessionId, "later")).stopReason).toBe("end_turn")
expect(acp.childUpdates.map(childUpdateKind)).toEqual(["status:created"])
})
test("streams tool pending, progress, success, and failure updates", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) =>
@@ -315,6 +364,7 @@ describe("acp turn events over the wire", () => {
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
expect(acp.elicitations).toEqual([])
})
test("reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)", async () => {
+87 -6
View File
@@ -9,6 +9,8 @@ import {
type AgentRequestResponsesByMethod,
type AnyMessage,
type ContentBlock,
type CreateElicitationRequest,
type CreateElicitationResponse,
type McpServer,
type RequestPermissionRequest,
type RequestPermissionResponse,
@@ -27,9 +29,11 @@ import {
type SessionMessageInfo,
type TokenUsageInfo,
} from "@opencode/client/promise"
import { Form } from "@opencode/schema/form"
import type { BunRequest } from "bun"
import { Effect, Exit, Logger, Option, Schema, Scope } from "effect"
import { Duration, Effect, Exit, Logger, Option, Schema, Scope } from "effect"
import { ACP } from "../../src/acp/agent"
import { ACPTurn } from "../../src/acp/turn"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
@@ -57,12 +61,22 @@ const SyntheticBody = Schema.Struct({
delivery: Delivery,
resume: Schema.optional(Schema.Boolean),
})
const CreateBody = Schema.Struct({ location: Schema.Struct({ directory: Schema.String }) })
const Permissions = Schema.Array(
Schema.Struct({ action: Schema.String, resource: Schema.String, effect: Schema.Literals(["allow", "deny", "ask"]) }),
)
const Metadata = Schema.Record(Schema.String, Schema.MutableJson)
const CreateBody = Schema.Struct({
location: Schema.Struct({ directory: Schema.String }),
permissions: Schema.optional(Permissions),
metadata: Schema.optional(Metadata),
})
const UpdateBody = Schema.Struct({ permissions: Schema.optional(Permissions), metadata: Schema.optional(Metadata) })
const ModelBody = Schema.Struct({
model: Schema.Struct({ providerID: Schema.String, id: Schema.String, variant: Schema.optional(Schema.String) }),
})
const AgentBody = Schema.Struct({ agent: Schema.String })
const ReplyBody = Schema.Struct({ decision: Schema.Literals(["once", "always", "reject"]) })
const FormReplyBody = Schema.Struct({ answer: Form.Answer })
const McpBody = Schema.Struct({ config: Schema.Unknown })
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
@@ -117,10 +131,22 @@ export type WireOptions = {
readonly decision: string
}>
readonly onFormCancel?: Hook<{ readonly sessionID: string; readonly formID: string }>
readonly onFormReply?: Hook<FormReply>
readonly permission?: (
request: RequestPermissionRequest,
signal: AbortSignal,
) => RequestPermissionResponse | Promise<RequestPermissionResponse>
readonly elicitation?: (
request: CreateElicitationRequest,
signal: AbortSignal,
) => CreateElicitationResponse | Promise<CreateElicitationResponse>
readonly cancelDrainTimeout?: Duration.Input
}
type FormReply = {
readonly sessionID: string
readonly formID: string
readonly answer: typeof FormReplyBody.Type.answer
}
type CatalogKind = "model" | "default" | "agent" | "command"
@@ -137,6 +163,7 @@ export type InitializeOptions = {
readonly writeTextFile?: boolean
readonly childSessionUpdates?: boolean
readonly terminalAuth?: boolean
readonly elicitation?: boolean
}
export const testModel = {
@@ -367,6 +394,7 @@ export async function startWire(options: WireOptions = {}) {
const permissions: RequestPermissionRequest[] = []
const writes: WriteTextFileRequest[] = []
const childUpdates: ChildUpdate[] = []
const elicitations: CreateElicitationRequest[] = []
// Client handlers record SDK-validated params; responses wait until they have seen every earlier agent message.
const counts = { sent: 0, handled: 0 }
const handled = <Value>(list: Value[], value: Value) => {
@@ -385,6 +413,10 @@ export async function startWire(options: WireOptions = {}) {
ndJsonStream(agentToClient.writable, clientToAgent.readable),
).pipe(
Scope.provide(agentScope),
(effect) =>
options.cancelDrainTimeout === undefined
? effect
: Effect.provideService(effect, ACPTurn.CancelDrainTimeout, options.cancelDrainTimeout),
Effect.provide(Logger.layer([Logger.make((log) => logs.push({ message: log.message, cause: log.cause }))])),
),
)
@@ -398,6 +430,10 @@ export async function startWire(options: WireOptions = {}) {
handled(permissions, ctx.params)
return options.permission?.(ctx.params, ctx.signal) ?? { outcome: { outcome: "cancelled" } }
})
.onRequest("elicitation/create", (ctx) => {
handled(elicitations, ctx.params)
return options.elicitation?.(ctx.params, ctx.signal) ?? { action: "cancel" }
})
.onRequest("fs/write_text_file", (ctx) => {
handled(writes, ctx.params)
return {}
@@ -453,6 +489,7 @@ export async function startWire(options: WireOptions = {}) {
protocolVersion: 1,
clientCapabilities: {
...(capabilities.writeTextFile ? { fs: { writeTextFile: true, readTextFile: false } } : {}),
...(capabilities.elicitation ? { elicitation: { form: {} } } : {}),
_meta: {
...(capabilities.childSessionUpdates ? { "opencode/child-session-updates": true } : {}),
...(capabilities.terminalAuth ? { "terminal-auth": true } : {}),
@@ -469,6 +506,7 @@ export async function startWire(options: WireOptions = {}) {
permissions,
writes,
childUpdates,
elicitations,
request,
until,
initialize,
@@ -528,6 +566,7 @@ function startServer(options: WireOptions, changed: () => void) {
const interrupts: string[] = []
const replies: Array<{ readonly sessionID: string; readonly requestID: string; readonly decision: string }> = []
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string }> = []
const repliedForms: FormReply[] = []
const mcp: Array<{ readonly name: string; readonly directory?: string; readonly config: unknown }> = []
const fake = {
requests,
@@ -543,6 +582,7 @@ function startServer(options: WireOptions, changed: () => void) {
interrupts,
replies,
cancelledForms,
repliedForms,
mcp,
send(...events: ReadonlyArray<OpenCodeEvent>) {
events.forEach((event) => {
@@ -571,6 +611,15 @@ function startServer(options: WireOptions, changed: () => void) {
const notFound = (sessionID: string) =>
Response.json({ _tag: "SessionNotFoundError", sessionID, message: "session not found" }, { status: 404 })
const noContent = () => new Response(null, { status: 204 })
// Routes without session-location middleware reject a malformed ID in their path decode; routes with it name the field.
const pathDecodeError = {
_tag: "InvalidRequestError",
message: 'Expected a string starting with "ses"',
kind: "Params",
}
const fieldError = { _tag: "InvalidRequestError", message: "Invalid session ID", field: "sessionID" }
const malformed = (sessionID: string, body: object) =>
sessionID.startsWith("ses") ? undefined : Response.json(body, { status: 400 })
// Handlers record facts synchronously before awaiting hooks, so waiters can observe a held request.
const observed = (response: Response | Promise<Response>) => {
@@ -672,20 +721,39 @@ function startServer(options: WireOptions, changed: () => void) {
return Response.json(page(sessions, query, 100))
}),
POST: body(CreateBody, (_req, input) =>
Response.json({ data: createSession(makeSession("", { cwd: input.location.directory })) }),
Response.json({
data: createSession({
...makeSession("", { cwd: input.location.directory }),
...(input.permissions ? { permissions: [...input.permissions] } : {}),
...(input.metadata ? { metadata: input.metadata } : {}),
}),
}),
),
},
"/api/session/:sessionID": {
GET: route((req) => {
const invalid = malformed(req.params.sessionID, pathDecodeError)
if (invalid) return invalid
const session = fake.sessions.get(req.params.sessionID)
return session ? Response.json({ data: session }) : notFound(req.params.sessionID)
}),
DELETE: route((req) =>
fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID),
),
DELETE: route((req) => {
const invalid = malformed(req.params.sessionID, pathDecodeError)
if (invalid) return invalid
return fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID)
}),
PATCH: body(UpdateBody, (req, input) => {
const session = fake.sessions.get(req.params.sessionID)
if (!session) return notFound(req.params.sessionID)
if (input.permissions) session.permissions = [...input.permissions]
if (input.metadata) session.metadata = input.metadata
return noContent()
}),
},
"/api/session/:sessionID/fork": {
POST: route((req) => {
const invalid = malformed(req.params.sessionID, fieldError)
if (invalid) return invalid
const source = fake.sessions.get(req.params.sessionID)
if (!source) return notFound(req.params.sessionID)
const forked = createSession(source)
@@ -766,6 +834,14 @@ function startServer(options: WireOptions, changed: () => void) {
return noContent()
}),
},
"/api/session/:sessionID/form/:formID/reply": {
POST: body(FormReplyBody, async (req, input) => {
const reply = { sessionID: req.params.sessionID, formID: req.params.formID, answer: input.answer }
fake.repliedForms.push(reply)
await emit(options.onFormReply?.(reply))
return noContent()
}),
},
"/api/experimental/mcp/:name": {
PUT: body(McpBody, (req, input, query) => {
fake.mcp.push({ name: req.params.name, directory: query["location[directory]"], config: input.config })
@@ -777,6 +853,11 @@ function startServer(options: WireOptions, changed: () => void) {
return Object.assign(fake, {
url: http.url.toString(),
/** Ends every open event stream while the server keeps answering requests. */
closeEvents() {
streams.forEach((stream) => stream.close())
streams.clear()
},
async stop() {
streams.forEach((stream) => stream.close())
streams.clear()
+8 -1
View File
@@ -1,8 +1,10 @@
import { NodeServices } from "@effect/platform-node"
import { EffectFlock } from "@opencode/util/effect-flock"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, PlatformError, Stream } from "effect"
import { Effect, FileSystem, Layer, PlatformError, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync, readdirSync, readFileSync } from "node:fs"
import path from "node:path"
@@ -59,6 +61,11 @@ function fixture(
const commands: string[][] = []
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(EffectFlock.node, {
replacements: [Global.node.replace(Layer.succeed(Global.Service, global))],
}),
),
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
+1
View File
@@ -93,6 +93,7 @@ const HOSTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
},
"cloudflare-workers-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-workers-ai" },
deepseek: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/deepseek" },
digitalocean: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/digitalocean" },
"fireworks-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/fireworks" },
"google-vertex": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/google-vertex/chat" },
"kimi-for-coding": protocols("moonshot"),
@@ -60,14 +60,17 @@ export const Plugin = define({
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
if (!discovery.global) return []
if (!discovery.global || !(yield* fs.isFile(globalFile))) return []
const file = yield* read(globalFile)
return file ? [file] : []
})
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
if (!project) return []
const walked = yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve)
const walked = yield* Effect.forEach(
yield* fs.up({ targets: ["AGENTS.md"], start, stop, type: "file" }),
fs.resolve,
)
const discovered = new Set(walked.filter((file) => discovery.global || file !== globalFile))
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
if (files.some((file) => file === undefined)) return Instructions.unavailable
-14
View File
@@ -24,7 +24,6 @@ type Cost = {
readonly cache_read?: Money.USDPerMillionTokens
readonly cache_write?: Money.USDPerMillionTokens
readonly tiers?: readonly (Cost & { readonly tier: { readonly type: "context"; readonly size: number } })[]
readonly context_over_200k?: Omit<Cost, "tiers" | "context_over_200k">
}
type Modality = "text" | "audio" | "image" | "video" | "pdf"
@@ -147,19 +146,6 @@ function cost(input: SourceModel["cost"]): Model.Info["cost"] {
write: item.cache_write ?? Money.USDPerMillionTokens.zero,
},
})) ?? []),
...(input?.context_over_200k
? [
{
tier: { type: "context" as const, size: 200_000 },
input: input.context_over_200k.input,
output: input.context_over_200k.output,
cache: {
read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero,
write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero,
},
},
]
: []),
]
}
+1 -1
View File
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-context automation","famLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-context automation","famLine truncated
+488 -191
View File
@@ -1,4 +1,5 @@
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
import { Clock, Effect, FiberHandle, Option, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
@@ -7,14 +8,22 @@ import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
import type { PluginInternal } from "../internal.js"
import { configuredSettings } from "./configured.js"
const cognitiveScope = "https://cognitiveservices.azure.com/.default"
const foundryScope = "https://ai.azure.com/.default"
const managementScope = "https://management.azure.com/.default"
const methodID = Integration.MethodID.make("azure-cli")
// A resource name becomes a hostname label and a query literal, so anything else never leaves the process. Azure
// allows only letters, digits, and hyphens in it.
// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains
const resourcePattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
const decodeToken = Schema.decodeUnknownEffect(
Schema.Struct({
@@ -23,209 +32,443 @@ const decodeToken = Schema.decodeUnknownEffect(
expiresOn: Schema.optional(Schema.NonEmptyString),
}),
)
export const AzurePlugin = define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const bus = yield* Bus.Service
const tokens = new Map<string, { access: string; expires: number }>()
const loading = Semaphore.makeUnsafe(1)
const loaded: { resource?: string } = {}
const ResourceDeployments = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResourceDeployment = Schema.decodeUnknownOption(
Schema.Struct({ id: Schema.NonEmptyString, model: Schema.NonEmptyString, status: Schema.String }),
)
const ManagementDeployments = Schema.Struct({
value: Schema.Array(Schema.Unknown),
nextLink: Schema.optional(Schema.NonEmptyString),
})
const decodeManagementDeployment = Schema.decodeUnknownOption(
Schema.Struct({
name: Schema.NonEmptyString,
properties: Schema.Struct({
model: Schema.Struct({ name: Schema.NonEmptyString }),
provisioningState: Schema.String,
}),
}),
)
const ResourceQuery = Schema.Struct({ query: Schema.String })
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
const command = (args: string[]) =>
processes
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
.pipe(
Effect.flatMap(AppProcess.requireSuccess),
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
type Deployment = { readonly name: string; readonly model: string }
export function make(
endpoints = {
resource: (name: string) => `https://${name}.openai.azure.com/openai`,
management: "https://management.azure.com",
},
) {
return define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const providers = yield* Provider.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const tokens = new Map<string, { access: string; expires: number }>()
// A resource keeps its Azure Resource Manager ID until it is deleted, so discovery looks it up once.
const resourceIDs = new Map<string, string>()
const loading = Semaphore.makeUnsafe(1)
const discovery = yield* FiberHandle.make<void, never>()
const loaded: {
resource?: string
url?: string
deployments?: readonly Deployment[]
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
} = {}
const command = (args: string[]) =>
processes
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
.pipe(
Effect.flatMap(AppProcess.requireSuccess),
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
if (cached && cached.expires - now > 5 * 60_000) return cached
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
Effect.flatMap(decodeToken),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
if (cached && cached.expires - now > 5 * 60_000) return cached
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
Effect.flatMap(decodeToken),
)
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires))
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
const refreshed = { access: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed
})
const available = Boolean(which("az"))
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires))
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
const refreshed = { access: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed
})
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
editor.method.update({
integrationID: Provider.ID.azure,
method: {
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(),
},
authorize: (answer) =>
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
callback: Effect.gen(function* () {
const resourceName =
typeof answer.resourceName === "string" ? answer.resourceName : resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
loaded.resource = resourceName
yield* ctx.provider.reload()
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
const management = (request: HttpClientRequest.HttpClientRequest) =>
token(managementScope).pipe(
Effect.flatMap((current) =>
http.execute(
request.pipe(
HttpClientRequest.bearerToken(current.access),
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
),
),
),
})
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
loaded.resource = undefined
return
}
const resource =
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
loaded.resource = resource
})
yield* load()
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
const available = Boolean(which("az"))
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
})
}
})
yield* ctx.model.transform((models) => {
for (const item of models.provider.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
editor.method.update({
integrationID: Provider.ID.azure,
method: {
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(),
},
authorize: (answer) =>
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
callback: Effect.gen(function* () {
const resourceName =
(typeof answer.resourceName === "string" ? answer.resourceName.trim() : "") ||
resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
),
),
})
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
// Startup awaits this, so it reads the stored credential: resolving the connection would refresh an
// expired token through the Azure CLI.
const stored =
connection?.type === "credential" ? yield* credentials.get(Credential.ID.make(connection.id)) : undefined
return { connection, resource: credentialResource(stored?.value) }
})
// Resource Graph searches every subscription the Azure CLI account can read, not only the selected one.
// https://learn.microsoft.com/rest/api/azureresourcegraph/resourcegraph/resources/resources
const findResource = Effect.fn("AzurePlugin.findResource")(function* (resource: string) {
const response = yield* HttpClientRequest.post(
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
).pipe(
HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceQuery(resource) }),
Effect.flatMap(management),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Resources)),
Effect.timeout("10 seconds"),
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
for (const model of models.list(item.provider.id)) {
models.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
const id = response.data[0]?.id
if (!id) return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found`))
return id
})
const managementDeployments = Effect.fn("AzurePlugin.managementDeployments")(function* (resource: string) {
const key = resource.toLowerCase()
const id = resourceIDs.get(key) ?? (yield* findResource(resource))
resourceIDs.set(key, id)
const origin = new URL(endpoints.management).origin
return yield* Stream.paginate(`${endpoints.management}${id}/deployments?api-version=2024-10-01`, (url) =>
management(HttpClientRequest.get(url)).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ManagementDeployments)),
Effect.timeout("10 seconds"),
Effect.flatMap((response) =>
// Every page carries the management token, so a page link must stay on the management endpoint.
// https://learn.microsoft.com/rest/api/aiservices/accountmanagement/deployments/list
response.nextLink !== undefined && URL.parse(response.nextLink)?.origin !== origin
? Effect.fail(new Error("Azure returned a deployment page outside the management endpoint"))
: Effect.succeed([
response.value.flatMap((raw): Deployment[] => {
const item = Option.getOrUndefined(decodeManagementDeployment(raw))
return item?.properties.provisioningState === "Succeeded"
? [{ name: item.name, model: item.properties.model.name }]
: []
}),
Option.fromNullishOr(response.nextLink),
] as const),
),
),
).pipe(
Stream.runCollect,
// A moved or recreated resource has a new ID, so the next discovery looks it up again.
Effect.tapError(() => Effect.sync(() => resourceIDs.delete(key))),
)
})
const resourceDeployments = Effect.fn("AzurePlugin.resourceDeployments")(function* (
url: string,
credential: Credential.Value,
) {
return yield* http
.execute(
HttpClientRequest.get(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
credential.type === "key"
? HttpClientRequest.setHeader("api-key", credential.key)
: HttpClientRequest.bearerToken(credential.access),
),
)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ResourceDeployments)),
Effect.timeout("10 seconds"),
Effect.map((response) =>
response.data.flatMap((raw): Deployment[] => {
const item = Option.getOrUndefined(decodeResourceDeployment(raw))
return item?.status === "succeeded" ? [{ name: item.id, model: item.model }] : []
}),
),
)
})
// Azure documents the management API as the deployment inventory, but only an Azure CLI session can reach it:
// Azure Resource Manager accepts Entra ID tokens, never resource keys.
// https://learn.microsoft.com/rest/api/aiservices/accountmanagement/deployments/list
// The resource's own inventory serves API keys and identities without Azure Resource Manager read access. Only
// data-plane version 2022-12-01 has it; later versions dropped `/deployments` and keep `/models`, which lists
// models the resource can deploy rather than its deployments.
// https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cognitiveservices/data-plane/OpenAIAuthoring/stable/2022-12-01/azureopenai.json
const deployments = (url: string, resource: string, credential: Credential.Value) =>
credential.type === "oauth"
? managementDeployments(resource).pipe(Effect.catch(() => resourceDeployments(url, credential)))
: resourceDeployments(url, credential)
// Local and quick, so a switch rebinds the provider before discovery for the new connection calls Azure.
const rebind = () =>
loading.withPermit(
Effect.gen(function* () {
const current = yield* load()
if (
IntegrationConnection.key(current.connection) === IntegrationConnection.key(loaded.connection) &&
current.resource === loaded.resource
)
return
Object.assign(loaded, current, { url: undefined, deployments: undefined })
yield* ctx.provider.reload()
}),
)
const discover = Effect.fn("AzurePlugin.discover")(function* () {
const connection = loaded.connection
const settings = (yield* providers.get(Provider.ID.azure))?.settings
const name = loaded.resource ?? resolveResourceName(settings)
// A custom endpoint may expose other deployments than the resource does, so it keeps the catalog.
const url =
connection && name !== undefined && resourcePattern.test(name) && typeof settings?.baseURL !== "string"
? `${endpoints.resource(name)}/deployments?api-version=2022-12-01`
: undefined
if (loaded.connection !== connection) return
// Keep the last inventory through transient failures only for the same connection and resource.
if (loaded.url !== url) {
loaded.url = url
if (loaded.deployments) {
loaded.deployments = undefined
yield* ctx.model.reload()
}
}
if (!connection || !name || !url) return
const credential = yield* ctx.integration.connection
.resolve(connection)
.pipe(Effect.orElseSucceed(() => undefined))
if (!credential || (credential.type === "oauth" && credential.methodID !== methodID)) return
const found = yield* deployments(url, name, credential).pipe(
// Azure promises no order; normalize it so a reordered response does not rebuild the model list.
Effect.map((list) => list.toSorted((a, b) => a.name.localeCompare(b.name))),
Effect.catch((cause) =>
Effect.logWarning("failed to sync Azure deployments", { cause }).pipe(Effect.as(undefined)),
),
)
if (!found) return
if (
loaded.connection !== connection ||
IntegrationConnection.key(connection) !==
IntegrationConnection.key(yield* ctx.integration.connection.active(Provider.ID.azure))
)
return
if (JSON.stringify(found) === JSON.stringify(loaded.deployments)) return
const catalog = new Map(
Array.from((yield* providers.snapshot()).records.get(Provider.ID.azure)?.models.keys() ?? [], (id) => [
id.toLowerCase(),
id,
]),
)
const unmatched = found.filter((deployment) => !catalogModel(catalog, deployment))
if (unmatched.length > 0)
yield* Effect.logWarning("Azure deployments of models outside the catalog need explicit configuration", {
deployments: unmatched.map((deployment) => deployment.name),
})
loaded.deployments = found
yield* ctx.model.reload()
})
const refresh = () => rebind().pipe(Effect.andThen(FiberHandle.run(discovery, discover())))
// The connection's resource wins for Azure itself, matching the runtime merge of credentials over settings.
const resourceFor = (provider: Provider.Info) =>
provider.id === Provider.ID.azure
? (loaded.resource ?? resolveResourceName(provider.settings))
: resolveResourceName(provider.settings, loaded.resource)
Object.assign(loaded, yield* load())
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resourceFor(item.provider)
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
}
}
})
const item = evt.get(Provider.ID.azure)
if (!item) return
// Bind resource settings and discovery to their account, so a switch hides them until the rebind.
// Keep the full templates here for explicit configuration; the model transform narrows the visible list.
evt.add({
info: item.provider,
models: Array.from(item.models.values()),
sourceConnection: loaded.connection,
})
})
yield* ctx.model.transform((models) => {
for (const item of models.provider.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resourceFor(item.provider)
for (const model of models.list(item.provider.id)) {
models.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
})
}
}
if (!loaded.deployments) return
// Narrowing here rather than in the provider catalog keeps every catalog model available as the base
// of a model the user configures explicitly; those are applied after this transform.
const catalog = models.list(Provider.ID.azure)
const deployed = deployedModels(loaded.deployments, catalog)
for (const model of catalog) {
if (!deployed.has(model.id)) models.remove(Provider.ID.azure, model.id)
}
for (const [id, model] of deployed) {
models.update(Provider.ID.azure, id, (draft) => Object.assign(draft, model))
}
})
const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
// A switch interrupts discovery for the previous connection instead of waiting for its Azure calls.
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
Stream.runForEach(() => refresh()),
Effect.forkScoped({ startImmediately: true }),
)
// Deployments load in the background so startup never waits on Azure; the catalog serves until they arrive.
// Later changes load when the connection changes, so a new deployment needs a reconnect or restart.
yield* refresh().pipe(Effect.forkScoped)
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected
// at the transport hooks rather than stored as a credential.
const bearer = Effect.fn("AzurePlugin.bearer")(function* (url: string) {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const target = new URL(url)
const scope =
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
return `Bearer ${current.access}`
})
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.request.url)
if (!authorization) return
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", authorization)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.session.hook(
"experimental.ws.handshake",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.url)
if (!authorization) return
delete evt.headers["api-key"]
delete evt.headers["x-api-key"]
evt.headers.authorization = authorization
evt.headers["user-agent"] = App.useragent(ctx.app)
}),
{ providerID: Provider.ID.azure },
)
}),
})
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected
// at the transport hooks rather than stored as a credential.
const bearer = Effect.fn("AzurePlugin.bearer")(function* (url: string) {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const target = new URL(url)
const scope =
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
return `Bearer ${current.access}`
})
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.request.url)
if (!authorization) return
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", authorization)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.session.hook(
"experimental.ws.handshake",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.url)
if (!authorization) return
delete evt.headers["api-key"]
delete evt.headers["x-api-key"]
evt.headers.authorization = authorization
evt.headers["user-agent"] = App.useragent(ctx.app)
}),
{ providerID: Provider.ID.azure },
)
}),
} satisfies PluginInternal.InternalPlugin)
}
export const AzurePlugin = make()
function resolveResourceName(settings: Readonly<Record<string, unknown>> | undefined, fallback?: string) {
const configured = settings?.resourceName
@@ -239,6 +482,60 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
// The Azure CLI method stores the resource as credential metadata, the API key method as its form answer.
function credentialResource(credential: Credential.Value | undefined) {
const resource =
credential?.type === "key"
? credential.configuration?.resourceName
: credential?.methodID === methodID
? credential.metadata?.resourceName
: undefined
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
}
function resourceQuery(resource: string) {
return [
"resources",
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
// The custom subdomain is the resource name of every endpoint, and Entra ID authentication requires one.
// Subdomains are globally unique, so a name matches at most one resource.
// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains
"| extend resourceName = tostring(properties.customSubDomainName)",
`| where resourceName =~ '${resource}'`,
"| project id",
"| take 1",
].join(" ")
}
// A deployment's ID is its name, while limits, costs, and routes come from the catalog model it deploys. Azure compares
// names without case and may return another case, so IDs are lowercase like the catalog's.
// https://learn.microsoft.com/azure/azure-resource-manager/management/resource-name-rules
function deployedModels(deployments: readonly Deployment[], catalog: readonly Model.MutableInfo[]) {
const models = new Map(catalog.map((model) => [model.id.toLowerCase(), model]))
return new Map(
deployments.flatMap((deployment) => {
const model = catalogModel(models, deployment)
if (!model) return []
const id = Model.ID.make(deployment.name.toLowerCase())
const info: Model.MutableInfo = {
...structuredClone(model),
id,
modelID: Model.ID.make(deployment.name),
name: id === model.id.toLowerCase() ? model.name : `${model.name} (${deployment.name})`,
}
return [[id, info] as const]
}),
)
}
// Azure spells some models unlike the catalog: a model name plus a separate version, such as `gpt-4` for GPT-4 Turbo,
// `gpt-35-turbo`, or mixed case such as `DeepSeek-V4-Flash`. A deployment named after a catalog model then stands for
// that model, as it did before discovery; any other is left to explicit configuration.
// https://learn.microsoft.com/azure/foundry/openai/concepts/retired-models
function catalogModel<T>(models: ReadonlyMap<string, T>, deployment: Deployment) {
return models.get(deployment.model.toLowerCase()) ?? models.get(deployment.name.toLowerCase())
}
function responsesWebSocketCapable(provider: Provider.Info) {
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
const settings = provider.settings
+1
View File
@@ -69,6 +69,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/cloudflare-workers-ai", () => import("@opencode/ai/providers/cloudflare-workers-ai")],
["@opencode/ai/providers/deepinfra", () => import("@opencode/ai/providers/deepinfra")],
["@opencode/ai/providers/deepseek", () => import("@opencode/ai/providers/deepseek")],
["@opencode/ai/providers/digitalocean", () => import("@opencode/ai/providers/digitalocean")],
["@opencode/ai/providers/fireworks", () => import("@opencode/ai/providers/fireworks")],
["@opencode/ai/providers/google", () => import("@opencode/ai/providers/google")],
["@opencode/ai/providers/google-vertex", () => import("@opencode/ai/providers/google-vertex")],
@@ -543,7 +543,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
yield* failAssistant({
type: "provider.content-filter",
message: [
`Provider blocked the response${event.reason.category === undefined ? "" : ` (${event.reason.category})`}`,
event.reason.explanation,
]
.filter(Boolean)
.join(": "),
})
return
}
return
+2 -1
View File
@@ -94,9 +94,10 @@ export const Plugin = {
targets: [FILENAME],
start: result.content.type === "list-page" ? resolved : dirname(resolved),
stop: root,
type: "file",
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => !FSUtil.contains(dirname(file), root),
(file) => !FSUtil.contains(dirname(file), root) && file !== resolved,
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
+1
View File
@@ -574,6 +574,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/cloudflare-workers-ai": workersAIChat,
"@opencode/ai/providers/deepinfra": deepinfraChat,
"@opencode/ai/providers/deepseek": deepseekChat,
"@opencode/ai/providers/digitalocean": openaiChat,
"@opencode/ai/providers/fireworks": openaiChat,
"@opencode/ai/providers/groq": openaiChat,
"@opencode/ai/providers/meta/chat": openaiChat,
@@ -431,7 +431,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
it.effect("canonicalizes boundaries and honors project opt-out", () =>
Effect.gen(function* () {
const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
const observed: { values: FSUtil.UpOptions[] } = { values: [] }
const observingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
@@ -487,7 +487,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
)
const repo = path.resolve("/repo")
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo, type: "file" }])
}),
)
})
+9 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { Agent } from "@opencode/core/agent"
import { Database } from "@opencode/core/database/database"
@@ -148,6 +148,14 @@ describe("Permission", () => {
}),
)
test("matches Windows rule resources against slash-normalized file access resources", () => {
const rules: Permission.Ruleset = [
{ action: "external_directory", resource: "C:\\Users\\x\\proj\\*", effect: "allow" },
]
expect(Permission.evaluate("external_directory", "C:/Users/x/proj/src/*", rules).effect).toBe("allow")
expect(Permission.evaluate("external_directory", "C:/Users/x/other/*", rules).effect).toBe("ask")
})
it.effect("allows managed output reads without granting external directory access", () =>
Effect.gen(function* () {
yield* setup([
File diff suppressed because it is too large. Load diff
@@ -89,10 +89,15 @@ const identity = {
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_nearby"),
}
const readCall = (sessionID: Session.ID, id: string, readPath: string): Parameters<Tool.Snapshot["execute"]>[0] => ({
const readCall = (
sessionID: Session.ID,
id: string,
readPath: string,
page: ReadToolFileSystem.PageInput = {},
): Parameters<Tool.Snapshot["execute"]>[0] => ({
sessionID,
...identity,
call: { type: "tool-call", id, name: "read", input: { path: readPath } },
call: { type: "tool-call", id, name: "read", input: { path: readPath, ...page } },
})
const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content))
@@ -131,7 +136,7 @@ describe("SessionInstructions", () => {
yield* writeAgents(rootPath, "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
yield* writeAgents(deepPath, "deep-instructions")
yield* writeAgents(otherPath, "other-instructions")
yield* writeAgents(otherPath, "other-instructions\nmore rules")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content"))
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2"))
@@ -155,13 +160,23 @@ describe("SessionInstructions", () => {
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
expect(firstInjected[0]!.text).not.toContain("root-instructions")
// Neither a full nor a partial read adds an automatic copy of the file itself.
const read = yield* executeTool(registry, readCall(sessionID, "call-direct", "sub/other/AGENTS.md"))
expect(read.content?.[0]).toMatchObject({ type: "text", text: expect.stringContaining("more rules") })
const partial = yield* executeTool(
registry,
readCall(sessionID, "call-partial", "sub/other/AGENTS.md", { limit: 1 }),
)
expect(partial.metadata).toEqual({ truncated: true })
expect(yield* synthetics(sessionID)).toHaveLength(1)
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
// injected for this session so it is not re-emitted, and the root is still excluded.
yield* executeTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
const secondInjected = yield* synthetics(sessionID)
expect(secondInjected).toHaveLength(2)
expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`)
expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions\nmore rules`)
expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`)
expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } })
expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false)
@@ -667,3 +667,29 @@ test("content-filter finish preserves partial streamed text and never ends the s
error: { type: "provider.content-filter" },
})
})
test("content-filter failure explains the refusal when the provider gives a reason", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(
publisher.publish(
LLMEvent.stepFinish({
index: 0,
reason: {
normalized: "content-filter",
raw: "refusal",
category: "cyber",
explanation: "This request was declined because it could enable cyber harm.",
},
}),
),
)
await Effect.runPromise(publisher.publishStepFailure())
expect(published.at(-1)?.data).toMatchObject({
error: {
type: "provider.content-filter",
message: "Provider blocked the response (cyber): This request was declined because it could enable cyber harm.",
},
})
})
+11
View File
@@ -296,6 +296,17 @@ test("spells Workers AI thinking controls through the chat template", () => {
})
test("spells Chat Completions variants for hosting providers", () => {
expect(
resolve(model("@opencode/ai/providers/digitalocean", "openai-gpt-5-nano", undefined, "digitalocean"), [
{ type: "effort", values: ["minimal", "low", "medium", "high"] },
]),
).toEqual([
{ id: "minimal", settings: { reasoningEffort: "minimal" } },
{ id: "low", settings: { reasoningEffort: "low" } },
{ id: "medium", settings: { reasoningEffort: "medium" } },
{ id: "high", settings: { reasoningEffort: "high" } },
])
expect(
resolve(model("@opencode/ai/providers/openai-compatible", "deepseek-ai/deepseek-v4-pro", undefined, "nvidia"), [
{ type: "effort", values: ["none", "high", "max"] },
+39 -28
View File
@@ -2,7 +2,7 @@ import type { FileDiffInfo } from "@opencode/client/promise"
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { checksum } from "@opencode/util/encode"
import { createQuery, skipToken, useQueryClient } from "@tanstack/solid-query"
import { createQuery, useQueryClient } from "@tanstack/solid-query"
import { debounce } from "@solid-primitives/scheduled"
import { createComputed, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
@@ -17,7 +17,6 @@ import {
} from "./kinds"
export type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
type FileSelection = { startLine: number; endLine: number; startChar: number; endChar: number }
export type Demand = { tree: number; files: number; panel: number; details: number }
@@ -109,12 +108,10 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
) {
list.push("branch")
}
// Turn snapshots are captured only for Git sessions.
if (project?.vcs === "git" && view.id) list.push("turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
const value = mode()
return value === "git" || value === "branch" ? value : undefined
})
const vcsKey = createMemo(
() =>
[
@@ -130,22 +127,25 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
const demand = input.demand
return demand.tree + demand.files + demand.panel > 0
})
const vcsQuery = createQuery(() => {
const value = vcsMode()
const turnKey = () => [ctx.id, view.server.id, "session-turn", view.id] as const
const diffQuery = createQuery(() => {
const value = mode()
const turn = value === "turn"
return {
queryKey: [...vcsKey(), value] as const,
queryKey: turn ? turnKey() : ([...vcsKey(), value] as const),
enabled: view.server.connected && wantsReview() && !!view.project?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
? () =>
// A finished turn does not change on focus or filesystem events; refresh it when the session goes idle.
refetchOnWindowFocus: !turn,
queryFn: turn
? () => view.server.client.session.diff({ sessionID: view.id })
: () =>
view.server.client.vcs
.diff({
location: { directory: directory() },
mode: value === "git" ? "working" : value,
})
.then((result) => result.data)
: skipToken,
.then((result) => result.data),
}
})
// The summary's changes row: the session directory's working tree, loaded only while the summary shows.
@@ -180,20 +180,17 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
on(
() => !layout.narrow() && layout.side.opened(view),
(open, previous) => {
if (!open || previous || vcsQuery.isFetching) return
if (!open || previous || diffQuery.isFetching) return
if (input.demand.tree > 0) {
refresh()
return
}
if (vcsMode() && view.server.connected && view.project?.vcs) void vcsQuery.refetch()
if (view.server.connected && view.project?.vcs) void diffQuery.refetch()
},
{ defer: true },
),
)
const diffs = (): FileDiffInfo[] => {
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return []
}
const diffs = (): FileDiffInfo[] => (diffQuery.isFetched ? (diffQuery.data ?? []) : [])
const renderable = createMemo(() => diffs().filter(filterRenderableDiff))
const kinds = createMemo(() => reviewDiffKinds(renderable()))
const activeFile = () => {
@@ -205,17 +202,12 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
// A project without VCS never enables diffQuery, so its status stays "pending" forever.
const project = view.project
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
return !diffQuery.isPending
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
const root = reviewRootDirectory(view.project?.worktree ?? directory())
const scoped = reviewDiffDirectory(root, path)
const source = diffs().find((diff) => diff.file === path)
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
if (!diff || !source) return undefined
@@ -223,6 +215,24 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
if (reviewDiffNeedsLoad(diff)) return undefined
return diff
}
const value = mode()
// Oversized full-file patches come back empty; bounded context usually fits.
if (value === "turn") {
return queryClient
.fetchQuery({
queryKey: [...turnKey(), "bounded", version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () => view.server.client.session.diff({ sessionID: view.id, context: 3 }),
})
.then((result) => valid(result.find((diff) => diff.file === path)))
.catch((error: unknown) => {
console.debug("[session-review] failed to load bounded turn diff", { path, error })
return undefined
})
}
const root = reviewRootDirectory(view.project?.worktree ?? directory())
const scoped = reviewDiffDirectory(root, path)
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
@@ -376,6 +386,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
(next, previous) => {
if (next !== "idle" || previous === undefined || previous === "idle") return
refresh()
void queryClient.invalidateQueries({ queryKey: turnKey() })
},
{ defer: true },
),
@@ -416,7 +427,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
count,
deferRender: () => state.deferRender,
details: (): FileDiffInfo[] | undefined => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
diffVersion: () => vcsQuery.dataUpdatedAt,
diffVersion: () => diffQuery.dataUpdatedAt,
diffs,
renderable,
kinds,
+2 -2
View File
@@ -27,7 +27,7 @@ export function ReviewTitle(props: { review: ReviewModel }) {
export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }) {
const ctx = useExtension()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const loading = () => !props.review.ready()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return ctx.t("empty.git")
@@ -60,7 +60,7 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
export function ReviewPanelEmpty(props: { review: ReviewModel }) {
const ctx = useExtension()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const loading = () => !props.review.ready()
const noGit = () => props.review.noGit()
return (
<Switch>
+16 -426
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": [
{
@@ -4629,21 +4508,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4727,6 +4591,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7318,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"],
@@ -13871,9 +13631,6 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13888,9 +13645,6 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13906,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": {
@@ -14114,12 +13746,6 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -16083,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"]
@@ -18631,16 +18234,6 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -19060,9 +18653,6 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
@@ -17,7 +17,7 @@ export type UpdateState =
export type UpdateSource = {
readonly remote: boolean
readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise<void>
readonly subscribe: (notify: (notice: ClientNotice) => void) => () => void
readonly check: (
signal: AbortSignal,
onInstall: (version: string) => void,
@@ -115,15 +115,8 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider
}
onMount(() => {
const updater = props.updater
if (!updater) return
const controller = new AbortController()
onCleanup(() => controller.abort())
void updater
.subscribe((notice) => notify({ ...notice, source: "client" }), controller.signal)
.catch((error) => {
if (!controller.signal.aborted) log.error("update check failed", { error })
})
if (!props.updater) return
onCleanup(props.updater.subscribe((notice) => notify({ ...notice, source: "client" })))
})
onCleanup(
+10 -6
View File
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test"
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Global } from "@opencode/util/global"
import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
@@ -216,6 +215,7 @@ test.each(["dismissed", "refreshing"])(
)
test("SIGHUP clears title and disposes scoped resources once", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
let started!: () => void
@@ -243,7 +243,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await ready
process.emit("SIGHUP")
@@ -259,6 +259,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
})
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
let initialTitle!: () => void
const initialTitleSet = new Promise<void>((resolve) => {
@@ -319,7 +320,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await initialTitleSet
@@ -345,6 +346,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
})
test("session title generated while an untitled session is loading remains visible", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
@@ -393,7 +395,7 @@ test("session title generated while an untitled session is loading remains visib
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await sessionRequested.promise
@@ -802,6 +804,7 @@ test("keeps assistant footer metrics current after prepend, same-length refresh,
})
test("session startup prompt is submitted exactly once", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const events = createEventStream()
const cwd = process.cwd()
@@ -855,7 +858,7 @@ test("session startup prompt is submitted exactly once", async () => {
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await Promise.race([
@@ -1344,6 +1347,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
test.skipIf(process.platform === "win32").each(["manual", "select"] as const)(
"selection copy and pane management respect %s mode in the prompt and terminal pane",
async (copy) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
@@ -1426,7 +1430,7 @@ test.skipIf(process.platform === "win32").each(["manual", "select"] as const)(
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
+4 -2
View File
@@ -1,9 +1,9 @@
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Global } from "@opencode/util/global"
import type { TuiInput } from "../../src/app"
import type { Config } from "../../src/config"
import { tmpdir } from "./fixture"
import { createEventStream, createFetch, type FetchHandler } from "./tui-client"
export async function createAppFixture(
@@ -17,6 +17,7 @@ export async function createAppFixture(
} = {},
) {
const { run } = await import("../../src/app")
const state = input.state ? undefined : await tmpdir()
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
@@ -38,7 +39,7 @@ export async function createAppFixture(
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
Effect.provide(Global.layerWith({ state: input.state ?? state?.path })),
Effect.provide(FileSystem.layerNoop({})),
),
)
@@ -52,6 +53,7 @@ export async function createAppFixture(
await task
} finally {
await server.stop()
await state?.[Symbol.asyncDispose]()
}
},
}
+9 -1
View File
@@ -32,6 +32,8 @@ export namespace FSUtil {
readonly start: string
readonly stop?: string
readonly mode?: "all" | "first"
/** Only match regular files or directories (following symlinks). By default any existing path matches. */
readonly type?: "file" | "directory"
}
export interface Interface extends FileSystem.FileSystem {
@@ -165,7 +167,13 @@ export namespace FSUtil {
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) {
const found =
options.type === "file"
? yield* isFile(search)
: options.type === "directory"
? yield* isDir(search)
: yield* fs.exists(search)
if (found) {
result.push(search)
if (options.mode === "first") return result
}
+16 -426
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": [
{
@@ -4629,21 +4508,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4727,6 +4591,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7318,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"],
@@ -13871,9 +13631,6 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13888,9 +13645,6 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13906,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": {
@@ -14114,12 +13746,6 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -16083,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"]
@@ -18631,16 +18234,6 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -19060,9 +18653,6 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
+16 -426
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": [
{
@@ -4629,21 +4508,6 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -4727,6 +4591,21 @@
"pattern": "^frm_"
},
"required": true
},
{
"name": "message",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"required": false
}
],
"security": [],
@@ -7318,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"],
@@ -13871,9 +13631,6 @@
"method": {
"type": "string",
"enum": ["key", "oauth"]
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "id", "label", "method"],
@@ -13888,9 +13645,6 @@
},
"name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/Connection.Status"
}
},
"required": ["type", "name"],
@@ -13906,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": {
@@ -14114,12 +13746,6 @@
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.Answer_1": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Form.Value"
}
},
"Form.BooleanField": {
"type": "object",
"properties": {
@@ -16083,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"]
@@ -18631,16 +18234,6 @@
"type": "integer",
"minimum": 100,
"maximum": 599
},
"response": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"],
"additionalProperties": false
}
},
"required": ["type", "message"],
@@ -19060,9 +18653,6 @@
"exit": {
"type": "number"
},
"signal": {
"type": "string"
},
"metadata": {
"type": "object"
},
@@ -3,7 +3,7 @@ title: "Commands"
description: "Reference for the opencode command line."
---
Every command accepts `--help` for its full flag list, for example `opencode run --help`. Commands that talk to a server also accept `--standalone` to run a private server and `--server <url>` to target a specific one.
Every command accepts `--help` for its full flag list, for example `opencode run --help`. Some server-backed commands accept `--standalone` to run a private server or `--server <url>` to target a specific one; check the command's help for its supported flags.
## run
@@ -167,7 +167,7 @@ $ opencode auth login anthropic
Log in with a specific authentication method.
```bash
$ opencode auth login anthropic --method api-key
$ opencode auth login anthropic --method key
```
Log out of a saved account.
@@ -462,13 +462,13 @@ $ opencode api GET /api/session
Call an operation ID with a query parameter.
```bash
$ opencode api v2.session.list --param limit=10
$ opencode api session.list --param limit=10
```
Send a JSON body.
```bash
$ opencode api v2.session.create --data '{"title": "New session"}'
$ opencode api session.create --data '{"title": "New session"}'
```
Add a request header.
@@ -540,7 +540,7 @@ $ opencode upgrade
Upgrade to a specific version with a specific package manager.
```bash
$ opencode upgrade 1.18.15 --method bun
$ opencode upgrade 2.0.21 --method bun
```
View all subcommands and flags.
@@ -353,7 +353,7 @@ Use permission actions to hide or deny tools without disconnecting their server.
## Context
For calls made on behalf of a session, OpenCode sends the session ID in `CallToolRequest.params._meta.sessionID`. This applies to direct tools and Code Mode over stdio and Streamable HTTP:
For calls made on behalf of a session, OpenCode sends the session ID in `CallToolRequest.params._meta["ai.opencode/sessionID"]`. This applies to direct tools and Code Mode over stdio and Streamable HTTP:
```json
{
@@ -361,7 +361,7 @@ For calls made on behalf of a session, OpenCode sends the session ID in `CallToo
"params": {
"name": "lookup",
"arguments": { "query": "example" },
"_meta": { "sessionID": "ses_..." }
"_meta": { "ai.opencode/sessionID": "ses_..." }
}
}
```
+31 -3
View File
@@ -268,7 +268,11 @@ choose **Microsoft Entra ID (Azure CLI)** when connecting Azure.
az login
```
For a resource in another tenant or subscription, select both explicitly.
Enter the resource name when connecting if it is not already supplied by configuration or the environment. A resource
name saved with a connection takes precedence over configuration; connect again to change it.
Azure CLI connections use the account selected in the Azure CLI. For a resource in another tenant or subscription,
select both explicitly.
```bash
az login --tenant TENANT_ID
@@ -278,8 +282,32 @@ az account set --subscription NAME_OR_ID
Instead of configuration, `AZURE_RESOURCE_NAME` supplies the resource name to the OpenCode server. The legacy
`AZURE_COGNITIVE_SERVICES_RESOURCE_NAME` variable also works.
OpenCode does not query Azure management APIs or discover deployments. If a deployment does not match its catalog
model name, map an OpenCode model ID to the deployment with `modelID`.
### Deployments
Azure serves a model only through a deployment, so OpenCode lists the deployments of your resource and shows those
instead of the whole Azure catalog. The list loads in the background after startup, after connecting, and after
switching accounts. To pick up a deployment added later, restart OpenCode or connect again.
- Until the list loads for a connection, or if it fails, the whole catalog stays available.
- A failed reload for the same connection keeps its last complete inventory.
- Switching accounts never shows the previous account's inventory; the new account starts from the whole catalog.
- Deployments inherit limits, costs, and capabilities from their catalog models.
Deployment names are their model IDs, in lowercase because Azure ignores case in names, for example
`azure/gpt-production`. A deployment named after its model, such as `gpt-5-mini`, keeps the catalog ID. These IDs stay
stable when other deployments are added or removed.
OpenCode matches a deployment to the catalog by the model it deploys, or by its name when Azure spells the model
differently, as with `gpt-4` for GPT-4 Turbo. A deployment named after another model, such as `gpt-5` deploying
`gpt-5-mini`, shows that model's name, limits, and costs.
With the Azure CLI, OpenCode lists deployments through the Azure management API, which needs read access to the
resource. If that fails, and always with an API key, it uses the resource's legacy deployment inventory. Discovery reads
every page before publishing the list. A custom `settings.baseURL` keeps the catalog.
Configure a deployment explicitly when its model is not in the catalog, such as a fine-tuned model, or to map a model ID
to a deployment yourself. OpenCode logs a warning naming such deployments. Explicitly configured models are always
kept.
```jsonc title="opencode.jsonc"
{
+2 -1
View File
@@ -12,7 +12,7 @@ The first search asks you to allow web search and select a provider. OpenCode re
## Providers
OpenCode includes four search providers:
OpenCode includes five search providers:
| Provider | ID | Environment variable |
| --- | --- | --- |
@@ -20,6 +20,7 @@ OpenCode includes four search providers:
| Firecrawl | `firecrawl` | `FIRECRAWL_API_KEY` |
| Parallel | `parallel` | `PARALLEL_API_KEY` |
| Tavily | `tavily` | `TAVILY_API_KEY` |
| TinyFish | `tinyfish` | `TINYFISH_API_KEY` (optional) |
Connect an account from the TUI with `/connect`, or set the provider's environment variable before starting OpenCode.