Compare commits

..
Author SHA1 Message Date
OpenCode Agent 37b2c91937 fix(core): attribute one-shot generation requests 2026-09-10 16:33:50 +00:00
157 changed files with 2490 additions and 6685 deletions
-4
View File
@@ -165,10 +165,6 @@ Native chronological system messages are route/model-specific. Open Responses lo
The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels.
### Effort Updates
`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change.
### Tools
Tool loops are represented in common messages and events:
+2 -9
View File
@@ -12,7 +12,6 @@
// count against the four-breakpoint budget; auto only fills remaining slots.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
import { effortUpdate } from "./effort-updates.js"
const AUTO: CachePolicyObject = {
tools: true,
@@ -122,15 +121,9 @@ const markMessages = (
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
if (strategy === "latest-assistant")
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
let start = messages.length
let remaining = strategy.tail
while (remaining > 0 && start > 0) {
start -= 1
if (effortUpdate(messages[start]!) === undefined) remaining -= 1
}
const start = Math.max(0, messages.length - strategy.tail)
let next = messages
for (let i = start; i < messages.length; i++)
if (effortUpdate(messages[i]!) === undefined) next = markMessageAt(next, i, hint, budget)
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
return next
}
-27
View File
@@ -1,27 +0,0 @@
// A top-level reasoning effort change invalidates the whole provider prompt cache, so a
// mid-conversation switch travels as a `Message.effort(...)` marker: protocols with a native
// per-message update freeze the top-level effort and lower the markers; every other route strips them.
import { LLMRequest, type EffortPart, type Message } from "./schema/messages.js"
export const effortUpdate = (message: Message): EffortPart | undefined => {
if (message.role !== "system" || message.content.length !== 1) return undefined
const part = message.content[0]
return part.type === "effort" ? part : undefined
}
export const stripEffortUpdates = (request: LLMRequest) => {
const messages = request.messages.filter((message) => effortUpdate(message) === undefined)
return messages.length === request.messages.length ? request : LLMRequest.update(request, { messages })
}
export const applyEffortUpdates = (request: LLMRequest): LLMRequest =>
request.model.route.supportsEffortUpdates?.(request) ? request : stripEffortUpdates(request)
// The markers must end at `current`: `revert.ts` never touches `session.model` and forks may select
// another variant, so on disagreement fall back to a plain top-level change instead of misreporting effort.
export const resolveEffortUpdates = (request: LLMRequest, current: string | undefined) => {
const updates = request.messages.flatMap((message) => effortUpdate(message) ?? [])
if (updates.length === 0) return { request, effort: current }
if (updates.at(-1)?.effort !== current) return { request: stripEffortUpdates(request), effort: current }
return { request, effort: updates[0]?.previous }
}
+18 -50
View File
@@ -27,7 +27,6 @@ import {
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -37,7 +36,6 @@ const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
const DEFAULT_EFFORT = "high"
const SSE_EVENTS = new Set([
"message",
@@ -288,11 +286,7 @@ type AnthropicToolResultBlock = Schema.Schema.Type<typeof AnthropicToolResultBlo
const AnthropicMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }),
Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Array(AnthropicTextBlock),
output_config: Schema.optional(Schema.Struct({ effort: Schema.String })),
}),
Schema.Struct({ role: Schema.Literal("system"), content: Schema.Array(AnthropicTextBlock) }),
]).pipe(Schema.toTaggedUnion("role"))
type AnthropicMessage = Schema.Schema.Type<typeof AnthropicMessage>
@@ -883,12 +877,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
const update = effortUpdate(message)
if (update) {
// Accepted at any position, so the text-update placement rules do not apply.
messages.push({ role: "system", content: [], output_config: { effort: update.effort ?? DEFAULT_EFFORT } })
continue
}
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
@@ -1046,11 +1034,18 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const output_config =
outputConfigEffort === undefined && outputConfigFormat === undefined
? undefined
: {
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
}
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
format: outputConfigFormat,
output_config,
service_tier,
metadata,
container,
@@ -1059,30 +1054,15 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
}
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
id.toLowerCase(),
)?.groups
if (!match) return undefined
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
}
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsThinkingBlockBinding
if (override !== undefined) return override
const version = claudeVersion(model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
}
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(model.id)
if (version === undefined) return false
if (version.family === "opus") return version.major >= 5
if (version.family !== "fable" && version.family !== "mythos") return false
return version.major > 5 || (version.major === 5 && version.minor >= 1)
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const version = /(?:^|[./])claude-[a-z]+-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/i.exec(model.id)?.groups
if (!version) return false
const major = Number(version.major)
const minor = Number(version.minor ?? 0)
return major > 5 || (major === 5 && minor >= 1)
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
@@ -1124,15 +1104,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(updates.request)
const flattened = ProviderShared.flattenToolRequest(request)
const tools =
flattened.tools.length === 0
? undefined
@@ -1160,13 +1138,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
)
}
const output_config =
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
const options = yield* resolveOptions(request)
const body = {
model: request.model.id,
system,
@@ -1180,7 +1152,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
output_config,
output_config: options.output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
container: options.container,
@@ -1705,7 +1677,6 @@ export const protocol = Protocol.make({
}),
step,
},
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
})
export const transport = <
@@ -1744,9 +1715,6 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
)
if (requestsCompaction || replaysCompaction) betas.push("compact-2026-01-12")
if (body.messages.some((message) => message.role === "system" && message.output_config !== undefined))
betas.push("mid-conversation-output-config-2026-07-01")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
betas.push("thinking-binding-controls-2026-08-01")
+6 -34
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -20,7 +20,6 @@ import {
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate } from "../effort-updates.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -165,13 +164,6 @@ export const CompactionItem = Schema.Struct({
encrypted_content: Schema.String,
})
// Kept out of the baseline `InputItem` union: only the OpenAI extension accepts it.
export const ConfigurationUpdate = Schema.Struct({
type: Schema.Literal("configuration_update"),
reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }),
})
type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
export const InputItem = Schema.Union([
CompactionItem,
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
@@ -216,7 +208,6 @@ export type HostedToolReplayItem = {
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ConfigurationUpdate
| {
readonly type: "message"
readonly id?: string
@@ -334,8 +325,9 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
// event-level `error` envelope, so accept all three shapes here.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -409,17 +401,6 @@ export const Event = Schema.StructWithRest(
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
@@ -643,8 +624,6 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
})
const DEFAULT_EFFORT = "medium"
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
@@ -657,14 +636,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
const update = effortUpdate(message)
if (update) {
// Consecutive updates are rejected, so a newer one replaces its predecessor.
const last = input.at(-1)
if (last !== undefined && "type" in last && last.type === "configuration_update") input.pop()
input.push({ type: "configuration_update", reasoning: { effort: update.effort ?? DEFAULT_EFFORT } })
continue
}
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
@@ -808,7 +779,8 @@ export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(fu
}
})
export const lowerGeneration = (request: LLMRequest, options = OpenResponsesOptions.resolve(request)) => {
export const lowerGeneration = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const generation = request.generation
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
+4 -23
View File
@@ -6,9 +6,7 @@ import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { resolveEffortUpdates } from "../effort-updates.js"
import { OpenResponses } from "./open-responses.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
@@ -96,15 +94,9 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
OpenResponses.InputItem,
OpenAIResponsesHostedToolItem,
OpenResponses.ConfigurationUpdate,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
@@ -127,7 +119,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
@@ -141,14 +133,6 @@ const adapter = {
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
const supportsEffortUpdates = (request: LLMRequest) => {
if (request.providerOptions?.contextManagement !== undefined) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
}
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
@@ -205,12 +189,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = OpenResponsesOptions.resolve(request)
const updates = resolveEffortUpdates(request, options.reasoningEffort)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return yield* decodeBody({
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
...(yield* OpenResponses.lowerConversation(request, adapter)),
...OpenResponses.lowerGeneration(request),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
tools:
request.tools.length === 0
@@ -313,7 +295,6 @@ export const protocol = Protocol.make({
step,
terminal: OpenResponses.terminal,
},
supportsEffortUpdates,
})
const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
@@ -1,7 +1,12 @@
import { Option, Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import type { LLMRequest } from "../../schema/index.js"
export { ReasoningEffort, ReasoningEfforts }
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const TextVerbosities = ["low", "medium", "high"] as const
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
@@ -11,7 +11,6 @@ import {
mergeJsonRecords,
} from "../../schema/index.js"
import type { CompactOperation } from "../../route/client.js"
import { stripEffortUpdates } from "../../effort-updates.js"
import { Endpoint } from "../../route/endpoint.js"
import { RequestExecutor } from "../../route/executor.js"
import { HttpTransport } from "../../route/transport/index.js"
@@ -76,8 +75,7 @@ const Response = Schema.Struct({
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
// The standalone compaction endpoint rejects histories containing configuration updates.
const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter)
const native = yield* OpenResponses.lowerConversation(request, adapter)
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
+3 -8
View File
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("alibaba")
@@ -82,13 +82,8 @@ export const configure = (input: Config) => {
? hosts.get(region)
: `${workspaceID}.${region}.maas.aliyuncs.com`
if (baseURL === undefined) {
if (region === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
if (host === undefined)
throw new ProviderConfigurationError({
provider: id,
message: `Alibaba region ${region} requires workspaceID or baseURL`,
})
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
}
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -79,12 +79,9 @@ const defaults = (input: Config) => {
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
})
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
+3 -4
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
@@ -39,9 +39,8 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
if (auth === "sigv4" && apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
@@ -36,12 +36,8 @@ const auth = (input: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config) => {
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
const provider = input.provider ?? "anthropic-compatible"
if (!input.baseURL)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
...rest,
@@ -65,13 +61,8 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
// Read before the exclusivity check narrows a conflicting settings object to `never`.
const provider = ProviderID.make(settings.provider ?? id)
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new ProviderConfigurationError({
provider,
message: "Anthropic-compatible apiKey cannot be combined with authToken",
})
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+2 -5
View File
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -57,10 +57,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Anthropic apiKey cannot be combined with authToken",
})
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+2 -2
View File
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { ProviderShared } from "../protocols/shared.js"
@@ -163,7 +163,7 @@ const config = (settings: Settings): Config => {
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-ai-gateway")
@@ -35,11 +35,7 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
})
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-workers-ai")
@@ -28,11 +28,7 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
})
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -37,8 +37,7 @@ const route = Route.make({
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -75,8 +74,7 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
@@ -67,7 +67,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
throw new Error("Google Vertex Messages does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -107,8 +107,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
@@ -39,7 +39,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
throw new Error("Google Vertex Responses does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -79,8 +79,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
modelID,
settings,
) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -1,10 +1,8 @@
import type { AnyAuthClient } from "google-auth-library"
import { Effect, Redacted } from "effect"
import { Auth, MissingCredentialError } from "../route/auth.js"
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
const id = ProviderID.make("google-vertex")
export type OAuthOptions =
| { readonly accessToken?: string; readonly auth?: never }
@@ -37,18 +35,12 @@ export const host = (location: string) => {
export const requireProject = (value: string | undefined) => {
if (value) return value
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex requires a project when baseURL is not configured",
})
throw new Error("Google Vertex requires a project when baseURL is not configured")
}
export const apiKey = (input: ApiKeyOptions) => {
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
}
@@ -76,10 +68,7 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex accessToken cannot be combined with auth",
})
throw new Error("Google Vertex accessToken cannot be combined with auth")
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
+3 -9
View File
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export interface GeminiOptionsInput extends Gemini.OptionsInput {
@@ -93,10 +93,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
const apiKey = GoogleVertexShared.apiKey(input)
const endpointModel = String(modelID).startsWith("endpoints/")
if (apiKey !== undefined && endpointModel)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex tuned models do not support Express Mode API keys",
})
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
const location = GoogleVertexShared.location(inputLocation, "us-central1")
const project = GoogleVertexShared.project(inputProject)
const endpoint =
@@ -126,10 +123,7 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
+2 -11
View File
@@ -7,7 +7,6 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { applyEffortUpdates } from "../effort-updates.js"
import { normalizeToolHistory } from "../tool-history.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
@@ -24,7 +23,6 @@ import {
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
ProviderConfigurationError,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -56,7 +54,6 @@ export interface Route<
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
@@ -131,10 +128,7 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
})
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
@@ -390,7 +384,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
transport: routeInput.transport,
defaults: routeInput.defaults ?? {},
body: protocol.body,
supportsEffortUpdates: protocol.supportsEffortUpdates,
with: (patch: RoutePatch<Body, Prepared>) => {
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
@@ -561,9 +554,7 @@ const prepareRequest = (request: LLMRequest) => {
[...new Map(tools.map((tool) => [`${tool.type}:${tool.name}`, tool])).values()].map((tool) =>
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
)
const resolved = applyCachePolicy(
applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })),
)
const resolved = applyCachePolicy(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) }))
const headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
? resolved
-2
View File
@@ -41,8 +41,6 @@ export interface Protocol<Body, Frame, Event, State> {
readonly body: ProtocolBody<Body>
/** Response side: streaming state machine. */
readonly stream: ProtocolStream<Frame, Event, State>
/** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
}
export interface ProtocolBody<Body> {
-13
View File
@@ -50,19 +50,6 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
route: Schema.optional(RouteID),
}) {}
/**
* Provider settings that are missing, conflicting, or unsupported, such as
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
* provider facade or package entrypoint configures a model, before any
* request exists, so it is not an `AIError` reason.
*/
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
"AI.Error.ProviderConfiguration",
)("ProviderConfiguration", {
provider: ProviderID,
message: Schema.String,
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
-13
View File
@@ -9,7 +9,6 @@ import {
LanguageModelSchema,
type LanguageModel,
ProviderOptions,
ReasoningEffort,
} from "./options.js"
import { ProviderID } from "./ids.js"
@@ -218,14 +217,6 @@ export const CompactionPart = Object.assign(compactionPartSchema, {
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
})
/** Reasoning effort changed here, from `previous` to `effort`; `undefined` is the model default. */
export const EffortPart = Schema.Struct({
type: Schema.Literal("effort"),
effort: Schema.optional(ReasoningEffort),
previous: Schema.optional(ReasoningEffort),
}).annotate({ identifier: "LLM.Content.Effort" })
export type EffortPart = Schema.Schema.Type<typeof EffortPart>
export const ContentPart = Schema.Union([
TextPart,
MediaPart,
@@ -233,7 +224,6 @@ export const ContentPart = Schema.Union([
ToolResultPart,
ReasoningPart,
CompactionPart,
EffortPart,
]).pipe(Schema.toTaggedUnion("type"))
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
@@ -275,9 +265,6 @@ export namespace Message {
*/
export const system = (content: SystemContentInput) => make({ role: "system", content })
export const effort = (input: { readonly effort?: ReasoningEffort; readonly previous?: ReasoningEffort }) =>
make({ role: "system", content: [{ type: "effort", effort: input.effort, previous: input.previous }] })
export const tool = (result: ToolResultPart | Parameters<typeof ToolResultPart.make>[0]) =>
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
}
-10
View File
@@ -140,14 +140,6 @@ export namespace LanguageModelDefaults {
}
}
/** Ordered lowest to highest. */
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type<typeof LanguageModelToolSchemaCompatibility>
@@ -173,8 +165,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
requireSignature: Schema.optional(Schema.Boolean),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
supportsThinkingBlockBinding: Schema.optional(Schema.Boolean),
/** Supports per-message effort updates. Overrides model-ID detection. */
supportsEffortUpdates: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
-439
View File
@@ -1,439 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message, ToolCallPart } from "../src/index.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AnthropicMessages } from "../src/protocols/anthropic-messages.js"
import { OpenAIResponses } from "../src/protocols/openai-responses.js"
import { Gemini } from "../src/protocols/gemini.js"
import { GoogleVertexMessages, OpenAI } from "../src/providers.js"
import { applyCachePolicy } from "../src/cache-policy.js"
import { applyEffortUpdates } from "../src/effort-updates.js"
import { it, testEffect } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
const anthropic = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) =>
AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id, compatibility })
const openai = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) =>
OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id, compatibility })
const opus5 = anthropic("claude-opus-5")
const astra = openai("gpt-6-astra")
const lowFromHigh = Message.effort({ effort: "low", previous: "high" })
const conversation = [Message.user("Before."), lowFromHigh, Message.user("After.")]
const systemMessages = (body: AnthropicMessages.AnthropicMessagesBody) =>
body.messages.filter((message) => message.role === "system")
const updates = (body: OpenAIResponses.OpenAIResponsesBody) =>
body.input.filter((item) => "type" in item && item.type === "configuration_update")
describe("applyEffortUpdates", () => {
test("keeps the request identity without markers and for protocols that lower them", () => {
const plain = LLM.request({ model: opus5, prompt: "hi" })
expect(applyEffortUpdates(plain)).toBe(plain)
const supported = LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" } })
expect(applyEffortUpdates(supported)).toBe(supported)
})
it.effect("compiles markers away for protocols without per-message effort", () =>
Effect.gen(function* () {
const model = Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-3.5-flash" })
const withMarkers = yield* compileRequest(LLM.request({ model, messages: conversation }))
const withoutMarkers = yield* compileRequest(
LLM.request({ model, messages: [Message.user("Before."), Message.user("After.")] }),
)
expect(withMarkers.body).toEqual(withoutMarkers.body)
}),
)
})
describe("cache policy", () => {
it.effect("walks the tail breakpoint back past a trailing effort marker", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [Message.user("first"), Message.assistant("reply"), Message.user("latest"), lowFromHigh],
providerOptions: { effort: "low" },
cache: "auto",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "first" }] },
{ role: "assistant", content: [{ type: "text", text: "reply" }] },
{ role: "user", content: [{ type: "text", text: "latest", cache_control: { type: "ephemeral" } }] },
{ role: "system", content: [], output_config: { effort: "low" } },
])
}),
)
})
describe("Anthropic Messages effort updates", () => {
it.effect("lowers markers to per-turn system messages and freezes the top-level effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" }, cache: "none" }),
)
expect(prepared.body.output_config).toEqual({ effort: "high" })
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Before." }] },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: [{ type: "text", text: "After." }] },
])
}),
)
it.effect("omits the frozen effort for the model default and sends `high` for a switch back to it", () =>
Effect.gen(function* () {
const format = { type: "json_schema" as const, schema: { type: "object" } }
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [
Message.user("One."),
Message.effort({ effort: "low" }),
Message.user("Two."),
Message.effort({ previous: "low" }),
Message.user("Three."),
],
providerOptions: { output_config: { format } },
cache: "none",
}),
)
expect(prepared.body.output_config).toEqual({ format })
expect(systemMessages(prepared.body)).toEqual([
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "system", content: [], output_config: { effort: "high" } },
])
}),
)
it.effect("requests the mid-conversation output config beta only when markers are sent", () =>
Effect.gen(function* () {
for (const [model, betas] of [
[opus5, "existing-beta,mid-conversation-output-config-2026-07-01"],
[anthropic("claude-sonnet-5"), "existing-beta"],
] as const) {
const request = LLM.request({
model,
messages: conversation,
providerOptions: { effort: "low" },
http: { headers: { "anthropic-beta": "existing-beta" } },
})
const compiled = yield* compileRequest(request)
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(prepared.request.headers["anthropic-beta"]).toBe(betas)
}
}),
)
it.effect("accepts a marker between a tool call and its result", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [
Message.user("Weather?"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
lowFromHigh,
Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 } }),
],
providerOptions: { effort: "low" },
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
])
}),
)
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
Effect.gen(function* () {
const drifted = yield* compileRequest(
LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "medium" }, cache: "none" }),
)
const plain = yield* compileRequest(
LLM.request({
model: opus5,
messages: [Message.user("Before."), Message.user("After.")],
providerOptions: { effort: "medium" },
cache: "none",
}),
)
expect(drifted.body).toEqual(plain.body)
expect(drifted.body.output_config).toEqual({ effort: "medium" })
}),
)
for (const [id, supported] of [
["claude-opus-5", true],
["claude-opus-5-20260901", true],
["anthropic/claude-opus-5", true],
["claude-fable-5-1", true],
["claude-mythos-5-1", true],
["claude-fable-5", false],
["claude-opus-4-8", false],
["claude-sonnet-5", false],
["kimi-k2.5", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: anthropic(id), messages: conversation, providerOptions: { effort: "low" } }),
)
expect(systemMessages(prepared.body)).toHaveLength(supported ? 1 : 0)
expect(prepared.body.output_config).toEqual({ effort: supported ? "high" : "low" })
}),
)
}
it.effect("honors the compatibility override in both directions", () =>
Effect.gen(function* () {
const enabled = yield* compileRequest(
LLM.request({
model: anthropic("claude-sonnet-5", { supportsEffortUpdates: true }),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
const disabled = yield* compileRequest(
LLM.request({
model: anthropic("claude-opus-5", { supportsEffortUpdates: false }),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(enabled.body)).toHaveLength(1)
expect(systemMessages(disabled.body)).toHaveLength(0)
}),
)
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-5",
),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(prepared.body)).toHaveLength(0)
expect(prepared.body.output_config).toEqual({ effort: "low" })
}),
)
})
describe("OpenAI Responses effort updates", () => {
it.effect("lowers markers to configuration_update items and freezes reasoning.effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "low" } }),
)
expect(prepared.body.reasoning).toEqual({ effort: "high" })
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "low" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
}),
)
it.effect("coalesces consecutive updates so the newest wins", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: [
Message.user("Before."),
Message.effort({ effort: "low", previous: "medium" }),
Message.effort({ effort: "xhigh", previous: "low" }),
Message.user("After."),
],
providerOptions: { reasoningEffort: "xhigh" },
}),
)
expect(prepared.body.reasoning).toEqual({ effort: "medium" })
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "xhigh" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
}),
)
it.effect("omits reasoning.effort for the model default and sends `medium` for a switch back to it", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: [
Message.user("One."),
Message.effort({ effort: "low" }),
Message.user("Two."),
Message.effort({ previous: "low" }),
Message.user("Three."),
],
}),
)
expect(prepared.body.reasoning).toBeUndefined()
expect(updates(prepared.body)).toEqual([
{ type: "configuration_update", reasoning: { effort: "low" } },
{ type: "configuration_update", reasoning: { effort: "medium" } },
])
}),
)
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
Effect.gen(function* () {
const drifted = yield* compileRequest(
LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "xhigh" } }),
)
const plain = yield* compileRequest(
LLM.request({
model: astra,
messages: [Message.user("Before."), Message.user("After.")],
providerOptions: { reasoningEffort: "xhigh" },
}),
)
expect(drifted.body).toEqual(plain.body)
expect(drifted.body.reasoning).toEqual({ effort: "xhigh" })
}),
)
it.effect("strips markers when automatic context management is enabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: conversation,
providerOptions: { reasoningEffort: "low", contextManagement: [{ type: "compaction" }] },
}),
)
expect(updates(prepared.body)).toEqual([])
expect(prepared.body.reasoning).toEqual({ effort: "low" })
expect(prepared.body.context_management).toEqual([{ type: "compaction" }])
}),
)
for (const [id, supported] of [
["gpt-6-astra", true],
["openai/gpt-6-astra", true],
["gpt-6-astra-2026-09-01", false],
["gpt-5.6-sol", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: openai(id), messages: conversation, providerOptions: { reasoningEffort: "low" } }),
)
expect(updates(prepared.body)).toHaveLength(supported ? 1 : 0)
expect(prepared.body.reasoning).toEqual({ effort: supported ? "high" : "low" })
}),
)
}
it.effect("honors the compatibility override in both directions", () =>
Effect.gen(function* () {
const enabled = yield* compileRequest(
LLM.request({
model: openai("gpt-5.5", { supportsEffortUpdates: true }),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
}),
)
const disabled = yield* compileRequest(
LLM.request({
model: openai("gpt-6-astra", { supportsEffortUpdates: false }),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
}),
)
expect(updates(enabled.body)).toHaveLength(1)
expect(updates(disabled.body)).toHaveLength(0)
}),
)
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const compactRequest = LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-6-astra"),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
})
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.reasoning).toEqual({ effort: "high" })
expect(body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "low" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
{ type: "compaction_trigger" },
])
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect("keeps configuration updates in the checkpoint body", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(compactRequest, { mechanism: "trigger" })
expect(result.checkpoint.encrypted).toBe("opaque")
}),
)
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
expect(JSON.parse(text).input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect("drops markers from the compaction endpoint body", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(compactRequest, { mechanism: "endpoint" })
expect(result.replacement.map((message) => message.content[0]?.type)).toEqual(["compaction"])
}),
)
})
+11 -16
View File
@@ -3,9 +3,6 @@ import { model } from "@opencode/ai/providers/openai"
import { LLM } from "../src/index.js"
import { Endpoint } from "../src/route/endpoint.js"
const configuration = (provider: string, message: string) =>
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -325,7 +322,7 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
).toThrow("Anthropic-compatible providers require a baseURL")
})
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
@@ -340,10 +337,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://messages.example.test/v1",
},
]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
).toThrow("Anthropic apiKey cannot be combined with authToken")
})
test("maps legacy OpenAI organization and project settings to headers", () => {
@@ -493,45 +490,43 @@ describe("provider package entrypoints", () => {
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
{ accessToken: "token", auth: {}, project: "vertex-project" },
])
expect(() => configured.model("gemini-3.5-flash")).toThrow(
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
)
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
).toThrow("Google Vertex Responses does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
).toThrow("Google Vertex Responses does not support API keys")
})
})
+1 -7
View File
@@ -76,13 +76,7 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
expect(() => Alibaba.configure({ region })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "alibaba",
message: `Alibaba region ${region} requires workspaceID or baseURL`,
}),
)
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
for (const config of [
{ baseURL: "https://gateway.example/prefix" },
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
@@ -1458,13 +1458,7 @@ describe("Bedrock Converse route", () => {
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "amazon-bedrock",
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
}),
)
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
}).pipe(
withProcessEnv({
...noAmbientAWS,
@@ -378,11 +378,7 @@ describe("Google Vertex providers", () => {
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "google-vertex",
message: "Google Vertex tuned models do not support Express Mode API keys",
}),
"Google Vertex tuned models do not support Express Mode API keys",
)
})
})
@@ -1,78 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
"type": "error",
"sequence_number": 4,
"code": "server_shutting_down",
"message": "Server is shutting down. Please retry your request.",
"param": null,
"diagnostic": "retain-original-frame"
}`
for (const model of [
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
),
]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
expect(error.reason.body).toBe(raw)
expect(error.reason.http?.status).toBe(200)
}
}),
)
@@ -19,13 +19,11 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
@@ -45,17 +43,6 @@ story("adding a server keeps all SSH challenges in the original connection dialo
await expect(dialog).toHaveCount(0)
})
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
await mount("app-dialog-ssh--incompatible-host")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
})
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
toolPart(
@@ -134,6 +134,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
+2 -6
View File
@@ -54,12 +54,8 @@ export function createWebPlatform(version: string) {
function getCurrentServerUrl() {
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (import.meta.env.DEV) {
const loopback =
location.hostname === "localhost" || location.hostname === "[::1]" || location.hostname.startsWith("127.")
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? (loopback ? location.hostname : "localhost")
return `http://${host}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
}
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
+2 -5
View File
@@ -10,7 +10,6 @@ import { createData } from "@opencode/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
@@ -34,7 +33,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
const models = createGlobalModels()
const notificationCoordinator = createNotificationCoordinator()
const settingsServer = createMemo(() => {
const list = server.list
@@ -59,7 +57,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
return createServerController(conn, server.scope(key), server.projects.forServer(key))
}, owner)
serverCtxs.set(key, serverCtx)
return serverCtx
@@ -133,7 +131,6 @@ function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
) {
const language = useLanguage()
const settings = useSettings()
@@ -162,7 +159,7 @@ function createServerController(
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
const notification = createServerNotificationState({ sdk, data, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -278,7 +278,6 @@ function Open(props: { initial?: string }) {
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
+2 -14
View File
@@ -120,13 +120,7 @@ export function DialogSsh(props: {
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<Show
when={
!props.promptOnly &&
item()?.stage !== "incompatible" &&
(!state.prompted || (!!error() && !prompt()))
}
>
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label" for="ssh-target">
{language.t("ssh.target")}
@@ -166,12 +160,6 @@ export function DialogSsh(props: {
/>
</div>
</Show>
<Show when={item()?.stage === "incompatible"}>
<div class="flex w-full min-w-0 flex-col gap-2" role="status" aria-live="polite">
<span class="text-14-medium text-v2-text-text-base">{language.t("ssh.stage.incompatible")}</span>
<span class="text-13-regular text-v2-text-text-muted">{language.t("ssh.error.version")}</span>
</div>
</Show>
<Show when={prompt()} keyed>
{(prompt) => (
<div class="flex w-full min-w-0 flex-col gap-2">
@@ -207,7 +195,7 @@ export function DialogSsh(props: {
</div>
)}
</Show>
<Show when={item()?.stage !== "incompatible" && error()}>
<Show when={error()}>
{(error) => (
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
{error()}
@@ -1,6 +1,6 @@
import { createMemo, createUniqueId, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
import { createQuery } from "@tanstack/solid-query"
import { Icon } from "@opencode/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
@@ -56,7 +56,6 @@ export function SessionFileBrowserTab(props: {
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
placeholderData: keepPreviousData,
}
})
const files = createMemo(() => {
@@ -1,93 +0,0 @@
import { onCleanup } from "solid-js"
const FOCUS_LOCK = "opencode:notification-focus"
const MAX_CLAIMED = 500
export function createNotificationCoordinator() {
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
const claimed = new Set<string>()
const focus = { pending: false, release: undefined as (() => void) | undefined }
const updateFocus = () => {
if (typeof document === "undefined" || !document.hasFocus()) {
focus.release?.()
return
}
if (!locks || focus.pending || focus.release) return
focus.pending = true
void locks
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
focus.pending = false
if (!document.hasFocus()) return
await new Promise<void>((resolve) => {
focus.release = resolve
})
focus.release = undefined
})
.catch(() => {
focus.pending = false
})
}
if (typeof window !== "undefined") {
window.addEventListener("focus", updateFocus)
window.addEventListener("blur", updateFocus)
document.addEventListener("visibilitychange", updateFocus)
updateFocus()
onCleanup(() => {
window.removeEventListener("focus", updateFocus)
window.removeEventListener("blur", updateFocus)
document.removeEventListener("visibilitychange", updateFocus)
focus.release?.()
})
}
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
const key = `${kind}:${eventID}`
const execute = async () => {
if (!claim(kind, key, claimed)) return
await run()
}
if (!locks) return execute()
await locks.request(`opencode:notification:${key}`, execute)
}
return {
sound(eventID: string, run: () => Promise<unknown> | void) {
return once("sound", eventID, run)
},
system(eventID: string, run: () => Promise<unknown> | void) {
return once("system", eventID, async () => {
if (typeof document !== "undefined" && document.hasFocus()) return
if (!locks) return run()
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
if (!lock) return
await run()
})
})
},
}
}
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const storageKey = `opencode:notification-${kind}`
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
@@ -11,8 +11,7 @@ import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
@@ -115,12 +114,7 @@ function buildNotificationIndex(list: Notification[]) {
return index
}
export function createServerNotificationState(input: {
sdk: ServerSDK
data: Data
key: ServerConnection.Key
coordinator: ReturnType<typeof createNotificationCoordinator>
}) {
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -229,7 +223,7 @@ export function createServerNotificationState(input: {
if (session.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
}
append({
@@ -241,10 +235,8 @@ export function createServerNotificationState(input: {
})
if (settings.notifications.agent()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
),
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
)
}
})
@@ -256,7 +248,7 @@ export function createServerNotificationState(input: {
if (session?.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
}
append({
@@ -271,10 +263,8 @@ export function createServerNotificationState(input: {
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
if (settings.notifications.errors()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
),
void platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
)
}
})
@@ -74,6 +74,9 @@ function getLoads() {
}
const cache = new Map<SoundID, Promise<string | undefined>>()
const claimed = new Set<string>()
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
const MAX_CLAIMED = 500
export function soundSrc(id: string | undefined) {
const loads = getLoads()
@@ -100,3 +103,34 @@ export function playSound(src: string | undefined) {
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
const play = async () => {
if (!claim(eventID)) return
await playSoundById(id)
}
if (typeof navigator === "undefined" || !navigator.locks) return play()
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
}
function claim(eventID: string) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
+1 -5
View File
@@ -76,10 +76,7 @@ export function buildEffortSelectOption(input: {
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
}
}
@@ -128,7 +125,6 @@ export function formatVariantName(variant: string) {
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
+2 -4
View File
@@ -201,7 +201,7 @@ export async function streamTurn(input: {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_thought_chunk",
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
@@ -455,8 +455,6 @@ async function replayMessage(
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({
@@ -474,7 +472,7 @@ async function replayMessage(
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
messageId: message.id,
content: { type: "text", text: part.text },
},
})
+5 -20
View File
@@ -41,12 +41,7 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode/schema/session-message"
import {
buildConfigOptions,
DEFAULT_VARIANT_VALUE,
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
@@ -280,7 +275,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value, state.model)
const selected = requireModel(state.catalog, params.value)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
@@ -289,10 +284,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (
!model ||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
)
if (!model?.variants.some((variant) => variant.id === params.value))
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
@@ -461,7 +453,7 @@ function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
}))
}
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
function requireModel(catalog: Catalog, modelID: string): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
@@ -469,14 +461,7 @@ function requireModel(catalog: Catalog, modelID: string, current: ModelRef): Mod
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selected.variant ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant }
return { providerID: model.providerID, id: model.id, variant: selected.variant }
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
null,
2,
)
: formatList(page.data)) + EOL
: formatTable(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
@@ -96,14 +96,18 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
),
)
function formatList(sessions: ReadonlyArray<SessionInfo>) {
return sessions
.map((session) =>
[
session.id,
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
new Date(session.time.updated).toLocaleString(),
].join("\t"),
)
.join(EOL)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
}
@@ -50,11 +50,11 @@ describe("acp config option subprocess", () => {
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("default")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}, 60_000)
test("effort survives model synchronization and can be reset to default", async () => {
test("effort switch updates currentValue", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
@@ -70,23 +70,5 @@ describe("acp config option subprocess", () => {
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
const synchronized = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: requireSelectOption(session.configOptions, "model").currentValue,
}),
)
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
const reset = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: "default",
}),
)
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
}, 60_000)
})
+6 -44
View File
@@ -91,7 +91,7 @@ describe("acp event behavior", () => {
}
})
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
test("preserves text and reasoning order before returning the terminal response", async () => {
const firstUpdate = Promise.withResolvers<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
@@ -108,14 +108,6 @@ describe("acp event behavior", () => {
delta: "think-1",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: " continued",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
@@ -128,7 +120,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
ordinal: 2,
delta: "think-2",
}),
)
@@ -152,7 +144,7 @@ describe("acp event behavior", () => {
firstUpdate.resolve()
await releaseUpdate.promise
}
if (updates.length === 4) allUpdates.resolve()
if (updates.length === 3) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
@@ -179,48 +171,18 @@ describe("acp event behavior", () => {
) {
return [
item.update.sessionUpdate,
item.update.messageId,
item.update.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
["agent_thought_chunk", "think-1"],
["agent_message_chunk", "answer"],
["agent_thought_chunk", "think-2"],
])
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
const replayed: SessionUpdateParams[] = []
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
{
id: "msg_order",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1 },
content: [
{ type: "reasoning", text: "think-1 continued" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "think-2" },
],
},
])
expect(replayed).toEqual([
{
sessionId: "ses_order",
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "msg_order:reasoning:0",
content: { type: "text", text: "think-1 continued" },
},
},
updates[2],
updates[3],
])
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
@@ -222,7 +222,7 @@ describe("acp service directory behavior", () => {
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
expect(currentValue(selectedModel, "effort")).toBe("default")
expect(currentValue(selectedModel, "effort")).toBe("low")
expect(currentValue(selectedEffort, "effort")).toBe("medium")
expect(currentValue(selectedMode, "mode")).toBe("plan")
expect(
@@ -32,7 +32,7 @@ describe("acp service lifecycle", () => {
model: { providerID: "test", id: "second-model" },
},
})
expect(currentValue(created, "effort")).toBe("default")
expect(currentValue(created, "effort")).toBe("none")
})
test("loads and forks with paginated replay while resume does not replay", async () => {
+1 -19
View File
@@ -11,7 +11,6 @@ import type { RelativePath } from "@opencode/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode/schema/model"
import type { DateTime } from "effect"
import type { Permission } from "@opencode/schema/permission"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptInput } from "@opencode/schema/prompt-input"
@@ -27,6 +26,7 @@ import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { Permission } from "@opencode/schema/permission"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
import type { Command } from "@opencode/schema/command"
@@ -209,7 +209,6 @@ export type SessionCreateInput = {
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionCreateOutput = Session.Info
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@@ -438,7 +437,6 @@ export type SessionLogOutput =
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
readonly version: string
}
}
@@ -491,15 +489,6 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
}
| {
readonly id: Event.ID
readonly created: number
@@ -1596,12 +1585,6 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1609,7 +1592,6 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
@@ -181,8 +181,6 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -397,7 +395,6 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -1148,14 +1145,6 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
request: { list: EndpointPermissionRequestList(raw) },
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
@@ -1163,7 +1152,6 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
@@ -175,8 +175,6 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -567,7 +565,6 @@ export function make(options: ClientOptions) {
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -1569,18 +1566,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
file: {
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
+42 -127
View File
@@ -551,6 +551,28 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
version: string
}
}
export type SessionAgentSelected = {
id: string
created: number
@@ -1629,6 +1651,24 @@ export type SessionInboxMove = {
delivery: SessionInboxDelivery
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
revert?: SessionRevert
}
export type SessionRevertStaged = {
id: string
created: number
@@ -1872,58 +1912,6 @@ export type AgentInfo = {
permissions: PermissionRuleset
}
export type SessionPermissionsUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.permissions.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; permissions: PermissionRuleset }
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
permissions?: PermissionRuleset
revert?: SessionRevert
}
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
permissions?: PermissionRuleset
version: string
}
}
export type ConfigEntry =
| {
type: "document"
@@ -2096,6 +2084,8 @@ export type ConfigEntry =
| { type: "agents"; path: string }
| { type: "claude"; path: string }
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = {
id: string
sessionID: string
@@ -2150,8 +2140,6 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields2 = [FormField1, ...Array<FormField1>]
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionInboxEnqueued = {
@@ -2245,7 +2233,6 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionDeleted
| SessionForked
@@ -2305,7 +2292,6 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
@@ -2818,11 +2804,6 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["id"]
readonly title?: {
readonly id?: string | null
@@ -2831,11 +2812,6 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["title"]
readonly agent?: {
readonly id?: string | null
@@ -2844,11 +2820,6 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["agent"]
readonly model?: {
readonly id?: string | null
@@ -2857,11 +2828,6 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["model"]
readonly location?: {
readonly id?: string | null
@@ -2870,11 +2836,6 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["location"]
readonly metadata?: {
readonly id?: string | null
@@ -2883,25 +2844,7 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["metadata"]
readonly permissions?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["permissions"]
}
export type SessionCreateOutput = { data: SessionInfo }["data"]
@@ -2939,11 +2882,6 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3249,11 +3187,6 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3559,11 +3492,6 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -5825,19 +5753,6 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void
export type PermissionRulesInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly permissions: {
readonly permissions: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}["permissions"]
}
export type PermissionRulesOutput = void
export type FileReadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
-4
View File
@@ -695,10 +695,6 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
+3 -2
View File
@@ -5,8 +5,9 @@ import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
const base64 = (name: "atob" | "btoa") =>
sync(name, (args, node) => {
if (args.length === 0)
throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node).as("TypeError")
if (args.length === 0) {
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
}
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
-8
View File
@@ -547,14 +547,6 @@ function assistantPart(part: ContentPart): AssistantContent {
]
case "tool-result":
return toolResultPart(part)
case "effort":
throw ProviderShared.unsupportedContent("AI SDK", "assistant", [
"text",
"media",
"reasoning",
"tool-call",
"tool-result",
])
}
}
-2
View File
@@ -45,7 +45,6 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
export const migrations = [
m00,
@@ -94,5 +93,4 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -1,13 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260910120000_clear_v1_session_permission",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`UPDATE \`session_v2\` SET \`permission\` = NULL;`)
})
},
}
export default migration
@@ -600,7 +600,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
+19 -11
View File
@@ -1,6 +1,7 @@
export * as Generate from "./generate.js"
import { LLM, LLMClient, AIError } from "@opencode/ai"
import { SessionID } from "@opencode/schema/session-id"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform.js"
@@ -41,8 +42,6 @@ export const layer = Layer.effect(
[
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.ModelConfigurationError",
"SessionRunnerModel.ModelInitializationError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
"SessionRunnerModel.UnsupportedCompactionError",
],
@@ -60,15 +59,24 @@ export const layer = Layer.effect(
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
const response = yield* llm
.generate(
LLM.request({
model: resolved.model,
prompt: input.prompt,
// Gateways require session attribution even for a stateless call; no Session is stored.
http: { headers: { "x-opencode-session": SessionID.create() } },
}),
)
.pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
return response.text
})
+5 -73
View File
@@ -1,7 +1,7 @@
export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LanguageModel, ProviderConfigurationError } from "@opencode/ai"
import { LanguageModel } from "@opencode/ai"
import { Auth } from "@opencode/ai/route"
import { Context, Effect, Layer, Schema, Struct } from "effect"
import { AISDK } from "./aisdk.js"
@@ -39,40 +39,6 @@ export class UnsupportedPackageError extends Schema.TaggedError<UnsupportedPacka
}
}
export const InitializationPhase = Schema.Literals(["load", "init", "construct"])
export type InitializationPhase = typeof InitializationPhase.Type
/** Provider settings are missing, conflicting, or unsupported; the provider's own message tells the user what to fix. */
export class ModelConfigurationError extends Schema.TaggedError<ModelConfigurationError>()(
"SessionRunnerModel.ModelConfigurationError",
{
providerID: Provider.ID,
modelID: ID,
package: Schema.String,
detail: Schema.String,
},
) {
override get message() {
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
}
}
/** A supported package failed unexpectedly while loading or constructing the model. */
export class ModelInitializationError extends Schema.TaggedError<ModelInitializationError>()(
"SessionRunnerModel.ModelInitializationError",
{
providerID: Provider.ID,
modelID: ID,
package: Schema.String,
phase: InitializationPhase,
detail: Schema.String,
},
) {
override get message() {
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
}
}
export class UnresolvedProviderVariablesError extends Schema.TaggedError<UnresolvedProviderVariablesError>()(
"SessionRunnerModel.UnresolvedProviderVariablesError",
{
@@ -102,8 +68,6 @@ export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCo
export type Error =
| VariantUnavailableError
| UnsupportedPackageError
| ModelConfigurationError
| ModelInitializationError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
| Integration.AuthorizationError
@@ -171,11 +135,7 @@ export const fromCatalogModel = (
dependencies?: Dependencies,
): Effect.Effect<
LanguageModel,
| UnsupportedPackageError
| ModelConfigurationError
| ModelInitializationError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
@@ -218,16 +178,14 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
...configuration,
}) ?? {},
)
return yield* loadAISDK({ ...resolved, settings }).pipe(
Effect.mapError((error) => initialization(resolved, "init", error.cause)),
)
return yield* loadAISDK({ ...resolved, settings }).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return yield* unsupported(resolved)
const specifier = native
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError((error) => initialization(resolved, "load", error.cause)),
Effect.mapError(() => unsupported(resolved)),
)
const settings = {
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
@@ -246,15 +204,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
: runtime.compatibility,
})
},
catch: (cause) =>
cause instanceof ProviderConfigurationError
? new ModelConfigurationError({
providerID: resolved.providerID,
modelID: resolved.id,
package: resolved.package ?? "unknown",
detail: cause.message,
})
: initialization(resolved, "construct", cause),
catch: () => unsupported(resolved),
})
})
@@ -327,24 +277,6 @@ const unsupported = (model: Info) =>
package: model.package ?? "unknown",
})
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
new ModelInitializationError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
phase,
detail: causeMessage(cause) ?? `${phase} failed for ${model.package ?? "unknown"}`,
})
// Unexpected throws still carry the most useful diagnosis in their message; a stack or an unknown value does not.
const causeMessage = (cause: unknown): string | undefined => {
if (typeof cause === "string") return cause.trim() || undefined
if (!(cause instanceof globalThis.Error)) return undefined
const message = cause.message.trim()
if (message) return message
return causeMessage(cause.cause)
}
export const resolveModel = (
model: Info,
variant: VariantID | undefined,
+1 -1
View File
@@ -154,7 +154,7 @@ const layer = Layer.effect(
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
return agent?.permissions ?? missingAgentPermissions
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
-3
View File
@@ -404,7 +404,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
rules: sessions.setPermissions,
},
plugin: {
list: () => response(plugin.list()),
@@ -510,8 +509,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
title: input?.title,
agent: input?.agent,
model: input?.model,
metadata: input?.metadata,
permissions: input?.permissions,
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
+2 -19
View File
@@ -11,7 +11,6 @@ import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
import PROMPT_KIMI from "./system-prompt/kimi.txt"
import PROMPT_META from "./system-prompt/meta.txt"
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
const id = model.id.toLowerCase()
@@ -19,19 +18,6 @@ export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
return id.includes("gpt-6") ? PROMPT_ASTRA : PROMPT_GPT
})
export const AnthropicPlugin = make(
"opencode.prompt.anthropic",
(model) => {
const id = model.id.toLowerCase()
if (!id.includes("claude")) return undefined
return PROMPT_ANTHROPIC
},
"append",
)
// Both OpenAIToolsPlugin and AnthropicToolsPlugin are disabled intentionally until we can figure out a good ux for displaying
// heavy grep/glob usage done via shell or other mechanisms
export const OpenAIToolsPlugin = make("opencode.optimize.openai.tools", (model, tools) => {
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
if (!ids.includes("gpt")) return undefined
@@ -59,12 +45,11 @@ export const MetaPlugin = make("opencode.prompt.meta", (model) => {
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
})
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
function make(
id: string,
optimize: (model: Model.Info, tools: SessionHooks["context"]["tools"]) => string | undefined,
mode: "override" | "append" = "override",
) {
return define({
id,
@@ -81,9 +66,7 @@ function make(
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
const rendered = SessionSystemPrompt.render(template, Object.keys(event.tools))
const text = mode === "append" ? `${system.text}\n\n${rendered}` : rendered
event.system[0] = { ...system, text }
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
}).pipe(Effect.catch(() => Effect.void))
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
@@ -1,2 +0,0 @@
# Code comments
By default, match the surrounding comment density: where the code has none, add none. Use comments sparingly, only where they are appropriate, such as for behavior that is not obvious from the code itself. Instructions from the user or the project take precedence over this guidance.
+2 -10
View File
@@ -18,7 +18,6 @@ import { SessionMessageTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { RelativePath } from "./schema.js"
import { Agent } from "@opencode/schema/agent"
import type { Permission } from "@opencode/schema/permission"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import path from "path"
@@ -82,7 +81,6 @@ type CreateBaseInput = {
agent?: Agent.ID
model?: Model.Ref
metadata?: SessionSchema.Metadata
permissions?: Permission.Ruleset
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
@@ -159,10 +157,6 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly setPermissions: (input: {
sessionID: SessionSchema.ID
permissions: Permission.Ruleset
}) => Effect.Effect<void, NotFoundError>
readonly move: SessionMove.Interface["move"]
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
@@ -254,10 +248,9 @@ const layer = Layer.effect(
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
// Children inherit metadata and permissions the way they inherit
// location, so host policies that read them treat the family uniformly.
// Children inherit metadata the way they inherit location, so
// host policies that read it treat the family uniformly.
metadata: input.metadata ?? parent?.metadata,
permissions: input.permissions ?? parent?.permissions,
model: input.model
? {
id: Model.ID.make(input.model.id),
@@ -394,7 +387,6 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
-1
View File
@@ -196,7 +196,6 @@ const estimateMedia = (mime: string) => {
const estimatePart = (part: ContentPart): number => {
// Encrypted checkpoints have no locally measurable token size.
if (part.type === "compaction") return Token.estimate(part.text ?? "")
if (part.type === "effort") return 0
if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text)
if (part.type === "media") return estimateMedia(part.mediaType)
if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? ""))
+1 -2
View File
@@ -1,7 +1,6 @@
export * as SessionContext from "./context.js"
import { Model } from "@opencode/schema/model"
import { Permission } from "../permission.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
@@ -130,7 +129,7 @@ const layer = Layer.effect(
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
tools: registry.snapshot(agent.info.permissions),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
-1
View File
@@ -50,7 +50,6 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
metadata: row.metadata ?? undefined,
permissions: row.permission ?? undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
@@ -116,7 +116,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.permissions.updated": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
-10
View File
@@ -160,7 +160,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
permission: parent.permission,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -451,7 +450,6 @@ const layer = Layer.effectDiscard(
agent: event.data.agent,
model: event.data.model,
metadata: event.data.metadata,
permission: event.data.permissions,
version: event.data.version,
time_created: event.created,
time_updated: event.created,
@@ -573,14 +571,6 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
db
.update(SessionTable)
.set({ permission: event.data.permissions, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) => {
const idle = event.data.idle
return db
@@ -33,10 +33,6 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const ModelConfigurationError = ModelResolver.ModelConfigurationError
export type ModelConfigurationError = ModelResolver.ModelConfigurationError
export const ModelInitializationError = ModelResolver.ModelInitializationError
export type ModelInitializationError = ModelResolver.ModelInitializationError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
@@ -1,11 +1,4 @@
import {
Message,
ReasoningEfforts,
ToolCallPart,
ToolResultPart,
type ContentPart,
type ProviderMetadata,
} from "@opencode/ai"
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode/ai"
import type { Model } from "@opencode/schema/model"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
@@ -229,31 +222,11 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
]
}
const EFFORT_VARIANTS = new Set<string>(ReasoningEfforts)
// Budget and toggle variants reuse these names; the protocol's drift check catches ones that are not effort options.
const variantEffort = (variant: Model.VariantID | undefined) => {
if (variant === undefined || variant === "default") return { effort: undefined }
return EFFORT_VARIANTS.has(variant) ? { effort: variant } : undefined
}
const modelSwitched = (message: SessionMessage.ModelSelected, model: Model.Ref): Message[] => {
const previous = message.previous
if (previous === undefined) return []
const same = (ref: Model.Ref) => ref.providerID === model.providerID && ref.id === model.id
if (!same(message.model) || !same(previous)) return []
const to = variantEffort(message.model.variant)
const from = variantEffort(previous.variant)
if (to === undefined || from === undefined) return []
return [Message.effort({ effort: to.effort, previous: from.effort })]
}
function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] {
switch (message.type) {
case "agent-switched":
return []
case "model-switched":
return modelSwitched(message, model)
return []
case "location-switched":
return [
Message.make({
-11
View File
@@ -3,7 +3,6 @@ export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Scope } from "effect"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Permission } from "@opencode/schema/permission"
import { Event } from "@opencode/schema/event"
import { FSUtil } from "@opencode/util/fs-util"
import { Bus } from "../bus.js"
@@ -73,13 +72,6 @@ export const make = Effect.fn("Session.make")(function* () {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const setPermissions = Effect.fn("Session.setPermissions")(function* (
sessionID: SessionSchema.ID,
input: { permissions: Permission.Ruleset },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
input: { agent: Agent.ID },
@@ -342,7 +334,6 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
@@ -365,7 +356,6 @@ export const make = Effect.fn("Session.make")(function* () {
const message = operations.message.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
const inbox = operations.inbox.bind(undefined, sessionID)
@@ -391,7 +381,6 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
+2 -2
View File
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { Permission } from "@opencode/schema/permission"
import type { PermissionV1 } from "@opencode/schema/permission-v1"
import type { Project } from "@opencode/schema/project"
import type { SessionSchema } from "./schema.js"
import type { Workspace } from "@opencode/schema/workspace"
@@ -49,7 +49,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
@@ -55,8 +55,6 @@ export function toSessionError(cause: unknown): SessionError.Error {
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
cause instanceof SessionRunnerModel.ModelConfigurationError ||
cause instanceof SessionRunnerModel.ModelInitializationError ||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
)
return { type: "provider.no-route", message: cause.message }
-1
View File
@@ -103,7 +103,6 @@ const layer = Layer.effect(
agent: input.data.info.agent,
model: input.data.info.model,
metadata: input.data.info.metadata,
permissions: input.data.info.permissions,
},
{
location: input.location,
+1 -12
View File
@@ -99,19 +99,8 @@ export const layer = Layer.effect(
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const output = () => {
if (result.structured !== undefined) return result.structured
if (text === "") return null
// Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.
if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) {
try {
return JSON.parse(text)
} catch {}
}
return text
}
return {
output: output(),
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
+56 -1
View File
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { LanguageModel } from "@opencode/ai"
import { LanguageModel, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { OpenAIChat } from "@opencode/ai/protocols"
import { TestLLM } from "@opencode/ai/testing"
import { AISDK } from "@opencode/core/aisdk"
@@ -11,6 +12,7 @@ import { ID, Info, Ref } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Npm } from "@opencode/util/npm"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -98,3 +100,56 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
})
}),
)
testEffect(Layer.empty).effect("attributes each stateless completion without creating a stored session", () =>
Effect.gen(function* () {
const sessions: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
const session = request.headers["x-opencode-session"]
if (!session)
return HttpClientResponse.fromWeb(
request,
Response.json(
{
error: { type: "MissingSessionID", message: "Session ID is required" },
},
{ status: 400 },
),
)
sessions.push(session)
return HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({
id: "completion",
object: "chat.completion.chunk",
created: 1,
model: "gemini",
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: "stop" }],
})}\n\ndata: [DONE]\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
}),
),
)
const native = LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(http))))
yield* Effect.gen(function* () {
const generate = yield* Generate.Service
for (let index = 0; index < 2; index++) {
expect(
yield* generate.text({
prompt: "Return exactly OK",
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
}),
).toBe("OK")
}
}).pipe(Effect.provide(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, native)))))
expect(sessions).toHaveLength(2)
expect(sessions[0]).toStartWith("ses_")
expect(sessions[1]).not.toBe(sessions[0])
}),
)
-74
View File
@@ -324,32 +324,6 @@ const mcp = Layer.mock(Mcp.Service, {
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "issues",
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "count",
description: "Returns a number as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "typed",
description: "Declares a string output and returns JSON as text",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "string" },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "issues",
codemode: false,
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
@@ -400,20 +374,6 @@ const mcp = Layer.mock(Mcp.Service, {
isError: false,
content: [{ type: "text", text: "hello" }],
})
if (input.name === "issues" || input.name === "typed")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
})
if (input.name === "count")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "42" }],
})
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
@@ -1983,7 +1943,6 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_issues",
"direct_lookup",
"direct_media",
"execute",
@@ -2074,39 +2033,6 @@ it.effect("returns content-only MCP results through Code Mode", () =>
}),
)
it.effect("parses JSON text results from MCP tools without an output schema", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
const run = (code: string) =>
toolSet
.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
})
.pipe(Effect.map((execution) => execution.output.output))
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
// Outside Code Mode the content the model reads is the original text.
expect(
yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
}),
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
-75
View File
@@ -1395,81 +1395,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("reports provider configuration errors from supported packages", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
modelID: "gpt-5.4-nano",
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "oauth-token",
refresh: "refresh",
expires: Date.now() + 60_000,
}),
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelConfigurationError",
providerID: "azure",
modelID: "test-model",
package: "aisdk:@ai-sdk/azure",
detail: "Azure requires resourceName or baseURL",
})
expect(failure.message).toBe("Cannot initialize azure/test-model: Azure requires resourceName or baseURL")
}),
)
it.effect("distinguishes unexpected constructor failures from configuration errors", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(model("@opencode/ai/providers/custom"), undefined, {
loadPackage: () =>
Effect.succeed({
model: () => {
throw new Error("custom provider crashed")
},
}),
}).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelInitializationError",
phase: "construct",
detail: "custom provider crashed",
})
}),
)
it.effect("reports package load and AISDK initialization failures with their causes", () =>
Effect.gen(function* () {
const load = yield* ModelResolver.fromCatalogModel(model("@opencode/ai/providers/custom"), undefined, {
loadPackage: (specifier) =>
Effect.fail(
new Provider.LoadError({ package: specifier, cause: new Error(`Provider package ${specifier} is broken`) }),
),
}).pipe(Effect.flip)
expect(load).toMatchObject({
_tag: "SessionRunnerModel.ModelInitializationError",
phase: "load",
detail: "Provider package @opencode/ai/providers/custom is broken",
})
const init = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/cohere")), undefined, {
loadAISDK: (runtime) =>
Effect.fail(
new AISDK.InitError({ providerID: runtime.providerID, cause: new Error("Cohere plugin failed") }),
),
}).pipe(Effect.flip)
expect(init).toMatchObject({
_tag: "SessionRunnerModel.ModelInitializationError",
phase: "init",
detail: "Cohere plugin failed",
})
expect(init.message).toBe("Cannot initialize test-provider/test-model: Cohere plugin failed")
}),
)
it.effect("drops an empty API key before loading an AISDK package", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
-28
View File
@@ -224,34 +224,6 @@ describe("Permission", () => {
}),
)
it.effect("merges session rules after agent rules and before saved approvals", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const { db } = yield* Database.Service
const service = yield* Permission.Service
const setSession = (permission: Permission.Ruleset) =>
db
.update(SessionTable)
.set({ permission })
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
yield* setSession([{ action: "edit", resource: "/original/**", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "edit", resources: ["/original/src/index.ts"] }))).toMatchObject({
effect: "deny",
})
yield* setRules([])
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
yield* setSession([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "deny" })
yield* setSession([{ action: "bash", resource: "*", effect: "ask" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "allow" })
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
-1
View File
@@ -108,7 +108,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
reply: () => Effect.die("unused permission.reply"),
rules: () => Effect.die("unused permission.rules"),
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
+2 -29
View File
@@ -19,11 +19,9 @@ import PROMPT_GPT from "../../src/plugin/system-prompt/gpt.txt"
import PROMPT_ASTRA from "../../src/plugin/system-prompt/gpt-astra.txt"
import PROMPT_KIMI from "../../src/plugin/system-prompt/kimi.txt"
import PROMPT_TRINITY from "../../src/plugin/system-prompt/trinity.txt"
import PROMPT_ANTHROPIC from "../../src/plugin/system-prompt/anthropic.txt"
const it = testEffect(PluginTestLayer)
const fallback = SessionSystemPrompt.make([])
const appended = `${fallback}\n\n${SessionSystemPrompt.render(PROMPT_ANTHROPIC, [])}`
const makeHost = Effect.gen(function* () {
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
@@ -50,7 +48,6 @@ describe("OptimizePlugin", () => {
test("enables prompt plugins without model-specific tool optimization", () => {
expect(OptimizePlugin.Plugins.map((plugin) => plugin.id)).toEqual([
"opencode.prompt.openai",
"opencode.prompt.anthropic",
"opencode.prompt.kimi",
"opencode.prompt.arcee",
"opencode.prompt.meta",
@@ -79,7 +76,7 @@ describe("OptimizePlugin", () => {
["gpt-5-codex", PROMPT_GPT],
["gpt-6-astra", PROMPT_ASTRA],
["gemini-2.5-pro", fallback],
["claude-sonnet-4", appended],
["claude-sonnet-4", fallback],
["kimi-k2", PROMPT_KIMI],
["trinity", PROMPT_TRINITY],
["meta/muse-spark-1.1", PROMPT_META.replaceAll("{{MODEL_NAME}}", "Muse Spark")],
@@ -131,30 +128,6 @@ describe("OptimizePlugin", () => {
}),
)
it.effect("appends the Anthropic prompt to the baseline without changing tools or project instructions", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* catalog.transform((editor) =>
editor.model.update(Provider.ID.make("test"), Model.ID.make("claude-sonnet-4"), () => {}),
)
yield* OptimizePlugin.AnthropicPlugin.effect(pluginHost)
const event = context("claude-sonnet-4")
event.system.push(SystemPart.make("Project instructions"))
yield* hooks.trigger("session", "context", event)
const baseline = SessionSystemPrompt.render(fallback, Object.keys(event.tools))
expect(event.system.map((part) => part.text)).toEqual([
`${baseline}\n\n${SessionSystemPrompt.render(PROMPT_ANTHROPIC, Object.keys(event.tools))}`,
"Project instructions",
])
expect(event.system[0]?.text.startsWith(baseline)).toBe(true)
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
}),
)
it.effect("curates search tools across providers without changing editing tools", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
@@ -325,7 +298,7 @@ describe("OptimizePlugin", () => {
["codex-family-alias", "custom-deployment", "GPT-CODEX", fallback],
["astra-api-alias", "gpt-6-astra", undefined, fallback],
["astra-family-alias", "custom-deployment", "gpt-6", fallback],
["claude-catalog-alias", "custom-model", undefined, appended],
["claude-catalog-alias", "custom-model", undefined, fallback],
["anthropic-api-alias", "Claude-Opus-4-8", undefined, fallback],
["anthropic-family-alias", "custom-deployment", "CLAUDE-SONNET", fallback],
] as const
+2 -39
View File
@@ -388,32 +388,6 @@ describe("Session.create", () => {
}),
)
it.effect("stores permission rules, inherits them through children and forks, and replaces them", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const permissions = [{ action: "edit", resource: "/original/**", effect: "deny" as const }]
const created = yield* session.create({ location, permissions })
expect(created.permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id })).permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id, permissions: [] })).permissions).toEqual([])
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
expect(forked.permissions).toEqual(permissions)
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
yield* session.setPermissions({ sessionID: created.id, permissions: replaced })
expect((yield* session.get(created.id)).permissions).toEqual(replaced)
expect(
yield* session.setPermissions({ sessionID: Session.ID.create(), permissions: replaced }).pipe(Effect.flip),
).toBeInstanceOf(Session.NotFoundError)
}),
)
it.effect("inherits location from an existing parent when omitted", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1356,12 +1330,7 @@ describe("SessionTransfer", () => {
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({
location,
title: "Exported",
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } })
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
@@ -1407,13 +1376,7 @@ describe("SessionTransfer", () => {
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({
id: sessionID,
title: "Exported",
location,
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } })
expect(imported.time).toMatchObject({
updated: DateTime.makeUnsafe(1_000),
idle: DateTime.makeUnsafe(200),
-24
View File
@@ -140,30 +140,6 @@ describe("toSessionError", () => {
})
})
test("preserves provider configuration and initialization errors", () => {
const configuration = new ModelResolver.ModelConfigurationError({
providerID: Provider.ID.make("azure"),
modelID: ID.make("gpt-5.4-nano"),
package: "aisdk:@ai-sdk/azure",
detail: "Azure requires resourceName or baseURL",
})
expect(toSessionError(configuration)).toEqual({
type: "provider.no-route",
message: "Cannot initialize azure/gpt-5.4-nano: Azure requires resourceName or baseURL",
})
const initialization = new ModelResolver.ModelInitializationError({
providerID: Provider.ID.make("custom"),
modelID: ID.make("model"),
package: "@opencode/ai/providers/custom",
phase: "load",
detail: "Provider package @opencode/ai/providers/custom is broken",
})
expect(toSessionError(initialization)).toEqual({
type: "provider.no-route",
message: "Cannot initialize custom/model: Provider package @opencode/ai/providers/custom is broken",
})
})
test("retries rate limits, provider-internal, transport, and unrecognized failures", () => {
const eligible = [
llm(new RateLimitError({ message: "rate" })),
@@ -202,44 +202,6 @@ Recent work
])
})
describe("model-switched", () => {
const ref = (variant?: string) =>
Model.Ref.make({
id: Model.ID.make("model"),
providerID: Provider.ID.make("provider"),
...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
})
const switched = (to: Model.Ref, previous?: Model.Ref) =>
SessionMessage.ModelSelected.make({
id: id("model"),
type: "model-switched",
model: to,
previous,
time: { created },
})
test("records a same-model effort switch as an effort update", () => {
expect(toLLMMessages([switched(ref("low"), ref("high"))], ref("low"))).toEqual([
Message.effort({ effort: "low", previous: "high" }),
])
})
test("maps the default variant and no variant to the model default effort", () => {
expect(toLLMMessages([switched(ref("low"), ref())], ref("low"))).toEqual([Message.effort({ effort: "low" })])
expect(toLLMMessages([switched(ref("default"), ref("max"))], ref())).toEqual([
Message.effort({ previous: "max" }),
])
})
test("ignores switches that are not effort changes on the requested model", () => {
const other = Model.Ref.make({ id: Model.ID.make("other"), providerID: Provider.ID.make("provider") })
expect(toLLMMessages([switched(ref("low"))], ref("low"))).toEqual([])
expect(toLLMMessages([switched(ref("low"), other)], ref("low"))).toEqual([])
expect(toLLMMessages([switched(ref("thinking"), ref("high"))], ref("thinking"))).toEqual([])
expect(toLLMMessages([switched(ref("low"), ref("high"))], other)).toEqual([])
})
})
test("lowers text attachments after the prompt in one user message", () => {
const file = FileAttachment.make({
data: Base64.make(Buffer.from("export const value = 1").toString("base64")),
-33
View File
@@ -1996,39 +1996,6 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("Fourth")
})
scenario("records a same-model effort switch as a cache-preserving effort update", function* (s) {
s.currentModel = LanguageModel.make({ id: "claude-opus-5", provider: "anthropic", route: AnthropicMessages.route })
const model = { id: ID.make("claude-opus-5"), providerID: Provider.ID.make("anthropic") }
yield* s.bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { ...model, variant: Model.VariantID.make("high") },
})
yield* s.llm.push(TestLLM.text("Earlier answer", "text-effort-high"))
yield* s.runPrompt("First")
yield* s.bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { ...model, variant: Model.VariantID.make("low") },
})
// The selected variant reaches the request as the provider effort option.
s.currentModel = LanguageModel.update(s.currentModel, { defaults: { providerOptions: { effort: "low" } } })
yield* s.llm.push(TestLLM.text("Later answer", "text-effort-low"))
yield* s.runPrompt("Second")
expect(messageRoles(s.requests[1])).toEqual(["user", "assistant", "system", "user"])
expect(s.requests[1]?.messages[2]).toEqual(Message.effort({ effort: "low", previous: "high" }))
const compiled = yield* compileRequest(s.requests[1]!)
expect(compiled.body).toMatchObject({
output_config: { effort: "high" },
messages: [
{ role: "user" },
{ role: "assistant" },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user" },
],
})
})
scenario("preserves instruction values while a source is temporarily unavailable", function* (s) {
yield* s.runPrompt("First")
yield* s.bus.publish(SessionEvent.ModelSelected, {
+2 -2
View File
@@ -22,7 +22,7 @@ it.live(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) => {
expect(request.url).toBe("https://registry.npmjs.org/@opencode%2fcli/beta")
expect(request.url).toBe("https://registry.npmjs.org/@opencode-ai%2fcli/beta")
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
}),
),
@@ -65,7 +65,7 @@ posix(
test("pins platform-specific artifacts and rejects unsafe inputs", () => {
expect(RemoteCli.archiveUrl("linux-x64-baseline-musl", "2.0.0-beta.1")).toBe(
"https://registry.npmjs.org/@opencode/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
"https://registry.npmjs.org/@opencode-ai/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
)
expect(() => RemoteCli.installScript({ version: '2.0.0"; whoami', source: { type: "installer" } })).toThrow()
expect(() => RemoteCli.archiveUrl("linux-x64;whoami", "2.0.0")).toThrow()
+3 -3
View File
@@ -68,7 +68,7 @@ printf 'OPENCODE_REMOTE_TARGET=%s\\n' "$target"
export function archiveUrl(target: string, version: string) {
if (!/^(linux|darwin)-(x64-baseline|arm64)(-musl)?$/.test(target))
throw new Failure({ code: "platform", detail: target })
return `https://registry.npmjs.org/@opencode/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
return `https://registry.npmjs.org/@opencode-ai/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
}
type Source = { type: "download"; url: string } | { type: "archive" } | { type: "installer"; binary?: string }
@@ -114,12 +114,12 @@ const Beta = Schema.Struct({ version: Schema.String.check(Schema.isPattern(/^0\.
export const latestBeta = Effect.fn("RemoteCli.latestBeta")(function* () {
const http = yield* HttpClient.HttpClient
const metadata = yield* http.get("https://registry.npmjs.org/@opencode%2fcli/beta").pipe(
const metadata = yield* http.get("https://registry.npmjs.org/@opencode-ai%2fcli/beta").pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Beta)),
Effect.timeout("30 seconds"),
Effect.mapError(
() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode%2fcli/beta" }),
() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode-ai%2fcli/beta" }),
),
)
return metadata.version
@@ -9,7 +9,6 @@ export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
silent: true,
})
notification.onclick = () => {
void api.showWindow()
@@ -1280,100 +1280,6 @@ flowchart TD
])
})
test("expands & node groups into fan-in and fan-out edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
N[Native] & M[Mapped] & O --> LM["LanguageModel"]
LM -->|prepare| REQ & LOG`)
expect(diagram.nodes).toEqual([
{ id: "N", label: "Native", shape: "box" },
{ id: "M", label: "Mapped", shape: "box" },
{ id: "O", label: "O", shape: "box" },
{ id: "LM", label: "LanguageModel", shape: "box" },
{ id: "REQ", label: "REQ", shape: "box" },
{ id: "LOG", label: "LOG", shape: "box" },
])
expect(diagram.edges).toEqual([
{ from: "N", to: "LM", label: "" },
{ from: "M", to: "LM", label: "" },
{ from: "O", to: "LM", label: "" },
{ from: "LM", to: "REQ", label: "prepare" },
{ from: "LM", to: "LOG", label: "prepare" },
])
})
test("expands & groups on both sides of an edge and through a chain", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A & B --> C & D --> E`)
expect(diagram.edges).toEqual([
{ from: "A", to: "C", label: "" },
{ from: "A", to: "D", label: "" },
{ from: "B", to: "C", label: "" },
{ from: "B", to: "D", label: "" },
{ from: "C", to: "E", label: "" },
{ from: "D", to: "E", label: "" },
])
})
test("declares every node of a bare & group inside the current subgraph", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
subgraph Runtime
A[Alpha] & B[Beta]:::focus
end
A --> B`)
expect(diagram.nodes).toEqual([
{ id: "A", label: "Alpha", shape: "box" },
{ id: "B", label: "Beta", shape: "box" },
])
expect(diagram.subgraphs?.[0]?.nodeIds).toEqual(["A", "B"])
})
test("keeps & inside quoted or bracketed labels as label text", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A["Fetch & parse"] & B[R&D] --> C[Done &amp; dusted]`)
expect(diagram.nodes).toEqual([
{ id: "A", label: "Fetch & parse", shape: "box" },
{ id: "B", label: "R&D", shape: "box" },
{ id: "C", label: "Done & dusted", shape: "box" },
])
expect(diagram.edges).toEqual([
{ from: "A", to: "C", label: "" },
{ from: "B", to: "C", label: "" },
])
})
test("keeps & inside edge labels as label text", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
X[a & b] -->|x & y| Y`)
expect(diagram.nodes.find((node) => node.id === "X")?.label).toBe("a & b")
expect(diagram.edges).toEqual([{ from: "X", to: "Y", label: "x & y" }])
})
test("rejects empty & group members", () => {
for (const statement of ["A & --> B", "& A --> B", "A --> B &", "A &"]) {
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(
`Unsupported syntax in flowchart diagram at line 2: "${statement}"`,
)
}
})
test("renders a fan-in expressed with & the same as separate edge statements", () => {
const grouped = renderFlowchartDiagram(`flowchart LR
N & M & O --> LM[LanguageModel] --> REQ[LLMRequest]`)
const separate = renderFlowchartDiagram(`flowchart LR
N --> LM[LanguageModel]
M --> LM
O --> LM
LM --> REQ[LLMRequest]`)
expect(grouped).toBe(separate)
expect(grouped).toContain("LanguageModel")
})
test("parses chained undirected solid edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A --- B --- C`)
+25 -66
View File
@@ -127,41 +127,6 @@ function stripNodeToken(token: string): string {
.trim()
}
/** Split an `&`-joined node group, leaving `&` inside labels (brackets or quotes) untouched. */
function splitNodeGroup(token: string): string[] {
const groups: string[] = []
const stack: string[] = []
let quote: '"' | "'" | undefined
let start = 0
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
for (let index = 0; index < token.length; index++) {
const character = token[index]!
if (quote) {
if (character === quote && token[index - 1] !== "\\") quote = undefined
continue
}
if (character === '"' || character === "'") {
quote = character
continue
}
if (character in closes) {
stack.push(character)
continue
}
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
stack.pop()
continue
}
if (stack.length === 0 && character === "&") {
groups.push(token.slice(start, index))
start = index + 1
}
}
groups.push(token.slice(start))
return groups
}
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
@@ -340,57 +305,51 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
const edgeOperators = parseEdgeOperators(line)
if (edgeOperators.length > 0) {
// Each chain position may be an `&` group (`A & B --> C`), so endpoints are lists of node tokens.
const nodeGroups = [
const nodeTokens = [
line.slice(0, edgeOperators[0]!.index),
...edgeOperators.map((operator, index) =>
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
),
].map((group) => splitNodeGroup(group).map(stripNodeToken))
]
if (nodeGroups.every((group) => group.every((token) => token.length > 0))) {
const unsupportedEndpoint = nodeGroups.find((group, index) => {
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
const unsupportedEndpoint = nodeTokens.find((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
return group.some(
(stripped) =>
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped),
return (
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped)
)
})
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
const chainNodeIds = nodeGroups.map((group, index) => {
const chainNodeIds = nodeTokens.map((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
return group.map((stripped) => {
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
})
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
})
for (const nodeId of chainNodeIds.flat()) {
for (const nodeId of chainNodeIds) {
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
}
for (let index = 0; index < edgeOperators.length; index++) {
const operator = edgeOperators[index]!
for (const from of chainNodeIds[index]!) {
for (const to of chainNodeIds[index + 1]!) {
const edge = createEdge(
from,
to,
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
}
const edge = createEdge(
chainNodeIds[index]!,
chainNodeIds[index + 1]!,
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
continue
}
}
const nodeGroup = splitNodeGroup(line)
if (nodeGroup.every(isSupportedNodeToken)) {
for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, ensureNode(nodes, stripNodeToken(token)).id)
if (isSupportedNodeToken(line)) {
const node = ensureNode(nodes, line)
addNodeToSubgraph(currentSubgraph, node.id)
continue
}
+1 -1
View File
@@ -29,7 +29,7 @@ describe("parser diagnostics", () => {
})
test("does not partially parse unsupported flowchart syntax", () => {
for (const statement of ["A & --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
for (const statement of ["A & B --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(MermaidSyntaxError)
}
})
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
readonly hook: Hooks<PermissionHooks>
}
-1
View File
@@ -438,7 +438,6 @@ export function fromPromise(plugin: Plugin) {
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
readonly hook: Hooks<PermissionHooks>
}
@@ -132,21 +132,4 @@ export const makePermissionGroup = <
}),
),
)
.add(
HttpApiEndpoint.put("session.permission.rules", "/api/session/:sessionID/permission/rules", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ permissions: Permission.Ruleset }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.rules",
summary: "Replace session permission rules",
description:
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
-2
View File
@@ -27,7 +27,6 @@ import {
import { Agent } from "@opencode/schema/agent"
import { Skill } from "@opencode/schema/skill"
import { Model } from "@opencode/schema/model"
import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
@@ -176,7 +175,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
metadata: Session.Metadata.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
}).annotateMerge(

Some files were not shown because too many files have changed in this diff Show More