Compare commits

...

8 Commits

Author SHA1 Message Date
Kit Langton 248a45801d effect(cli): convert stream.transport to SessionTransport.Service
Replace the per-input `Layer.fresh` + `makeRuntime` facade with a proper
`SessionTransport.Service` exposing a `make(input)` method that returns the
transport handle. The layer is now static; per-input state lives inside the
handle returned by `svc.make(...)`. Update the runtime.ts caller to yield
the service inside `Effect.gen` and wrap the resulting `TransportHandle` in
the existing Promise-shaped `SessionTransport` for the Promise-based
prompt-queue code. `createSessionTransport(...)` is kept as a thin
backward-compatible wrapper for tests.
2026-05-13 19:41:54 -04:00
Aiden Cline df3895d74f cleanup: make smallOptions rely on variants (#27390) 2026-05-13 18:30:36 -05:00
Kit Langton 55e0af1405 fix(provider): type model not found errors (#27334) 2026-05-13 22:23:09 +00:00
Kit Langton 5182a3698d test(workspace): use Effect for local session warp cases (#27393) 2026-05-13 22:22:51 +00:00
opencode 73e1de4513 sync release versions for v1.14.49 2026-05-13 22:18:40 +00:00
Frank 44b432c3fd sync 2026-05-13 18:05:06 -04:00
Kit Langton 0d8c9f3437 docs: add LayerMap example (#27388) 2026-05-13 17:46:47 -04:00
opencode-agent[bot] 0d074492df chore: update nix node_modules hashes 2026-05-13 21:20:10 +00:00
40 changed files with 1366 additions and 904 deletions
+17 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.48",
"version": "1.14.49",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -307,7 +307,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.48",
"version": "1.14.49",
"bin": {
"opencode": "./bin/opencode",
},
@@ -520,7 +520,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -558,7 +558,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +573,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +608,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +657,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-cRhvzZoW6gBbE0sQm1+e+6/WgajuA6MSIL5iroFsfqs=",
"aarch64-linux": "sha256-0knZfxBULqkt5u6sXFx+a/vqw2rc6IC1+LeAd4TNFhM=",
"aarch64-darwin": "sha256-jL4tO+EHSmUF+gQGEaLzAbTxxjkL8OyhTk13vsbomgM=",
"x86_64-darwin": "sha256-bsa7IpS3GaxagcigTa0yqZTkf4e/nbcTQ9aZeb+5eHQ="
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.48",
"version": "1.14.49",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.48",
"version": "1.14.49",
"type": "module",
"license": "MIT",
"scripts": {
@@ -216,7 +216,7 @@ export async function handler(
// ie. 400 error is usually provider error like malformed request
res.status !== 400 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
!(modelInfo.id.startsWith("gpt-") && res.status === 404) &&
// ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider &&
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.48",
"version": "1.14.49",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.48",
"version": "1.14.49",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.48",
"version": "1.14.49",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.49",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -0,0 +1,94 @@
export * as LayerMapExample from "./layer-map.example"
import { Context, Effect, Layer, LayerMap } from "effect"
import { Npm } from "../npm"
/**
* Tutorial: split global services from context-specific services.
*
* Use this pattern when part of the app should be constructed once at the app edge,
* while another part should be cached per request/project/workspace key.
*
* In this example:
* - Npm.Service is the global service. It is not keyed by request context and should
* be provided once by the application runtime.
* - ConfigService is context-specific. It is built from a RequestContext key and is
* cached by LayerMap for that key.
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
* request can provide ConfigServiceMap.get(context) to select the right instance.
*
* Lifetime model:
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
* ConfigService for exactly that context key.
* - The cached ConfigService entry stays alive while something is using it. Once idle,
* it remains cached for idleTimeToLive, then its scope is finalized.
* - invalidate(context) removes the cache entry for future lookups. Active users keep
* running on the old instance; the next lookup can create a fresh instance.
*
* Key model:
* - Keys can be strings, structs, classes, arrays, etc.
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
* lookup, so mutating an object after it has been used as a key is a bug.
*/
export type RequestContext = {
readonly directory: string
readonly workspace: string
}
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
"@opencode/example/RequestContextRef",
) {}
export interface ConfigServiceShape {
readonly directory: string
readonly workspace: string
readonly nextUse: () => Effect.Effect<number>
readonly which: Npm.Interface["which"]
}
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
"@opencode/example/ConfigService",
) {}
const configServiceLayer = Layer.effect(
ConfigService,
Effect.gen(function* () {
const context = yield* RequestContextRef
const npm = yield* Npm.Service
let useCount = 0
return ConfigService.of({
directory: context.directory,
workspace: context.workspace,
nextUse: () => Effect.succeed(++useCount),
which: npm.which,
})
}),
)
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
lookup: (context: RequestContext) =>
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
idleTimeToLive: "5 minutes",
}) {}
export const appLayer = ConfigServiceMap.layer
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
const config = yield* ConfigService
return {
directory: config.directory,
workspace: config.workspace,
useCount: yield* config.nextUse(),
}
})
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
})
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.48",
"version": "1.14.49",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.48",
"version": "1.14.49",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.48"
version = "1.14.49"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.48",
"version": "1.14.49",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.49",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.49",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.49",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -0,0 +1,235 @@
# Error Boundaries Plan
Plan for removing `NamedError` as connective tissue while keeping public
wire contracts stable.
## Desired Shape
```text
Domain/service error
Schema.TaggedErrorClass
- catchable with catchTag / catchTags
- appears in service method error type
- no HTTP status
- no toObject()
HTTP public error
Schema.ErrorClass / TaggedErrorClass with httpApiStatus
- endpoint-declared public contract
- owns legacy { name, data } only when that is the SDK wire shape
CLI/user rendering
FormatError and small format helpers
- converts domain errors to text
- preserves useful structured fields
Session/model-visible error
first-class session/message error schema or helper
- owns { name, data } event/message shape
- not a service error class
```
The important rule: a service error should not also be the HTTP body, CLI
formatter, and session event body. Each seam adapts the error into the
shape it owns.
## Concrete Example: Provider Model Not Found
Before:
```ts
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
```
Problems:
- Throwing it inside `Effect.fn` made it behave like a defect unless a
compatibility bridge caught it.
- HTTP middleware knew that this one domain error should be a `400`.
- Callers read `.data.*`, which couples them to the legacy `{ name, data }`
wire shape.
After:
```ts
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
}
```
Boundary adapters:
```text
CLI
└─ FormatError sees _tag ProviderModelNotFoundError -> nice text
Session prompt
└─ catch ModelNotFoundError -> publish Session.Event.Error as message/session wire shape
HTTP route
└─ catch ModelNotFoundError -> declared BadRequest public API error when the endpoint needs it
HTTP middleware
└─ no Provider.ModelNotFoundError knowledge
```
## Refining Known Promise Failures
Use `EffectPromise.refineRejection(...)` when a Promise boundary can reject
with many unknown values, but only one or two rejection classes are expected
domain failures. Unknown rejections stay defects; the helper maps only known
rejection shapes to typed errors.
```ts
const language =
yield *
EffectPromise.refineRejection(
async () => loadFromProvider(),
(cause) => (cause instanceof NoSuchModelError ? new ModelNotFoundError({ providerID, modelID, cause }) : undefined),
)
```
Use this when the Promise can genuinely reject and most rejection values are
still defects for the current module. Use `Effect.tryPromise({ try, catch })`
when every rejection should become the same expected error type. Use
`Effect.promise(...)` only when rejection means a defect and you do not need
to refine known rejection classes.
## Helper Modules We Probably Want
Add helpers only when repeated call sites prove the seam is real.
### HTTP API Errors
Likely location: `src/server/routes/instance/httpapi/errors.ts`.
Purpose:
- construct public HTTP error bodies
- preserve legacy `{ name, data }` where needed
- attach `httpApiStatus`
Good helpers:
```ts
notFound(message)
badRequest(message)
unknown()
```
Avoid:
```ts
mapAnyDomainError(error)
```
That recreates the giant middleware mapper problem.
### Session / Message Error Wire Helpers
Likely location: near `src/session/message-error.ts` or a new narrow
module such as `src/session/event-error.ts`.
Purpose:
- construct the `{ name, data }` shape used by `Session.Event.Error` and
assistant message errors
- replace `new NamedError.Unknown(...).toObject()` call sites
- keep model-visible error bodies separate from service/domain errors
Good helpers:
```ts
unknown(message)
agentNotFound(agent, available)
commandNotFound(command, available)
modelNotFound(error: Provider.ModelNotFoundError)
```
### CLI Formatters
Likely location: `src/cli/error.ts` until repetition demands domain-local
format helpers.
Purpose:
- produce human-readable terminal messages from typed errors
- support old `{ name, data }` shapes only while compatibility is needed
## Migration Queue
### Remove Domain Knowledge From HTTP Middleware
- [x] Storage not found no longer maps through defect fallback.
- [x] Worktree expected errors moved to typed errors.
- [x] Provider auth expected errors moved to typed errors.
- [x] Provider model not found no longer needs an HTTP middleware status
special case.
- [ ] Convert `Session.BusyError` and map it at route boundaries.
- [ ] Delete the broad `NamedError` middleware branch once no route relies
on defect-wrapped legacy domain errors.
- [ ] Keep one final unknown-defect fallback that logs `Cause.pretty(cause)`
and returns a safe `500` body.
### Remaining `NamedError.create(...)` Service Errors
These should become `Schema.TaggedErrorClass` when touched:
- [ ] `src/provider/provider.ts``ProviderInitError`.
- [ ] `src/storage/db.ts` — database `NotFoundError`.
- [ ] `src/mcp/index.ts``MCPFailed`.
- [ ] `src/skill/index.ts``SkillInvalidError`,
`SkillNameMismatchError`.
- [ ] `src/lsp/client.ts``LSPInitializeError`.
- [ ] `src/ide/index.ts` — install errors.
- [ ] `src/config/error.ts`, `src/config/config.ts`,
`src/config/markdown.ts` — config errors. These already render well
in the CLI, so migrate carefully and preserve diagnostics.
### Session / Message Wire Errors
These are not ordinary service errors. They mostly build `{ name, data }`
objects for model-visible/session-visible output.
- [ ] Add a first-class session/message error wire helper.
- [ ] Replace `new NamedError.Unknown(...).toObject()` in
`src/session/prompt.ts`.
- [ ] Replace `new NamedError.Unknown(...).toObject()` in config/skill/plugin
session event publishing.
- [ ] Move `src/session/message-error.ts` and `src/session/message-v2.ts`
away from `NamedError.create(...)` once the wire helper exists.
- [ ] Update retry/message tests to assert the wire schema/helper output,
not `NamedError` instances.
### CLI Rendering
- [x] Tagged config errors render with useful diagnostics.
- [x] Provider model not found renders from both old `{ name, data }` and
new `_tag` shapes.
- [ ] Add typed render cases as more `NamedError.create(...)` domains move
to `Schema.TaggedErrorClass`.
- [ ] Eventually remove old-shape compatibility branches when no callers can
produce them.
## PR Checklist
For each migrated error:
- [ ] Domain error is `Schema.TaggedErrorClass`.
- [ ] Service method exposes the typed error in its error channel.
- [ ] No service error has `toObject()` just for compatibility.
- [ ] CLI, HTTP, and session/message adapters each own their output shape.
- [ ] HTTP middleware gets smaller or stays unchanged.
- [ ] Focused tests cover the domain error and any public rendering/wire
shape touched by the PR.
+3
View File
@@ -4,6 +4,9 @@ This note expands the `ERR`, `RENDER`, and `HTTP` tracks from
[`todo.md`](./todo.md). It is the current reference for expected failures,
typed service errors, and HTTP error boundaries.
For the migration architecture and queue, see
[`error-boundaries-plan.md`](./error-boundaries-plan.md).
## Goal
- Expected service failures live on the Effect error channel.
+3 -2
View File
@@ -2,8 +2,9 @@
Short roadmap for Effect cleanup in `packages/opencode`.
Current patterns and examples live in [`guide.md`](./guide.md). Test
migration rules live in
Current patterns and examples live in [`guide.md`](./guide.md). Error
boundary migration details live in
[`error-boundaries-plan.md`](./error-boundaries-plan.md). Test migration rules live in
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
Older deep-dive notes in this directory may still be useful, but treat
this roadmap and the guide as the current entry points.
+8 -5
View File
@@ -62,11 +62,14 @@ export interface Interface {
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}) => Effect.Effect<
{
identifier: string
whenToUse: string
systemPrompt: string
},
Provider.ModelNotFoundError
>
}
type State = Omit<Interface, "generate">
+26 -11
View File
@@ -13,7 +13,9 @@
// local sessions,
// 4. runs the prompt queue until the footer closes.
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppRuntime } from "@/effect/app-runtime"
import { createRunDemo } from "./demo"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
@@ -70,9 +72,10 @@ type RunLocalInput = {
demo?: RunInput["demo"]
}
type StreamModule = Awaited<typeof import("./stream.transport")>
type StreamState = {
mod: Awaited<typeof import("./stream.transport")>
handle: Awaited<ReturnType<Awaited<typeof import("./stream.transport")>["createSessionTransport"]>>
mod: StreamModule
handle: import("./stream.transport").SessionTransport
}
type ResolvedSession = {
@@ -485,15 +488,27 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
throw new Error("runtime closed")
}
const handle = await mod.createSessionTransport({
sdk: ctx.sdk,
directory: ctx.directory,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
footer,
trace: log,
})
// Yield SessionTransport.Service inside Effect.gen and call svc.make.
// The handle holds its own internal scope; teardown happens via handle.close().
const inner = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* mod.SessionTransport.Service
return yield* svc.make({
sdk: ctx.sdk,
directory: ctx.directory,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
footer,
trace: log,
})
}).pipe(Effect.provide(mod.SessionTransport.layer)),
)
const handle: import("./stream.transport").SessionTransport = {
runPromptTurn: (next) => AppRuntime.runPromise(inner.runPromptTurn(next)),
selectSubagent: (sessionID) => AppRuntime.runSync(inner.selectSubagent(sessionID)),
close: () => AppRuntime.runPromise(inner.close()),
}
if (footer.isClosed) {
await handle.close()
throw new Error("runtime closed")
File diff suppressed because it is too large Load Diff
+7 -8
View File
@@ -1,5 +1,6 @@
import { NamedError } from "@opencode-ai/core/util/error"
import { errorFormat } from "@/util/error"
import { isRecord } from "@/util/record"
interface ErrorLike {
name?: string
@@ -10,10 +11,6 @@ interface ErrorLike {
type ConfigIssue = { message: string; path: string[] }
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null
}
function isTaggedError(error: unknown, tag: string): boolean {
return isRecord(error) && error._tag === tag
}
@@ -61,11 +58,13 @@ export function FormatError(input: unknown) {
}
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
const data = (input as ErrorLike).data
const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : []
const providerModelNotFound = configData(input, "ProviderModelNotFoundError")
if (providerModelNotFound) {
const suggestions = Array.isArray(providerModelNotFound.suggestions)
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
: []
return [
`Model not found: ${data?.providerID}/${data?.modelID}`,
`Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
+17
View File
@@ -0,0 +1,17 @@
import { Cause, Effect } from "effect"
export function refineRejection<A, E>(
evaluate: (signal: AbortSignal) => PromiseLike<A>,
refine: (cause: unknown) => E | undefined,
) {
return Effect.tryPromise(evaluate).pipe(
Effect.catch((error) => {
const cause = Cause.isUnknownError(error) ? error.cause : error
const refined = refine(cause)
if (refined !== undefined) return Effect.fail(refined)
return Effect.die(cause)
}),
)
}
export * as EffectPromise from "./promise"
+33 -32
View File
@@ -21,6 +21,7 @@ import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectPromise } from "@/effect/promise"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
@@ -963,11 +964,24 @@ export function defaultModelIDs<T extends { models: Record<string, { id: string
return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {
static isInstance(input: unknown): input is ModelNotFoundError {
return input instanceof ModelNotFoundError
}
}
export type Error = ModelNotFoundError
export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3>
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
readonly closest: (
providerID: ProviderID,
query: string[],
@@ -1638,14 +1652,14 @@ const layer = Layer.effect(
: fuzzysort
.go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
.map((m) => m.target)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
}
const info = provider.models[modelID]
if (!info) {
const current = modelSuggestions(provider, modelID)
const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], modelID)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
}
return info
})
@@ -1656,11 +1670,10 @@ const layer = Layer.effect(
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
return yield* Effect.promise(async () => {
const provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s, envs)
try {
const provider = s.providers[model.providerID]
return yield* EffectPromise.refineRejection(
async () => {
const sdk = await resolveSDK(model, s, envs)
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
...provider.options,
@@ -1669,18 +1682,12 @@ const layer = Layer.effect(
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language
} catch (e) {
if (e instanceof NoSuchModelError)
throw new ModelNotFoundError(
{
modelID: model.id,
providerID: model.providerID,
},
{ cause: e },
)
throw e
}
})
},
(cause) =>
cause instanceof NoSuchModelError
? new ModelNotFoundError({ modelID: model.id, providerID: model.providerID, cause })
: undefined,
)
})
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
@@ -1700,7 +1707,7 @@ const layer = Layer.effect(
if (cfg.small_model) {
const parsed = parseModel(cfg.small_model)
return yield* getModel(parsed.providerID, parsed.modelID)
return yield* getModel(parsed.providerID, parsed.modelID).pipe(Effect.orDie)
}
const s = yield* InstanceState.get(state)
@@ -1728,22 +1735,22 @@ const layer = Layer.effect(
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)).pipe(Effect.orDie)
const region = provider.options?.region
if (region) {
const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)).pipe(Effect.orDie)
}
}
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)).pipe(Effect.orDie)
} else {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)).pipe(Effect.orDie)
}
}
}
@@ -1818,12 +1825,6 @@ export function parseModel(model: string) {
}
}
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
export const InitError = NamedError.create("ProviderInitError", {
providerID: ProviderID,
})
+6 -27
View File
@@ -617,14 +617,6 @@ function googleThinkingBudgetMax(apiId: string) {
return 24_576
}
function googleSmallThinkingConfig(apiId: string) {
const levels = googleThinkingLevelEfforts(apiId)
if (apiId.toLowerCase().includes("gemini-3")) {
return { thinkingLevel: levels.includes("minimal") ? "minimal" : levels.includes("low") ? "low" : "high" }
}
return { thinkingBudget: googleThinkingBudgetMax(apiId) === 32_768 ? 128 : 0 }
}
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {}
@@ -1184,40 +1176,27 @@ export function options(input: {
}
export function smallOptions(model: Provider.Model) {
const small = Object.values(model.variants ?? {})[0] ?? {}
if (
model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot"
) {
if (model.api.id.includes("gpt-5")) {
if (model.api.id.includes("-chat")) {
if (gpt5Version(model.api.id) === undefined) return { store: false }
return { store: false, reasoningEffort: "medium" }
}
if (model.api.id.includes("search-api")) return { store: false }
if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) {
return { store: false, reasoningEffort: "low" }
}
return { store: false, reasoningEffort: "minimal" }
}
return { store: false }
}
if (model.providerID === "google") {
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
return { thinkingConfig: googleSmallThinkingConfig(model.api.id) }
const base = { store: false }
return mergeDeep(base, small)
}
if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
if (model.api.id.includes("google")) {
if (Object.keys(small).length === 0 && model.api.id.includes("google")) {
return { reasoning: { enabled: false } }
}
return { reasoningEffort: "minimal" }
}
if (model.providerID === "venice") {
if (Object.keys(small).length > 0) return small
return { veniceParameters: { disableThinking: true } }
}
return {}
return small
}
// Maps model ID prefix to provider slug used in providerOptions.
@@ -1,5 +1,3 @@
import { Provider } from "@/provider/provider"
import { iife } from "@/util/iife"
import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Effect } from "effect"
@@ -23,14 +21,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
log.error("failed", { error, cause: Cause.pretty(cause) })
if (error instanceof NamedError) {
return Effect.succeed(
HttpServerResponse.jsonUnsafe(error.toObject(), {
status: iife(() => {
if (error instanceof Provider.ModelNotFoundError) return 400
return 500
}),
}),
)
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 500 }))
}
return Effect.succeed(
HttpServerResponse.jsonUnsafe(
+5 -3
View File
@@ -390,8 +390,8 @@ export const layer: Layer.Layer<
const agent = yield* agents.get("compaction")
const model = agent.model
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie)
const cfg = yield* config.get()
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
const prior = completedCompactions(history)
@@ -519,7 +519,9 @@ export const layer: Layer.Layer<
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
model: yield* provider
.getModel(userMessage.model.providerID, userMessage.model.modelID)
.pipe(Effect.orDie),
provider: {
source: info.source,
info,
+6 -4
View File
@@ -1057,15 +1057,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (Exit.isSuccess(exit)) return exit.value
const err = Cause.squash(exit.cause)
if (Provider.ModelNotFoundError.isInstance(err)) {
const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : ""
const hint = err.suggestions?.length ? ` Did you mean: ${err.suggestions.join(", ")}?` : ""
yield* bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({
message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`,
message: `Model not found: ${err.providerID}/${err.modelID}.${hint}`,
}).toObject(),
})
}
return yield* Effect.failCause(exit.cause)
return yield* Effect.die(err)
})
const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
@@ -1108,7 +1108,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
const full =
!input.variant && ag.variant && same
? yield* provider.getModel(model.providerID, model.modelID).pipe(Effect.catchDefect(() => Effect.void))
? yield* provider
.getModel(model.providerID, model.modelID)
.pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))
: undefined
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
+17
View File
@@ -64,6 +64,23 @@ describe("cli.error", () => {
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
})
test("formats legacy and tagged provider model errors the same way", () => {
const data = {
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestions: ["claude-sonnet-4"],
}
const expected = [
"Model not found: anthropic/claude-sonet-4",
"Did you mean: claude-sonnet-4",
"Try: `opencode models` to list available models",
"Or check your config (opencode.json) provider/model names",
].join("\n")
expect(FormatError({ name: "ProviderModelNotFoundError", data })).toBe(expected)
expect(FormatError({ _tag: "ProviderModelNotFoundError", ...data })).toBe(expected)
})
test("formats cancelled UI errors as empty output", () => {
expect(FormatError(new UI.CancelledError())).toBe("")
})
@@ -749,30 +749,39 @@ describe("workspace CRUD", () => {
})
})
test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => {
await withInstance(async (dir) => {
const type = unique("remove-local")
const recorded = localAdapter(path.join(dir, "remove-local"))
registerAdapter(Instance.project.id, type, recorded.adapter)
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(one.id, info.id)
attachSessionToWorkspace(two.id, info.id)
it.instance(
"remove deletes the workspace, associated sessions, adapter resources, and status",
() => {
return Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const type = unique("remove-local")
const recorded = localAdapter(path.join(dir, "remove-local"))
registerAdapter(instance.project.id, type, recorded.adapter)
const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null })
const one = yield* sessionSvc.create({})
const two = yield* sessionSvc.create({})
attachSessionToWorkspace(one.id, info.id)
attachSessionToWorkspace(two.id, info.id)
const removed = await removeWorkspace(info.id)
const removed = yield* workspace.remove(info.id)
expect(removed).toEqual(info)
expect(await getWorkspace(info.id)).toBeUndefined()
expect(recorded.calls.remove).toEqual([info])
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
expect(
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(),
),
).toEqual([])
})
})
expect(removed).toEqual(info)
expect(yield* workspace.get(info.id)).toBeUndefined()
expect(recorded.calls.remove).toEqual([info])
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
expect(
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(),
),
).toEqual([])
})
},
{ git: true },
)
test("remove still deletes the row when the adapter cannot remove resources", async () => {
await withInstance(async () => {
@@ -797,84 +806,111 @@ describe("workspace CRUD", () => {
})
})
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-prev-local")
const targetType = unique("warp-target-local")
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
it.instance(
"sessionWarp moves a session into a local workspace and claims ownership",
() => {
return Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-prev-local")
const targetType = unique("warp-target-local")
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
})
})
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id)
})
},
{ git: true },
)
test("sessionWarp applies source workspace patch to local target workspace", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-patch-prev-local")
const targetType = unique("warp-patch-target-local")
const previousDir = path.join(dir, "warp-patch-prev-local")
const targetDir = path.join(dir, "warp-patch-target-local")
await initGitRepo(previousDir)
await initGitRepo(targetDir)
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
it.instance(
"sessionWarp applies source workspace patch to local target workspace",
() => {
return Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-patch-prev-local")
const targetType = unique("warp-patch-target-local")
const previousDir = path.join(dir, "warp-patch-prev-local")
const targetDir = path.join(dir, "warp-patch-target-local")
yield* Effect.promise(() => initGitRepo(previousDir))
yield* Effect.promise(() => initGitRepo(targetDir))
yield* Effect.promise(() => fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(previousDir, "new.txt"), "new\n"))
const previous = workspaceInfo(Instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
registerAdapter(instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n")
expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n")
})
})
expect(yield* Effect.promise(() => fs.readFile(path.join(targetDir, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(targetDir, "new.txt"), "utf8"))).toBe("new\n")
})
},
{ git: true },
)
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
await withInstance(async (dir) => {
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(Instance.project.id, previousType)
insertWorkspace(previous)
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
it.instance(
"sessionWarp detaches a session to the local project and claims project ownership",
() => {
return Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const instance = yield* InstanceRef
if (!instance) return yield* Effect.die(new Error("missing test instance"))
const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(instance.project.id, previousType)
insertWorkspace(previous)
registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
yield* workspace.sessionWarp({ workspaceID: null, sessionID: session.id })
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
})
})
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(instance.project.id)
})
},
{ git: true },
)
test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => {
await withInstance(async () => {
@@ -1572,8 +1572,8 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
expect(true).toBe(false) // Should not reach here
} catch (e: any) {
expect(e.data.suggestions).toBeDefined()
expect(e.data.suggestions.length).toBeGreaterThan(0)
expect(e.suggestions).toBeDefined()
expect(e.suggestions.length).toBeGreaterThan(0)
}
},
})
@@ -1598,8 +1598,8 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
expect(true).toBe(false) // Should not reach here
} catch (e: any) {
expect(e.data.suggestions).toBeDefined()
expect(e.data.suggestions).toContain("anthropic")
expect(e.suggestions).toBeDefined()
expect(e.suggestions).toContain("anthropic")
}
},
})
@@ -1625,7 +1625,7 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async
throw new Error("expected model lookup to fail")
} catch (e) {
if (!Provider.ModelNotFoundError.isInstance(e)) throw e
expect(e.data.suggestions).toContain("claude-haiku-4-5")
expect(e.suggestions).toContain("claude-haiku-4-5")
}
},
})
@@ -3602,8 +3602,8 @@ describe("ProviderTransform.variants", () => {
})
describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
const createModel = (apiId: string) =>
({
const createModel = (apiId: string) => {
const model = {
id: `openai/${apiId}`,
providerID: "openai",
api: {
@@ -3611,13 +3611,43 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
}) as any
capabilities: { reasoning: true },
limit: { output: 64_000 },
release_date: "2026-01-01",
} as any
model.variants = ProviderTransform.variants(model)
return model
}
for (const testCase of [
{ id: "gpt-5-chat-latest", options: { store: false } },
{ id: "gpt-5.1-chat-latest", options: { store: false, reasoningEffort: "medium" } },
{ id: "gpt-5.2-chat-latest", options: { store: false, reasoningEffort: "medium" } },
{ id: "gpt-5-search-api", options: { store: false } },
{
id: "gpt-5.1-chat-latest",
options: {
store: false,
reasoningEffort: "medium",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
{
id: "gpt-5.2-chat-latest",
options: {
store: false,
reasoningEffort: "medium",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
{
id: "gpt-5-search-api",
options: {
store: false,
reasoningEffort: "none",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
]) {
test(`${testCase.id} returns only supported small options`, () => {
expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options)
@@ -3626,8 +3656,8 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
})
describe("ProviderTransform.smallOptions - google thinking controls", () => {
const createGoogleModel = (apiId: string) =>
({
const createGoogleModel = (apiId: string) => {
const model = {
id: `google/${apiId}`,
providerID: "google",
api: {
@@ -3635,20 +3665,44 @@ describe("ProviderTransform.smallOptions - google thinking controls", () => {
url: "https://generativelanguage.googleapis.com",
npm: "@ai-sdk/google",
},
}) as any
capabilities: { reasoning: true },
limit: { output: 64_000 },
} as any
model.variants = ProviderTransform.variants(model)
return model
}
for (const testCase of [
{ id: "gemini-3-pro-preview", options: { thinkingConfig: { thinkingLevel: "low" } } },
{ id: "gemini-3-flash-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } },
{ id: "gemini-3.1-flash-image-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } },
{ id: "gemini-3-pro-image-preview", options: { thinkingConfig: { thinkingLevel: "high" } } },
{ id: "gemini-2.5-pro", options: { thinkingConfig: { thinkingBudget: 128 } } },
{ id: "gemini-2.5-flash", options: { thinkingConfig: { thinkingBudget: 0 } } },
{ id: "gemini-3-pro-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } } },
{ id: "gemini-3-flash-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" } } },
{
id: "gemini-3.1-flash-image-preview",
options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" } },
},
{ id: "gemini-3-pro-image-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } } },
{ id: "gemini-2.5-pro", options: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
{ id: "gemini-2.5-flash", options: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
]) {
test(`${testCase.id} returns supported small thinking options`, () => {
expect(ProviderTransform.smallOptions(createGoogleModel(testCase.id))).toEqual(testCase.options)
})
}
test("uses the first configured variant when available", () => {
expect(
ProviderTransform.smallOptions({
...createGoogleModel("gemini-2.5-pro"),
variants: {
high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32768 } },
},
}),
).toEqual({ thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } })
})
test("does not synthesize thinking options when variants are empty", () => {
expect(ProviderTransform.smallOptions({ ...createGoogleModel("gemini-2.5-pro"), variants: {} })).toEqual({})
})
})
describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.48",
"version": "1.14.49",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.48",
"version": "1.14.49",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.48",
"version": "1.14.49",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.48",
"version": "1.14.49",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.48",
"version": "1.14.49",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.48",
"version": "1.14.49",
"publisher": "sst-dev",
"repository": {
"type": "git",