Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd45c7bb0f | ||
|
|
df3895d74f | ||
|
|
55e0af1405 | ||
|
|
5182a3698d | ||
|
|
73e1de4513 | ||
|
|
44b432c3fd | ||
|
|
0d8c9f3437 | ||
|
|
0d074492df |
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -307,7 +307,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
@@ -384,7 +384,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -520,7 +520,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -558,7 +558,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -573,7 +573,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -608,7 +608,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -657,7 +657,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-cRhvzZoW6gBbE0sQm1+e+6/WgajuA6MSIL5iroFsfqs=",
|
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
|
||||||
"aarch64-linux": "sha256-0knZfxBULqkt5u6sXFx+a/vqw2rc6IC1+LeAd4TNFhM=",
|
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
|
||||||
"aarch64-darwin": "sha256-jL4tO+EHSmUF+gQGEaLzAbTxxjkL8OyhTk13vsbomgM=",
|
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
|
||||||
"x86_64-darwin": "sha256-bsa7IpS3GaxagcigTa0yqZTkf4e/nbcTQ9aZeb+5eHQ="
|
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export async function handler(
|
|||||||
// ie. 400 error is usually provider error like malformed request
|
// ie. 400 error is usually provider error like malformed request
|
||||||
res.status !== 400 &&
|
res.status !== 400 &&
|
||||||
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
|
// 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
|
// ie. cannot change codex model providers mid-session
|
||||||
modelInfo.stickyProvider !== "strict" &&
|
modelInfo.stickyProvider !== "strict" &&
|
||||||
modelInfo.fallbackProvider &&
|
modelInfo.fallbackProvider &&
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.14.48"
|
version = "1.14.49"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[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"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"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.
|
||||||
@@ -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,
|
[`todo.md`](./todo.md). It is the current reference for expected failures,
|
||||||
typed service errors, and HTTP error boundaries.
|
typed service errors, and HTTP error boundaries.
|
||||||
|
|
||||||
|
For the migration architecture and queue, see
|
||||||
|
[`error-boundaries-plan.md`](./error-boundaries-plan.md).
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
- Expected service failures live on the Effect error channel.
|
- Expected service failures live on the Effect error channel.
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Short roadmap for Effect cleanup in `packages/opencode`.
|
Short roadmap for Effect cleanup in `packages/opencode`.
|
||||||
|
|
||||||
Current patterns and examples live in [`guide.md`](./guide.md). Test
|
Current patterns and examples live in [`guide.md`](./guide.md). Error
|
||||||
migration rules live in
|
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).
|
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
|
||||||
Older deep-dive notes in this directory may still be useful, but treat
|
Older deep-dive notes in this directory may still be useful, but treat
|
||||||
this roadmap and the guide as the current entry points.
|
this roadmap and the guide as the current entry points.
|
||||||
|
|||||||
@@ -62,11 +62,14 @@ export interface Interface {
|
|||||||
readonly generate: (input: {
|
readonly generate: (input: {
|
||||||
description: string
|
description: string
|
||||||
model?: { providerID: ProviderID; modelID: ModelID }
|
model?: { providerID: ProviderID; modelID: ModelID }
|
||||||
}) => Effect.Effect<{
|
}) => Effect.Effect<
|
||||||
identifier: string
|
{
|
||||||
whenToUse: string
|
identifier: string
|
||||||
systemPrompt: string
|
whenToUse: string
|
||||||
}>
|
systemPrompt: string
|
||||||
|
},
|
||||||
|
Provider.ModelNotFoundError
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = Omit<Interface, "generate">
|
type State = Omit<Interface, "generate">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import * as ConfigPaths from "@/config/paths"
|
import { ConfigPaths } from "@/config/paths"
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.migrate" })
|
const log = Log.create({ service: "tui.migrate" })
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createBindingLookup } from "@opentui/keymap/extras"
|
|||||||
import { mergeDeep, unique } from "remeda"
|
import { mergeDeep, unique } from "remeda"
|
||||||
import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect"
|
import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect"
|
||||||
import { ConfigParse } from "@/config/parse"
|
import { ConfigParse } from "@/config/parse"
|
||||||
import * as ConfigPaths from "@/config/paths"
|
import { ConfigPaths } from "@/config/paths"
|
||||||
import { migrateTuiConfig } from "./tui-migrate"
|
import { migrateTuiConfig } from "./tui-migrate"
|
||||||
import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema"
|
import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
@@ -99,6 +99,7 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
|
|||||||
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||||
const afs = yield* AppFileSystem.Service
|
const afs = yield* AppFileSystem.Service
|
||||||
let appliedOrder = 0
|
let appliedOrder = 0
|
||||||
|
const paths = yield* ConfigPaths.Service
|
||||||
|
|
||||||
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -191,10 +192,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
|||||||
|
|
||||||
// Every config dir we may read from: global config dir, any `.opencode`
|
// Every config dir we may read from: global config dir, any `.opencode`
|
||||||
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
||||||
const directories = yield* ConfigPaths.directories(ctx.directory)
|
const directories = yield* paths.directories(ctx.directory)
|
||||||
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
|
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
|
||||||
|
|
||||||
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
|
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* paths.projectFiles("tui", ctx.directory)
|
||||||
|
|
||||||
const acc: Acc = {
|
const acc: Acc = {
|
||||||
result: {},
|
result: {},
|
||||||
@@ -295,7 +296,11 @@ export const layer = Layer.effect(
|
|||||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Npm.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { errorFormat } from "@/util/error"
|
import { errorFormat } from "@/util/error"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
interface ErrorLike {
|
interface ErrorLike {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -10,10 +11,6 @@ interface ErrorLike {
|
|||||||
|
|
||||||
type ConfigIssue = { message: string; path: string[] }
|
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 {
|
function isTaggedError(error: unknown, tag: string): boolean {
|
||||||
return isRecord(error) && error._tag === tag
|
return isRecord(error) && error._tag === tag
|
||||||
}
|
}
|
||||||
@@ -61,11 +58,13 @@ export function FormatError(input: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||||
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
const providerModelNotFound = configData(input, "ProviderModelNotFoundError")
|
||||||
const data = (input as ErrorLike).data
|
if (providerModelNotFound) {
|
||||||
const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : []
|
const suggestions = Array.isArray(providerModelNotFound.suggestions)
|
||||||
|
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
|
||||||
|
: []
|
||||||
return [
|
return [
|
||||||
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
`Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
|
||||||
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||||
`Try: \`opencode models\` to list available models`,
|
`Try: \`opencode models\` to list available models`,
|
||||||
`Or check your config (opencode.json) provider/model names`,
|
`Or check your config (opencode.json) provider/model names`,
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ export const layer = Layer.effect(
|
|||||||
const accountSvc = yield* Account.Service
|
const accountSvc = yield* Account.Service
|
||||||
const env = yield* Env.Service
|
const env = yield* Env.Service
|
||||||
const npmSvc = yield* Npm.Service
|
const npmSvc = yield* Npm.Service
|
||||||
|
const paths = yield* ConfigPaths.Service
|
||||||
|
|
||||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
||||||
|
|
||||||
@@ -562,7 +563,7 @@ export const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||||
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
|
for (const file of yield* paths.projectFiles("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
|
||||||
yield* merge(file, yield* loadFile(file), "local")
|
yield* merge(file, yield* loadFile(file), "local")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -571,7 +572,7 @@ export const layer = Layer.effect(
|
|||||||
result.mode = result.mode || {}
|
result.mode = result.mode || {}
|
||||||
result.plugin = result.plugin || []
|
result.plugin = result.plugin || []
|
||||||
|
|
||||||
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
|
const directories = yield* paths.directories(ctx.directory, ctx.worktree)
|
||||||
|
|
||||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||||
@@ -829,6 +830,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Account.defaultLayer),
|
Layer.provide(Account.defaultLayer),
|
||||||
Layer.provide(Npm.defaultLayer),
|
Layer.provide(Npm.defaultLayer),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
|
|||||||
@@ -4,42 +4,73 @@ import path from "path"
|
|||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { unique } from "remeda"
|
import { unique } from "remeda"
|
||||||
import * as Effect from "effect/Effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { ConfigParse } from "./parse"
|
||||||
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
|
|
||||||
name: string,
|
|
||||||
directory: string,
|
|
||||||
worktree?: string,
|
|
||||||
) {
|
|
||||||
const afs = yield* AppFileSystem.Service
|
|
||||||
return (yield* afs.up({
|
|
||||||
targets: [`${name}.jsonc`, `${name}.json`],
|
|
||||||
start: directory,
|
|
||||||
stop: worktree,
|
|
||||||
})).toReversed()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
|
|
||||||
const afs = yield* AppFileSystem.Service
|
|
||||||
return unique([
|
|
||||||
Global.Path.config,
|
|
||||||
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
|
||||||
? yield* afs.up({
|
|
||||||
targets: [".opencode"],
|
|
||||||
start: directory,
|
|
||||||
stop: worktree,
|
|
||||||
})
|
|
||||||
: []),
|
|
||||||
...(yield* afs.up({
|
|
||||||
targets: [".opencode"],
|
|
||||||
start: Global.Path.home,
|
|
||||||
stop: Global.Path.home,
|
|
||||||
})),
|
|
||||||
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
export function fileInDirectory(dir: string, name: string) {
|
export function fileInDirectory(dir: string, name: string) {
|
||||||
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly projectFiles: (
|
||||||
|
name: string,
|
||||||
|
directory: string,
|
||||||
|
worktree?: string,
|
||||||
|
) => Effect.Effect<string[], AppFileSystem.Error>
|
||||||
|
readonly directories: (directory: string, worktree?: string) => Effect.Effect<string[], AppFileSystem.Error>
|
||||||
|
readonly readFile: (filepath: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
|
||||||
|
readonly parseText: (text: string, filepath: string) => Effect.Effect<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPaths") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const afs = yield* AppFileSystem.Service
|
||||||
|
|
||||||
|
const projectFiles = Effect.fn("ConfigPaths.projectFiles")(function* (
|
||||||
|
name: string,
|
||||||
|
directory: string,
|
||||||
|
worktree?: string,
|
||||||
|
) {
|
||||||
|
return (yield* afs.up({
|
||||||
|
targets: [`${name}.jsonc`, `${name}.json`],
|
||||||
|
start: directory,
|
||||||
|
stop: worktree,
|
||||||
|
})).toReversed()
|
||||||
|
})
|
||||||
|
|
||||||
|
const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
|
||||||
|
return unique([
|
||||||
|
Global.Path.config,
|
||||||
|
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
? yield* afs.up({
|
||||||
|
targets: [".opencode"],
|
||||||
|
start: directory,
|
||||||
|
stop: worktree,
|
||||||
|
})
|
||||||
|
: []),
|
||||||
|
...(yield* afs.up({
|
||||||
|
targets: [".opencode"],
|
||||||
|
start: Global.Path.home,
|
||||||
|
stop: Global.Path.home,
|
||||||
|
})),
|
||||||
|
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
const readFile = Effect.fn("ConfigPaths.readFile")(function* (filepath: string) {
|
||||||
|
return yield* afs.readFileStringSafe(filepath)
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseText = Effect.fn("ConfigPaths.parseText")(function* (text: string, filepath: string) {
|
||||||
|
return ConfigParse.jsonc(text, filepath)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ projectFiles, directories, readFile, parseText })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
printParseErrorCode,
|
printParseErrorCode,
|
||||||
} from "jsonc-parser"
|
} from "jsonc-parser"
|
||||||
|
|
||||||
import * as ConfigPaths from "@/config/paths"
|
import { ConfigPaths } from "@/config/paths"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Flock } from "@opencode-ai/core/util/flock"
|
import { Flock } from "@opencode-ai/core/util/flock"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { pathToFileURL } from "url"
|
|||||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { EffectPromise } from "@/effect/promise"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
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)
|
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 {
|
export interface Interface {
|
||||||
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
|
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
|
||||||
readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
|
readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
|
||||||
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model>
|
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
|
||||||
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3>
|
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
|
||||||
readonly closest: (
|
readonly closest: (
|
||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
query: string[],
|
query: string[],
|
||||||
@@ -1638,14 +1652,14 @@ const layer = Layer.effect(
|
|||||||
: fuzzysort
|
: fuzzysort
|
||||||
.go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
|
.go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
|
||||||
.map((m) => m.target)
|
.map((m) => m.target)
|
||||||
throw new ModelNotFoundError({ providerID, modelID, suggestions })
|
return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = provider.models[modelID]
|
const info = provider.models[modelID]
|
||||||
if (!info) {
|
if (!info) {
|
||||||
const current = modelSuggestions(provider, modelID)
|
const current = modelSuggestions(provider, modelID)
|
||||||
const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], 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
|
return info
|
||||||
})
|
})
|
||||||
@@ -1656,11 +1670,10 @@ const layer = Layer.effect(
|
|||||||
const key = `${model.providerID}/${model.id}`
|
const key = `${model.providerID}/${model.id}`
|
||||||
if (s.models.has(key)) return s.models.get(key)!
|
if (s.models.has(key)) return s.models.get(key)!
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
const provider = s.providers[model.providerID]
|
||||||
const provider = s.providers[model.providerID]
|
return yield* EffectPromise.refineRejection(
|
||||||
const sdk = await resolveSDK(model, s, envs)
|
async () => {
|
||||||
|
const sdk = await resolveSDK(model, s, envs)
|
||||||
try {
|
|
||||||
const language = s.modelLoaders[model.providerID]
|
const language = s.modelLoaders[model.providerID]
|
||||||
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
|
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
|
||||||
...provider.options,
|
...provider.options,
|
||||||
@@ -1669,18 +1682,12 @@ const layer = Layer.effect(
|
|||||||
: sdk.languageModel(model.api.id)
|
: sdk.languageModel(model.api.id)
|
||||||
s.models.set(key, language)
|
s.models.set(key, language)
|
||||||
return language
|
return language
|
||||||
} catch (e) {
|
},
|
||||||
if (e instanceof NoSuchModelError)
|
(cause) =>
|
||||||
throw new ModelNotFoundError(
|
cause instanceof NoSuchModelError
|
||||||
{
|
? new ModelNotFoundError({ modelID: model.id, providerID: model.providerID, cause })
|
||||||
modelID: model.id,
|
: undefined,
|
||||||
providerID: model.providerID,
|
)
|
||||||
},
|
|
||||||
{ cause: e },
|
|
||||||
)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
||||||
@@ -1700,7 +1707,7 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
if (cfg.small_model) {
|
if (cfg.small_model) {
|
||||||
const parsed = parseModel(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)
|
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 candidates = Object.keys(provider.models).filter((m) => m.includes(item))
|
||||||
|
|
||||||
const globalMatch = candidates.find((m) => m.startsWith("global."))
|
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
|
const region = provider.options?.region
|
||||||
if (region) {
|
if (region) {
|
||||||
const regionPrefix = region.split("-")[0]
|
const regionPrefix = region.split("-")[0]
|
||||||
if (regionPrefix === "us" || regionPrefix === "eu") {
|
if (regionPrefix === "us" || regionPrefix === "eu") {
|
||||||
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
|
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)))
|
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 {
|
} else {
|
||||||
for (const model of Object.keys(provider.models)) {
|
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", {
|
export const InitError = NamedError.create("ProviderInitError", {
|
||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -617,14 +617,6 @@ function googleThinkingBudgetMax(apiId: string) {
|
|||||||
return 24_576
|
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>> {
|
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
|
||||||
if (!model.capabilities.reasoning) return {}
|
if (!model.capabilities.reasoning) return {}
|
||||||
|
|
||||||
@@ -1184,40 +1176,27 @@ export function options(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function smallOptions(model: Provider.Model) {
|
export function smallOptions(model: Provider.Model) {
|
||||||
|
const small = Object.values(model.variants ?? {})[0] ?? {}
|
||||||
if (
|
if (
|
||||||
model.providerID === "openai" ||
|
model.providerID === "openai" ||
|
||||||
model.api.npm === "@ai-sdk/openai" ||
|
model.api.npm === "@ai-sdk/openai" ||
|
||||||
model.api.npm === "@ai-sdk/github-copilot"
|
model.api.npm === "@ai-sdk/github-copilot"
|
||||||
) {
|
) {
|
||||||
if (model.api.id.includes("gpt-5")) {
|
const base = { store: false }
|
||||||
if (model.api.id.includes("-chat")) {
|
return mergeDeep(base, small)
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
|
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 { reasoning: { enabled: false } }
|
||||||
}
|
}
|
||||||
return { reasoningEffort: "minimal" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.providerID === "venice") {
|
if (model.providerID === "venice") {
|
||||||
|
if (Object.keys(small).length > 0) return small
|
||||||
return { veniceParameters: { disableThinking: true } }
|
return { veniceParameters: { disableThinking: true } }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {}
|
return small
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps model ID prefix to provider slug used in providerOptions.
|
// 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 { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Cause, Effect } from "effect"
|
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) })
|
log.error("failed", { error, cause: Cause.pretty(cause) })
|
||||||
|
|
||||||
if (error instanceof NamedError) {
|
if (error instanceof NamedError) {
|
||||||
return Effect.succeed(
|
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 500 }))
|
||||||
HttpServerResponse.jsonUnsafe(error.toObject(), {
|
|
||||||
status: iife(() => {
|
|
||||||
if (error instanceof Provider.ModelNotFoundError) return 400
|
|
||||||
return 500
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
HttpServerResponse.jsonUnsafe(
|
HttpServerResponse.jsonUnsafe(
|
||||||
|
|||||||
@@ -390,8 +390,8 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
const agent = yield* agents.get("compaction")
|
const agent = yield* agents.get("compaction")
|
||||||
const model = agent.model
|
const model = agent.model
|
||||||
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
|
? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
|
||||||
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
|
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie)
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
|
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
|
||||||
const prior = completedCompactions(history)
|
const prior = completedCompactions(history)
|
||||||
@@ -519,7 +519,9 @@ export const layer: Layer.Layer<
|
|||||||
{
|
{
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
agent: userMessage.agent,
|
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: {
|
provider: {
|
||||||
source: info.source,
|
source: info.source,
|
||||||
info,
|
info,
|
||||||
|
|||||||
@@ -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
|
if (Exit.isSuccess(exit)) return exit.value
|
||||||
const err = Cause.squash(exit.cause)
|
const err = Cause.squash(exit.cause)
|
||||||
if (Provider.ModelNotFoundError.isInstance(err)) {
|
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, {
|
yield* bus.publish(Session.Event.Error, {
|
||||||
sessionID,
|
sessionID,
|
||||||
error: new NamedError.Unknown({
|
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(),
|
}).toObject(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return yield* Effect.failCause(exit.cause)
|
return yield* Effect.die(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
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 same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
|
||||||
const full =
|
const full =
|
||||||
!input.variant && ag.variant && same
|
!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
|
: undefined
|
||||||
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
|
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { pathToFileURL } from "url"
|
|||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
|
import { ConfigPaths } from "../../src/config/paths"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
@@ -29,6 +30,7 @@ const configLayer = Config.layer.pipe(
|
|||||||
Layer.provide(AuthTest.empty),
|
Layer.provide(AuthTest.empty),
|
||||||
Layer.provide(AccountTest.empty),
|
Layer.provide(AccountTest.empty),
|
||||||
Layer.provide(NpmTest.noop),
|
Layer.provide(NpmTest.noop),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
)
|
)
|
||||||
const pluginLayer = Plugin.layer.pipe(
|
const pluginLayer = Plugin.layer.pipe(
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
|
|||||||
@@ -64,6 +64,23 @@ describe("cli.error", () => {
|
|||||||
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
|
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", () => {
|
test("formats cancelled UI errors as empty output", () => {
|
||||||
expect(FormatError(new UI.CancelledError())).toBe("")
|
expect(FormatError(new UI.CancelledError())).toBe("")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Effect, Layer, Option } from "effect"
|
|||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { ConfigManaged } from "@/config/managed"
|
import { ConfigManaged } from "@/config/managed"
|
||||||
|
import { ConfigPaths } from "@/config/paths"
|
||||||
import { ConfigParse } from "../../src/config/parse"
|
import { ConfigParse } from "../../src/config/parse"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ const layer = Config.layer.pipe(
|
|||||||
Layer.provide(emptyAccount),
|
Layer.provide(emptyAccount),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
Layer.provide(noopNpm),
|
Layer.provide(noopNpm),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const it = testEffect(layer)
|
const it = testEffect(layer)
|
||||||
@@ -548,6 +550,7 @@ test("resolves env templates in account config with account token", async () =>
|
|||||||
Layer.provide(fakeAccount),
|
Layer.provide(fakeAccount),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
Layer.provide(noopNpm),
|
Layer.provide(noopNpm),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1059,6 +1062,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||||||
Layer.provide(emptyAccount),
|
Layer.provide(emptyAccount),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
Layer.provide(noopNpm),
|
Layer.provide(noopNpm),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1944,6 +1948,7 @@ test("project config overrides remote well-known config", async () => {
|
|||||||
Layer.provide(emptyAccount),
|
Layer.provide(emptyAccount),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
Layer.provide(noopNpm),
|
Layer.provide(noopNpm),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2002,6 +2007,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
|||||||
Layer.provide(emptyAccount),
|
Layer.provide(emptyAccount),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
Layer.provide(noopNpm),
|
Layer.provide(noopNpm),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2074,6 +2080,7 @@ test("wellknown remote_config supports templated env vars in headers", async ()
|
|||||||
Layer.provide(emptyAccount),
|
Layer.provide(emptyAccount),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
Layer.provide(noopNpm),
|
Layer.provide(noopNpm),
|
||||||
|
Layer.provide(ConfigPaths.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -749,30 +749,39 @@ describe("workspace CRUD", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => {
|
it.instance(
|
||||||
await withInstance(async (dir) => {
|
"remove deletes the workspace, associated sessions, adapter resources, and status",
|
||||||
const type = unique("remove-local")
|
() => {
|
||||||
const recorded = localAdapter(path.join(dir, "remove-local"))
|
return Effect.gen(function* () {
|
||||||
registerAdapter(Instance.project.id, type, recorded.adapter)
|
const { directory: dir } = yield* TestInstance
|
||||||
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
|
const instance = yield* InstanceRef
|
||||||
const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
if (!instance) return yield* Effect.die(new Error("missing test instance"))
|
||||||
const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
const workspace = yield* Workspace.Service
|
||||||
attachSessionToWorkspace(one.id, info.id)
|
const sessionSvc = yield* SessionNs.Service
|
||||||
attachSessionToWorkspace(two.id, info.id)
|
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(removed).toEqual(info)
|
||||||
expect(await getWorkspace(info.id)).toBeUndefined()
|
expect(yield* workspace.get(info.id)).toBeUndefined()
|
||||||
expect(recorded.calls.remove).toEqual([info])
|
expect(recorded.calls.remove).toEqual([info])
|
||||||
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
|
expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
|
||||||
expect(
|
expect(
|
||||||
Database.use((db) =>
|
Database.use((db) =>
|
||||||
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(),
|
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(),
|
||||||
),
|
),
|
||||||
).toEqual([])
|
).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
test("remove still deletes the row when the adapter cannot remove resources", async () => {
|
test("remove still deletes the row when the adapter cannot remove resources", async () => {
|
||||||
await withInstance(async () => {
|
await withInstance(async () => {
|
||||||
@@ -797,84 +806,111 @@ describe("workspace CRUD", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
|
it.instance(
|
||||||
await withInstance(async (dir) => {
|
"sessionWarp moves a session into a local workspace and claims ownership",
|
||||||
const previousType = unique("warp-prev-local")
|
() => {
|
||||||
const targetType = unique("warp-target-local")
|
return Effect.gen(function* () {
|
||||||
const previous = workspaceInfo(Instance.project.id, previousType)
|
const { directory: dir } = yield* TestInstance
|
||||||
const target = workspaceInfo(Instance.project.id, targetType)
|
const instance = yield* InstanceRef
|
||||||
insertWorkspace(previous)
|
if (!instance) return yield* Effect.die(new Error("missing test instance"))
|
||||||
insertWorkspace(target)
|
const workspace = yield* Workspace.Service
|
||||||
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
|
const sessionSvc = yield* SessionNs.Service
|
||||||
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
|
const previousType = unique("warp-prev-local")
|
||||||
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
const targetType = unique("warp-target-local")
|
||||||
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(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(
|
expect(
|
||||||
Database.use((db) =>
|
Database.use((db) =>
|
||||||
db
|
db
|
||||||
.select({ workspaceID: SessionTable.workspace_id })
|
.select({ workspaceID: SessionTable.workspace_id })
|
||||||
.from(SessionTable)
|
.from(SessionTable)
|
||||||
.where(eq(SessionTable.id, session.id))
|
.where(eq(SessionTable.id, session.id))
|
||||||
.get(),
|
.get(),
|
||||||
)?.workspaceID,
|
)?.workspaceID,
|
||||||
).toBe(target.id)
|
).toBe(target.id)
|
||||||
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
expect(sessionSequenceOwner(session.id)).toBe(target.id)
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
test("sessionWarp applies source workspace patch to local target workspace", async () => {
|
it.instance(
|
||||||
await withInstance(async (dir) => {
|
"sessionWarp applies source workspace patch to local target workspace",
|
||||||
const previousType = unique("warp-patch-prev-local")
|
() => {
|
||||||
const targetType = unique("warp-patch-target-local")
|
return Effect.gen(function* () {
|
||||||
const previousDir = path.join(dir, "warp-patch-prev-local")
|
const { directory: dir } = yield* TestInstance
|
||||||
const targetDir = path.join(dir, "warp-patch-target-local")
|
const instance = yield* InstanceRef
|
||||||
await initGitRepo(previousDir)
|
if (!instance) return yield* Effect.die(new Error("missing test instance"))
|
||||||
await initGitRepo(targetDir)
|
const workspace = yield* Workspace.Service
|
||||||
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")
|
const sessionSvc = yield* SessionNs.Service
|
||||||
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n")
|
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 previous = workspaceInfo(instance.project.id, previousType)
|
||||||
const target = workspaceInfo(Instance.project.id, targetType)
|
const target = workspaceInfo(instance.project.id, targetType)
|
||||||
insertWorkspace(previous)
|
insertWorkspace(previous)
|
||||||
insertWorkspace(target)
|
insertWorkspace(target)
|
||||||
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
|
registerAdapter(instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
|
||||||
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
|
registerAdapter(instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
|
||||||
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
const session = yield* sessionSvc.create({})
|
||||||
attachSessionToWorkspace(session.id, previous.id)
|
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(yield* Effect.promise(() => 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, "new.txt"), "utf8"))).toBe("new\n")
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
|
it.instance(
|
||||||
await withInstance(async (dir) => {
|
"sessionWarp detaches a session to the local project and claims project ownership",
|
||||||
const previousType = unique("warp-detach-local")
|
() => {
|
||||||
const previous = workspaceInfo(Instance.project.id, previousType)
|
return Effect.gen(function* () {
|
||||||
insertWorkspace(previous)
|
const { directory: dir } = yield* TestInstance
|
||||||
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
|
const instance = yield* InstanceRef
|
||||||
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
|
if (!instance) return yield* Effect.die(new Error("missing test instance"))
|
||||||
attachSessionToWorkspace(session.id, previous.id)
|
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(
|
expect(
|
||||||
Database.use((db) =>
|
Database.use((db) =>
|
||||||
db
|
db
|
||||||
.select({ workspaceID: SessionTable.workspace_id })
|
.select({ workspaceID: SessionTable.workspace_id })
|
||||||
.from(SessionTable)
|
.from(SessionTable)
|
||||||
.where(eq(SessionTable.id, session.id))
|
.where(eq(SessionTable.id, session.id))
|
||||||
.get(),
|
.get(),
|
||||||
)?.workspaceID,
|
)?.workspaceID,
|
||||||
).toBeNull()
|
).toBeNull()
|
||||||
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
|
expect(sessionSequenceOwner(session.id)).toBe(instance.project.id)
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => {
|
test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => {
|
||||||
await withInstance(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
|
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
|
||||||
expect(true).toBe(false) // Should not reach here
|
expect(true).toBe(false) // Should not reach here
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
expect(e.data.suggestions).toBeDefined()
|
expect(e.suggestions).toBeDefined()
|
||||||
expect(e.data.suggestions.length).toBeGreaterThan(0)
|
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
|
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
|
||||||
expect(true).toBe(false) // Should not reach here
|
expect(true).toBe(false) // Should not reach here
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
expect(e.data.suggestions).toBeDefined()
|
expect(e.suggestions).toBeDefined()
|
||||||
expect(e.data.suggestions).toContain("anthropic")
|
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")
|
throw new Error("expected model lookup to fail")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!Provider.ModelNotFoundError.isInstance(e)) throw 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", () => {
|
describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
|
||||||
const createModel = (apiId: string) =>
|
const createModel = (apiId: string) => {
|
||||||
({
|
const model = {
|
||||||
id: `openai/${apiId}`,
|
id: `openai/${apiId}`,
|
||||||
providerID: "openai",
|
providerID: "openai",
|
||||||
api: {
|
api: {
|
||||||
@@ -3611,13 +3611,43 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
|
|||||||
url: "https://api.openai.com",
|
url: "https://api.openai.com",
|
||||||
npm: "@ai-sdk/openai",
|
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 [
|
for (const testCase of [
|
||||||
{ id: "gpt-5-chat-latest", options: { store: false } },
|
{ 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.1-chat-latest",
|
||||||
{ id: "gpt-5-search-api", options: { store: false } },
|
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`, () => {
|
test(`${testCase.id} returns only supported small options`, () => {
|
||||||
expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.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", () => {
|
describe("ProviderTransform.smallOptions - google thinking controls", () => {
|
||||||
const createGoogleModel = (apiId: string) =>
|
const createGoogleModel = (apiId: string) => {
|
||||||
({
|
const model = {
|
||||||
id: `google/${apiId}`,
|
id: `google/${apiId}`,
|
||||||
providerID: "google",
|
providerID: "google",
|
||||||
api: {
|
api: {
|
||||||
@@ -3635,20 +3665,44 @@ describe("ProviderTransform.smallOptions - google thinking controls", () => {
|
|||||||
url: "https://generativelanguage.googleapis.com",
|
url: "https://generativelanguage.googleapis.com",
|
||||||
npm: "@ai-sdk/google",
|
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 [
|
for (const testCase of [
|
||||||
{ id: "gemini-3-pro-preview", options: { thinkingConfig: { thinkingLevel: "low" } } },
|
{ id: "gemini-3-pro-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } } },
|
||||||
{ id: "gemini-3-flash-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } },
|
{ id: "gemini-3-flash-preview", options: { thinkingConfig: { includeThoughts: true, 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-3.1-flash-image-preview",
|
||||||
{ id: "gemini-2.5-pro", options: { thinkingConfig: { thinkingBudget: 128 } } },
|
options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" } },
|
||||||
{ id: "gemini-2.5-flash", options: { thinkingConfig: { thinkingBudget: 0 } } },
|
},
|
||||||
|
{ 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`, () => {
|
test(`${testCase.id} returns supported small thinking options`, () => {
|
||||||
expect(ProviderTransform.smallOptions(createGoogleModel(testCase.id))).toEqual(testCase.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", () => {
|
describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.14.48",
|
"version": "1.14.49",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user