mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-03 14:06:29 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81eb2ae9b5 |
No files matched your search
@@ -45,16 +45,14 @@ runs:
|
||||
- name: Get cache directory
|
||||
id: cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
|
||||
run: echo "dir=$(bun pm cache)" >> "$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 }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
@@ -77,4 +75,4 @@ runs:
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
@@ -7,10 +7,6 @@ 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 aarch64-darwin"
|
||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||
PACKAGES="opencode"
|
||||
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
|
||||
OPTIONAL_PACKAGES="desktop"
|
||||
|
||||
@@ -34,6 +34,8 @@ 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 }}
|
||||
@@ -124,7 +126,7 @@ jobs:
|
||||
|
||||
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
|
||||
for SYSTEM in x86_64-linux aarch64-linux aarch64-darwin; do
|
||||
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
|
||||
FILE="hashes/hash-${SYSTEM}/hash.txt"
|
||||
if [ -f "$FILE" ]; then
|
||||
HASH="$(tr -d '[:space:]' < "$FILE")"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"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";
|
||||
@@ -21,7 +22,7 @@
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
bun
|
||||
nodejs
|
||||
nodejs_20
|
||||
pkg-config
|
||||
openssl
|
||||
git
|
||||
|
||||
@@ -7,6 +7,7 @@ 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=";
|
||||
}
|
||||
+4
-3
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-yCdtDQsXERjfL9bJg7YXNloMBOPwFkPMFVNAzXlarBM=",
|
||||
"aarch64-linux": "sha256-iQ1bLIszETcFoD4CENpoaDZJZF/KBKZFeTp9r7ogJ9Y=",
|
||||
"aarch64-darwin": "sha256-G7oIrTXEFQ5iEF8KSpA4xJPw9xwqRDpbIR472iIoqC4="
|
||||
"x86_64-linux": "sha256-7DgxTpKv6ITKTom0mhlJNPQfdEjtEHK5P8uyCr26HZw=",
|
||||
"aarch64-linux": "sha256-PJxW1Ibfx6oS1neWPSHqzP1Pm1HT9my1TrrHmOb6/no=",
|
||||
"aarch64-darwin": "sha256-VIme5VHfM8JxNiDSOykkr5FytghDLI0FxkhiOXUSyQw=",
|
||||
"x86_64-darwin": "sha256-rQ/j0QkR1vxAq4jgUbr0nY4RDyiLTJqN8q1AfoiEqVQ="
|
||||
}
|
||||
}
|
||||
@@ -81,5 +81,6 @@ stdenvNoCC.mkDerivation {
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
}
|
||||
@@ -451,9 +451,6 @@ 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)
|
||||
@@ -1420,14 +1417,10 @@ 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,
|
||||
}
|
||||
|
||||
@@ -40,8 +40,7 @@ const patterns = [
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
// Z.ai code 1261 arrives as `Prompt too long` or `Prompt 超长`.
|
||||
/prompt (?:too long|超长)/i,
|
||||
/prompt too long; exceeded (?:max )?context length/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,
|
||||
@@ -157,15 +156,13 @@ 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.
|
||||
// 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
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/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|detected potentially unsafe or sensitive content/i
|
||||
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system/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
|
||||
|
||||
|
||||
@@ -264,10 +264,6 @@ 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,17 +6,10 @@ 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) normalized.push(missingToolResults(pending.values()))
|
||||
if (pending.size === 0) return
|
||||
normalized.push(missingToolResults(pending.values()))
|
||||
pending.clear()
|
||||
releaseHeld()
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
@@ -25,12 +18,6 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("moves a marker between a tool call and its result after the result", () =>
|
||||
it.effect("accepts a marker between a tool call and its 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: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -284,82 +284,6 @@ describe("provider error classification", () => {
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("classifies Z.ai plan and balance limits as quota rather than throttling", () => {
|
||||
const zai = (code: string, message: string) => ({ error: { code, message } })
|
||||
const cases = [
|
||||
zai("1113", "Insufficient balance or no resource package. Please recharge."),
|
||||
zai("1308", "Usage limit reached for 5 hours. Your limit will reset at 2026-10-01 00:00:00"),
|
||||
zai(
|
||||
"1309",
|
||||
"Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.",
|
||||
),
|
||||
zai("1310", "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-10-01 00:00:00"),
|
||||
zai("1311", "Your current subscription plan does not yet include access to glm-5"),
|
||||
zai("1314", "Your enterprise package has expired. Please contact your enterprise administrator."),
|
||||
// Z.ai's Anthropic-compatible endpoint wraps the code and request ID into the message.
|
||||
{
|
||||
type: "error",
|
||||
error: {
|
||||
type: "rate_limit_error",
|
||||
code: "1309",
|
||||
message:
|
||||
"[1309][Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.][20260929132151e73af01340d54b58]",
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(cases.length).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("classifies Z.ai prompt length rejections as context overflow", () => {
|
||||
const cases = [
|
||||
{ error: { code: "1261", message: "Prompt 超长" } },
|
||||
{ error: { code: "1261", message: "Prompt too long" } },
|
||||
{
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", code: "1261", message: "[1261][Prompt too long][2026092913]" },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
cases.map((body) => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: body.error.message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify(body),
|
||||
})
|
||||
return reason._tag === "InvalidRequest" ? reason.classification : reason._tag
|
||||
}),
|
||||
).toEqual(["context-overflow", "context-overflow", "context-overflow"])
|
||||
})
|
||||
|
||||
test("classifies Z.ai sensitive content rejections as content policy", () => {
|
||||
const message =
|
||||
"System detected potentially unsafe or sensitive content in input or generation. Please avoid using prompts that may generate sensitive content. Thank you for your cooperation."
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify({ error: { code: "1301", message } }),
|
||||
})._tag,
|
||||
).toBe("ContentPolicy")
|
||||
})
|
||||
|
||||
test("keeps Z.ai throttling and overload retryable", () => {
|
||||
expect(
|
||||
[
|
||||
{ error: { code: "1302", message: "Rate limit reached for requests" } },
|
||||
{ error: { code: "1305", message: "The service may be temporarily overloaded, please try again later" } },
|
||||
].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["RateLimit", "RateLimit"])
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
|
||||
@@ -588,9 +588,9 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("moves a system update between a local tool call and its result after the result", () =>
|
||||
it.effect("rejects a system update between a local tool call and its result", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
@@ -601,13 +601,9 @@ describe("Anthropic Messages route", () => {
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
).pipe(Effect.flip)
|
||||
|
||||
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 }] },
|
||||
])
|
||||
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1673,40 +1669,6 @@ 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(
|
||||
|
||||
@@ -112,51 +112,4 @@ 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)
|
||||
})
|
||||
})
|
||||
+43
-117
@@ -1,126 +1,52 @@
|
||||
import {
|
||||
agent,
|
||||
RequestError,
|
||||
type AgentHandlerContext,
|
||||
type AgentNotificationHandlersByMethod,
|
||||
type AgentNotificationMethod,
|
||||
type AgentRequestHandlersByMethod,
|
||||
type AgentRequestMethod,
|
||||
type JsonRpcId,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { agent, RequestError, 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"
|
||||
|
||||
// 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)))
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
async function run<A>(promise: Promise<A>) {
|
||||
try {
|
||||
return await promise
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError) throw error
|
||||
if (isACPError(error)) throw ACPError.toRequestError(error)
|
||||
throw ACPError.toRequestError(ACPError.fromUnknown(error))
|
||||
}
|
||||
}
|
||||
|
||||
const spanName = (method: string) => `cli.acp.${method.replaceAll("/", ".")}`
|
||||
function isACPError(error: unknown): error is ACPError.Error {
|
||||
return (
|
||||
error instanceof ACPError.SessionNotFoundError ||
|
||||
error instanceof ACPError.SessionDirectoryMismatchError ||
|
||||
error instanceof ACPError.InvalidConfigOptionError ||
|
||||
error instanceof ACPError.InvalidModelError ||
|
||||
error instanceof ACPError.InvalidEffortError ||
|
||||
error instanceof ACPError.InvalidModeError ||
|
||||
error instanceof ACPError.AuthRequiredError ||
|
||||
error instanceof ACPError.UnknownAuthMethodError ||
|
||||
error instanceof ACPError.ServiceFailureError
|
||||
)
|
||||
}
|
||||
|
||||
export * as ACP from "./agent"
|
||||
+100
-162
@@ -1,190 +1,128 @@
|
||||
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 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 = {
|
||||
export type Live = {
|
||||
readonly cwd: string
|
||||
readonly catalog: SubscriptionRef.SubscriptionRef<Catalog>
|
||||
readonly lock: Semaphore.Semaphore
|
||||
requested: number
|
||||
loaded: number
|
||||
current: Catalog
|
||||
}
|
||||
|
||||
// Provider, integration, and credential changes reach the catalog through model.updated.
|
||||
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
|
||||
|
||||
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>()
|
||||
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
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
// 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 reload = (live: Live) => {
|
||||
const current = running.get(live)
|
||||
if (current) {
|
||||
queued.add(live)
|
||||
return current
|
||||
}
|
||||
const run = (async () => {
|
||||
do {
|
||||
queued.delete(live)
|
||||
const next = await load(input.client, live.cwd).catch(() => undefined)
|
||||
if (!next) break
|
||||
const previous = live.current
|
||||
live.current = next
|
||||
await input.changed(live, previous).catch(() => {})
|
||||
} while (queued.has(live))
|
||||
running.delete(live)
|
||||
})()
|
||||
running.set(live, run)
|
||||
return run
|
||||
}
|
||||
|
||||
const create = Effect.fnUntraced(function* (cwd: string) {
|
||||
yield* Deferred.await(connected)
|
||||
return {
|
||||
cwd,
|
||||
catalog: yield* SubscriptionRef.make<Catalog>(yield* load(client, cwd)),
|
||||
lock: Semaphore.makeUnsafe(1),
|
||||
requested: 0,
|
||||
loaded: 0,
|
||||
} satisfies Entry
|
||||
})
|
||||
const 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 entry = (cwd: string) =>
|
||||
Effect.suspend(() => {
|
||||
const key = FSUtil.resolve(cwd)
|
||||
const cached = entries.get(key)
|
||||
if (cached) return Deferred.await(cached)
|
||||
const loading = Deferred.makeUnsafe<Entry, Error>()
|
||||
entries.set(key, loading)
|
||||
return create(cwd).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
if (Exit.isFailure(exit)) entries.delete(key)
|
||||
return Deferred.done(loading, exit)
|
||||
}),
|
||||
Effect.forkIn(scope),
|
||||
Effect.andThen(Deferred.await(loading)),
|
||||
)
|
||||
})
|
||||
return { get, reload }
|
||||
}
|
||||
|
||||
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) {
|
||||
async function load(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
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
|
||||
})
|
||||
// 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)
|
||||
}
|
||||
|
||||
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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"
|
||||
|
||||
@@ -27,34 +25,6 @@ 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,86 +1,30 @@
|
||||
import {
|
||||
methods,
|
||||
type AgentApp,
|
||||
type AnyMessage,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type JsonRpcId,
|
||||
type RequestError,
|
||||
type AgentConnection,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SendRequestOptions,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
type WriteTextFileRequest,
|
||||
type WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Context, Deferred, Effect } from "effect"
|
||||
import type { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
/**
|
||||
* 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 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>
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
})
|
||||
export function make(connection: AgentConnection): Connection {
|
||||
return {
|
||||
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)
|
||||
},
|
||||
}),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
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"
|
||||
@@ -34,39 +34,22 @@ export class UnknownAuthMethodError extends Schema.TaggedError<UnknownAuthMethod
|
||||
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 class ServerUnavailableError extends Schema.TaggedError<ServerUnavailableError>()(
|
||||
"ACPServerUnavailableError",
|
||||
{},
|
||||
) {}
|
||||
|
||||
const Errors = Schema.Union([
|
||||
SessionNotFoundError,
|
||||
SessionDirectoryMismatchError,
|
||||
InvalidConfigOptionError,
|
||||
InvalidModelError,
|
||||
InvalidEffortError,
|
||||
InvalidModeError,
|
||||
AuthRequiredError,
|
||||
UnknownAuthMethodError,
|
||||
InvalidRequestError,
|
||||
ServiceFailureError,
|
||||
ServerUnavailableError,
|
||||
])
|
||||
|
||||
export type Error = typeof Errors.Type
|
||||
|
||||
export const is = Schema.is(Errors)
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| SessionDirectoryMismatchError
|
||||
| InvalidConfigOptionError
|
||||
| InvalidModelError
|
||||
| InvalidEffortError
|
||||
| InvalidModeError
|
||||
| AuthRequiredError
|
||||
| UnknownAuthMethodError
|
||||
| ServiceFailureError
|
||||
|
||||
export function toRequestError(error: Error): RequestError {
|
||||
switch (error._tag) {
|
||||
@@ -92,8 +75,6 @@ export function toRequestError(error: Error): RequestError {
|
||||
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(
|
||||
{
|
||||
@@ -102,8 +83,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
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,78 +1,66 @@
|
||||
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode/client/promise"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { Result } from "effect"
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
import { pendingToolCall, stringValue, toLocations, toToolKind, 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" },
|
||||
]
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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
|
||||
}) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||
const result = yield* input.connection.requestPermission({
|
||||
sessionId: input.clientSessionID,
|
||||
const title = permissionTitle(toolName, toolInput, previews)
|
||||
const request = {
|
||||
sessionId: input.clientSessionID ?? input.sessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
|
||||
title: prefixedTitle(input.titlePrefix, title),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||
...(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) {
|
||||
@@ -81,50 +69,83 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
|
||||
export async function syncEditedFiles(input: {
|
||||
readonly connection: Pick<ACPConnection.Connection, "writeTextFile">
|
||||
readonly writeTextFile: boolean
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly toolName: string
|
||||
readonly toolInput: ToolInput
|
||||
readonly metadata: Readonly<Record<string, unknown>>
|
||||
readonly signal?: AbortSignal
|
||||
}) {
|
||||
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
||||
const files = Array.isArray(input.metadata.files)
|
||||
? input.metadata.files.flatMap((file): string[] => {
|
||||
if (!file || typeof file !== "object") return []
|
||||
const path = "file" in file ? file.file : undefined
|
||||
return typeof path === "string" ? [path] : []
|
||||
})
|
||||
: []
|
||||
const path = filePath(input.toolInput)
|
||||
const paths = [...new Set([...files, ...(path ? [path] : [])])]
|
||||
await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const target = resolvePath(path, input.cwd)
|
||||
const file = Bun.file(target)
|
||||
if (!(await file.exists())) return
|
||||
await input.connection.writeTextFile?.(
|
||||
{ sessionId: input.sessionID, path: target, content: await file.text() },
|
||||
{ cancellationSignal: input.signal },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
const file = filePath(input)
|
||||
if (!file) return []
|
||||
const path = absolutePath(file, cwd)
|
||||
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 === "write") {
|
||||
const content = stringValue(input.content)
|
||||
if (content === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
return [diff(path, oldText, content)]
|
||||
return content === undefined ? [] : [{ type: "diff", path, oldText, newText: 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 [diff(path, oldText, newText)]
|
||||
})
|
||||
|
||||
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
|
||||
function patchPreviews(input: ToolInput, cwd: string) {
|
||||
return Effect.forEach(
|
||||
patchHunks(input),
|
||||
(hunk) =>
|
||||
Effect.gen(function* () {
|
||||
const path = absolutePath(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return diff(path, "", newText)
|
||||
}
|
||||
const oldText = yield* readText(path)
|
||||
if (hunk.type === "delete") return diff(path, oldText, "")
|
||||
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
|
||||
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
|
||||
return [{ type: "diff", path, oldText, newText }]
|
||||
}
|
||||
|
||||
function diff(path: string, oldText: string, newText: string): ToolCallContent {
|
||||
return { type: "diff", path, oldText, newText }
|
||||
async function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
||||
const patchText = stringValue(input.patchText)
|
||||
if (!patchText) return []
|
||||
try {
|
||||
const parsed = Patch.parse(patchText)
|
||||
if (Result.isFailure(parsed)) return []
|
||||
return await Promise.all(
|
||||
parsed.success.map(async (hunk): Promise<ToolCallContent> => {
|
||||
const oldText = hunk.type === "add" ? "" : await readText(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return { type: "diff", path: hunk.path, oldText, newText }
|
||||
}
|
||||
if (hunk.type === "delete") return { type: "diff", path: hunk.path, oldText, newText: "" }
|
||||
return {
|
||||
type: "diff",
|
||||
path: hunk.movePath ?? hunk.path,
|
||||
oldText,
|
||||
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
|
||||
@@ -155,15 +176,27 @@ 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: absolutePath(path, cwd) }))
|
||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
|
||||
}
|
||||
|
||||
// A missing file previews as empty.
|
||||
function readText(path: string) {
|
||||
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
|
||||
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)
|
||||
}
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
@@ -1,21 +0,0 @@
|
||||
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"
|
||||
+449
-181
@@ -1,12 +1,13 @@
|
||||
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,
|
||||
@@ -25,11 +26,11 @@ import type {
|
||||
ListSessionsResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestError,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
@@ -38,123 +39,171 @@ import type {
|
||||
SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { ACPCatalog, type Catalog } from "./catalog"
|
||||
import { buildConfigOptions, 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 { 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"
|
||||
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
// Model and mode are unset while the session follows the server defaults.
|
||||
type Attached = {
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly abort: AbortController
|
||||
readonly catalog: ACPCatalog.Live
|
||||
model?: ModelRef
|
||||
modeID?: string
|
||||
}
|
||||
|
||||
/** What the client advertised in `initialize`. */
|
||||
export type Capabilities = { readonly childSessionUpdates: boolean; readonly formElicitation: boolean }
|
||||
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
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
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>
|
||||
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>
|
||||
}
|
||||
|
||||
export function make(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
readonly sessions: ACPSessions.Interface
|
||||
readonly capabilities: Ref.Ref<Capabilities>
|
||||
readonly turn: ACPTurn.Interface
|
||||
readonly connection: ACPConnection.Connection
|
||||
}): Interface {
|
||||
const currentOptions = Effect.fnUntraced(function* (attached: Attached) {
|
||||
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
|
||||
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)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
// 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 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 })
|
||||
}
|
||||
|
||||
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 })
|
||||
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()
|
||||
}
|
||||
})
|
||||
return input.client.session.interrupt({ sessionID })
|
||||
}
|
||||
|
||||
// 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 })
|
||||
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,
|
||||
}
|
||||
})
|
||||
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: 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,
|
||||
})
|
||||
initialize: async (params) => {
|
||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -172,45 +221,37 @@ export function make(input: {
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ACPTranslate.ChildSessionUpdatesCapability]: true },
|
||||
_meta: { [ChildSessionUpdatesCapability]: true },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
}
|
||||
}),
|
||||
authenticate: Effect.fnUntraced(function* (params) {
|
||||
if (params.methodId !== AuthMethodID)
|
||||
return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
|
||||
},
|
||||
authenticate: async (params) => {
|
||||
if (params.methodId !== AuthMethodID) throw new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
|
||||
return {}
|
||||
}),
|
||||
newSession: Effect.fnUntraced(function* (params) {
|
||||
},
|
||||
newSession: async (params) => {
|
||||
// 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.
|
||||
yield* input.catalog.get(params.cwd)
|
||||
const created = yield* ACPPromise.promise(() =>
|
||||
input.client.session.create({ location: { directory: params.cwd } }),
|
||||
)
|
||||
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 session = yield* getSession(params.sessionId, params.cwd)
|
||||
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 } : {}),
|
||||
}),
|
||||
)
|
||||
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 } : {}),
|
||||
})
|
||||
return {
|
||||
sessions: page.data.map((session) => ({
|
||||
sessionId: session.id,
|
||||
@@ -220,61 +261,178 @@ export function make(input: {
|
||||
})),
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
}
|
||||
}),
|
||||
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)
|
||||
},
|
||||
deleteSession: async (params) => {
|
||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
detach(params.sessionId)
|
||||
return {}
|
||||
}),
|
||||
resumeSession: Effect.fnUntraced(function* (params) {
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
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)
|
||||
},
|
||||
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)
|
||||
return {}
|
||||
}),
|
||||
forkSession: Effect.fnUntraced(function* (params) {
|
||||
const forked = yield* ACPPromise.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
|
||||
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)
|
||||
},
|
||||
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)
|
||||
const value = params.value
|
||||
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))
|
||||
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))
|
||||
return {}
|
||||
}),
|
||||
prompt: input.turn.prompt,
|
||||
cancel: input.turn.cancel,
|
||||
},
|
||||
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(() => {})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: ModelRef) {
|
||||
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 selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
|
||||
)
|
||||
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
|
||||
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
|
||||
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
|
||||
return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
throw new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
const variant =
|
||||
selected.variant ??
|
||||
(current.providerID === model.providerID &&
|
||||
@@ -282,14 +440,124 @@ const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: str
|
||||
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
|
||||
? current.variant
|
||||
: undefined)
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies ModelRef
|
||||
})
|
||||
return { providerID: model.providerID, id: model.id, variant }
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
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() }
|
||||
}
|
||||
|
||||
export * as ACPService from "./service"
|
||||
@@ -1,210 +0,0 @@
|
||||
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,8 +1,6 @@
|
||||
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<
|
||||
@@ -38,29 +36,28 @@ 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":
|
||||
return locationFrom(cwd, stringValue(input.workdir) ?? stringValue(input.cwd) ?? cwd)
|
||||
case "shell": {
|
||||
const workdir = shellWorkdir(input, cwd)
|
||||
return workdir ? [{ path: workdir }] : []
|
||||
}
|
||||
case "read":
|
||||
return locationFrom(input.path)
|
||||
case "edit":
|
||||
case "write":
|
||||
return locationFrom(cwd, filePath(input))
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return locationFrom(
|
||||
cwd,
|
||||
...patchHunks(input).flatMap((hunk) => [hunk.path, hunk.type === "update" ? hunk.movePath : undefined]),
|
||||
)
|
||||
return locationFrom(input.filePath ?? input.filepath)
|
||||
case "external_directory":
|
||||
return locationFrom(cwd, input.filepath)
|
||||
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return locationFrom(cwd, input.path)
|
||||
return locationFrom(input.path)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -70,7 +67,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,
|
||||
@@ -87,7 +84,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,
|
||||
@@ -106,7 +103,6 @@ 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.
|
||||
@@ -119,15 +115,20 @@ 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 || path === undefined
|
||||
oldText === undefined || newText === undefined
|
||||
? []
|
||||
: [{ type: "diff", path: absolutePath(path, input.cwd), oldText, newText }]
|
||||
: [
|
||||
{
|
||||
type: "diff",
|
||||
path: stringValue(input.input.path) ?? stringValue(input.input.filePath) ?? "",
|
||||
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 }),
|
||||
@@ -142,7 +143,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,
|
||||
@@ -173,9 +174,16 @@ 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
|
||||
return { ...input, cwd }
|
||||
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)
|
||||
}
|
||||
|
||||
function isShell(toolName: string) {
|
||||
@@ -183,29 +191,20 @@ function isShell(toolName: string) {
|
||||
return tool === "bash" || tool === "shell"
|
||||
}
|
||||
|
||||
function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
|
||||
function locationFrom(...values: unknown[]): ToolCallLocation[] {
|
||||
return Array.from(
|
||||
new Set(values.flatMap((value) => (typeof value === "string" && value ? [absolutePath(value, cwd)] : []))),
|
||||
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] : []
|
||||
}),
|
||||
),
|
||||
(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
|
||||
}
|
||||
|
||||
@@ -1,633 +0,0 @@
|
||||
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"
|
||||
@@ -1,442 +0,0 @@
|
||||
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 { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
|
||||
import { currentModel } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { promptContentToParts } 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 prompt = preparePrompt(catalog, params.prompt, 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, 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 }
|
||||
}
|
||||
|
||||
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,9 +1,7 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { Effect } from "effect"
|
||||
import { Writable } from "node:stream"
|
||||
import { ACP } from "../../acp/agent"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
@@ -15,26 +13,23 @@ 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 connection = yield* ACP.connect(client, ndJsonStream(Writable.toWeb(process.stdout), Bun.stdin.stream()))
|
||||
const code = yield* Effect.raceFirst(
|
||||
Effect.promise(() => connection.closed).pipe(Effect.as(0)),
|
||||
endpoint.exited.pipe(
|
||||
Effect.match({
|
||||
onSuccess: (code) => `code ${code}`,
|
||||
onFailure: (error) =>
|
||||
error.cause instanceof CrossSpawnSpawner.KilledBySignal ? `signal ${error.cause.signal}` : error.message,
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write: (chunk) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
|
||||
}),
|
||||
// stdout carries ACP, so the diagnostic goes to stderr.
|
||||
Effect.flatMap((reason) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(`opencode acp: server exited unexpectedly (${reason})\n`)
|
||||
return 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Closing the handler scope would wait for the private server's graceful shutdown; its lease pipe already
|
||||
// ends the server once this process exits.
|
||||
yield* Effect.sync(() => process.exit(code))
|
||||
})
|
||||
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))
|
||||
}),
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Service, type Endpoint } 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,8 +53,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
||||
url: ready.url,
|
||||
auth: { type: "basic" as const, username: "opencode", password },
|
||||
pid: proc.pid,
|
||||
exited: proc.exitCode,
|
||||
}
|
||||
} satisfies Endpoint & { readonly pid: number }
|
||||
},
|
||||
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
|
||||
)
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
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(Array.from({ length: 3 }, () => ["review", "compact"]))
|
||||
expect(acp.updates.map(commandNames)).toEqual([["review"], ["review"], ["review"]])
|
||||
})
|
||||
|
||||
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
|
||||
@@ -57,28 +57,14 @@ 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 === 3)
|
||||
expect(commandNames(commands)).toEqual(["review", "ship", "compact"])
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
|
||||
expect(commandNames(commands)).toEqual(["review", "ship"])
|
||||
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]
|
||||
@@ -95,7 +81,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 === 3)
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item)?.length === 2)
|
||||
expect(commands).toEqual({
|
||||
sessionId: session.sessionId,
|
||||
update: {
|
||||
@@ -103,7 +89,6 @@ 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" },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -118,73 +103,24 @@ 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 set("model", "test/second-model")
|
||||
const reloadedModelReads = modelReads(acp)
|
||||
const missingModel = await rpcError(set("model", "test/missing-model"))
|
||||
const missingModelReads = modelReads(acp)
|
||||
const model = await acp.request("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
|
||||
const reads = agentReads(acp)
|
||||
const missing = await rpcError(set("mode", "missing"))
|
||||
const missing = await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "mode", value: "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) => {
|
||||
@@ -257,64 +193,17 @@ describe("acp catalog and config options over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
test.todo(
|
||||
"advertises the built-in compact command (https://github.com/anomalyco/opencode/issues/37229)",
|
||||
async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
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"])
|
||||
})
|
||||
const commands = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
expect(commandNames(commands)).toContain("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)
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
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" }] }),
|
||||
]
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
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,8 +1,6 @@
|
||||
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", () => {
|
||||
@@ -12,12 +10,9 @@ 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, -32602,
|
||||
])
|
||||
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602])
|
||||
})
|
||||
|
||||
test("includes safe validation details", () => {
|
||||
@@ -63,53 +58,3 @@ 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([])
|
||||
})
|
||||
})
|
||||
@@ -101,8 +101,8 @@ describe("acp lifecycle subprocess", () => {
|
||||
}, 60_000)
|
||||
|
||||
// The private server is found with `pgrep`, which Windows lacks.
|
||||
const testOutsideWindows = process.platform === "win32" ? test.skip : test
|
||||
testOutsideWindows(
|
||||
const todoOutsideWindows = process.platform === "win32" ? test.skip : test.todo
|
||||
todoOutsideWindows(
|
||||
"exits when the private server process dies (https://github.com/anomalyco/opencode/issues/51716)",
|
||||
async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
@@ -121,9 +121,7 @@ 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).toBe(1)
|
||||
await acp[Symbol.asyncDispose]()
|
||||
expect(acp.stderr()).toContain("opencode acp: server exited unexpectedly (signal SIGKILL)")
|
||||
expect(exited).not.toBe("running")
|
||||
},
|
||||
60_000,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -90,7 +89,12 @@ describe("acp permissions over the wire", () => {
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_external", {
|
||||
action: "external_directory",
|
||||
metadata: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
||||
metadata: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
@@ -99,9 +103,14 @@ describe("acp permissions over the wire", () => {
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "/tmp/outside",
|
||||
locations: [{ path: "/tmp/outside/a.ts" }],
|
||||
rawInput: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
||||
title: "Create external directory",
|
||||
locations: [{ path: "/tmp/outside" }],
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
directories: ["/tmp/outside"],
|
||||
patterns: ["/tmp/outside/*"],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,33 +142,6 @@ 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 }) =>
|
||||
@@ -187,23 +169,6 @@ 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({
|
||||
@@ -230,34 +195,6 @@ 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 })
|
||||
@@ -324,36 +261,10 @@ describe("acp permissions over the wire", () => {
|
||||
params: { requestId: asked?.id },
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects asks queued behind a cancelled one without sending them to the client", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
permissionAsked(sessionID, "perm_pending"),
|
||||
permissionAsked(sessionID, "perm_queued"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
permission: (_request, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
||||
}),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length === 1, "permission request")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_pending"])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_pending", "reject"],
|
||||
["perm_queued", "reject"],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp edit previews over the wire", () => {
|
||||
test("previews edits during approval", async () => {
|
||||
describe("acp edit previews and client file sync over the wire", () => {
|
||||
test("previews edits during approval and syncs the completed file", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "before")
|
||||
@@ -376,7 +287,7 @@ describe("acp edit previews over the wire", () => {
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
@@ -384,12 +295,13 @@ describe("acp edit previews over the wire", () => {
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: file }],
|
||||
content: [{ type: "diff", path: file, oldText: "before", newText: "after" }],
|
||||
locations: [{ path: "file.ts" }],
|
||||
content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
|
||||
})
|
||||
expect(acp.writes).toEqual([{ sessionId: session.sessionId, path: file, content: "after" }])
|
||||
})
|
||||
|
||||
test("previews each file in a patch", async () => {
|
||||
test("previews and syncs each file in a patch", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(dir.path, "first.ts"), "one\n"),
|
||||
@@ -429,7 +341,7 @@ describe("acp edit previews over the wire", () => {
|
||||
},
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
@@ -437,126 +349,36 @@ describe("acp edit previews over the wire", () => {
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "2 files",
|
||||
kind: "edit",
|
||||
locations: [{ path: path.join(dir.path, "first.ts") }, { path: path.join(dir.path, "second.ts") }],
|
||||
locations: [{ path: "first.ts" }, { path: "second.ts" }],
|
||||
content: [
|
||||
{ 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" },
|
||||
{ type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
|
||||
{ type: "diff", path: "second.ts", oldText: "alpha\n", newText: "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],
|
||||
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("does not echo completed edits to a client that advertises writeTextFile", async () => {
|
||||
test("does not sync edits when the client did not advertise writeTextFile", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "after")
|
||||
await fs.writeFile(path.join(dir.path, "file.ts"), "after")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { filePath: file, oldString: "before", newString: "after" }),
|
||||
toolSucceeded(sessionID, "call_edit", { files: [{ file }] }, "edited"),
|
||||
toolCalled(sessionID, "call_edit", { filePath: path.join(dir.path, "file.ts") }),
|
||||
toolSucceeded(sessionID, "call_edit", {}, "edited"),
|
||||
),
|
||||
})
|
||||
await acp.initialize({ writeTextFile: true })
|
||||
await acp.initialize()
|
||||
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" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 {
|
||||
childCreated,
|
||||
assistantMessage,
|
||||
delivered,
|
||||
durableEvent,
|
||||
failed,
|
||||
@@ -15,9 +14,6 @@ import {
|
||||
succeeded,
|
||||
textDelta,
|
||||
tokens,
|
||||
toolCalled,
|
||||
toolFailed,
|
||||
toolStarted,
|
||||
turn,
|
||||
type Wire,
|
||||
type WireOptions,
|
||||
@@ -36,6 +32,7 @@ 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")
|
||||
|
||||
@@ -117,6 +114,7 @@ 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")
|
||||
|
||||
@@ -147,6 +145,7 @@ 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")
|
||||
})
|
||||
@@ -192,20 +191,14 @@ describe("acp prompt turns over the wire", () => {
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject({ code: -32000 })
|
||||
})
|
||||
|
||||
test("maps an assistant step auth error to auth required", async () => {
|
||||
test("maps an assistant message auth error to auth required", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
textDelta(sessionID, "msg_auth", "partial"),
|
||||
durableEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_auth",
|
||||
error: { type: "provider.auth", message: "expired" },
|
||||
}),
|
||||
),
|
||||
turn(sessionID, id, textDelta(sessionID, "msg_auth", "partial"), stepEnded(sessionID, "msg_auth")),
|
||||
})
|
||||
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 })
|
||||
})
|
||||
@@ -297,20 +290,6 @@ 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)
|
||||
|
||||
@@ -323,91 +302,6 @@ 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()
|
||||
@@ -459,110 +353,27 @@ describe("acp prompt turns over the wire", () => {
|
||||
expect((await first).stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
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", {
|
||||
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(
|
||||
sessionID,
|
||||
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 } }),
|
||||
),
|
||||
})
|
||||
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 } }),
|
||||
])
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
const response = await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
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 },
|
||||
})
|
||||
})
|
||||
expect(response.usage).toEqual({ inputTokens: 30, outputTokens: 12, totalTokens: 42 })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// The server answered admission before streaming the chunk, and this request round-trips through the server after it,
|
||||
@@ -572,20 +383,6 @@ 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,
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
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,17 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { currentValue } from "./select-options"
|
||||
import {
|
||||
ephemeralEvent,
|
||||
makeSession,
|
||||
rpcError,
|
||||
secondModel,
|
||||
startSession,
|
||||
startWire,
|
||||
testModel,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
import { makeSession, rpcError, secondModel, startSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp session lifecycle over the wire", () => {
|
||||
test("initialize advertises capabilities and terminal auth only when the client asks", async () => {
|
||||
@@ -64,13 +54,7 @@ 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: "" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
update: { sessionUpdate: "available_commands_update", availableCommands: [{ name: "review", description: "" }] },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,7 +70,7 @@ describe("acp session lifecycle over the wire", () => {
|
||||
expect(acp.server.selections).toEqual([])
|
||||
})
|
||||
|
||||
test("loads with paginated replay while resume and fork do not replay", async () => {
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
await using acp = await startWire()
|
||||
const history = Array.from({ length: 201 }, (_, index) => ({
|
||||
id: `msg_${index}`,
|
||||
@@ -132,7 +116,7 @@ describe("acp session lifecycle over the wire", () => {
|
||||
: [],
|
||||
)
|
||||
expect(replayed("ses_loaded")).toEqual(history.map((message) => message.id))
|
||||
expect(replayed(forked.sessionId)).toEqual([])
|
||||
expect(replayed(forked.sessionId)).toEqual(history.map((message) => message.id))
|
||||
expect(replayed("ses_resume")).toEqual([])
|
||||
expect(
|
||||
acp.updates.find((item) => item.sessionId === "ses_loaded" && item.update.sessionUpdate === "user_message_chunk")
|
||||
@@ -144,67 +128,6 @@ 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) =>
|
||||
@@ -251,8 +174,6 @@ 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" }),
|
||||
@@ -260,38 +181,6 @@ 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",
|
||||
@@ -331,58 +220,4 @@ 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,105 +28,22 @@ describe("acp tools", () => {
|
||||
})
|
||||
|
||||
test("extracts file locations from tool input", () => {
|
||||
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("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("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("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") }])
|
||||
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([{ path: "/tmp/missing-file-path.ts" }])
|
||||
})
|
||||
|
||||
test("builds completed content with text and image attachments", () => {
|
||||
@@ -135,10 +52,9 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
input: {
|
||||
path: "/tmp/file.ts",
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
@@ -171,10 +87,9 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "write",
|
||||
input: {
|
||||
path: "/tmp/file.ts",
|
||||
filePath: "/tmp/file.ts",
|
||||
content: "created",
|
||||
},
|
||||
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
||||
@@ -192,7 +107,6 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-read",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/file.ts" },
|
||||
content: [
|
||||
@@ -211,7 +125,6 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-list",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp" },
|
||||
content: [
|
||||
@@ -233,11 +146,10 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
pendingToolCall({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
input: {
|
||||
path: "/tmp/file.ts",
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
@@ -249,7 +161,7 @@ describe("acp tools", () => {
|
||||
kind: "edit",
|
||||
locations: [{ path: "/tmp/file.ts" }],
|
||||
rawInput: {
|
||||
path: "/tmp/file.ts",
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
@@ -258,10 +170,9 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
input: {
|
||||
path: "/tmp/file.ts",
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
@@ -271,7 +182,6 @@ describe("acp tools", () => {
|
||||
).toEqual({
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
locations: [{ path: "/tmp/file.ts" }],
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
@@ -294,7 +204,6 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
runningToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
state: { input: { path: "/tmp/a" } },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
@@ -320,7 +229,6 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
@@ -333,7 +241,6 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
@@ -345,7 +252,6 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [
|
||||
@@ -367,7 +273,6 @@ describe("acp tools", () => {
|
||||
expect(
|
||||
errorToolUpdate({
|
||||
toolCallId: "call",
|
||||
cwd: "/workspace",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/a" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
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,8 +8,6 @@ import {
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
permissionAsked,
|
||||
reasoningDelta,
|
||||
startSession,
|
||||
stepEnded,
|
||||
@@ -237,53 +235,6 @@ 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 }) =>
|
||||
@@ -364,53 +315,55 @@ describe("acp turn events over the wire", () => {
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
|
||||
expect(acp.elicitations).toEqual([])
|
||||
})
|
||||
|
||||
test("reports locations for native edit, write, and patch tools (https://github.com/anomalyco/opencode/issues/49591)", async () => {
|
||||
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.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"),
|
||||
),
|
||||
})
|
||||
|
||||
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,
|
||||
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" }]],
|
||||
])
|
||||
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,8 +9,6 @@ import {
|
||||
type AgentRequestResponsesByMethod,
|
||||
type AnyMessage,
|
||||
type ContentBlock,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type McpServer,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
@@ -29,11 +27,9 @@ import {
|
||||
type SessionMessageInfo,
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import type { BunRequest } from "bun"
|
||||
import { Duration, Effect, Exit, Logger, Option, Schema, Scope } from "effect"
|
||||
import { Option, Schema } 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>
|
||||
@@ -67,7 +63,6 @@ const ModelBody = Schema.Struct({
|
||||
})
|
||||
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))
|
||||
|
||||
@@ -122,22 +117,10 @@ 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"
|
||||
@@ -154,7 +137,6 @@ export type InitializeOptions = {
|
||||
readonly writeTextFile?: boolean
|
||||
readonly childSessionUpdates?: boolean
|
||||
readonly terminalAuth?: boolean
|
||||
readonly elicitation?: boolean
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
@@ -385,7 +367,6 @@ 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) => {
|
||||
@@ -396,20 +377,9 @@ export async function startWire(options: WireOptions = {}) {
|
||||
|
||||
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
|
||||
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 agentConnection = ACP.connect(
|
||||
OpenCode.make({ baseUrl: server.url }),
|
||||
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
||||
)
|
||||
const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable)
|
||||
const connection = client({ name: "test" })
|
||||
@@ -421,10 +391,6 @@ 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 {}
|
||||
@@ -480,7 +446,6 @@ 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 } : {}),
|
||||
@@ -491,13 +456,11 @@ export async function startWire(options: WireOptions = {}) {
|
||||
|
||||
return {
|
||||
server,
|
||||
logs,
|
||||
received,
|
||||
updates,
|
||||
permissions,
|
||||
writes,
|
||||
childUpdates,
|
||||
elicitations,
|
||||
request,
|
||||
until,
|
||||
initialize,
|
||||
@@ -515,7 +478,6 @@ export async function startWire(options: WireOptions = {}) {
|
||||
async [Symbol.asyncDispose]() {
|
||||
connection.close()
|
||||
agentConnection.close()
|
||||
await Effect.runPromise(Scope.close(agentScope, Exit.void))
|
||||
await server.stop()
|
||||
},
|
||||
}
|
||||
@@ -557,7 +519,6 @@ 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,
|
||||
@@ -573,7 +534,6 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
interrupts,
|
||||
replies,
|
||||
cancelledForms,
|
||||
repliedForms,
|
||||
mcp,
|
||||
send(...events: ReadonlyArray<OpenCodeEvent>) {
|
||||
events.forEach((event) => {
|
||||
@@ -602,15 +562,6 @@ 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>) => {
|
||||
@@ -717,21 +668,15 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
},
|
||||
"/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) => {
|
||||
const invalid = malformed(req.params.sessionID, pathDecodeError)
|
||||
if (invalid) return invalid
|
||||
return fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID)
|
||||
}),
|
||||
DELETE: route((req) =>
|
||||
fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID),
|
||||
),
|
||||
},
|
||||
"/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)
|
||||
@@ -812,14 +757,6 @@ 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 })
|
||||
@@ -831,11 +768,6 @@ 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()
|
||||
|
||||
@@ -412,7 +412,11 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: SessionInstructionsEntryRemoveInput,
|
||||
) => Effect.Effect<SessionInstructionsEntryRemoveOutput, E>
|
||||
|
||||
export type SessionGenerateInput = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type SessionGenerateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly prompt: string
|
||||
readonly model?: Model.Ref | undefined
|
||||
}
|
||||
export type SessionGenerateOutput = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (
|
||||
input: SessionGenerateInput,
|
||||
|
||||
@@ -660,7 +660,10 @@ const EndpointSessionInstructionsEntryRemove =
|
||||
|
||||
const EndpointSessionGenerate = (raw: RawClient["server.session"]) => (input: SessionGenerateInput) =>
|
||||
preserveEffect<SessionGenerateOutput>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
raw["session.generate"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { prompt: input["prompt"], model: input["model"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
|
||||
@@ -952,7 +952,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/generate`,
|
||||
body: { prompt: input["prompt"] },
|
||||
body: { prompt: input["prompt"], model: input["model"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 503],
|
||||
empty: false,
|
||||
|
||||
@@ -4579,7 +4579,14 @@ export type SessionInstructionsEntryRemoveOutput = void
|
||||
|
||||
export type SessionGenerateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly prompt: { readonly prompt: string }["prompt"]
|
||||
readonly prompt: {
|
||||
readonly prompt: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
}["prompt"]
|
||||
readonly model?: {
|
||||
readonly prompt: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
}["model"]
|
||||
}
|
||||
|
||||
export type SessionGenerateOutput = SessionGenerateResponse["data"]
|
||||
|
||||
@@ -955,7 +955,11 @@ test("session methods use the public HTTP contract", async () => {
|
||||
text: "Hello",
|
||||
resume: false,
|
||||
})
|
||||
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Summarize this session" })
|
||||
const generated = await client.session.generate({
|
||||
sessionID: "ses_test",
|
||||
prompt: "Summarize this session",
|
||||
model: { providerID: "openai", id: "gpt-5-mini", variant: "low" },
|
||||
})
|
||||
const synthetic = await client.session.synthetic({
|
||||
sessionID: "ses_test",
|
||||
text: "Completed",
|
||||
@@ -967,7 +971,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const context = await client.session.context({ sessionID: "ses_test" })
|
||||
const log = []
|
||||
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
|
||||
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
|
||||
const interrupted = await client.session.interrupt({ sessionID: "ses_test", resume: true })
|
||||
const message = await client.session.message.get({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
@@ -976,6 +980,10 @@ test("session methods use the public HTTP contract", async () => {
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(generated.text).toBe("A transient answer")
|
||||
expect(JSON.parse(String(requests.find((request) => request.url.endsWith("/generate"))?.init?.body))).toEqual({
|
||||
prompt: "Summarize this session",
|
||||
model: { providerID: "openai", id: "gpt-5-mini", variant: "low" },
|
||||
})
|
||||
expect(interrupted).toEqual({ interrupted: true })
|
||||
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
|
||||
expect(context).toEqual([])
|
||||
@@ -995,7 +1003,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/experimental/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt?resume=true"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
])
|
||||
const viewBody = requests.find((request) => request.url.endsWith("/api/session/ses_test/view"))?.init?.body
|
||||
|
||||
@@ -4,8 +4,6 @@ import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import {
|
||||
Client,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
StreamableHTTPClientTransport,
|
||||
UnauthorizedError,
|
||||
@@ -37,18 +35,6 @@ 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
|
||||
|
||||
@@ -122,7 +108,7 @@ export interface Connection {
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: (reason: string) => void) => void
|
||||
readonly onClose: (callback: () => void) => void
|
||||
readonly onSessionExpired: (callback: () => void) => void
|
||||
readonly onToolsChanged: (callback: () => void) => void
|
||||
readonly onPromptsChanged: (callback: () => void) => void
|
||||
@@ -152,15 +138,7 @@ 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: {} } } : {}),
|
||||
@@ -175,12 +153,6 @@ 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) =>
|
||||
@@ -207,13 +179,13 @@ export const connect = Effect.fnUntraced(function* (
|
||||
reported: false,
|
||||
}
|
||||
const failure = (error: unknown) => {
|
||||
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return new Error(explain(error))
|
||||
if (!(error instanceof SdkHttpError) || session.transport?.sessionId === undefined) return toError(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 new Error(explain(error))
|
||||
if (!expired) return toError(error)
|
||||
if (!session.reported) {
|
||||
session.reported = true
|
||||
session.expired?.()
|
||||
@@ -270,8 +242,6 @@ 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({
|
||||
@@ -331,7 +301,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
),
|
||||
).pipe(Effect.map(toCallToolResult)),
|
||||
onClose: (callback) => {
|
||||
client.onclose = () => callback(lastError ? `Connection closed: ${lastError}` : "Connection closed")
|
||||
client.onclose = callback
|
||||
},
|
||||
onSessionExpired: (callback) => {
|
||||
session.expired = callback
|
||||
@@ -355,7 +325,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: explain(error) })
|
||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
|
||||
// Absent config is legacy: the SDK sends the plain initialize handshake with no discover probe.
|
||||
|
||||
@@ -62,19 +62,6 @@ 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
|
||||
@@ -401,10 +388,10 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
|
||||
const live = whenLive(name, entry, connection)
|
||||
connection.onClose((reason) =>
|
||||
connection.onClose(() =>
|
||||
live(
|
||||
Effect.gen(function* () {
|
||||
entry.status = { status: "failed", error: reason }
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
yield* stopServer(name, entry)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}),
|
||||
@@ -673,18 +660,13 @@ export const layer = (options?: Options) =>
|
||||
return yield* new ToolCallError({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
message: unavailable(target.name, target.entry.status),
|
||||
message: "MCP server is not connected",
|
||||
})
|
||||
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: `MCP tool "${input.name}" on server "${target.name}" failed: ${error.message}`,
|
||||
}),
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: 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, Fiber, Option, Queue, Scope, Stream } from "effect"
|
||||
import { Cause, Duration, Effect, 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,9 +13,6 @@ 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. */
|
||||
@@ -53,7 +50,6 @@ 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.
|
||||
@@ -143,20 +139,6 @@ 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) =>
|
||||
@@ -170,26 +152,25 @@ export const make = Effect.fnUntraced(function* (options: Options) {
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
const unexpected = state.phase !== "closed"
|
||||
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)
|
||||
}
|
||||
if (unexpected) 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
|
||||
|
||||
+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
|
||||
@@ -186,6 +186,7 @@ export interface Interface {
|
||||
readonly generate: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: string
|
||||
model?: Model.Ref
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
readonly command: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -404,7 +405,7 @@ const layer = Layer.effect(
|
||||
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
|
||||
generate: Effect.fn("Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionGenerate.generate({ session, prompt: input.prompt }).pipe(
|
||||
return yield* SessionGenerate.generate({ session, prompt: input.prompt, model: input.model }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(LLMClient.Service, llm),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
import { Plugin } from "../plugin/service.js"
|
||||
import type { Model } from "../model.js"
|
||||
import type { Instructions } from "../instructions/index.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
@@ -20,6 +21,7 @@ export type Error = AgentNotFoundError | Instructions.InitializationBlocked | Se
|
||||
export const generate = Effect.fn("SessionGenerate.generate")(function* (input: {
|
||||
session: SessionSchema.Info
|
||||
prompt: string
|
||||
model?: Model.Ref
|
||||
}) {
|
||||
const instances = yield* Instance.Service
|
||||
const database = yield* Database.Service
|
||||
@@ -29,7 +31,9 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
yield* Plugin.awaitActivation
|
||||
const context = yield* SessionContext.Service
|
||||
const selection = yield* context.select(input.session.id)
|
||||
const model = yield* context.resolveModel(selection.session)
|
||||
const model = yield* context.resolveModel(
|
||||
input.model === undefined ? selection.session : { ...selection.session, model: input.model },
|
||||
)
|
||||
const history = yield* SessionHistory.preview(
|
||||
database.db,
|
||||
selection.session.id,
|
||||
|
||||
@@ -543,15 +543,7 @@ 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${event.reason.category === undefined ? "" : ` (${event.reason.category})`}`,
|
||||
event.reason.explanation,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(": "),
|
||||
})
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
return
|
||||
}
|
||||
return
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Instructions } from "@opencode/core/instructions/index"
|
||||
import { InstructionBuiltIns } from "@opencode/core/instructions/builtins"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { McpInstructions } from "@opencode/core/mcp/instructions"
|
||||
import { ID } from "@opencode/core/model"
|
||||
import { ID, Model } from "@opencode/core/model"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { ReferenceInstructions } from "@opencode/core/reference/instructions"
|
||||
@@ -60,6 +60,7 @@ let instruction: string | Instructions.Unavailable = "Initial context"
|
||||
const sessionID = SessionSchema.ID.make("ses_generate_test")
|
||||
|
||||
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
|
||||
const alternate = LanguageModel.make({ id: "cheap-model", provider: "other", route: OpenAIChat.route })
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.die(new Error("unused")),
|
||||
generate: (request, requestOptions) =>
|
||||
@@ -83,10 +84,11 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
}),
|
||||
})
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
resolve: (session) =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
SessionRunnerModel.resolved(session.model?.id === "cheap-model" ? alternate : model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
variant: session.model?.variant,
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
}),
|
||||
@@ -353,6 +355,39 @@ it.effect(
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(options[0]?.webSocket).toBeUndefined()
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const override = Model.Ref.make({
|
||||
providerID: Provider.ID.make("other"),
|
||||
id: ID.make("cheap-model"),
|
||||
variant: Model.VariantID.make("low"),
|
||||
})
|
||||
let generates = 0
|
||||
yield* hooks.register(
|
||||
"session",
|
||||
"generate",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
generates++
|
||||
expect(event.model).toEqual(override)
|
||||
}),
|
||||
{ providerID: override.providerID },
|
||||
)
|
||||
yield* SessionGenerate.generate({ session, prompt: "Summarize cheaply", model: override }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
)
|
||||
expect(generates).toBe(1)
|
||||
expect(requests[1]?.model).toBe(alternate)
|
||||
expect(requests[1]?.promptCacheKey).toBe(sessionID)
|
||||
expect(userTexts(requests[1])).toEqual(["Existing durable context", "Summarize cheaply"])
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
|
||||
yield* SessionGenerate.generate({ session, prompt: "Use the session model again" }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
)
|
||||
expect(requests[2]?.model).toBe(model)
|
||||
expect(generates).toBe(1)
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
@@ -667,29 +667,3 @@ 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.",
|
||||
},
|
||||
})
|
||||
})
|
||||
+437
-17
@@ -48,6 +48,106 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -886,9 +986,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location.",
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -907,6 +1017,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3797,7 +3918,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Generate transient text from the current session context without mutating session history.",
|
||||
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
|
||||
"summary": "Generate text from session context",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -3807,6 +3928,16 @@
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -4508,6 +4639,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4591,21 +4737,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7197,6 +7328,125 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -13631,6 +13881,9 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13645,6 +13898,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13660,6 +13916,128 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13746,6 +14124,12 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15709,6 +16093,29 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -18234,6 +18641,16 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -18653,6 +19070,9 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -708,7 +708,7 @@ export const makeSessionGroup = <
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.generate", "/api/session/:sessionID/generate", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ prompt: Schema.String }),
|
||||
payload: Schema.Struct({ prompt: Schema.String, model: Model.Ref.pipe(Schema.optional) }),
|
||||
success: Schema.Struct({
|
||||
data: Schema.Struct({ text: Schema.String }),
|
||||
}).annotate({ identifier: "SessionGenerateResponse" }),
|
||||
@@ -719,7 +719,8 @@ export const makeSessionGroup = <
|
||||
OpenApi.annotations({
|
||||
identifier: "session.generate",
|
||||
summary: "Generate text from session context",
|
||||
description: "Generate transient text from the current session context without mutating session history.",
|
||||
description:
|
||||
"Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -589,7 +589,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
"session.generate",
|
||||
Effect.fn(function* (ctx) {
|
||||
const text = yield* session
|
||||
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })
|
||||
.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt, model: ctx.payload.model })
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error._tag === "Session.NotFoundError"
|
||||
|
||||
@@ -51,6 +51,14 @@ it.live(
|
||||
limit: { context: 200_000, output: 8_192 },
|
||||
},
|
||||
)
|
||||
const alternate = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "cheap-model", provider: "other", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 8_192 },
|
||||
},
|
||||
)
|
||||
// Host and private instances must reuse the same global layer identities.
|
||||
const replacements: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
@@ -61,7 +69,9 @@ it.live(
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
|
||||
Layer.succeed(SessionRunnerModel.Service, {
|
||||
resolve: (session) => Effect.succeed(session.model?.id === alternate.ref.id ? alternate : model),
|
||||
}),
|
||||
),
|
||||
Instance.node.replace(
|
||||
makeGlobalNode({
|
||||
@@ -235,6 +245,17 @@ it.live(
|
||||
})),
|
||||
)
|
||||
|
||||
const selected = yield* sessions.get(first.id)
|
||||
yield* llm.push(TestLLM.text("A cheaper answer", "cheap-generation"))
|
||||
const generated = yield* request(`/api/session/${first.id}/generate`, {
|
||||
prompt: "Summarize cheaply",
|
||||
model: alternate.ref,
|
||||
})
|
||||
expect(generated.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => generated.json())).toEqual({ data: { text: "A cheaper answer" } })
|
||||
expect((yield* llm.requests()).at(-1)?.model).toBe(alternate.model)
|
||||
expect((yield* sessions.get(first.id)).model).toEqual(selected.model)
|
||||
|
||||
// Seed through Core, then use HTTP to reach those exact private instances.
|
||||
const pending = yield* Effect.forEach(configs, (config) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { MetaProvider, Title } from "@solidjs/meta"
|
||||
import { Router } from "@solidjs/router"
|
||||
import { FileRoutes } from "@solidjs/start/router"
|
||||
import { Suspense } from "solid-js"
|
||||
import { PatternRipple } from "./component/pattern-ripple"
|
||||
import { I18nProvider, useI18n } from "./context/i18n"
|
||||
import { LanguageProvider } from "./context/language"
|
||||
import { strip } from "./lib/language"
|
||||
@@ -24,7 +23,6 @@ export default function App() {
|
||||
<I18nProvider>
|
||||
<MetaProvider>
|
||||
<AppMeta />
|
||||
<PatternRipple />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MetaProvider>
|
||||
</I18nProvider>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
|
||||
const PATTERNS = '[data-page="stats"] [data-slot$="-pattern"]'
|
||||
const RIPPLE = {
|
||||
duration: 4400,
|
||||
brightness: 50,
|
||||
trail: 400,
|
||||
trailBody: 100,
|
||||
front: 90,
|
||||
ease: 4,
|
||||
fade: 3.3,
|
||||
reach: 1.3,
|
||||
rearm: 400,
|
||||
}
|
||||
|
||||
type Ripple = { x: number; y: number; radius: number; start: number }
|
||||
|
||||
// Plays one soft wave of light across a dot pattern when the cursor moves onto its visible dots.
|
||||
export function PatternRipple() {
|
||||
onMount(() => {
|
||||
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return
|
||||
|
||||
const ripples = new Map<HTMLElement, Ripple>()
|
||||
const leftAt = new WeakMap<HTMLElement, number>()
|
||||
const state = { current: undefined as HTMLElement | undefined, primed: false, running: false }
|
||||
|
||||
const frame = (now: number) => {
|
||||
ripples.forEach((ripple, el) => {
|
||||
const progress = (now - ripple.start) / RIPPLE.duration
|
||||
if (progress >= 1) {
|
||||
ripples.delete(el)
|
||||
el.style.removeProperty("--dot-ripple")
|
||||
return
|
||||
}
|
||||
el.style.setProperty("--dot-ripple", rippleGradient(ripple, progress))
|
||||
})
|
||||
if (ripples.size > 0) return requestAnimationFrame(frame)
|
||||
state.running = false
|
||||
}
|
||||
|
||||
const start = (el: HTMLElement, clientX: number, clientY: number) => {
|
||||
const box = el.getBoundingClientRect()
|
||||
const visible = visibleBox(el)
|
||||
ripples.set(el, {
|
||||
x: clientX - box.left,
|
||||
y: clientY - box.top,
|
||||
radius: Math.hypot(
|
||||
Math.max(clientX - visible.left, visible.right - clientX),
|
||||
Math.max(clientY - visible.top, visible.bottom - clientY),
|
||||
),
|
||||
start: performance.now(),
|
||||
})
|
||||
if (state.running) return
|
||||
state.running = true
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
// The pattern must be the top element under the cursor, so text cut-outs over a pattern count as off the dots.
|
||||
// A cursor already resting on a pattern when the page loads does not trigger a ripple.
|
||||
const move = (event: PointerEvent) => {
|
||||
const now = performance.now()
|
||||
const hit = document.elementFromPoint(event.clientX, event.clientY)
|
||||
const el = hit instanceof HTMLElement && hit.matches(PATTERNS) ? hit : undefined
|
||||
if (el !== state.current) {
|
||||
if (state.current) leftAt.set(state.current, now)
|
||||
if (el && state.primed && now - (leftAt.get(el) ?? -Infinity) >= RIPPLE.rearm) start(el, event.clientX, event.clientY)
|
||||
state.current = el
|
||||
}
|
||||
state.primed = true
|
||||
}
|
||||
|
||||
document.addEventListener("pointermove", move, { passive: true })
|
||||
onCleanup(() => document.removeEventListener("pointermove", move))
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function rippleGradient(ripple: Ripple, progress: number) {
|
||||
const r = ripple.radius * RIPPLE.reach * (1 - (1 - progress) ** RIPPLE.ease)
|
||||
const alpha = RIPPLE.brightness * (1 - progress) ** RIPPLE.fade
|
||||
const mix = (amount: number) => `color-mix(in srgb, var(--stats-text) ${amount.toFixed(1)}%, transparent)`
|
||||
const at = (offset: number) => `${Math.max(0, r + offset).toFixed(1)}px`
|
||||
return `radial-gradient(circle at ${ripple.x}px ${ripple.y}px, transparent ${at(-RIPPLE.trail)}, ${mix((alpha * RIPPLE.trailBody) / 100)} ${at(-RIPPLE.trail / 2)}, ${mix(alpha)} ${at(0)}, transparent ${at(RIPPLE.front)})`
|
||||
}
|
||||
|
||||
// Some patterns are larger than their clipping container, so the ripple only needs to reach the visible part.
|
||||
function visibleBox(el: HTMLElement) {
|
||||
const box = el.getBoundingClientRect()
|
||||
const clip = el.parentElement?.getBoundingClientRect() ?? box
|
||||
return {
|
||||
left: Math.max(box.left, clip.left),
|
||||
top: Math.max(box.top, clip.top),
|
||||
right: Math.min(box.right, clip.right),
|
||||
bottom: Math.min(box.bottom, clip.bottom),
|
||||
}
|
||||
}
|
||||
@@ -674,7 +674,7 @@ body {
|
||||
[data-page="stats"] [data-slot="footer-pattern"] {
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
background-color: var(--stats-hero-pattern);
|
||||
background: var(--stats-hero-pattern);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: center top;
|
||||
mask-repeat: repeat;
|
||||
@@ -883,18 +883,13 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ripple layer drawn by PatternRipple. Pattern rules set only background-color so this layer survives. */
|
||||
[data-page="stats"] [data-slot$="-pattern"] {
|
||||
background-image: var(--dot-ripple, none);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="hero-pattern"] {
|
||||
order: 2;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
background-color: var(--stats-hero-pattern);
|
||||
background: var(--stats-hero-pattern);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-repeat: repeat;
|
||||
mask-size: 6px 6px;
|
||||
@@ -1099,6 +1094,7 @@ body {
|
||||
[data-page="stats"] [data-component="top-models-chart"] {
|
||||
--top-models-bar-gap: 12px;
|
||||
--top-models-dot-size: 6px;
|
||||
--top-models-dot-offset: 2px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 34px minmax(0, 1fr);
|
||||
@@ -1228,36 +1224,38 @@ body {
|
||||
padding-inline: calc(var(--top-models-bar-gap) / 2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
|
||||
--top-models-bar-inset: calc((100% - 6px) / 6);
|
||||
}
|
||||
|
||||
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
|
||||
[data-page="stats"] [data-slot="top-models-bar"]::before {
|
||||
position: absolute;
|
||||
inset: 0 var(--top-models-bar-inset);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--top-models-bar-gap));
|
||||
transform: translateX(-50%);
|
||||
content: "";
|
||||
background: var(--stats-dot);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
mask-position: left top;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: var(--top-models-dot-offset) top;
|
||||
mask-repeat: repeat;
|
||||
mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: left top;
|
||||
mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: var(--top-models-dot-offset) top;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
|
||||
transition: background-color 120ms ease;
|
||||
-webkit-mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="top-models-bar"]:is(:hover, [data-active="true"])::before {
|
||||
background: color-mix(in srgb, var(--stats-dot), var(--stats-text) 25%);
|
||||
@supports (width: round(down, 100%, 1px)) {
|
||||
[data-page="stats"] [data-slot="top-models-bar"]::before {
|
||||
width: round(down, calc(100% - var(--top-models-bar-gap)), var(--top-models-dot-size));
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="top-models-stack"] {
|
||||
position: absolute;
|
||||
right: var(--top-models-bar-inset);
|
||||
right: calc(var(--top-models-bar-gap) / 2);
|
||||
bottom: 0;
|
||||
left: var(--top-models-bar-inset);
|
||||
left: calc(var(--top-models-bar-gap) / 2);
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
height: var(--top-models-bar-height);
|
||||
@@ -1877,7 +1875,7 @@ body {
|
||||
height: 16px;
|
||||
margin: 16px 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--stats-hero-pattern);
|
||||
background: var(--stats-hero-pattern);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: center top;
|
||||
mask-repeat: repeat;
|
||||
@@ -2878,7 +2876,7 @@ body {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
overflow: hidden;
|
||||
background-color: var(--stats-hero-pattern);
|
||||
background: var(--stats-hero-pattern);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-repeat: repeat;
|
||||
mask-size: 6px 6px;
|
||||
@@ -3018,7 +3016,7 @@ body {
|
||||
min-width: 64px;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: center top;
|
||||
mask-repeat: repeat;
|
||||
@@ -3335,7 +3333,7 @@ body {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
mask-position: center top;
|
||||
-webkit-mask-position: center top;
|
||||
}
|
||||
@@ -3482,7 +3480,7 @@ body {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
background-color: var(--stats-line);
|
||||
background: var(--stats-line);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: center top;
|
||||
mask-repeat: repeat;
|
||||
@@ -3956,7 +3954,7 @@ body {
|
||||
height: 16px;
|
||||
margin-bottom: 40px;
|
||||
overflow: hidden;
|
||||
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: center top;
|
||||
mask-repeat: repeat;
|
||||
@@ -4081,7 +4079,7 @@ body {
|
||||
height: 16px;
|
||||
margin-bottom: 40px;
|
||||
overflow: hidden;
|
||||
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: center top;
|
||||
mask-repeat: repeat;
|
||||
@@ -4547,6 +4545,7 @@ body {
|
||||
[data-page="stats"] [data-component="model-usage-chart"] {
|
||||
--model-usage-bar-gap: 6px;
|
||||
--model-usage-dot-size: 6px;
|
||||
--model-usage-dot-offset: 2px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 40px minmax(0, 1fr);
|
||||
@@ -4604,36 +4603,38 @@ body {
|
||||
padding-inline: calc(var(--model-usage-bar-gap) / 2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
|
||||
--model-usage-bar-inset: calc((100% - 6px) / 6);
|
||||
}
|
||||
|
||||
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
position: absolute;
|
||||
inset: 0 var(--model-usage-bar-inset);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--model-usage-bar-gap));
|
||||
transform: translateX(-50%);
|
||||
content: "";
|
||||
background: var(--stats-dot);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
mask-position: left top;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: var(--model-usage-dot-offset) top;
|
||||
mask-repeat: repeat;
|
||||
mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: left top;
|
||||
mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: var(--model-usage-dot-offset) top;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
|
||||
transition: background-color 120ms ease;
|
||||
-webkit-mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-column"]:is(:hover, [data-active="true"])::before {
|
||||
background: color-mix(in srgb, var(--stats-dot), var(--stats-text) 25%);
|
||||
@supports (width: round(down, 100%, 1px)) {
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
width: round(down, calc(100% - var(--model-usage-bar-gap)), var(--model-usage-dot-size));
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-bar"] {
|
||||
position: absolute;
|
||||
right: var(--model-usage-bar-inset);
|
||||
right: calc(var(--model-usage-bar-gap) / 2);
|
||||
bottom: 0;
|
||||
left: var(--model-usage-bar-inset);
|
||||
left: calc(var(--model-usage-bar-gap) / 2);
|
||||
z-index: 1;
|
||||
height: var(--model-usage-fill);
|
||||
min-height: 0;
|
||||
@@ -5689,7 +5690,7 @@ body {
|
||||
height: 16px;
|
||||
margin-inline: -40px;
|
||||
overflow: hidden;
|
||||
background-color: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: center top;
|
||||
mask-repeat: repeat;
|
||||
@@ -8195,8 +8196,7 @@ body {
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="model-usage-chart"] {
|
||||
/* 18px puts three 2px dot columns 6px apart, matching the 6px bar gap. */
|
||||
--model-usage-mobile-bar-width: 18px;
|
||||
--model-usage-mobile-bar-width: 16px;
|
||||
--model-usage-mobile-edge-space: 48px;
|
||||
--model-usage-mobile-track-width: calc(
|
||||
var(--model-usage-count) * (var(--model-usage-mobile-bar-width) + var(--model-usage-bar-gap)) +
|
||||
@@ -8237,7 +8237,7 @@ body {
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-bar"] {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -215,7 +216,6 @@ 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(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
await ready
|
||||
process.emit("SIGHUP")
|
||||
@@ -259,7 +259,6 @@ 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) => {
|
||||
@@ -320,7 +319,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(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await initialTitleSet
|
||||
@@ -346,7 +345,6 @@ 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)
|
||||
@@ -395,7 +393,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(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await sessionRequested.promise
|
||||
@@ -804,7 +802,6 @@ 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()
|
||||
@@ -858,7 +855,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(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await Promise.race([
|
||||
@@ -1347,7 +1344,6 @@ 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>()
|
||||
@@ -1430,7 +1426,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(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), 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,7 +17,6 @@ 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,
|
||||
@@ -39,7 +38,7 @@ export async function createAppFixture(
|
||||
args: input.args ?? {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
Effect.provide(Global.layerWith({ state: input.state ?? state?.path })),
|
||||
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
@@ -53,7 +52,6 @@ export async function createAppFixture(
|
||||
await task
|
||||
} finally {
|
||||
await server.stop()
|
||||
await state?.[Symbol.asyncDispose]()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+437
-17
@@ -48,6 +48,106 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -886,9 +986,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location.",
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -907,6 +1017,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3797,7 +3918,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Generate transient text from the current session context without mutating session history.",
|
||||
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
|
||||
"summary": "Generate text from session context",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -3807,6 +3928,16 @@
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -4508,6 +4639,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4591,21 +4737,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7197,6 +7328,125 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -13631,6 +13881,9 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13645,6 +13898,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13660,6 +13916,128 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13746,6 +14124,12 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15709,6 +16093,29 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -18234,6 +18641,16 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -18653,6 +19070,9 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -48,6 +48,106 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -886,9 +986,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location.",
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -907,6 +1017,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3797,7 +3918,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Generate transient text from the current session context without mutating session history.",
|
||||
"description": "Generate transient text from the current session context without mutating session history. An optional model overrides the session model for this request only; omitting it preserves the session model and its prompt-cache reuse opportunity.",
|
||||
"summary": "Generate text from session context",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -3807,6 +3928,16 @@
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -4508,6 +4639,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4591,21 +4737,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7197,6 +7328,125 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -13631,6 +13881,9 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13645,6 +13898,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13660,6 +13916,128 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13746,6 +14124,12 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15709,6 +16093,29 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -18234,6 +18641,16 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -18653,6 +19070,9 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -821,6 +821,22 @@ effect: (ctx) =>
|
||||
}),
|
||||
```
|
||||
|
||||
`session.generate` uses the session's model by default, retaining the opportunity to reuse its prompt cache.
|
||||
Supply `model` to override it for one request without changing the session; switching models generally cannot reuse
|
||||
the existing model's cache.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
|
||||
const generated = yield* ctx.session.generate({
|
||||
sessionID: created.id,
|
||||
prompt: "Summarize this project",
|
||||
model: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5-mini") },
|
||||
}).pipe(Effect.orDie)
|
||||
}),
|
||||
```
|
||||
|
||||
Remove a temporary session with `remove`. Removal stops active execution and recursively deletes child sessions.
|
||||
|
||||
```ts
|
||||
@@ -890,7 +906,7 @@ effect: (ctx) =>
|
||||
id: Skill.ID.make("review"),
|
||||
name: Skill.Name.make("Review"),
|
||||
description: "Review the current changes",
|
||||
path: "/workspace/.opencode/skills/review/SKILL.md",
|
||||
location: "/workspace/.opencode/skills/review.md",
|
||||
content: "Review the current changes for correctness and missing tests.",
|
||||
}))
|
||||
const review = editor.get("review")
|
||||
|
||||
@@ -766,6 +766,18 @@ const command = await ctx.session.command({ sessionID, command: "review", argume
|
||||
const synthetic = await ctx.session.synthetic({ sessionID, text: "Deployment completed" })
|
||||
```
|
||||
|
||||
`session.generate` uses the session's model by default, retaining the opportunity to reuse its prompt cache.
|
||||
Supply `model` to override it for one request without changing the session; switching models generally cannot reuse
|
||||
the existing model's cache.
|
||||
|
||||
```ts
|
||||
const generated = await ctx.session.generate({
|
||||
sessionID,
|
||||
prompt: "Summarize this project",
|
||||
model: { providerID: "openai", id: "gpt-5-mini" },
|
||||
})
|
||||
```
|
||||
|
||||
Rename, interrupt, or wait for a session.
|
||||
|
||||
```ts
|
||||
@@ -829,7 +841,7 @@ await ctx.skill.transform((editor) => {
|
||||
id: "review",
|
||||
name: "Review",
|
||||
description: "Review the current changes",
|
||||
path: "/workspace/.opencode/skills/review/SKILL.md",
|
||||
location: "/workspace/.opencode/skills/review.md",
|
||||
content: "Review the current changes for correctness and missing tests.",
|
||||
})
|
||||
const review = editor.get("review")
|
||||
|
||||
Reference in new issue
Block a user