mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-01 21:16:24 +00:00
Compare commits
40
Commits
generate-model
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9f0a9ae3 | ||
|
|
f9e61fd9a3 | ||
|
|
5256112bac | ||
|
|
afc5d1e21f | ||
|
|
80ecb3fc89 | ||
|
|
31a683f5ca | ||
|
|
5d707f1a2b | ||
|
|
e2948f5a51 | ||
|
|
fbe8a9f7b4 | ||
|
|
033b9ea560 | ||
|
|
7bb3fbf095 | ||
|
|
507e117466 | ||
|
|
1549712761 | ||
|
|
4f37be6265 | ||
|
|
7b04288099 | ||
|
|
96339544bf | ||
|
|
b03d760f43 | ||
|
|
b73e75fb6a | ||
|
|
08c15ec1cd | ||
|
|
48f3c2e923 | ||
|
|
3d94a90978 | ||
|
|
2c33922412 | ||
|
|
7e42a897bc | ||
|
|
d8663e1fe8 | ||
|
|
a6b10e3e87 | ||
|
|
3400375fb3 | ||
|
|
ffb51318f6 | ||
|
|
b1157ebb04 | ||
|
|
3e4cdcd180 | ||
|
|
0a6f380db7 | ||
|
|
44336e8b84 | ||
|
|
20cf09752d | ||
|
|
966e718b31 | ||
|
|
cf0c9fb914 | ||
|
|
bebc3640f4 | ||
|
|
aa6a4f93bd | ||
|
|
8433dd732f | ||
|
|
c34d09fe97 | ||
|
|
2fb7985bb7 | ||
|
|
a931e8a9fd |
No files matched your search
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
echo "=== Flake structure ==="
|
||||
nix flake show --all-systems
|
||||
|
||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||
SYSTEMS="x86_64-linux aarch64-linux aarch64-darwin"
|
||||
PACKAGES="opencode"
|
||||
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
|
||||
OPTIONAL_PACKAGES="desktop"
|
||||
|
||||
@@ -34,8 +34,6 @@ jobs:
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- system: aarch64-linux
|
||||
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- system: x86_64-darwin
|
||||
runner: macos-15-intel
|
||||
- system: aarch64-darwin
|
||||
runner: macos-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
@@ -126,7 +124,7 @@ jobs:
|
||||
|
||||
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
|
||||
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
|
||||
for SYSTEM in x86_64-linux aarch64-linux aarch64-darwin; do
|
||||
FILE="hashes/hash-${SYSTEM}/hash.txt"
|
||||
if [ -f "$FILE" ]; then
|
||||
HASH="$(tr -d '[:space:]' < "$FILE")"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
rev = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
@@ -22,7 +21,7 @@
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
bun
|
||||
nodejs_20
|
||||
nodejs
|
||||
pkg-config
|
||||
openssl
|
||||
git
|
||||
|
||||
@@ -7,7 +7,6 @@ in
|
||||
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
|
||||
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
|
||||
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
|
||||
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
|
||||
# fetchzip hashes the unpacked headers, not the release tarball.
|
||||
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
|
||||
}
|
||||
+3
-4
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-7DgxTpKv6ITKTom0mhlJNPQfdEjtEHK5P8uyCr26HZw=",
|
||||
"aarch64-linux": "sha256-PJxW1Ibfx6oS1neWPSHqzP1Pm1HT9my1TrrHmOb6/no=",
|
||||
"aarch64-darwin": "sha256-VIme5VHfM8JxNiDSOykkr5FytghDLI0FxkhiOXUSyQw=",
|
||||
"x86_64-darwin": "sha256-rQ/j0QkR1vxAq4jgUbr0nY4RDyiLTJqN8q1AfoiEqVQ="
|
||||
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
|
||||
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
|
||||
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,5 @@ stdenvNoCC.mkDerivation {
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,15 @@ 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,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
// Z.ai code 1261 arrives as `Prompt too long` or `Prompt 超长`.
|
||||
/prompt (?:too long|超长)/i,
|
||||
/too large for model with \d+ maximum context length/i,
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||
/model_context_window_exceeded/i,
|
||||
@@ -156,13 +159,15 @@ const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const AUTH_TEXT = /incorrect api key provided/i
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Z.ai reports balance, plan expiry, plan limits, and plan model access on 429.
|
||||
const QUOTA_TEXT =
|
||||
/insufficient[-_\s]?(?:quota|balance)|quota[-_\s]?exceeded|budget exceeded|usage limit|limit exhausted|package has expired|plan does not yet include/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
// under `invalid_request_error`.
|
||||
const CONTENT_POLICY_TEXT =
|
||||
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system/i
|
||||
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system|detected potentially unsafe or sensitive content/i
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -6,10 +6,17 @@ const MISSING_TOOL_RESULT = "Tool result missing"
|
||||
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
const normalized: Message[] = []
|
||||
const pending = new Map<string, ToolCallPart>()
|
||||
// System updates cannot sit between a tool call and its results, so they wait until every pending call is answered.
|
||||
const held: Message[] = []
|
||||
const releaseHeld = () => {
|
||||
if (pending.size > 0) return
|
||||
normalized.push(...held)
|
||||
held.length = 0
|
||||
}
|
||||
const appendMissingResults = () => {
|
||||
if (pending.size === 0) return
|
||||
normalized.push(missingToolResults(pending.values()))
|
||||
if (pending.size > 0) normalized.push(missingToolResults(pending.values()))
|
||||
pending.clear()
|
||||
releaseHeld()
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
@@ -18,6 +25,12 @@ export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
if (message.role === "tool") {
|
||||
const tool = normalizeToolMessage(message, pending)
|
||||
if (tool) normalized.push(tool)
|
||||
releaseHeld()
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.role === "system" && pending.size > 0) {
|
||||
held.push(message)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a marker between a tool call and its result", () =>
|
||||
it.effect("moves a marker between a tool call and its result after the result", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -166,8 +166,8 @@ describe("Anthropic Messages effort updates", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -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}`, () =>
|
||||
|
||||
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+53
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+53
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -284,6 +285,82 @@ describe("provider error classification", () => {
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("classifies Z.ai plan and balance limits as quota rather than throttling", () => {
|
||||
const zai = (code: string, message: string) => ({ error: { code, message } })
|
||||
const cases = [
|
||||
zai("1113", "Insufficient balance or no resource package. Please recharge."),
|
||||
zai("1308", "Usage limit reached for 5 hours. Your limit will reset at 2026-10-01 00:00:00"),
|
||||
zai(
|
||||
"1309",
|
||||
"Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.",
|
||||
),
|
||||
zai("1310", "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-10-01 00:00:00"),
|
||||
zai("1311", "Your current subscription plan does not yet include access to glm-5"),
|
||||
zai("1314", "Your enterprise package has expired. Please contact your enterprise administrator."),
|
||||
// Z.ai's Anthropic-compatible endpoint wraps the code and request ID into the message.
|
||||
{
|
||||
type: "error",
|
||||
error: {
|
||||
type: "rate_limit_error",
|
||||
code: "1309",
|
||||
message:
|
||||
"[1309][Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.][20260929132151e73af01340d54b58]",
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(cases.length).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("classifies Z.ai prompt length rejections as context overflow", () => {
|
||||
const cases = [
|
||||
{ error: { code: "1261", message: "Prompt 超长" } },
|
||||
{ error: { code: "1261", message: "Prompt too long" } },
|
||||
{
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", code: "1261", message: "[1261][Prompt too long][2026092913]" },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
cases.map((body) => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: body.error.message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify(body),
|
||||
})
|
||||
return reason._tag === "InvalidRequest" ? reason.classification : reason._tag
|
||||
}),
|
||||
).toEqual(["context-overflow", "context-overflow", "context-overflow"])
|
||||
})
|
||||
|
||||
test("classifies Z.ai sensitive content rejections as content policy", () => {
|
||||
const message =
|
||||
"System detected potentially unsafe or sensitive content in input or generation. Please avoid using prompts that may generate sensitive content. Thank you for your cooperation."
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify({ error: { code: "1301", message } }),
|
||||
})._tag,
|
||||
).toBe("ContentPolicy")
|
||||
})
|
||||
|
||||
test("keeps Z.ai throttling and overload retryable", () => {
|
||||
expect(
|
||||
[
|
||||
{ error: { code: "1302", message: "Rate limit reached for requests" } },
|
||||
{ error: { code: "1305", message: "The service may be temporarily overloaded, please try again later" } },
|
||||
].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["RateLimit", "RateLimit"])
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
|
||||
@@ -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) =>
|
||||
@@ -588,9 +591,9 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a system update between a local tool call and its result", () =>
|
||||
it.effect("moves a system update between a local tool call and its result after the result", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
@@ -601,9 +604,13 @@ describe("Anthropic Messages route", () => {
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
)
|
||||
|
||||
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
|
||||
expect(prepared.body.messages.slice(1)).toEqual([
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '"Done."' }] },
|
||||
{ role: "system", content: [{ type: "text", text: "Too early.", cache_control: undefined }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1669,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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -112,4 +112,51 @@ describe("tool history normalization", () => {
|
||||
|
||||
expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined })
|
||||
})
|
||||
|
||||
test("moves system updates after the results of pending calls", () => {
|
||||
const calls = Message.assistant([toolCall("first"), toolCall("second")])
|
||||
const first = toolResult("first", "one", "first", "text")
|
||||
const second = toolResult("second", "two", "second", "text")
|
||||
const update = Message.system("First update.")
|
||||
const later = Message.system("Second update.")
|
||||
const user = Message.user("Continue.")
|
||||
const missing = (id: string) =>
|
||||
Message.tool(ToolResultPart.make({ id, name: id, result: "Tool result missing", resultType: "error" }))
|
||||
|
||||
expect(normalizeToolHistory([calls, first, update, later, second, user])).toEqual([
|
||||
calls,
|
||||
first,
|
||||
second,
|
||||
update,
|
||||
later,
|
||||
user,
|
||||
])
|
||||
expect(normalizeToolHistory([Message.assistant(toolCall("first")), update, user])).toEqual([
|
||||
Message.assistant(toolCall("first")),
|
||||
missing("first"),
|
||||
update,
|
||||
user,
|
||||
])
|
||||
expect(normalizeToolHistory([Message.assistant(toolCall("first")), update])).toEqual([
|
||||
Message.assistant(toolCall("first")),
|
||||
missing("first"),
|
||||
update,
|
||||
])
|
||||
})
|
||||
|
||||
test("moves effort updates after the results of pending calls", () => {
|
||||
const call = Message.assistant(toolCall("first"))
|
||||
const result = toolResult("first", "one", "first", "text")
|
||||
const effort = Message.effort({ effort: "low", previous: "high" })
|
||||
|
||||
expect(normalizeToolHistory([call, effort, result])).toEqual([call, result, effort])
|
||||
})
|
||||
|
||||
test("keeps system updates in place when no call is pending", () => {
|
||||
const history = [Message.assistant(toolCall("first")), toolResult("first", "one", "first", "text")]
|
||||
const update = Message.system("Update.")
|
||||
const input = [Message.user("Start."), update, ...history, update, Message.user("Continue.")]
|
||||
|
||||
expect(normalizeToolHistory(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
@@ -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:",
|
||||
|
||||
+117
-43
@@ -1,52 +1,126 @@
|
||||
import { agent, RequestError, type Stream } from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
agent,
|
||||
RequestError,
|
||||
type AgentHandlerContext,
|
||||
type AgentNotificationHandlersByMethod,
|
||||
type AgentNotificationMethod,
|
||||
type AgentRequestHandlersByMethod,
|
||||
type AgentRequestMethod,
|
||||
type JsonRpcId,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
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"
|
||||
|
||||
export function connect(client: OpenCodeClient, stream: Stream) {
|
||||
const connection = agent({ name: "opencode" })
|
||||
.onRequest("initialize", (ctx) => run(service.initialize(ctx.params)))
|
||||
.onRequest("authenticate", (ctx) => run(service.authenticate(ctx.params)))
|
||||
.onRequest("session/new", (ctx) => run(service.newSession(ctx.params)))
|
||||
.onRequest("session/load", (ctx) => run(service.loadSession(ctx.params)))
|
||||
.onRequest("session/list", (ctx) => run(service.listSessions(ctx.params)))
|
||||
.onRequest("session/delete", (ctx) => run(service.deleteSession(ctx.params)))
|
||||
.onRequest("session/resume", (ctx) => run(service.resumeSession(ctx.params)))
|
||||
.onRequest("session/close", (ctx) => run(service.closeSession(ctx.params)))
|
||||
.onRequest("session/fork", (ctx) => run(service.forkSession(ctx.params)))
|
||||
.onRequest("session/set_config_option", (ctx) => run(service.setSessionConfigOption(ctx.params)))
|
||||
.onRequest("session/set_mode", (ctx) => run(service.setSessionMode(ctx.params)))
|
||||
.onRequest("session/prompt", (ctx) => run(service.prompt(ctx.params, ctx.signal)))
|
||||
.onNotification("session/cancel", (ctx) => run(service.cancel(ctx.params)))
|
||||
.connect(stream)
|
||||
// Inbound dispatch starts after the stream's async read loop yields, so handlers never observe this before assignment.
|
||||
const service = ACPService.make({ client, connection: ACPConnection.make(connection) })
|
||||
return connection
|
||||
}
|
||||
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
|
||||
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
|
||||
const run = Effect.runPromiseWith(yield* Effect.context<Scope.Scope>())
|
||||
const catalog = yield* ACPCatalog.make(client)
|
||||
// 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: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPService.Failure>,
|
||||
) =>
|
||||
(name: string) => {
|
||||
const handler = Effect.fn(name)(
|
||||
(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> & { 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>(
|
||||
method: Method,
|
||||
make: (name: string) => AgentRequestHandlersByMethod[Method],
|
||||
) => app.onRequest(method, make(spanName(method)))
|
||||
const notification = <Method extends AgentNotificationMethod>(
|
||||
method: Method,
|
||||
make: (name: string) => AgentNotificationHandlersByMethod[Method],
|
||||
) => app.onNotification(method, make(spanName(method)))
|
||||
|
||||
async function run<A>(promise: Promise<A>) {
|
||||
try {
|
||||
return await promise
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError) throw error
|
||||
if (isACPError(error)) throw ACPError.toRequestError(error)
|
||||
throw ACPError.toRequestError(ACPError.fromUnknown(error))
|
||||
}
|
||||
}
|
||||
|
||||
function isACPError(error: unknown): error is ACPError.Error {
|
||||
return (
|
||||
error instanceof ACPError.SessionNotFoundError ||
|
||||
error instanceof ACPError.SessionDirectoryMismatchError ||
|
||||
error instanceof ACPError.InvalidConfigOptionError ||
|
||||
error instanceof ACPError.InvalidModelError ||
|
||||
error instanceof ACPError.InvalidEffortError ||
|
||||
error instanceof ACPError.InvalidModeError ||
|
||||
error instanceof ACPError.AuthRequiredError ||
|
||||
error instanceof ACPError.UnknownAuthMethodError ||
|
||||
error instanceof ACPError.ServiceFailureError
|
||||
request(
|
||||
"initialize",
|
||||
handle((service, ctx) => service.initialize(ctx.params)),
|
||||
)
|
||||
}
|
||||
request(
|
||||
"authenticate",
|
||||
handle((service, ctx) => service.authenticate(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/new",
|
||||
handle((service, ctx) => service.newSession(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/load",
|
||||
handle((service, ctx) => service.loadSession(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/list",
|
||||
handle((service, ctx) => service.listSessions(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/delete",
|
||||
handle((service, ctx) => service.deleteSession(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/resume",
|
||||
handle((service, ctx) => service.resumeSession(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/close",
|
||||
handle((service, ctx) => service.closeSession(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/fork",
|
||||
handle((service, ctx) => service.forkSession(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/set_config_option",
|
||||
handle((service, ctx) => service.setSessionConfigOption(ctx.params)),
|
||||
)
|
||||
request(
|
||||
"session/set_mode",
|
||||
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((service, ctx) => service.prompt(ctx.params, ctx.signal)),
|
||||
)
|
||||
notification(
|
||||
"session/cancel",
|
||||
handle((service, ctx) => service.cancel(ctx.params)),
|
||||
)
|
||||
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("/", ".")}`
|
||||
|
||||
export * as ACP from "./agent"
|
||||
+162
-100
@@ -1,128 +1,190 @@
|
||||
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, 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[]
|
||||
}
|
||||
|
||||
export type Live = {
|
||||
export class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
|
||||
reason: Schema.Literals(["models", "agents"]),
|
||||
}) {
|
||||
override get message() {
|
||||
return this.reason === "models" ? "No models are available" : "No primary agents are available"
|
||||
}
|
||||
}
|
||||
|
||||
export class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error = NotReadyError | LoadError
|
||||
|
||||
export interface Interface {
|
||||
/** Loads a directory's catalog once. Concurrent callers share the load, and a failed load is not cached. */
|
||||
readonly get: (cwd: string) => Effect.Effect<Catalog, Error>
|
||||
/** Resolves after a reload that started after the call. A failed reload keeps the previous catalog. */
|
||||
readonly reload: (cwd: string) => Effect.Effect<void, Error>
|
||||
/** Emits the current catalog, then each reloaded one. */
|
||||
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Catalog") {}
|
||||
|
||||
type Entry = {
|
||||
readonly cwd: string
|
||||
current: Catalog
|
||||
readonly catalog: SubscriptionRef.SubscriptionRef<Catalog>
|
||||
readonly lock: Semaphore.Semaphore
|
||||
requested: number
|
||||
loaded: number
|
||||
}
|
||||
|
||||
// Provider, integration, and credential changes reach the catalog through model.updated.
|
||||
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
|
||||
|
||||
export function make(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly signal?: AbortSignal
|
||||
readonly changed: (live: Live, previous: Catalog) => Promise<unknown>
|
||||
}) {
|
||||
const entries = new Map<string, Promise<Live>>()
|
||||
const running = new Map<Live, Promise<void>>()
|
||||
const queued = new Set<Live>()
|
||||
let subscribed: Promise<void> | undefined
|
||||
export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
const scope = yield* Effect.scope
|
||||
const entries = new Map<string, Deferred.Deferred<Entry, Error>>()
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
const get = (cwd: string) => {
|
||||
const key = FSUtil.resolve(cwd)
|
||||
const cached = entries.get(key)
|
||||
if (cached) return cached
|
||||
// Subscribe before the first read so an update between the read and the subscription is not lost.
|
||||
const loaded = subscribe()
|
||||
.then(() => load(input.client, cwd))
|
||||
.then((current): Live => ({ cwd, current }))
|
||||
.catch((error) => {
|
||||
entries.delete(key)
|
||||
throw error
|
||||
})
|
||||
entries.set(key, loaded)
|
||||
return loaded
|
||||
}
|
||||
// A reload covers every request made before it starts, so requests queued behind a running reload share
|
||||
// one more load. Typed load failures keep the previous catalog and still settle the requests they covered.
|
||||
const reload = (entry: Entry) =>
|
||||
Effect.suspend(() => {
|
||||
const target = ++entry.requested
|
||||
return entry.lock.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (entry.loaded >= target) return Effect.void
|
||||
const generation = entry.requested
|
||||
return load(client, entry.cwd).pipe(
|
||||
Effect.flatMap((next) => SubscriptionRef.set(entry.catalog, next)),
|
||||
Effect.ignore,
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
entry.loaded = generation
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reload = (live: Live) => {
|
||||
const current = running.get(live)
|
||||
if (current) {
|
||||
queued.add(live)
|
||||
return current
|
||||
}
|
||||
const run = (async () => {
|
||||
do {
|
||||
queued.delete(live)
|
||||
const next = await load(input.client, live.cwd).catch(() => undefined)
|
||||
if (!next) break
|
||||
const previous = live.current
|
||||
live.current = next
|
||||
await input.changed(live, previous).catch(() => {})
|
||||
} while (queued.has(live))
|
||||
running.delete(live)
|
||||
})()
|
||||
running.set(live, run)
|
||||
return run
|
||||
}
|
||||
// Subscribe before the first read so an update between the read and the subscription is not lost.
|
||||
yield* Stream.fromAsyncIterable(client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return Deferred.succeed(connected, undefined)
|
||||
if (!reloadOn.has(event.type)) return Effect.void
|
||||
const directory = event.location?.directory
|
||||
const targets = directory === undefined ? [...entries.values()] : [entries.get(FSUtil.resolve(directory))]
|
||||
return Effect.forEach(
|
||||
targets.filter((entry) => entry !== undefined),
|
||||
(entry) => Deferred.await(entry).pipe(Effect.flatMap(reload), Effect.ignore, Effect.forkIn(scope)),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
Effect.ignore,
|
||||
Effect.ensuring(Deferred.succeed(connected, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const subscribe = () =>
|
||||
(subscribed ??= new Promise<void>((ready) => {
|
||||
void (async () => {
|
||||
for await (const event of input.client.event.subscribe({ signal: input.signal })) {
|
||||
if (event.type === "server.connected") ready()
|
||||
if (!reloadOn.has(event.type)) continue
|
||||
const directory = event.location?.directory
|
||||
const targets = directory === undefined ? [...entries.values()] : [entries.get(FSUtil.resolve(directory))]
|
||||
targets.forEach((entry) => void entry?.then(reload, () => {}))
|
||||
}
|
||||
})()
|
||||
.catch(() => {})
|
||||
.finally(ready)
|
||||
}))
|
||||
const create = Effect.fnUntraced(function* (cwd: string) {
|
||||
yield* Deferred.await(connected)
|
||||
return {
|
||||
cwd,
|
||||
catalog: yield* SubscriptionRef.make<Catalog>(yield* load(client, cwd)),
|
||||
lock: Semaphore.makeUnsafe(1),
|
||||
requested: 0,
|
||||
loaded: 0,
|
||||
} satisfies Entry
|
||||
})
|
||||
|
||||
return { get, reload }
|
||||
}
|
||||
const entry = (cwd: string) =>
|
||||
Effect.suspend(() => {
|
||||
const key = FSUtil.resolve(cwd)
|
||||
const cached = entries.get(key)
|
||||
if (cached) return Deferred.await(cached)
|
||||
const loading = Deferred.makeUnsafe<Entry, Error>()
|
||||
entries.set(key, loading)
|
||||
return create(cwd).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
if (Exit.isFailure(exit)) entries.delete(key)
|
||||
return Deferred.done(loading, exit)
|
||||
}),
|
||||
Effect.forkIn(scope),
|
||||
Effect.andThen(Deferred.await(loading)),
|
||||
)
|
||||
})
|
||||
|
||||
async function load(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
return Service.of({
|
||||
get: Effect.fn("cli.acp.catalog.get")(function* (cwd) {
|
||||
const loaded = yield* entry(cwd)
|
||||
return yield* SubscriptionRef.get(loaded.catalog)
|
||||
}),
|
||||
reload: Effect.fn("cli.acp.catalog.reload")(function* (cwd) {
|
||||
yield* reload(yield* entry(cwd))
|
||||
}),
|
||||
changes: (cwd) => Stream.unwrap(entry(cwd).pipe(Effect.map((loaded) => SubscriptionRef.changes(loaded.catalog)))),
|
||||
})
|
||||
})
|
||||
|
||||
const load = (client: OpenCodeClient, cwd: string) =>
|
||||
read(client, cwd).pipe(
|
||||
// Some providers discover models in the background after plugin startup begins.
|
||||
Effect.retry({
|
||||
while: (error) => error._tag === "ACPCatalogNotReadyError",
|
||||
schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })),
|
||||
}),
|
||||
Effect.withSpan("cli.acp.catalog.load"),
|
||||
)
|
||||
|
||||
const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
const location = { directory: cwd }
|
||||
// Some providers discover models in the background after plugin startup begins.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
|
||||
client.model.list({ location }),
|
||||
client.model.default({ location }),
|
||||
client.agent.list({ location }),
|
||||
client.command.list({ location }),
|
||||
])
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// Parallel reads can straddle initialization; select only from this model list.
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
|
||||
if (defaultModel && defaultAgent) {
|
||||
return {
|
||||
providers: providers(models),
|
||||
models,
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data,
|
||||
}
|
||||
}
|
||||
missing = defaultModel ? "No primary agents are available" : "No models are available"
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error(missing)
|
||||
}
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
Promise.all([
|
||||
client.model.list({ location }, { signal }),
|
||||
client.model.default({ location }, { signal }),
|
||||
client.agent.list({ location }, { signal }),
|
||||
client.command.list({ location }, { signal }),
|
||||
]),
|
||||
catch: (cause) => new LoadError({ cause }),
|
||||
})
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// Parallel reads can straddle initialization; select only from this model list.
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
if (!defaultModel) return yield* new NotReadyError({ reason: "models" })
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
// Core lists its resolved default agent first, the same one a new session runs.
|
||||
const defaultAgent = agents[0]
|
||||
if (!defaultAgent) return yield* new NotReadyError({ reason: "agents" })
|
||||
return {
|
||||
providers: providers(models),
|
||||
models,
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data.filter((command) => !builtinCommands.has(command.name)),
|
||||
} satisfies Catalog
|
||||
})
|
||||
|
||||
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,30 +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 WriteTextFileRequest,
|
||||
type WriteTextFileResponse,
|
||||
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>
|
||||
writeTextFile?(params: WriteTextFileRequest, options?: SendRequestOptions): Promise<WriteTextFileResponse>
|
||||
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),
|
||||
writeTextFile: (params, options) => connection.client.request(methods.client.fs.writeTextFile, 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)
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -28,28 +28,51 @@ 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),
|
||||
errorName: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| SessionDirectoryMismatchError
|
||||
| InvalidConfigOptionError
|
||||
| InvalidModelError
|
||||
| InvalidEffortError
|
||||
| InvalidModeError
|
||||
| AuthRequiredError
|
||||
| UnknownAuthMethodError
|
||||
| ServiceFailureError
|
||||
export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailableError>()(
|
||||
"ACPServerUnavailableError",
|
||||
{},
|
||||
) {}
|
||||
|
||||
const Errors = Schema.Union([
|
||||
SessionNotFoundError,
|
||||
SessionDirectoryMismatchError,
|
||||
InvalidConfigOptionError,
|
||||
InvalidModelError,
|
||||
InvalidEffortError,
|
||||
InvalidModeError,
|
||||
InvalidAdditionalDirectoryError,
|
||||
AuthRequiredError,
|
||||
UnknownAuthMethodError,
|
||||
InvalidRequestError,
|
||||
ServiceFailureError,
|
||||
ServerUnavailableError,
|
||||
])
|
||||
|
||||
export type Error = typeof Errors.Type
|
||||
|
||||
export const is = Schema.is(Errors)
|
||||
|
||||
export function toRequestError(error: Error): RequestError {
|
||||
switch (error._tag) {
|
||||
@@ -71,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(
|
||||
{
|
||||
@@ -83,6 +113,8 @@ export function toRequestError(error: Error): RequestError {
|
||||
},
|
||||
error.safeMessage,
|
||||
)
|
||||
case "ACPServerUnavailableError":
|
||||
return RequestError.internalError({ errorName: "ServerUnavailable" }, "OpenCode server is unavailable")
|
||||
}
|
||||
const exhaustive: never = error
|
||||
return exhaustive
|
||||
|
||||
@@ -1,624 +0,0 @@
|
||||
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
} from "@opencode/client/promise"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import { replyPermission, syncEditedFiles } from "./permission"
|
||||
import {
|
||||
completedToolUpdate,
|
||||
errorToolUpdate,
|
||||
pendingToolCall,
|
||||
runningToolUpdate,
|
||||
type ToolContent,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
|
||||
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
|
||||
|
||||
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 writeTextFile: boolean
|
||||
readonly action?: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
readonly connectionSignal?: AbortSignal
|
||||
readonly sessionSignal?: AbortSignal
|
||||
}): Promise<PromptResponse> {
|
||||
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 assistantMessageID: string | undefined
|
||||
let finish: SessionMessageAssistant["finish"]
|
||||
let executionError: { readonly type: string; readonly message: string } | 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 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) assistantMessageID = event.data.assistantMessageID
|
||||
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") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
await send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
tools.set(toolKey(event.data.sessionID, event.data.id), {
|
||||
name: event.data.name,
|
||||
input: {},
|
||||
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") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
current.input = event.data.input
|
||||
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 syncEditedFiles({
|
||||
connection: input.connection,
|
||||
writeTextFile: input.writeTextFile,
|
||||
sessionID: input.sessionID,
|
||||
cwd: input.cwd,
|
||||
toolName: current.name,
|
||||
toolInput: current.input,
|
||||
metadata: event.data.metadata ?? {},
|
||||
signal: control.admission.signal,
|
||||
}).catch(() => {})
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
}),
|
||||
})
|
||||
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) {
|
||||
assistantMessageID = event.data.assistantMessageID
|
||||
finish = event.data.finish
|
||||
}
|
||||
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(undefined, undefined, "succeeded", control.cancelled, undefined)
|
||||
}
|
||||
if (control.cancelled) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
if (!started) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "interrupted", true, undefined)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
const assistant = assistantMessageID
|
||||
? await input.client.session
|
||||
.message.get({ sessionID: input.sessionID, messageID: assistantMessageID })
|
||||
.catch(() => undefined)
|
||||
: undefined
|
||||
return response(
|
||||
assistant?.type === "assistant" ? assistant : undefined,
|
||||
executionError,
|
||||
terminal,
|
||||
control.cancelled,
|
||||
finish,
|
||||
retries.get(input.sessionID),
|
||||
)
|
||||
} 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,
|
||||
}),
|
||||
},
|
||||
})
|
||||
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(
|
||||
assistant: SessionMessageAssistant | undefined,
|
||||
executionError: { readonly type: string; readonly message: string } | undefined,
|
||||
terminal: "succeeded" | "failed" | "interrupted",
|
||||
cancelled: boolean,
|
||||
finish: SessionMessageAssistant["finish"],
|
||||
retry?: RetryStatus,
|
||||
): PromptResponse {
|
||||
const error = assistant?.error ?? executionError
|
||||
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
throw new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
const tokens = assistant?.tokens
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write,
|
||||
...(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"
|
||||
@@ -1,66 +1,78 @@
|
||||
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 { Result } from "effect"
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import { Cause, Effect } from "effect"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { pendingToolCall, stringValue, toLocations, toToolKind, 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" | "writeTextFile">
|
||||
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,
|
||||
}),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
},
|
||||
options,
|
||||
}
|
||||
// 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) {
|
||||
@@ -69,83 +81,50 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
export async function syncEditedFiles(input: {
|
||||
readonly connection: Pick<ACPConnection.Connection, "writeTextFile">
|
||||
readonly writeTextFile: boolean
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly toolName: string
|
||||
readonly toolInput: ToolInput
|
||||
readonly metadata: Readonly<Record<string, unknown>>
|
||||
readonly signal?: AbortSignal
|
||||
}) {
|
||||
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
||||
const files = Array.isArray(input.metadata.files)
|
||||
? input.metadata.files.flatMap((file): string[] => {
|
||||
if (!file || typeof file !== "object") return []
|
||||
const path = "file" in file ? file.file : undefined
|
||||
return typeof path === "string" ? [path] : []
|
||||
})
|
||||
: []
|
||||
const path = filePath(input.toolInput)
|
||||
const paths = [...new Set([...files, ...(path ? [path] : [])])]
|
||||
await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const target = resolvePath(path, input.cwd)
|
||||
const file = Bun.file(target)
|
||||
if (!(await file.exists())) return
|
||||
await input.connection.writeTextFile?.(
|
||||
{ sessionId: input.sessionID, path: target, content: await file.text() },
|
||||
{ cancellationSignal: input.signal },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
||||
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
|
||||
const path = filePath(input)
|
||||
if (!path) return []
|
||||
const oldText = await readText(path, cwd)
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
const file = filePath(input)
|
||||
if (!file) return []
|
||||
const path = absolutePath(file, cwd)
|
||||
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)]
|
||||
})
|
||||
|
||||
async function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
||||
const patchText = stringValue(input.patchText)
|
||||
if (!patchText) return []
|
||||
try {
|
||||
const parsed = Patch.parse(patchText)
|
||||
if (Result.isFailure(parsed)) return []
|
||||
return await Promise.all(
|
||||
parsed.success.map(async (hunk): Promise<ToolCallContent> => {
|
||||
const oldText = hunk.type === "add" ? "" : await readText(hunk.path, cwd)
|
||||
// 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 { type: "diff", path: hunk.path, oldText, newText }
|
||||
}
|
||||
if (hunk.type === "delete") return { type: "diff", path: hunk.path, oldText, newText: "" }
|
||||
return {
|
||||
type: "diff",
|
||||
path: hunk.movePath ?? hunk.path,
|
||||
oldText,
|
||||
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
|
||||
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)
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
|
||||
}
|
||||
|
||||
function diff(path: string, oldText: string, newText: string): ToolCallContent {
|
||||
return { type: "diff", path, oldText, newText }
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
|
||||
@@ -176,27 +155,15 @@ function permissionLocations(
|
||||
input: ToolInput,
|
||||
resources: ReadonlyArray<string>,
|
||||
cwd: string,
|
||||
previews: ReadonlyArray<ToolCallContent>,
|
||||
): ToolCallLocation[] {
|
||||
const paths = previews.flatMap((preview) => (preview.type === "diff" ? [preview.path] : []))
|
||||
if (paths.length > 0) return [...new Set(paths)].map((path) => ({ path }))
|
||||
const locations = toLocations(toolName, input, cwd)
|
||||
if (locations.length > 0) return locations
|
||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
|
||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
|
||||
}
|
||||
|
||||
function readText(path: string, cwd: string) {
|
||||
return Bun.file(resolvePath(path, cwd))
|
||||
.text()
|
||||
.catch(() => "")
|
||||
}
|
||||
|
||||
function filePath(input: ToolInput) {
|
||||
return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
|
||||
}
|
||||
|
||||
function resolvePath(path: string, cwd: string) {
|
||||
return isAbsolute(path) ? path : resolve(cwd, path)
|
||||
// A missing file previews as empty.
|
||||
function readText(path: string) {
|
||||
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
|
||||
}
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
@@ -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"
|
||||
+204
-456
@@ -1,13 +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 { Effect, Option, Ref, Result, Stream } from "effect"
|
||||
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
|
||||
import type {
|
||||
AuthenticateRequest,
|
||||
@@ -26,11 +25,11 @@ import type {
|
||||
ListSessionsResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestError,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
@@ -39,171 +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"
|
||||
|
||||
// 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
|
||||
}
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
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 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 = { writeTextFile: false, childSessionUpdates: false }
|
||||
|
||||
const catalogs = ACPCatalog.make({
|
||||
client: input.client,
|
||||
signal: input.connection.signal,
|
||||
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.map((command) => ({
|
||||
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.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||
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",
|
||||
@@ -220,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,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
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 (response) => {
|
||||
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
|
||||
return 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 ? 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?.name === "compact") 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 &&
|
||||
@@ -440,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"
|
||||
@@ -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"
|
||||
@@ -1,6 +1,8 @@
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||
import { readDisplayText } from "@opencode/tui/mini/tool"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { Result } from "effect"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
export type ToolContent = ReadonlyArray<
|
||||
@@ -36,28 +38,29 @@ export function toToolKind(toolName: string): ToolKind {
|
||||
}
|
||||
}
|
||||
|
||||
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
|
||||
export function toLocations(toolName: string, input: ToolInput, cwd: string): ToolCallLocation[] {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
case "shell": {
|
||||
const workdir = shellWorkdir(input, cwd)
|
||||
return workdir ? [{ path: workdir }] : []
|
||||
}
|
||||
case "shell":
|
||||
return locationFrom(cwd, stringValue(input.workdir) ?? stringValue(input.cwd) ?? cwd)
|
||||
case "read":
|
||||
return locationFrom(input.path)
|
||||
case "edit":
|
||||
case "write":
|
||||
return locationFrom(cwd, filePath(input))
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return locationFrom(input.filePath ?? input.filepath)
|
||||
return locationFrom(
|
||||
cwd,
|
||||
...patchHunks(input).flatMap((hunk) => [hunk.path, hunk.type === "update" ? hunk.movePath : undefined]),
|
||||
)
|
||||
case "external_directory":
|
||||
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
|
||||
return locationFrom(cwd, input.filepath)
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return locationFrom(input.path)
|
||||
return locationFrom(cwd, input.path)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -67,7 +70,7 @@ export function pendingToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly state: { readonly input: ToolInput; readonly title?: string }
|
||||
readonly cwd?: string
|
||||
readonly cwd: string
|
||||
}): ToolCall {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
@@ -84,7 +87,7 @@ export function runningToolUpdate(input: {
|
||||
readonly toolName: string
|
||||
readonly state: { readonly input: ToolInput; readonly title?: string }
|
||||
readonly content?: ToolContent
|
||||
readonly cwd?: string
|
||||
readonly cwd: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
@@ -103,6 +106,7 @@ export function completedToolUpdate(input: {
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly cwd: string
|
||||
}): ToolCallUpdate {
|
||||
const normalized = toolContent(input.content)
|
||||
// Read's model content is a JSON page envelope; show the clean text instead.
|
||||
@@ -115,20 +119,15 @@ export function completedToolUpdate(input: {
|
||||
: [{ type: "content" as const, content: { type: "text" as const, text: read } }]
|
||||
const oldText = stringValue(input.input.oldString)
|
||||
const newText = stringValue(input.input.newString)
|
||||
const path = filePath(input.input)
|
||||
const diff: ToolCallContent[] =
|
||||
oldText === undefined || newText === undefined
|
||||
oldText === undefined || newText === undefined || path === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "diff",
|
||||
path: stringValue(input.input.path) ?? stringValue(input.input.filePath) ?? "",
|
||||
oldText,
|
||||
newText,
|
||||
},
|
||||
]
|
||||
: [{ type: "diff", path: absolutePath(path, input.cwd), oldText, newText }]
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "completed",
|
||||
locations: toLocations(input.toolName, input.input, input.cwd),
|
||||
content: [...primary, ...diff, ...images],
|
||||
rawOutput: {
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
@@ -143,7 +142,7 @@ export function errorToolUpdate(input: {
|
||||
readonly content?: ToolContent
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly error: string
|
||||
readonly cwd?: string
|
||||
readonly cwd: string
|
||||
}): ToolCallUpdate {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
@@ -174,16 +173,9 @@ function toolTitle(toolName: string, input: ToolInput, fallback: string | undefi
|
||||
return fallback || toolName
|
||||
}
|
||||
|
||||
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
|
||||
function rawInput(toolName: string, input: ToolInput, cwd: string): ToolInput {
|
||||
if (!isShell(toolName) || input.cwd || input.workdir) return input
|
||||
const workdir = shellWorkdir(input, cwd)
|
||||
return workdir ? { ...input, cwd: workdir } : input
|
||||
}
|
||||
|
||||
function shellWorkdir(input: ToolInput, cwd?: string) {
|
||||
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
|
||||
if (!explicit) return cwd
|
||||
return isAbsolute(explicit) ? explicit : resolve(cwd ?? process.cwd(), explicit)
|
||||
return { ...input, cwd }
|
||||
}
|
||||
|
||||
function isShell(toolName: string) {
|
||||
@@ -191,20 +183,29 @@ function isShell(toolName: string) {
|
||||
return tool === "bash" || tool === "shell"
|
||||
}
|
||||
|
||||
function locationFrom(...values: unknown[]): ToolCallLocation[] {
|
||||
function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values.flatMap((value): string[] => {
|
||||
if (Array.isArray(value))
|
||||
return value.filter((item): item is string => typeof item === "string" && item.length > 0)
|
||||
const path = stringValue(value)
|
||||
return path ? [path] : []
|
||||
}),
|
||||
),
|
||||
new Set(values.flatMap((value) => (typeof value === "string" && value ? [absolutePath(value, cwd)] : []))),
|
||||
(path) => ({ path }),
|
||||
)
|
||||
}
|
||||
|
||||
// Sessions migrated from V1 keep their original `filePath` tool inputs.
|
||||
export function filePath(input: ToolInput) {
|
||||
return stringValue(input.path) ?? stringValue(input.filePath)
|
||||
}
|
||||
|
||||
export function patchHunks(input: ToolInput) {
|
||||
const patchText = stringValue(input.patchText)
|
||||
if (!patchText) return []
|
||||
const parsed = Patch.parse(patchText)
|
||||
return Result.isSuccess(parsed) ? parsed.success : []
|
||||
}
|
||||
|
||||
export function absolutePath(path: string, cwd: string) {
|
||||
return isAbsolute(path) ? path : resolve(cwd, path)
|
||||
}
|
||||
|
||||
export function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -1,7 +1,9 @@
|
||||
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"
|
||||
import { Effect } from "effect"
|
||||
import { Writable } from "node:stream"
|
||||
import { ACP } from "../../acp/agent"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
@@ -13,23 +15,30 @@ export default Runtime.handler(
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const endpoint = yield* Standalone.start()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write: (chunk) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
|
||||
const connection = yield* ACP.connect(client, ndJsonStream(Writable.toWeb(process.stdout), Bun.stdin.stream()))
|
||||
const 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,
|
||||
}),
|
||||
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(() => {
|
||||
// stdout carries ACP, so the diagnostic goes to stderr.
|
||||
if (failure) process.stderr.write(`opencode acp: ${failure}\n`)
|
||||
process.exit(failure ? 1 : 0)
|
||||
})
|
||||
const output = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)))
|
||||
process.stdin.on("end", () => controller.close())
|
||||
process.stdin.on("error", (error) => controller.error(error))
|
||||
},
|
||||
})
|
||||
const connection = ACP.connect(client, ndJsonStream(input, output))
|
||||
process.stdin.resume()
|
||||
yield* Effect.promise(() => connection.closed)
|
||||
// EOF owns this stdio process; exiting also closes the private server's lease pipe.
|
||||
yield* Effect.sync(() => process.exit(0))
|
||||
}),
|
||||
)
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Service, type Endpoint } from "@opencode/client/effect/service"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Deferred, Effect, Schema, Stream } from "effect"
|
||||
@@ -53,7 +53,8 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
||||
url: ready.url,
|
||||
auth: { type: "basic" as const, username: "opencode", password },
|
||||
pid: proc.pid,
|
||||
} satisfies Endpoint & { readonly pid: number }
|
||||
exited: proc.exitCode,
|
||||
}
|
||||
},
|
||||
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Clock, Duration, Effect, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { it } from "../../../core/test/lib/effect"
|
||||
import { ACPCatalog } from "../../src/acp/catalog"
|
||||
import { buildAgent, planAgent, startWire, testModel, type Wire, type WireOptions } from "./wire-fixture"
|
||||
|
||||
describe("acp catalog service", () => {
|
||||
it.effect("coalesces reloads requested during a reload into one more load", () => {
|
||||
const gate = { held: false, release: Promise.withResolvers<void>() }
|
||||
return withCatalog(
|
||||
{
|
||||
fetch: (request) =>
|
||||
request.path === "/api/agent" && gate.held ? gate.release.promise.then(() => undefined) : undefined,
|
||||
},
|
||||
(acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
yield* catalog.get("/workspace")
|
||||
gate.held = true
|
||||
|
||||
const running = yield* catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.promise(() => acp.until(() => requests(acp, "/api/agent") === 2, "the held reload"))
|
||||
const queued = yield* Effect.all(
|
||||
[0, 1].map(() => catalog.reload("/workspace").pipe(Effect.forkChild({ startImmediately: true }))),
|
||||
)
|
||||
acp.server.catalog.agents = [planAgent, buildAgent]
|
||||
gate.held = false
|
||||
gate.release.resolve()
|
||||
yield* Fiber.join(running)
|
||||
yield* Fiber.joinAll(queued)
|
||||
|
||||
expect(reads(acp, "agent")).toBe(3)
|
||||
expect((yield* catalog.get("/workspace")).defaultModeID).toBe("plan")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps the previous catalog when a reload fails", () => {
|
||||
const failing = { model: false }
|
||||
return withCatalog(
|
||||
{
|
||||
fetch: (request) =>
|
||||
failing.model && request.path === "/api/model"
|
||||
? Response.json({ name: "ModelsNotReadyError", data: { message: "catalog is warming" } }, { status: 503 })
|
||||
: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
const before = yield* catalog.get("/workspace")
|
||||
failing.model = true
|
||||
|
||||
yield* catalog.reload("/workspace")
|
||||
|
||||
expect(yield* catalog.get("/workspace")).toBe(before)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("waits 25ms between readiness reads", () =>
|
||||
withCatalog({}, (acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
acp.server.catalog.models = []
|
||||
|
||||
const loading = yield* catalog.get("/workspace").pipe(Effect.forkChild)
|
||||
yield* Effect.promise(() => acp.until(() => reads(acp, "model") === 1, "the first model read"))
|
||||
acp.server.catalog.models = [testModel]
|
||||
yield* advance("5 millis", () => reads(acp, "model") === 2)
|
||||
const retriedAt = yield* Clock.currentTimeMillis
|
||||
const loaded = yield* Fiber.join(loading)
|
||||
|
||||
expect(retriedAt).toBeGreaterThanOrEqual(25)
|
||||
expect(loaded.defaultModel).toEqual({ providerID: "test", id: "test-model", variant: "default" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("gives up with the last readiness failure after 5 seconds", () =>
|
||||
withCatalog({}, (acp) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* ACPCatalog.Service
|
||||
acp.server.catalog.agents = []
|
||||
|
||||
const loading = yield* catalog.get("/workspace").pipe(Effect.flip, Effect.timed, Effect.forkChild)
|
||||
yield* advance("25 millis", () => loading.pollUnsafe() !== undefined)
|
||||
const [elapsed, error] = yield* Fiber.join(loading)
|
||||
|
||||
expect(error).toEqual(new ACPCatalog.NotReadyError({ reason: "agents" }))
|
||||
expect(error.message).toBe("No primary agents are available")
|
||||
// Reads in flight while the clock steps push the last attempt slightly past the deadline.
|
||||
expect(Duration.toMillis(elapsed)).toBeGreaterThanOrEqual(5_000)
|
||||
expect(Duration.toMillis(elapsed)).toBeLessThan(6_000)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function withCatalog<A, E>(options: WireOptions, body: (acp: Wire) => Effect.Effect<A, E, ACPCatalog.Service>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => startWire(options)),
|
||||
(acp) => Effect.promise(() => acp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((acp) =>
|
||||
body(acp).pipe(
|
||||
Effect.provideServiceEffect(ACPCatalog.Service, ACPCatalog.make(OpenCode.make({ baseUrl: acp.server.url }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Catalog reads are real HTTP that settles between sleeps, so the clock moves in steps until the reads catch up.
|
||||
function advance(step: Duration.Input, done: () => boolean) {
|
||||
return TestClock.adjust(step).pipe(
|
||||
Effect.andThen(TestClock.withLive(Effect.sleep("1 millis"))),
|
||||
Effect.repeat({ until: done }),
|
||||
)
|
||||
}
|
||||
|
||||
function reads(acp: Wire, kind: "model" | "agent") {
|
||||
return acp.server.catalogReads.filter((read) => read.kind === kind).length
|
||||
}
|
||||
|
||||
function requests(acp: Wire, path: string) {
|
||||
return acp.server.requests.filter((request) => request.path === path).length
|
||||
}
|
||||
@@ -37,7 +37,7 @@ describe("acp catalog and config options over the wire", () => {
|
||||
[other.sessionId]: "/other",
|
||||
})
|
||||
await acp.until(() => acp.updates.filter((item) => commandNames(item)).length === 3, "commands for each session")
|
||||
expect(acp.updates.map(commandNames)).toEqual([["review"], ["review"], ["review"]])
|
||||
expect(acp.updates.map(commandNames)).toEqual(Array.from({ length: 3 }, () => ["review", "compact"]))
|
||||
})
|
||||
|
||||
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
|
||||
@@ -57,14 +57,28 @@ describe("acp catalog and config options over the wire", () => {
|
||||
currentValue: "copilot-build",
|
||||
options: ["copilot-build", "build", "plan"],
|
||||
})
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
|
||||
expect(commandNames(commands)).toEqual(["review", "ship"])
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 3)
|
||||
expect(commandNames(commands)).toEqual(["review", "ship", "compact"])
|
||||
expect(agentReads(acp)).toBe(reads + 1)
|
||||
|
||||
const second = await acp.newSession()
|
||||
expect(currentValue(second, "mode")).toBe("copilot-build")
|
||||
})
|
||||
|
||||
test("defaults the mode to the first selectable agent the server lists", async () => {
|
||||
const configured = { ...buildAgent, id: "review", name: "Review", mode: "all" as const }
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.agents = [configured, buildAgent, planAgent]
|
||||
await acp.initialize()
|
||||
|
||||
const session = await acp.newSession()
|
||||
|
||||
expect(modeOption(session.configOptions ?? [])).toEqual({
|
||||
currentValue: "review",
|
||||
options: ["review", "build", "plan"],
|
||||
})
|
||||
})
|
||||
|
||||
test("pushes config options on model.updated and commands on command.updated", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
@@ -81,7 +95,7 @@ describe("acp catalog and config options over the wire", () => {
|
||||
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 3)
|
||||
expect(commands).toEqual({
|
||||
sessionId: session.sessionId,
|
||||
update: {
|
||||
@@ -89,6 +103,7 @@ describe("acp catalog and config options over the wire", () => {
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -103,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) => {
|
||||
@@ -193,17 +257,64 @@ describe("acp catalog and config options over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.todo(
|
||||
"advertises the built-in compact command (https://github.com/anomalyco/opencode/issues/37229)",
|
||||
async () => {
|
||||
await using acp = await startSession()
|
||||
test("advertises and runs the built-in compact over a server command (https://github.com/anomalyco/opencode/issues/37229)", async () => {
|
||||
await using acp = await startSession()
|
||||
const advertised = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
expect(commandNames(commands)).toContain("compact")
|
||||
},
|
||||
)
|
||||
acp.server.catalog.commands = [
|
||||
reviewCommand,
|
||||
{ name: "compact", description: "Server compact" },
|
||||
{ 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([
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -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" }] }),
|
||||
]
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Cause } from "effect"
|
||||
import { ACPError } from "../../src/acp/error"
|
||||
import { rpcError, startSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp errors", () => {
|
||||
test("maps validation failures to invalid params", () => {
|
||||
@@ -10,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", () => {
|
||||
@@ -58,3 +63,53 @@ describe("acp errors", () => {
|
||||
expect(serialized).not.toContain("stack")
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp error boundary over the wire", () => {
|
||||
test("maps unexpected server failures to the generic internal error", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.method === "POST" && request.path === "/api/session" ? new Response(null, { status: 500 }) : undefined,
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
expect(await rpcError(acp.newSession())).toEqual({
|
||||
code: -32603,
|
||||
message: "Internal error: Internal service failure",
|
||||
data: { errorName: "ClientError" },
|
||||
})
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP request failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("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()
|
||||
|
||||
expect(await rpcError(acp.request("session/list", {}))).toEqual({
|
||||
code: -32603,
|
||||
message: "Internal error: OpenCode server is unavailable",
|
||||
data: { errorName: "ServerUnavailable" },
|
||||
})
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -101,8 +115,8 @@ describe("acp lifecycle subprocess", () => {
|
||||
}, 60_000)
|
||||
|
||||
// The private server is found with `pgrep`, which Windows lacks.
|
||||
const todoOutsideWindows = process.platform === "win32" ? test.skip : test.todo
|
||||
todoOutsideWindows(
|
||||
const testOutsideWindows = process.platform === "win32" ? test.skip : test
|
||||
testOutsideWindows(
|
||||
"exits when the private server process dies (https://github.com/anomalyco/opencode/issues/51716)",
|
||||
async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
@@ -121,7 +135,9 @@ describe("acp lifecycle subprocess", () => {
|
||||
const timeout = Promise.withResolvers<"running">()
|
||||
const timer = setTimeout(() => timeout.resolve("running"), 10_000)
|
||||
const exited = await Promise.race([acp.exited, timeout.promise]).finally(() => clearTimeout(timer))
|
||||
expect(exited).not.toBe("running")
|
||||
expect(exited).toBe(1)
|
||||
await acp[Symbol.asyncDispose]()
|
||||
expect(acp.stderr()).toContain("opencode acp: server exited unexpectedly (signal SIGKILL)")
|
||||
},
|
||||
60_000,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -89,12 +90,7 @@ describe("acp permissions over the wire", () => {
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_external", {
|
||||
action: "external_directory",
|
||||
metadata: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
metadata: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
@@ -103,14 +99,9 @@ describe("acp permissions over the wire", () => {
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "Create external directory",
|
||||
locations: [{ path: "/tmp/outside" }],
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
title: "/tmp/outside",
|
||||
locations: [{ path: "/tmp/outside/a.ts" }],
|
||||
rawInput: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,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 }) =>
|
||||
@@ -169,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({
|
||||
@@ -195,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 })
|
||||
@@ -261,10 +324,36 @@ 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 and client file sync over the wire", () => {
|
||||
test("previews edits during approval and syncs the completed file", async () => {
|
||||
describe("acp edit previews over the wire", () => {
|
||||
test("previews edits during approval", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "before")
|
||||
@@ -287,7 +376,7 @@ describe("acp edit previews and client file sync over the wire", () => {
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
@@ -295,13 +384,12 @@ describe("acp edit previews and client file sync over the wire", () => {
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: "file.ts" }],
|
||||
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
|
||||
locations: [{ path: file }],
|
||||
content: [{ type: "diff", path: file, oldText: "before", newText: "after" }],
|
||||
})
|
||||
expect(acp.writes).toEqual([{ sessionId: session.sessionId, path: file, content: "after" }])
|
||||
})
|
||||
|
||||
test("previews and syncs each file in a patch", async () => {
|
||||
test("previews each file in a patch", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
|
||||
@@ -341,7 +429,7 @@ describe("acp edit previews and client file sync over the wire", () => {
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
@@ -349,36 +437,126 @@ describe("acp edit previews and client file sync over the wire", () => {
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "2 files",
|
||||
kind: "edit",
|
||||
locations: [{ path: "first.ts" }, { path: "second.ts" }],
|
||||
locations: [{ path: path.join(dir.path, "first.ts") }, { path: path.join(dir.path, "second.ts") }],
|
||||
content: [
|
||||
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
|
||||
{ type: "diff", path: path.join(dir.path, "first.ts"), oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: path.join(dir.path, "second.ts"), oldText: "alpha\n", newText: "beta\n" },
|
||||
],
|
||||
})
|
||||
expect(acp.writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
|
||||
{ sessionId: session.sessionId, path: path.join(dir.path, "first.ts"), content: "two\n" },
|
||||
{ sessionId: session.sessionId, path: path.join(dir.path, "second.ts"), content: "beta\n" },
|
||||
})
|
||||
|
||||
test("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")
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: old.ts",
|
||||
"*** Move to: new.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_move", "patch"),
|
||||
toolCalled(sessionID, "call_move", { patchText }),
|
||||
permissionAsked(sessionID, "perm_move", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_move", id: "call_move" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: ({ sessionID }) => [
|
||||
toolSucceeded(sessionID, "call_move", {}, "patched"),
|
||||
succeeded(sessionID),
|
||||
],
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
const locations = [{ path: path.join(dir.path, "old.ts") }, { path: path.join(dir.path, "new.ts") }]
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
locations,
|
||||
content: [{ type: "diff", path: path.join(dir.path, "new.ts"), oldText: "one\n", newText: "two\n" }],
|
||||
})
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.toolCallId === "call_move"
|
||||
? [[item.update.status, item.update.locations]]
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
["in_progress", locations],
|
||||
["completed", locations],
|
||||
])
|
||||
})
|
||||
|
||||
test("does not sync edits when the client did not advertise writeTextFile", async () => {
|
||||
test("does not echo completed edits to a client that advertises writeTextFile", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "file.ts"), "after")
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "after")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { filePath: path.join(dir.path, "file.ts") }),
|
||||
toolSucceeded(sessionID, "call_edit", {}, "edited"),
|
||||
toolCalled(sessionID, "call_edit", { filePath: file, oldString: "before", newString: "after" }),
|
||||
toolSucceeded(sessionID, "call_edit", { files: [{ file }] }, "edited"),
|
||||
),
|
||||
})
|
||||
await acp.initialize()
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
expect(await acp.prompt(session.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.writes).toEqual([])
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed" ? [item.update] : [],
|
||||
),
|
||||
).toMatchObject([
|
||||
{
|
||||
toolCallId: "call_edit",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "edited" } },
|
||||
{ type: "diff", path: file, oldText: "before", newText: "after" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -1,8 +1,13 @@
|
||||
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 {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
failed,
|
||||
@@ -14,6 +19,9 @@ import {
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
toolCalled,
|
||||
toolFailed,
|
||||
toolStarted,
|
||||
turn,
|
||||
type Wire,
|
||||
type WireOptions,
|
||||
@@ -32,7 +40,6 @@ describe("acp prompt turns over the wire", () => {
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_assistant", "hello"), stepEnded(sessionID, "msg_assistant")),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hi")
|
||||
|
||||
@@ -77,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([
|
||||
@@ -97,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" },
|
||||
],
|
||||
}),
|
||||
])
|
||||
})
|
||||
@@ -114,7 +154,6 @@ describe("acp prompt turns over the wire", () => {
|
||||
value: "test/second-model",
|
||||
})
|
||||
acp.server.sessions.set(acp.sessionId, makeSession(acp.sessionId, { cost: 3.5 }))
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant", { tokens: assistantTokens })])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
@@ -145,7 +184,6 @@ describe("acp prompt turns over the wire", () => {
|
||||
return turn(sessionID, id, stepEnded(sessionID, "msg_assistant"))
|
||||
},
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [assistantMessage("msg_assistant")])
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
})
|
||||
@@ -191,14 +229,20 @@ describe("acp prompt turns over the wire", () => {
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("maps an assistant message auth error to auth required", async () => {
|
||||
test("maps an assistant step auth error to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_auth", "partial"), stepEnded(sessionID, "msg_auth")),
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
textDelta(sessionID, "msg_auth", "partial"),
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_auth",
|
||||
error: { type: "provider.auth", message: "expired" },
|
||||
}),
|
||||
),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
assistantMessage("msg_auth", { error: { type: "provider.auth", message: "expired" } }),
|
||||
])
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
@@ -290,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)
|
||||
|
||||
@@ -302,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()
|
||||
@@ -353,27 +496,110 @@ describe("acp prompt turns over the wire", () => {
|
||||
expect((await first).stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
test.todo(
|
||||
"reports usage summed across every step of the turn (https://github.com/anomalyco/opencode/issues/41660)",
|
||||
async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
test("reports usage summed across every step of the turn", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
|
||||
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
|
||||
})
|
||||
|
||||
test("publishes the last step's context usage rather than the turn sum", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
stepEnded(sessionID, "msg_step_1", {
|
||||
finish: "tool-calls",
|
||||
tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 50 } },
|
||||
}),
|
||||
stepEnded(sessionID, "msg_step_2", {
|
||||
tokens: { input: 20, output: 5, reasoning: 3, cache: { read: 150, write: 0 } },
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({
|
||||
inputTokens: 120,
|
||||
outputTokens: 15,
|
||||
thoughtTokens: 3,
|
||||
cachedReadTokens: 150,
|
||||
cachedWriteTokens: 50,
|
||||
totalTokens: 338,
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toEqual({
|
||||
sessionId: acp.sessionId,
|
||||
update: { sessionUpdate: "usage_update", used: 178, size: 100_000, cost: { amount: 0, currency: "USD" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("counts a failed step's tokens and clears its error when the next step starts", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
id,
|
||||
stepEnded(sessionID, "msg_step_1", { finish: "tool-calls", tokens: { ...tokens(), input: 10, output: 5 } }),
|
||||
stepEnded(sessionID, "msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
acp.server.messages.set(acp.sessionId, [
|
||||
assistantMessage("msg_step_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
])
|
||||
assistantMessageID: "msg_1",
|
||||
error: { type: "provider.stream", message: "stream interrupted" },
|
||||
cost: 0,
|
||||
tokens: { ...tokens(), input: 40, output: 4 },
|
||||
}),
|
||||
durableEvent("session.step.started", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_2",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test-model" },
|
||||
started: 0,
|
||||
}),
|
||||
stepEnded(sessionID, "msg_2", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
|
||||
},
|
||||
)
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 60, outputTokens: 11, totalTokens: 71 },
|
||||
_meta: {},
|
||||
})
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
|
||||
update: { used: 27 },
|
||||
})
|
||||
})
|
||||
|
||||
test("excludes child session steps from the turn usage", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
stepEnded("ses_child", "msg_child", { tokens: { ...tokens(), input: 500, output: 50 } }),
|
||||
succeeded("ses_child"),
|
||||
stepEnded(sessionID, "msg_root", { tokens: { ...tokens(), input: 20, output: 7 } }),
|
||||
),
|
||||
})
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(response.usage).toEqual({ inputTokens: 20, outputTokens: 7, totalTokens: 27 })
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "usage_update")).toMatchObject({
|
||||
update: { used: 27 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// The server answered admission before streaming the chunk, and this request round-trips through the server after it,
|
||||
@@ -383,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,
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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" },
|
||||
@@ -54,7 +64,13 @@ describe("acp session lifecycle over the wire", () => {
|
||||
])
|
||||
expect(await acp.waitForUpdate((item) => item.update.sessionUpdate === "available_commands_update")).toEqual({
|
||||
sessionId: result.sessionId,
|
||||
update: { sessionUpdate: "available_commands_update", availableCommands: [{ name: "review", description: "" }] },
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,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}`,
|
||||
@@ -116,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")
|
||||
@@ -128,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) =>
|
||||
@@ -174,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" }),
|
||||
@@ -181,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",
|
||||
@@ -220,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")
|
||||
}
|
||||
@@ -28,22 +28,105 @@ describe("acp tools", () => {
|
||||
})
|
||||
|
||||
test("extracts file locations from tool input", () => {
|
||||
expect(toLocations("read", { path: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
|
||||
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
|
||||
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
|
||||
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
|
||||
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
|
||||
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
|
||||
expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
|
||||
{ path: "/tmp/outside" },
|
||||
expect(toLocations("read", { path: "/tmp/a.ts" }, "/workspace")).toEqual([{ path: "/tmp/a.ts" }])
|
||||
expect(toLocations("edit", { path: "/tmp/b.ts", oldString: "a", newString: "b" }, "/workspace")).toEqual([
|
||||
{ path: "/tmp/b.ts" },
|
||||
])
|
||||
expect(toLocations("write", { path: "/tmp/c.ts", content: "c" }, "/workspace")).toEqual([{ path: "/tmp/c.ts" }])
|
||||
expect(toLocations("read", { filePath: "/tmp/v1.ts" }, "/workspace")).toEqual([{ path: "/tmp/v1.ts" }])
|
||||
expect(toLocations("edit", { path: "src/b.ts" }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "src/b.ts") },
|
||||
])
|
||||
expect(toLocations("grep", { path: "/repo/src" }, "/workspace")).toEqual([{ path: "/repo/src" }])
|
||||
expect(toLocations("glob", { path: "/repo/test" }, "/workspace")).toEqual([{ path: "/repo/test" }])
|
||||
expect(toLocations("grep", { pattern: "x", path: "src" }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "src") },
|
||||
])
|
||||
expect(toLocations("glob", { pattern: "*.ts" }, "/workspace")).toEqual([])
|
||||
expect(toLocations("context7_get_library_docs", { path: "/docs" }, "/workspace")).toEqual([{ path: "/docs" }])
|
||||
expect(
|
||||
toLocations("external_directory", { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" }, "/workspace"),
|
||||
).toEqual([{ path: "/tmp/outside/a.ts" }])
|
||||
expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
|
||||
expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "subdir") },
|
||||
])
|
||||
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
|
||||
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([{ path: "/tmp/missing-file-path.ts" }])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" }, "/workspace")).toEqual([
|
||||
{ path: "/tmp/missing-file-path.ts" },
|
||||
])
|
||||
})
|
||||
|
||||
test("extracts patch locations from every hunk in the patch body", () => {
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: src/new.ts",
|
||||
"+created",
|
||||
"*** Update File: /abs/old.ts",
|
||||
"*** Move to: src/moved.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** Update File: src/same.ts",
|
||||
"@@",
|
||||
"-a",
|
||||
"+b",
|
||||
"*** Delete File: src/gone.ts",
|
||||
"*** Update File: src/new.ts",
|
||||
"@@",
|
||||
"-created",
|
||||
"+changed",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
|
||||
expect(toLocations("patch", { patchText }, "/workspace")).toEqual([
|
||||
{ path: resolve("/workspace", "src/new.ts") },
|
||||
{ path: "/abs/old.ts" },
|
||||
{ path: resolve("/workspace", "src/moved.ts") },
|
||||
{ path: resolve("/workspace", "src/same.ts") },
|
||||
{ path: resolve("/workspace", "src/gone.ts") },
|
||||
])
|
||||
expect(toLocations("apply_patch", { patchText }, "/workspace")).toHaveLength(5)
|
||||
})
|
||||
|
||||
test("returns no patch locations when the patch body cannot be parsed", () => {
|
||||
expect(toLocations("patch", { patchText: "*** Update File: src/a.ts\n-a\n+b" }, "/workspace")).toEqual([])
|
||||
expect(toLocations("patch", { patchText: "*** Begin Patch\nnot a hunk\n*** End Patch" }, "/workspace")).toEqual([])
|
||||
expect(toLocations("patch", {}, "/workspace")).toEqual([])
|
||||
})
|
||||
|
||||
test("reports resolved locations on completed and failed updates", () => {
|
||||
const patchText = ["*** Begin Patch", "*** Add File: src/a.ts", "+a", "*** End Patch"].join("\n")
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
toolName: "patch",
|
||||
input: { patchText },
|
||||
content: [{ type: "text", text: "patched" }],
|
||||
cwd: "/workspace",
|
||||
}).locations,
|
||||
).toEqual([{ path: resolve("/workspace", "src/a.ts") }])
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
toolName: "edit",
|
||||
input: { path: "src/b.ts", oldString: "before", newString: "after" },
|
||||
content: [],
|
||||
cwd: "/workspace",
|
||||
}),
|
||||
).toMatchObject({
|
||||
locations: [{ path: resolve("/workspace", "src/b.ts") }],
|
||||
content: [{ type: "diff", path: resolve("/workspace", "src/b.ts"), oldText: "before", newText: "after" }],
|
||||
})
|
||||
expect(
|
||||
errorToolUpdate({
|
||||
toolCallId: "call",
|
||||
toolName: "write",
|
||||
input: { path: "src/c.ts", content: "c" },
|
||||
error: "denied",
|
||||
cwd: "/workspace",
|
||||
}).locations,
|
||||
).toEqual([{ path: resolve("/workspace", "src/c.ts") }])
|
||||
})
|
||||
|
||||
test("builds completed content with text and image attachments", () => {
|
||||
@@ -52,9 +135,10 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
path: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
@@ -87,9 +171,10 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "write",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
path: "/tmp/file.ts",
|
||||
content: "created",
|
||||
},
|
||||
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
||||
@@ -107,6 +192,7 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-read",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/file.ts" },
|
||||
content: [
|
||||
@@ -125,6 +211,7 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-list",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp" },
|
||||
content: [
|
||||
@@ -146,10 +233,11 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
pendingToolCall({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
path: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
@@ -161,7 +249,7 @@ describe("acp tools", () => {
|
||||
kind: "edit",
|
||||
locations: [{ path: "/tmp/file.ts" }],
|
||||
rawInput: {
|
||||
filePath: "/tmp/file.ts",
|
||||
path: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
@@ -170,9 +258,10 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
path: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
@@ -182,6 +271,7 @@ describe("acp tools", () => {
|
||||
).toEqual({
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
locations: [{ path: "/tmp/file.ts" }],
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
@@ -204,6 +294,7 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
runningToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
state: { input: { path: "/tmp/a" } },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
@@ -229,6 +320,7 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
@@ -241,6 +333,7 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
@@ -252,6 +345,7 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [
|
||||
@@ -273,6 +367,7 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
errorToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/a" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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,55 +364,53 @@ 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.todo(
|
||||
"reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)",
|
||||
async () => {
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: /workspace/src/c.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "/workspace/src/a.ts", oldString: "a", newString: "b" }),
|
||||
toolSucceeded(sessionID, "call_edit", {}, "edited"),
|
||||
toolStarted(sessionID, "call_write", "write"),
|
||||
toolCalled(sessionID, "call_write", { path: "/workspace/src/b.ts", content: "b" }),
|
||||
toolSucceeded(sessionID, "call_write", {}, "written"),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
toolSucceeded(sessionID, "call_patch", {}, "patched"),
|
||||
),
|
||||
})
|
||||
test("reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)", async () => {
|
||||
const patchText = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: /workspace/src/c.ts",
|
||||
"@@",
|
||||
"-one",
|
||||
"+two",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "/workspace/src/a.ts", oldString: "a", newString: "b" }),
|
||||
toolSucceeded(sessionID, "call_edit", {}, "edited"),
|
||||
toolStarted(sessionID, "call_write", "write"),
|
||||
toolCalled(sessionID, "call_write", { path: "/workspace/src/b.ts", content: "b" }),
|
||||
toolSucceeded(sessionID, "call_write", {}, "written"),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
toolSucceeded(sessionID, "call_patch", {}, "patched"),
|
||||
),
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const locations = turnUpdates(acp.updates)
|
||||
.filter((item) => item.update.sessionUpdate === "tool_call_update")
|
||||
.map((item) => [
|
||||
toolCallID(item),
|
||||
toolStatus(item),
|
||||
"locations" in item.update ? item.update.locations : undefined,
|
||||
])
|
||||
expect(locations).toEqual([
|
||||
["call_edit", "in_progress", [{ path: "/workspace/src/a.ts" }]],
|
||||
["call_edit", "completed", [{ path: "/workspace/src/a.ts" }]],
|
||||
["call_write", "in_progress", [{ path: "/workspace/src/b.ts" }]],
|
||||
["call_write", "completed", [{ path: "/workspace/src/b.ts" }]],
|
||||
["call_patch", "in_progress", [{ path: "/workspace/src/c.ts" }]],
|
||||
["call_patch", "completed", [{ path: "/workspace/src/c.ts" }]],
|
||||
const locations = turnUpdates(acp.updates)
|
||||
.filter((item) => item.update.sessionUpdate === "tool_call_update")
|
||||
.map((item) => [
|
||||
toolCallID(item),
|
||||
toolStatus(item),
|
||||
"locations" in item.update ? item.update.locations : undefined,
|
||||
])
|
||||
},
|
||||
)
|
||||
expect(locations).toEqual([
|
||||
["call_edit", "in_progress", [{ path: "/workspace/src/a.ts" }]],
|
||||
["call_edit", "completed", [{ path: "/workspace/src/a.ts" }]],
|
||||
["call_write", "in_progress", [{ path: "/workspace/src/b.ts" }]],
|
||||
["call_write", "completed", [{ path: "/workspace/src/b.ts" }]],
|
||||
["call_patch", "in_progress", [{ path: "/workspace/src/c.ts" }]],
|
||||
["call_patch", "completed", [{ path: "/workspace/src/c.ts" }]],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
|
||||
@@ -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 { Option, Schema } 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) => {
|
||||
@@ -377,9 +405,20 @@ export async function startWire(options: WireOptions = {}) {
|
||||
|
||||
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const agentConnection = ACP.connect(
|
||||
OpenCode.make({ baseUrl: server.url }),
|
||||
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
||||
const logs: Array<Pick<Logger.Options<unknown>, "message" | "cause">> = []
|
||||
const agentScope = Scope.makeUnsafe()
|
||||
const agentConnection = await Effect.runPromise(
|
||||
ACP.connect(
|
||||
OpenCode.make({ baseUrl: server.url }),
|
||||
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
||||
).pipe(
|
||||
Scope.provide(agentScope),
|
||||
(effect) =>
|
||||
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 }))])),
|
||||
),
|
||||
)
|
||||
const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable)
|
||||
const connection = client({ name: "test" })
|
||||
@@ -391,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 {}
|
||||
@@ -446,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 } : {}),
|
||||
@@ -456,11 +500,13 @@ export async function startWire(options: WireOptions = {}) {
|
||||
|
||||
return {
|
||||
server,
|
||||
logs,
|
||||
received,
|
||||
updates,
|
||||
permissions,
|
||||
writes,
|
||||
childUpdates,
|
||||
elicitations,
|
||||
request,
|
||||
until,
|
||||
initialize,
|
||||
@@ -478,6 +524,7 @@ export async function startWire(options: WireOptions = {}) {
|
||||
async [Symbol.asyncDispose]() {
|
||||
connection.close()
|
||||
agentConnection.close()
|
||||
await Effect.runPromise(Scope.close(agentScope, Exit.void))
|
||||
await server.stop()
|
||||
},
|
||||
}
|
||||
@@ -519,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,
|
||||
@@ -534,6 +582,7 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
interrupts,
|
||||
replies,
|
||||
cancelledForms,
|
||||
repliedForms,
|
||||
mcp,
|
||||
send(...events: ReadonlyArray<OpenCodeEvent>) {
|
||||
events.forEach((event) => {
|
||||
@@ -562,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>) => {
|
||||
@@ -663,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)
|
||||
@@ -757,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 })
|
||||
@@ -768,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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@ import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import {
|
||||
Client,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
StreamableHTTPClientTransport,
|
||||
UnauthorizedError,
|
||||
@@ -35,6 +37,18 @@ const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
|
||||
const TERMINATE_TIMEOUT = 1_000
|
||||
const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))
|
||||
|
||||
// HTTP statuses and network error codes live on error properties and are lost once flattened to a message.
|
||||
const describe = (error: unknown) => {
|
||||
if (!(error instanceof Error)) return String(error)
|
||||
const detail =
|
||||
error instanceof SdkHttpError
|
||||
? `HTTP ${error.status}`
|
||||
: !(error instanceof SdkError) && "code" in error && typeof error.code === "string"
|
||||
? error.code
|
||||
: undefined
|
||||
return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message
|
||||
}
|
||||
|
||||
export type { GetPromptResult, Prompt, ReadResourceResult, Resource, Tool }
|
||||
export type ResourceTemplate = ResourceTemplateType
|
||||
|
||||
@@ -108,7 +122,7 @@ export interface Connection {
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
readonly onClose: (callback: (reason: string) => void) => void
|
||||
readonly onSessionExpired: (callback: () => void) => void
|
||||
readonly onToolsChanged: (callback: () => void) => void
|
||||
readonly onPromptsChanged: (callback: () => void) => void
|
||||
@@ -138,7 +152,15 @@ export const connect = Effect.fnUntraced(function* (
|
||||
onChanged: () => changed[key](),
|
||||
})
|
||||
|
||||
// The SDK fails pending requests with a bare "Connection closed"; the transport error before it says why.
|
||||
let lastError: string | undefined
|
||||
const explain = (error: unknown) =>
|
||||
error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed && lastError
|
||||
? `${describe(error)}: ${lastError}`
|
||||
: describe(error)
|
||||
|
||||
const initialize = Effect.fnUntraced(function* (transport: Transport) {
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const client = new Client(clientInfo, {
|
||||
capabilities: {
|
||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||
@@ -153,6 +175,12 @@ export const connect = Effect.fnUntraced(function* (
|
||||
resources: listChanged("resources"),
|
||||
},
|
||||
})
|
||||
// Background work such as the standalone SSE stream reports failures only here; aborts come from close.
|
||||
client.onerror = (error) => {
|
||||
if (error.name === "AbortError") return
|
||||
lastError = describe(error)
|
||||
runFork(Effect.logWarning("mcp transport error", { server, error: lastError }))
|
||||
}
|
||||
client.setRequestHandler("roots/list", () => ({ roots: [{ uri: pathToFileURL(directory).href }] }))
|
||||
if (elicitation) {
|
||||
client.setRequestHandler("elicitation/create", (request, ctx) =>
|
||||
@@ -179,13 +207,13 @@ export const connect = Effect.fnUntraced(function* (
|
||||
reported: false,
|
||||
}
|
||||
const failure = (error: unknown) => {
|
||||
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return toError(error)
|
||||
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return new Error(explain(error))
|
||||
const expired =
|
||||
error.status === 404 ||
|
||||
(error.status === 400 &&
|
||||
typeof error.data.text === "string" &&
|
||||
error.data.text.includes("Bad Request: Server not initialized"))
|
||||
if (!expired) return toError(error)
|
||||
if (!expired) return new Error(explain(error))
|
||||
if (!session.reported) {
|
||||
session.reported = true
|
||||
session.expired?.()
|
||||
@@ -242,6 +270,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
// Close only aborts streams; the legacy session lives on until the server expires it unless
|
||||
// terminated explicitly. Terminate first: close aborts the signal the DELETE shares.
|
||||
const transport = session.transport
|
||||
// Termination failures are logged below; onerror would report them a second time.
|
||||
client.onerror = undefined
|
||||
if (transport?.sessionId !== undefined && !session.reported)
|
||||
yield* Effect.tryPromise({ try: () => transport.terminateSession(), catch: toError }).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
@@ -301,7 +331,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
),
|
||||
).pipe(Effect.map(toCallToolResult)),
|
||||
onClose: (callback) => {
|
||||
client.onclose = callback
|
||||
client.onclose = () => callback(lastError ? `Connection closed: ${lastError}` : "Connection closed")
|
||||
},
|
||||
onSessionExpired: (callback) => {
|
||||
session.expired = callback
|
||||
@@ -325,7 +355,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
server,
|
||||
message: `${error.message}; the server supports ${error.supported.join(", ")}. Set "protocol" for this server to one of those or to "legacy".`,
|
||||
})
|
||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||
return yield* new ConnectError({ server, message: explain(error) })
|
||||
})
|
||||
|
||||
// Absent config is legacy: the SDK sends the plain initialize handshake with no discover probe.
|
||||
|
||||
@@ -62,6 +62,19 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const unavailable = (server: ServerName, status: Status) => {
|
||||
switch (status.status) {
|
||||
case "failed":
|
||||
return `MCP server "${server}" is not connected: ${status.error}. Reconnect it from /mcps.`
|
||||
case "needs_auth":
|
||||
return `MCP server "${server}" needs authentication: ${status.error}. Sign in from /mcps.`
|
||||
case "disabled":
|
||||
return `MCP server "${server}" is disabled.`
|
||||
default:
|
||||
return `MCP server "${server}" is not connected.`
|
||||
}
|
||||
}
|
||||
|
||||
type ServerEntry = {
|
||||
readonly config: Mcp.ServerConfig
|
||||
status: Status
|
||||
@@ -388,10 +401,10 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
|
||||
const live = whenLive(name, entry, connection)
|
||||
connection.onClose(() =>
|
||||
connection.onClose((reason) =>
|
||||
live(
|
||||
Effect.gen(function* () {
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
entry.status = { status: "failed", error: reason }
|
||||
yield* stopServer(name, entry)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}),
|
||||
@@ -660,13 +673,18 @@ export const layer = (options?: Options) =>
|
||||
return yield* new ToolCallError({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
message: "MCP server is not connected",
|
||||
message: unavailable(target.name, target.entry.status),
|
||||
})
|
||||
const result = yield* recovering(target.name, target.entry, target.entry.client, (connection) =>
|
||||
connection.callTool({ name: input.name, args: input.args, sessionID: input.sessionID }),
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
(error) =>
|
||||
new ToolCallError({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
message: `MCP tool "${input.name}" on server "${target.name}" failed: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { ...result, server: target.name, tool: input.name }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as McpStdio from "./stdio.js"
|
||||
|
||||
import { ReadBuffer, serializeMessage, type JSONRPCMessage, type Transport } from "@modelcontextprotocol/client"
|
||||
import { Cause, Duration, Effect, Queue, Scope, Stream } from "effect"
|
||||
import { Cause, Duration, Effect, Fiber, Option, Queue, Scope, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Environment } from "../environment/index.js"
|
||||
@@ -13,6 +13,9 @@ const CLOSE_GRACE = Duration.seconds(2)
|
||||
const FORCE_KILL_AFTER = Duration.seconds(2)
|
||||
const OUTGOING_CAPACITY = 64
|
||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024
|
||||
// An unexpected exit is reported with its exit code and the end of stderr, which usually names the cause.
|
||||
const EXIT_WAIT = Duration.seconds(1)
|
||||
const STDERR_TAIL = 1_000
|
||||
|
||||
export interface Options {
|
||||
/** Server name; only used to attribute logs. */
|
||||
@@ -50,6 +53,7 @@ export const make = Effect.fnUntraced(function* (options: Options) {
|
||||
let startup: Promise<void> | undefined
|
||||
let closing: Promise<void> | undefined
|
||||
let trailingBytes = 0
|
||||
let stderr = ""
|
||||
|
||||
const stop = Effect.fnUntraced(function* (handle: ChildProcessHandle) {
|
||||
// Exit completion can precede descendant cleanup after the capture deadline.
|
||||
@@ -139,6 +143,20 @@ export const make = Effect.fnUntraced(function* (options: Options) {
|
||||
|
||||
const startOutput = (handle: ChildProcessHandle) =>
|
||||
Effect.gen(function* () {
|
||||
// Drain stderr into the debug log so chatty servers cannot stall on a full pipe.
|
||||
const drain = yield* Effect.forkScoped(
|
||||
handle.stderr.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.tap((output) => Effect.sync(() => (stderr = (stderr + output).slice(-STDERR_TAIL)))),
|
||||
Stream.runForEach((output) =>
|
||||
output.trim() === ""
|
||||
? Effect.void
|
||||
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
|
||||
),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
Stream.runForEach(handle.stdout, deliver).pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -152,25 +170,26 @@ export const make = Effect.fnUntraced(function* (options: Options) {
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const unexpected = state.phase !== "closed"
|
||||
if (unexpected) yield* Effect.promise(close)
|
||||
if (unexpected) {
|
||||
// stdout can end before the final stderr output has been read.
|
||||
const code = yield* Fiber.await(drain).pipe(
|
||||
Effect.andThen(handle.exitCode),
|
||||
Effect.timeoutOption(EXIT_WAIT),
|
||||
Effect.orElseSucceed(() => Option.none()),
|
||||
)
|
||||
const exited = Option.match(code, {
|
||||
onNone: () => "MCP server process exited",
|
||||
onSome: (code) => `MCP server process exited with code ${code}`,
|
||||
})
|
||||
const output = stderr.trim()
|
||||
transport.onerror?.(new Error(output ? `${exited}: ${output}` : exited))
|
||||
yield* Effect.promise(close)
|
||||
}
|
||||
transport.onclose?.()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Drain stderr into the debug log so chatty servers cannot stall on a full pipe.
|
||||
yield* Effect.forkScoped(
|
||||
handle.stderr.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.runForEach((output) =>
|
||||
output.trim() === ""
|
||||
? Effect.void
|
||||
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
|
||||
),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return transport
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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.",
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -890,7 +890,7 @@ effect: (ctx) =>
|
||||
id: Skill.ID.make("review"),
|
||||
name: Skill.Name.make("Review"),
|
||||
description: "Review the current changes",
|
||||
location: "/workspace/.opencode/skills/review.md",
|
||||
path: "/workspace/.opencode/skills/review/SKILL.md",
|
||||
content: "Review the current changes for correctness and missing tests.",
|
||||
}))
|
||||
const review = editor.get("review")
|
||||
|
||||
@@ -829,7 +829,7 @@ await ctx.skill.transform((editor) => {
|
||||
id: "review",
|
||||
name: "Review",
|
||||
description: "Review the current changes",
|
||||
location: "/workspace/.opencode/skills/review.md",
|
||||
path: "/workspace/.opencode/skills/review/SKILL.md",
|
||||
content: "Review the current changes for correctness and missing tests.",
|
||||
})
|
||||
const review = editor.get("review")
|
||||
|
||||
@@ -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"
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user