Compare commits

...

8 Commits

Author SHA1 Message Date
Aiden Cline 2e0f414e78 fix(core): relocate AI SDK tool media 2026-08-11 15:31:28 -05:00
Aiden Cline ab6e18e4ff fix(core): route Vertex Anthropic natively (#41836) 2026-08-11 15:29:02 -05:00
Kit Langton 2b6dd2559c fix(core): prefer nearest skill source (#41788) 2026-08-11 16:13:32 -04:00
Kit Langton 2b7bd84867 test(core): speed up subagent tool suite (#41835) 2026-08-11 16:13:04 -04:00
Kit Langton baa54a5150 test(core): speed up shell tool suite (#41831) 2026-08-11 16:03:50 -04:00
Kit Langton c5704d3e3b feat(core): sqlite driver for durable object storage (#41659) 2026-08-11 15:45:23 -04:00
Kit Langton e3336725db fix(core): normalize provider endpoint templates (#41816) 2026-08-11 15:42:54 -04:00
opencode-agent[bot] ff3ecefd24 fix(cli): sign macOS preview binaries (#40662)
Co-authored-by: Ryan Vogel <89211796+R44VC0RP@users.noreply.github.com>
2026-08-11 15:37:44 -04:00
21 changed files with 1177 additions and 205 deletions
+56 -1
View File
@@ -124,12 +124,66 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
name: opencode-preview-cli-unsigned
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
- name: Sign macOS CLI binaries
run: |
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
if [ -z "$identity" ]; then
echo "Developer ID Application identity not found"
exit 1
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
found=1
codesign \
--force \
--timestamp \
--options runtime \
--entitlements packages/cli/script/entitlements.plist \
--sign "$identity" \
"$file"
codesign --verify --deep --strict --verbose=4 "$file"
codesign --display --requirements - "$file"
done
if [ "$found" -eq 0 ]; then
echo "No macOS CLI binaries found"
exit 1
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
@@ -468,6 +522,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-macos
- build-node-cli
- sign-cli-windows
- build-electron
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+1
View File
@@ -25,6 +25,7 @@
},
"imports": {
"#sqlite": {
"workerd": "./src/database/sqlite.workerd.ts",
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.bun.ts"
+36 -8
View File
@@ -51,6 +51,27 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/google-vertex/anthropic":
return {
package: "@opencode-ai/ai/providers/google-vertex/messages",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isRecord(input.settings.thinking) || typeof input.settings.effort === "string"
? {
providerOptions: {
anthropic: {
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
},
},
}
: {}),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -88,9 +109,13 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const credentials = mapBedrockCredentials(settings)
const region = bedrockRegion(settings)
const credentials = mapBedrockCredentials(settings, region)
return {
...baseSettings,
...(typeof baseSettings.baseURL === "string" && region !== undefined
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
: {}),
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
? { baseURL: settings.endpoint }
: {}),
@@ -155,14 +180,8 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
}
}
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
const region =
typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
if (
region === undefined ||
typeof credentials.accessKeyId !== "string" ||
@@ -177,6 +196,15 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
}
}
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
return typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
+44 -8
View File
@@ -33,6 +33,7 @@ import {
} from "@opencode-ai/ai"
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import type { Content } from "@opencode-ai/schema/tool"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
import { Provider } from "./provider"
@@ -40,9 +41,15 @@ import { State } from "./state"
type SDK = any
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
type UserFileContent = Extract<UserContent[number], { type: "file" }>
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
const TOOL_RESULT_ATTACHMENT_TEXT = "Media attached in following user message."
const isToolResultMedia = (item: Content): item is Extract<Content, { type: "file" }> =>
item.type === "file" && (item.mime.toLowerCase().startsWith("image/") || item.mime === "application/pdf")
export interface SDKEvent {
readonly model: Info
readonly package: string
@@ -435,21 +442,34 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
.map((part) => part.text)
.filter(Boolean)
.join("\n\n")
const messages = request.messages.flatMap(message)
const messages: LanguageModelV3Message[] = []
const media: UserFileContent[] = []
const flushMedia = () => {
if (media.length === 0) return
messages.push({
role: "user",
content: [{ type: "text", text: SYNTHETIC_ATTACHMENT_PROMPT }, ...media.splice(0)],
})
}
for (const input of request.messages) {
if (input.role !== "tool") flushMedia()
messages.push(...message(input, media))
}
flushMedia()
if (!system.length) return messages
return [{ role: "system", content: system }, ...messages]
}
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
function message(input: LLMRequest["messages"][number], media: UserFileContent[]): LanguageModelV3Message[] {
switch (input.role) {
case "system":
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
case "user":
return [{ role: "user", content: input.content.flatMap(userPart) }]
case "assistant":
return [{ role: "assistant", content: input.content.flatMap(assistantPart) }]
return [{ role: "assistant", content: input.content.flatMap((part) => assistantPart(part, media)) }]
case "tool": {
const content = input.content.flatMap(toolResultPart)
const content = input.content.flatMap((part) => toolResultPart(part, media))
return content.length ? [{ role: "tool", content }] : []
}
}
@@ -466,7 +486,7 @@ function userPart(part: ContentPart): UserContent {
return []
}
function assistantPart(part: ContentPart): AssistantContent {
function assistantPart(part: ContentPart, media: UserFileContent[]): AssistantContent {
switch (part.type) {
case "text":
return [{ type: "text", text: part.text }]
@@ -486,18 +506,34 @@ function assistantPart(part: ContentPart): AssistantContent {
},
]
case "tool-result":
return toolResultPart(part)
return toolResultPart(part, media)
}
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
function toolResultPart(part: ContentPart, media: UserFileContent[]): ToolResultContent[] {
if (part.type !== "tool-result") return []
const result = (() => {
if (part.result.type !== "content") return part.result
const extracted = part.result.value.filter(isToolResultMedia)
if (extracted.length === 0) return part.result
media.push(
...extracted.map((item) => ({
type: "file" as const,
mediaType: item.mime,
data: item.uri,
filename: item.name,
})),
)
const content = part.result.value.filter((item) => !isToolResultMedia(item))
if (content.length === 0) return { type: "text" as const, value: TOOL_RESULT_ATTACHMENT_TEXT }
return { type: "content" as const, value: content }
})()
return [
{
type: "tool-result",
toolCallId: part.id,
toolName: part.name,
output: toolOutput(part.result),
output: toolOutput(result),
providerOptions: providerOptions(part.providerMetadata),
},
]
+2 -2
View File
@@ -204,13 +204,13 @@ export const layer = (options?: Options) =>
const claude = [
...new Set([
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".claude"),
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".agents"),
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
+18 -8
View File
@@ -1,8 +1,9 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { sqliteLayer } from "#sqlite"
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
import { Context, Effect, Layer, Schema } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
@@ -27,12 +28,15 @@ const databaseLayer = Layer.effect(
Effect.gen(function* () {
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
yield* DatabaseMigration.apply(db)
return { db }
@@ -42,7 +46,7 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
@@ -51,6 +55,12 @@ export function layer(options: Options = { path: ":memory:" }) {
)
}
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required
// because migrations may read it (the v1 import).
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
+12 -3
View File
@@ -2,6 +2,7 @@ export * as DatabaseMigration from "./migration"
import { sql } from "drizzle-orm"
import { Effect, Semaphore } from "effect"
import { supportsForeignKeyToggle } from "#sqlite"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
import schema from "./schema.gen"
@@ -20,8 +21,10 @@ export type Migration = {
export function apply(db: Database) {
return lock.withPermit(
Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
@@ -103,9 +106,15 @@ export function applyOnly(db: Database, input: Migration[]) {
})
continue
}
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
// Durable Object SQLite rejects the foreign_keys toggle; the closest
// allowlisted relaxation is deferring enforcement to transaction commit.
const relaxForeignKeys = supportsForeignKeyToggle
? db.run(sql`PRAGMA foreign_keys = OFF`)
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
yield* relaxForeignKeys
yield* apply.pipe(
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
+5
View File
@@ -8,6 +8,11 @@ import { Sqlite } from "./sqlite"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
export const supportsTuningPragmas = true
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
readonly filename: string
readonly readonly?: boolean
@@ -8,6 +8,11 @@ import { Sqlite } from "./sqlite"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
export const supportsTuningPragmas = true
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
readonly filename: string
readonly readonly?: boolean
@@ -0,0 +1,254 @@
import { drizzle } from "drizzle-orm/durable-sqlite"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
type TypeId = typeof TypeId
// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
export const supportsTuningPragmas = false
// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
// allowlisted for migrations that must relax checking inside a transaction.
export const supportsForeignKeyToggle = false
// Minimal structural types for the Durable Object storage API so this adapter
// does not depend on @cloudflare/workers-types (whose ambient globals conflict
// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
type SqlStorageValue = ArrayBuffer | string | number | null
interface SqlStorageCursor {
readonly columnNames: Array<string>
raw(): IterableIterator<Array<SqlStorageValue>>
toArray(): Array<Record<string, SqlStorageValue>>
}
export interface SqlStorage {
exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
}
export interface DurableObjectStorage {
readonly sql: SqlStorage
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
transactionSync<T>(closure: () => T): T
}
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly updateValues: never
}
interface Config {
readonly storage: DurableObjectStorage
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
// transaction SQL can never run. withTransaction is replaced below with a
// DurableObjectStorage.transaction-backed implementation; this service only
// tracks the active transaction connection for statements and nesting checks.
const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
"@opencode-ai/core/database/SqliteWorkerdTransaction",
)
const transactionError = (message: string) =>
new SqlError({
reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
})
const makeWithTransaction =
(
storage: DurableObjectStorage,
connection: Connection,
semaphore: Semaphore.Semaphore,
): SqlClient.SqlClient["withTransaction"] =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
Effect.withFiber((fiber) => {
const services = fiber.context
if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
return Effect.fail(
transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
)
const effectWithTxn = Effect.provideContext(
effect,
Context.add(services, WorkerdTransaction, [connection, 0] as const),
)
return semaphore.withPermits(1)(
Effect.callback((resume) => {
let interrupted = false
const promise = storage
.transaction(
(txn) =>
new Promise<void>((resolve) => {
if (interrupted) return resolve()
resume(
Effect.onExit(effectWithTxn, (exit) => {
if (Exit.isFailure(exit)) txn.rollback()
resolve()
// wait for the transaction to complete
return Effect.promise(() => promise)
}),
)
}),
)
.catch((cause) =>
resume(
Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
}),
),
),
)
return Effect.suspend(() => {
interrupted = true
return Effect.promise(() => promise)
})
}),
)
})
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
// SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
// mode and always returns integers as numbers. Blobs come back as
// ArrayBuffer and are normalized to Uint8Array to match the other adapters.
function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
const cursor = native.sql.exec(query, ...params)
const columns = cursor.columnNames
for (const row of cursor.raw()) {
const record: Record<string, unknown> = {}
for (let i = 0; i < columns.length; i++) {
const value = row[i]
record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
}
yield record
}
}
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.try({
try: () => Array.from(runIterator(query, params)),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
})
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.try({
try: () =>
Array.from(native.sql.exec(query, ...params).raw(), (row) =>
row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
})
const connection = identity<Connection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
})
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const client = Object.assign(
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
transactionService: WorkerdTransaction,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
withTransaction: makeWithTransaction(native, connection, semaphore),
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
// as the drizzle session must route through withTransaction instead.
transactionStatements: false,
},
)
return client
})
// Defends against the shared path-based Database.layer, which passes a
// filename instead of storage when resolved under the workerd condition.
const nativeLayer = (config: Config) =>
config.storage
? Layer.succeed(Sqlite.Native, config.storage)
: Layer.effect(
Sqlite.Native,
Effect.die(
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
),
)
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
return drizzle(native) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
}
+110 -70
View File
@@ -158,45 +158,42 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
const prepared = prepareRuntimeModel(model, credential)
if (prepared.unresolved.length > 0)
return Effect.fail(
new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: prepared.unresolved,
}),
)
const resolved = prepared.model
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
const resolved = prepareRuntimeModel(model, credential)
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
}
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const mapping = Provider.isAISDK(resolved.package)
@@ -208,64 +205,107 @@ export const fromCatalogModel = (
: undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
const loadAISDK = dependencies?.loadAISDK
if (!loadAISDK) return yield* unsupported(resolved)
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...credential?.metadata,
...configuration,
})
}) ?? {},
)
const runtime = produce(resolved, (draft) => {
draft.settings = settings
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return Effect.fail(unsupported(resolved))
if (!native) return yield* unsupported(resolved)
const specifier = native
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const mapped = mapping?.settings ?? configured
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
}
})
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
const prepared = produce(model, (draft) => {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
if (typeof draft.settings?.baseURL !== "string") return
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
return process.env[name] ?? placeholder
})
})
const baseURL = prepared.settings?.baseURL
const unresolved =
typeof baseURL === "string"
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
(name, index, names) => names.indexOf(name) === index,
)
: []
return { model: prepared, unresolved }
}
function validateProviderVariables(
model: Info,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
if (typeof baseURL !== "string") return Effect.succeed(resolved)
const failure = unresolvedProviderVariables(model, baseURL)
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
}
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
if (!model.settings) return Effect.succeed(model)
return prepareProviderSettings(model, model.settings).pipe(
Effect.map((settings) =>
settings === model.settings
? model
: produce(model, (draft) => {
draft.settings = settings
}),
),
)
}
function prepareProviderSettings(
model: Info,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
if (typeof baseURL !== "string") return Effect.succeed(settings)
return prepareProviderURL(model, baseURL).pipe(
Effect.map((prepared) => (prepared === baseURL ? settings : { ...settings, baseURL: prepared })),
)
}
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
}
function unresolvedProviderVariables(model: Info, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: Array.from(variables),
})
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
+42 -2
View File
@@ -123,6 +123,16 @@ describe("AISDKNative", () => {
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
expect(
map(
"@ai-sdk/amazon-bedrock/mantle",
{
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
"openai.gpt-5.5",
),
).toMatchObject({ settings: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" } })
})
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
@@ -134,8 +144,9 @@ describe("AISDKNative", () => {
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
region: "eu-west-1",
},
region: "eu-west-1",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
profile: "ignored",
credentialProvider: "ignored",
fetch: "ignored",
@@ -152,7 +163,7 @@ describe("AISDKNative", () => {
sessionToken: "session",
region: "eu-west-1",
},
region: "eu-west-1",
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
providerOptions: { openai: { store: false } },
},
})
@@ -262,6 +273,35 @@ describe("AISDKNative", () => {
})
})
test("maps Vertex Anthropic settings to native Messages", () => {
expect(
map("@ai-sdk/google-vertex/anthropic", {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
headers: { "x-test": "value" },
location: "eu",
project: "vertex-project",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex/messages",
settings: {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
location: "eu",
project: "vertex-project",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
},
headers: { "x-test": "value" },
})
})
test("maps supported xAI settings", () => {
expect(
map("@ai-sdk/xai", {
+85 -10
View File
@@ -275,7 +275,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("preserves tool result content in AI SDK prompts", () =>
it.effect("moves tool result images and PDFs into an AI SDK user message", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
@@ -323,21 +323,96 @@ it.effect("preserves tool result content in AI SDK prompts", () =>
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
{
type: "file-data",
data: "JVBERg==",
mediaType: "application/pdf",
filename: "document.pdf",
},
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
{ type: "image-url", url: "https://example.com/pixel.png" },
{ type: "file-url", url: "https://example.com/document.pdf" },
],
},
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,AAAA", filename: "pixel.png" },
{
type: "file",
mediaType: "application/pdf",
data: "data:application/pdf;charset=utf-8;base64,JVBERg==",
filename: "document.pdf",
},
{ type: "file", mediaType: "image/png", data: "https://example.com/pixel.png" },
{ type: "file", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
],
},
])
}),
)
it.effect("groups consecutive AI SDK tool media and keeps file-only results non-empty", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "one.png" }],
},
}),
Message.tool({
id: "call_2",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,BBBB", mime: "image/png", name: "two.png" }],
},
}),
Message.assistant("Images received"),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "read",
output: { type: "text", value: "Media attached in following user message." },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_2",
toolName: "read",
output: { type: "text", value: "Media attached in following user message." },
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,AAAA", filename: "one.png" },
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,BBBB", filename: "two.png" },
],
},
{ role: "assistant", content: [{ type: "text", text: "Images received" }] },
])
}),
)
+4 -4
View File
@@ -1464,13 +1464,13 @@ describe("Config", () => {
])
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
AbsolutePath.make(path.join(directory, ".agents")),
])
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(path.join(directory, ".claude")),
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
@@ -1483,11 +1483,11 @@ describe("Config", () => {
])
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
AbsolutePath.make(path.join(directory, ".agents")),
"global",
AbsolutePath.make(global),
"outside",
+53
View File
@@ -16,6 +16,7 @@ import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -23,6 +24,8 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { tmpdir } from "../fixture/tmpdir"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@@ -91,6 +94,25 @@ const start = (skills: string[], directory: string) =>
directory,
)
const discover = (directory: string, global: string) =>
Effect.gen(function* () {
const config = yield* Config.Service
return yield* config.entries()
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
[Watcher.node, Watcher.testLayer],
]),
),
)
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
@@ -218,6 +240,37 @@ describe("ConfigSkillPlugin.Plugin", () => {
),
)
it.live("prefers a worktree skill over the parent checkout copy", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const checkout = path.join(tmp.path, "repo")
const worktree = path.join(checkout, ".worktrees", "feature")
const parentSkills = path.join(checkout, ".agents", "skills")
const worktreeSkills = path.join(worktree, ".agents", "skills")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(checkout, ".git"), { recursive: true })
await fs.mkdir(path.join(parentSkills, "review"), { recursive: true })
await fs.mkdir(path.join(worktreeSkills, "review"), { recursive: true })
await fs.writeFile(path.join(worktree, ".git"), "gitdir: ../../../.git/worktrees/feature\n")
await write(parentSkills, "review", "Parent checkout")
await write(worktreeSkills, "review", "Worktree")
})
const entries = yield* discover(worktree, path.join(tmp.path, "global"))
const skill = yield* startEntries(entries, worktree)
const review = (yield* skill.list()).find((item) => item.id === "review")
expect(review?.description).toBe("Worktree")
expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
}),
),
),
)
it.live("keeps directory skills when a URL source fails", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -84,6 +84,19 @@ describe("DatabaseMigration", () => {
).rejects.toThrow("Database is not empty and has no session table")
})
test("bootstraps alongside underscore-prefixed embedder tables", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE _embedder_state (id text PRIMARY KEY)`)
yield* DatabaseMigration.apply(db)
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual(
{ name: "session_v2" },
)
}),
)
})
test("applies generic migrations once and records their order", async () => {
await run(
Effect.gen(function* () {
+167 -1
View File
@@ -123,6 +123,24 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves environment templates before native providers inspect endpoints", () =>
withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
settings: { baseURL: "https://${AZURE_HOST}/openai" },
}),
)
expect(resolved.route.endpoint).toMatchObject({
baseURL: "https://resource.openai.azure.com/openai/v1",
query: { "api-version": "v1" },
})
}),
),
)
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })
@@ -152,6 +170,46 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () =>
withEnv({ AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
providerID: Provider.ID.amazonBedrock,
modelID: "openai.gpt-5.5",
settings: {
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(resolved.route).toMatchObject({
id: "bedrock-mantle-responses",
endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" },
})
expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1")
}),
),
)
it.effect("prefers the configured Mantle region over the environment", () =>
withEnv({ AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-5.5",
settings: {
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
}),
)
expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
}),
),
)
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
@@ -265,7 +323,7 @@ describe("ModelResolver", () => {
),
)
it.effect("rejects unresolved provider URL variables before route construction", () =>
it.effect("rejects unresolved variables in constructed provider routes", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
@@ -705,6 +763,57 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const credential = Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "vertex-token",
refresh: "refresh",
expires: Date.now() + 60_000,
})
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google-vertex/anthropic"), {
modelID: "claude-sonnet-4-6",
settings: {
location: "eu",
project: "vertex-project",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
}),
credential,
{
loadPackage: (specifier) => {
expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("claude-sonnet-4-6")
expect(settings).toMatchObject({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
})
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
},
})
},
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
},
)
expect(resolved).toMatchObject({ id: "claude-sonnet-4-6", provider: "test-provider" })
}),
)
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
@@ -823,6 +932,63 @@ describe("ModelResolver", () => {
}),
)
it.effect("rejects unresolved variables before loading opaque AISDK packages", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["REQUIRED_HOST"],
})
}),
),
)
it.effect("rejects placeholders introduced by environment expansion before loading providers", () =>
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
}),
undefined,
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["MISSING_HOST"],
})
}),
),
)
it.effect("rejects unresolved variables before loading native provider packages", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
{ loadPackage: () => Effect.die("Native package loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["REQUIRED_HOST"],
})
}),
),
)
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { Effect, Layer } from "effect"
import { SqlClient } from "effect/unstable/sql"
import { SqlError } from "effect/unstable/sql/SqlError"
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { tempGlobalLayer } from "./fixture/global"
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
// be verified without workerd or Cloudflare runtime dependencies.
const makeFakeStorage = () => {
const native = new Database(":memory:")
const toSqlStorageValue = (value: unknown) => {
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
const buffer = new ArrayBuffer(value.byteLength)
new Uint8Array(buffer).set(value)
return buffer
}
const storage: DurableObjectStorage = {
sql: {
exec(query: string, ...bindings: Array<unknown>) {
const statement = native.query(query)
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
const columnNames = statement.columnNames
return {
columnNames,
raw: () => rows[Symbol.iterator](),
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
}
},
},
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
native.run("BEGIN")
let rolledBack = false
return closure({ rollback: () => (rolledBack = true) }).then(
(result) => {
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
return result
},
(error) => {
native.run("ROLLBACK")
throw error
},
)
},
transactionSync<T>(closure: () => T): T {
return native.transaction(closure)()
},
}
return storage
}
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
describe("sqlite.workerd", () => {
test("executes statements with bindings and maps rows to records", async () => {
const rows = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
yield* sql`INSERT INTO item (id, name) VALUES (${1}, ${"one"}), (${2}, ${"two"})`
return yield* sql<{ id: number; name: string }>`SELECT id, name FROM item ORDER BY id`
}),
)
expect(rows).toEqual([
{ id: 1, name: "one" },
{ id: 2, name: "two" },
])
})
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
const rows = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
yield* sql`INSERT INTO blob (data) VALUES (${new Uint8Array([1, 2, 3])})`
return yield* sql<{ data: Uint8Array }>`SELECT data FROM blob`
}),
)
expect(rows[0].data).toBeInstanceOf(Uint8Array)
expect(Array.from(rows[0].data)).toEqual([1, 2, 3])
})
test("withTransaction commits on success and rolls back on failure", async () => {
const storage = makeFakeStorage()
const count = await run(
storage,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
yield* sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"kept"})`)
yield* sql
.withTransaction(
Effect.gen(function* () {
yield* sql`INSERT INTO t (value) VALUES (${"discarded"})`
return yield* Effect.fail("rollback")
}),
)
.pipe(Effect.ignore)
return yield* sql<{ count: number }>`SELECT count(*) AS count FROM t`
}),
)
expect(count[0].count).toBe(1)
})
test("nested withTransaction fails with SqlError", async () => {
const error = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
return yield* sql
.withTransaction(sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"nested"})`))
.pipe(Effect.flip)
}),
)
expect(error).toBeInstanceOf(SqlError)
})
test("boots the full database layer with migrations over injected storage", async () => {
const storage = makeFakeStorage()
const core = await import("@opencode-ai/core/database/database")
await Effect.runPromise(
Effect.scoped(
Layer.build(
core.Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })), Layer.provide(tempGlobalLayer)),
),
),
)
const names = storage.sql
.exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.toArray()
.map((row) => row.name)
expect(names).toContain("migration")
expect(names).toContain("session_v2")
})
})
+84 -62
View File
@@ -3,17 +3,20 @@ import { realpathSync } from "node:fs"
import os from "os"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -36,7 +39,7 @@ import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const sessionID = Session.ID.make("ses_shell_tool_test")
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
@@ -124,27 +127,42 @@ const executionNode = makeGlobalNode({
deps: [Bus.node, SessionStore.node],
})
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
filesystem,
FSUtil.node,
Global.node,
]),
[
[SessionExecution.node, executionNode],
[Permission.node, permission],
[Global.node, tempGlobalLayer],
const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [
Config.node,
Environment.node,
LocationMutation.node,
Permission.node,
PluginRuntime.node,
Shell.node,
Tool.node,
],
)
})
const it = testEffect(layer)
const nodes = LayerNode.group([
Database.node,
Bus.node,
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
filesystem,
FSUtil.node,
Global.node,
])
const replacements = [
[SessionExecution.node, executionNode],
[Permission.node, permission],
[Global.node, tempGlobalLayer],
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
sessionID,
@@ -165,9 +183,6 @@ const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const timeoutOutputCommand = isWindows
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
: "printf 'before timeout'; sleep 60"
const steadyProgressCommand = isWindows
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
: "printf steady; sleep 3.4"
const bodyExitCommand = isWindows
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
: "printf body && exit 7"
@@ -203,42 +218,49 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
})
describe("ShellTool", () => {
it.live("registers and returns real successful output from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const definition = definitions.find((tool) => tool.name === "shell")
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
// Code Mode receives the declared output schema, including the command output text.
expect(definition?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).not.toContain("shell")
productionIt.live(
"registers and returns real successful output from the active Location",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const definition = definitions.find((tool) => tool.name === "shell")
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
// Code Mode receives the declared output schema, including the command output text.
expect(definition?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).not.toContain("shell")
const settled = yield* executeTool(registry, call({ command: helloCommand }))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
const settled = yield* executeTool(registry, call({ command: helloCommand }))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([
{
sessionID,
action: "shell",
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
},
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live("resolves a relative workdir from the active Location", () =>
@@ -577,7 +599,7 @@ describe("ShellTool", () => {
)
it.live(
"does not repeat shell ID progress",
"reports shell ID progress once",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -587,7 +609,7 @@ describe("ShellTool", () => {
Effect.gen(function* () {
const updates: Tool.Metadata[] = []
yield* executeTool(registry, {
...call({ command: steadyProgressCommand }, "call-steady-progress"),
...call({ command: helloCommand }, "call-shell-id-progress"),
progress: (update) => Effect.sync(() => updates.push(update)),
})
expect(updates).toHaveLength(1)
+29 -26
View File
@@ -1,13 +1,14 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import path from "path"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -24,12 +25,13 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Permission } from "@opencode-ai/core/permission"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const childText = "child final response"
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
@@ -92,23 +94,30 @@ const executionNode = makeGlobalNode({
deps: [Bus.node, SessionStore.node],
})
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
]),
[
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
],
)
const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
const it = testEffect(layer)
const nodes = LayerNode.group([
Database.node,
Bus.node,
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
])
const replacements = [
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
@@ -136,7 +145,7 @@ const withSubagent = (location: Location.Ref) =>
})
describe("SubagentTool", () => {
it.live("registers globally while resolving agents from the caller location", () =>
productionIt.live("registers globally while resolving agents from the caller location", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -150,7 +159,6 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect(
yield* executeTool(registry, {
@@ -186,7 +194,6 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect(
yield* executeTool(registry, {
@@ -229,7 +236,6 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* executeTool(registry, {
sessionID: parent.id,
@@ -270,7 +276,6 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const progress: Tool.Metadata[] = []
const settled = yield* executeTool(registry, {
@@ -333,7 +338,6 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect(
yield* executeTool(registry, {
@@ -371,7 +375,6 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const bus = yield* Bus.Service
const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),