diff --git a/bun.lock b/bun.lock index 483f551d31..c700ba66ec 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -81,7 +81,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -115,7 +115,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -142,7 +142,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -166,7 +166,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -190,7 +190,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -223,7 +223,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -266,7 +266,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -295,7 +295,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -311,7 +311,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.1", + "version": "1.4.2", "bin": { "opencode": "./bin/opencode", }, @@ -447,7 +447,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -481,7 +481,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "cross-spawn": "catalog:", }, @@ -496,7 +496,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -531,7 +531,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -580,7 +580,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "zod": "catalog:", }, @@ -591,7 +591,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index a052793d82..3e12c492b6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.1", + "version": "1.4.2", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 786c2baeda..dc3362e979 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 5184c2fc0a..9059734ccd 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.4.1", + "version": "1.4.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b34fa73777..d8f0e0c0e2 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.1", + "version": "1.4.2", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 04a0a06bae..0976bb3e21 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index f8274a4759..48c17d0df2 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 016a205bdb..943cbeb203 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index c9d98dc039..10a909c8c6 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.1", + "version": "1.4.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7c49722a58..95fc2b1c53 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.4.1" +version = "1.4.2" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index baeee69438..dd54e9c092 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.1", + "version": "1.4.2", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f842a97bf5..934ef0869c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.1", + "version": "1.4.2", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c45b9e55d0..41e498102e 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async () => {}, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 18335b6e5f..94308bb034 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -189,7 +189,7 @@ export function Session() { const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) - const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true) + const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1952e3b572..ff79b739fe 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -399,6 +399,10 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), + redirectUri: z + .string() + .optional() + .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) .strict() .meta({ diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index cb12b4c52b..38c45a6342 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,10 +1,10 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: Runner.State readonly busy: boolean readonly ensureRunning: (work: Effect.Effect) => Effect.Effect - readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + readonly startShell: (work: Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } @@ -20,7 +20,6 @@ export namespace Runner { interface ShellHandle { id: number fiber: Fiber.Fiber - abort: AbortController } interface PendingHandle { @@ -100,13 +99,7 @@ export namespace Runner { }), ).pipe(Effect.flatten) - const stopShell = (shell: ShellHandle) => - Effect.gen(function* () { - shell.abort.abort() - const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis")) - if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber) - yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid) - }) + const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( @@ -138,7 +131,7 @@ export namespace Runner { ), ) - const startShell = (work: (signal: AbortSignal) => Effect.Effect) => + const startShell = (work: Effect.Effect) => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { @@ -153,9 +146,8 @@ export namespace Runner { } yield* busy const id = next() - const abort = new AbortController() - const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber, abort } satisfies ShellHandle + const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) + const shell = { id, fiber } satisfies ShellHandle return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 45e3e2567a..3196c87768 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -286,6 +286,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -716,13 +717,16 @@ export namespace MCP { if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) - yield* Effect.promise(() => McpOAuthCallback.ensureRunning()) + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined + + // Start the callback server with custom redirectUri if configured + yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)) const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") yield* auth.updateOAuthState(mcpName, oauthState) - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, @@ -731,6 +735,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index dd1d886fc1..b5b6a7a6eb 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,10 +1,14 @@ import { createConnection } from "net" import { createServer } from "http" import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) +// Current callback server configuration (may differ from defaults if custom redirectUri is used) +let currentPort = OAUTH_CALLBACK_PORT +let currentPath = OAUTH_CALLBACK_PATH + const HTML_SUCCESS = ` @@ -71,9 +75,9 @@ export namespace McpOAuthCallback { } function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { - const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`) + const url = new URL(req.url || "/", `http://localhost:${currentPort}`) - if (url.pathname !== OAUTH_CALLBACK_PATH) { + if (url.pathname !== currentPath) { res.writeHead(404) res.end("Not found") return @@ -135,19 +139,31 @@ export namespace McpOAuthCallback { res.end(HTML_SUCCESS) } - export async function ensureRunning(): Promise { + export async function ensureRunning(redirectUri?: string): Promise { + // Parse the redirect URI to get port and path (uses defaults if not provided) + const { port, path } = parseRedirectUri(redirectUri) + + // If server is running on a different port/path, stop it first + if (server && (currentPort !== port || currentPath !== path)) { + log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) + await stop() + } + if (server) return - const running = await isPortInUse() + const running = await isPortInUse(port) if (running) { - log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server already running on another instance", { port }) return } + currentPort = port + currentPath = path + server = createServer(handleRequest) await new Promise((resolve, reject) => { - server!.listen(OAUTH_CALLBACK_PORT, () => { - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + server!.listen(currentPort, () => { + log.info("oauth callback server started", { port: currentPort, path: currentPath }) resolve() }) server!.on("error", reject) @@ -182,9 +198,9 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(): Promise { + export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { return new Promise((resolve) => { - const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1") + const socket = createConnection(port, "127.0.0.1") socket.on("connect", () => { socket.destroy() resolve(true) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index b4da73169e..d675fc71e4 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -17,6 +17,7 @@ export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string + redirectUri?: string } export interface McpOAuthCallbacks { @@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { + if (this.config.redirectUri) { + return this.config.redirectUri + } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider { } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } + +/** + * Parse a redirect URI to extract port and path for the callback server. + * Returns defaults if the URI can't be parsed. + */ +export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { + if (!redirectUri) { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } + + try { + const url = new URL(redirectUri) + const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 + const path = url.pathname || OAUTH_CALLBACK_PATH + return { port, path } + } catch { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } +} diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index bdeef9823f..1e127fae54 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "gpt-5.4", "gpt-5.4-mini", ]) - for (const modelId of Object.keys(provider.models)) { + for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue - if (allowedModels.has(modelId)) continue + if (allowedModels.has(model.api.id)) continue delete provider.models[modelId] } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 23f61d804e..2d787588b0 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -22,6 +22,27 @@ export namespace ModelsDev { ) const ttl = 5 * 60 * 1000 + type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] + + const JsonValue: z.ZodType = z.lazy(() => + z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]), + ) + + const Cost = z.object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + export const Model = z.object({ id: z.string(), name: z.string(), @@ -41,22 +62,7 @@ export namespace ModelsDev { .strict(), ]) .optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - .optional(), + cost: Cost.optional(), limit: z.object({ context: z.number(), input: z.number().optional(), @@ -68,7 +74,24 @@ export namespace ModelsDev { output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), }) .optional(), - experimental: z.boolean().optional(), + experimental: z + .object({ + modes: z + .record( + z.string(), + z.object({ + cost: Cost.optional(), + provider: z + .object({ + body: z.record(z.string(), JsonValue).optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .optional(), + }), + ) + .optional(), + }) + .optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(), provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8d5c9f2ced..004fb77f91 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -926,6 +926,28 @@ export namespace Provider { export class Service extends ServiceMap.Service()("@opencode/Provider") {} + function cost(c: ModelsDev.Model["cost"]): Model["cost"] { + const result: Model["cost"] = { + input: c?.input ?? 0, + output: c?.output ?? 0, + cache: { + read: c?.cache_read ?? 0, + write: c?.cache_write ?? 0, + }, + } + if (c?.context_over_200k) { + result.experimentalOver200K = { + cache: { + read: c.context_over_200k.cache_read ?? 0, + write: c.context_over_200k.cache_write ?? 0, + }, + input: c.context_over_200k.input, + output: c.context_over_200k.output, + } + } + return result + } + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const m: Model = { id: ModelID.make(model.id), @@ -940,24 +962,7 @@ export namespace Provider { status: model.status ?? "active", headers: {}, options: {}, - cost: { - input: model.cost?.input ?? 0, - output: model.cost?.output ?? 0, - cache: { - read: model.cost?.cache_read ?? 0, - write: model.cost?.cache_write ?? 0, - }, - experimentalOver200K: model.cost?.context_over_200k - ? { - cache: { - read: model.cost.context_over_200k.cache_read ?? 0, - write: model.cost.context_over_200k.cache_write ?? 0, - }, - input: model.cost.context_over_200k.input, - output: model.cost.context_over_200k.output, - } - : undefined, - }, + cost: cost(model.cost), limit: { context: model.limit.context, input: model.limit.input, @@ -994,13 +999,31 @@ export namespace Provider { } export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { + const models: Record = {} + for (const [key, model] of Object.entries(provider.models)) { + models[key] = fromModelsDevModel(provider, model) + for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { + const id = `${model.id}-${mode}` + const m = fromModelsDevModel(provider, model) + m.id = ModelID.make(id) + m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}` + if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost)) + // convert body params to camelCase for ai sdk compatibility + if (opts.provider?.body) + m.options = Object.fromEntries( + Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]), + ) + if (opts.provider?.headers) m.headers = opts.provider.headers + models[id] = m + } + } return { id: ProviderID.make(provider.id), source: "custom", name: provider.name, env: provider.env ?? [], options: {}, - models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)), + models, } } diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 68be1a6a5a..b57ed9d47c 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -121,7 +121,6 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - log.info("SEARCH", { url: c.req.url }) const session = await Session.get(sessionID) return c.json(session) }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de962..8e1ed9dcc9 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -12,7 +12,7 @@ import { Installation } from "../installation" import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" import { SyncEvent } from "../sync" import type { SQL } from "../storage/db" -import { SessionTable } from "./session.sql" +import { PartTable, SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import { Log } from "../util/log" @@ -345,6 +345,11 @@ export namespace Session { messageID: MessageID partID: PartID }) => Effect.Effect + readonly getPart: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) => Effect.Effect readonly updatePart: (part: T) => Effect.Effect readonly updatePartDelta: (input: { sessionID: SessionID @@ -492,6 +497,29 @@ export namespace Session { return part }).pipe(Effect.withSpan("Session.updatePart")) + const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { + const row = Database.use((db) => + db + .select() + .from(PartTable) + .where( + and( + eq(PartTable.session_id, input.sessionID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.id, input.partID), + ), + ) + .get(), + ) + if (!row) return + return { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + }) + const create = Effect.fn("Session.create")(function* (input?: { parentID?: SessionID title?: string @@ -675,6 +703,7 @@ export namespace Session { removeMessage, removePart, updatePart, + getPart, updatePartDelta, initialize, }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 78604fbf78..61c159646d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -751,16 +751,32 @@ export namespace MessageV2 { ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } - if (part.state.status === "error") - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: part.state.error, - ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), - }) + if (part.state.status === "error") { + const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined + if (typeof output === "string") { + assistantMessage.parts.push({ + type: ("tool-" + part.tool) as `tool-${string}`, + state: "output-available", + toolCallId: part.callID, + input: part.state.input, + output, + ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), + ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), + }) + } else { + assistantMessage.parts.push({ + type: ("tool-" + part.tool) as `tool-${string}`, + state: "output-error", + toolCallId: part.callID, + input: part.state.input, + errorText: part.state.error, + ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), + ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), + }) + } + } + // Handle pending/running tool calls to prevent dangling tool_use blocks + // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result if (part.state.status === "pending" || part.state.status === "running") assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 8e4225fed3..225961aef0 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -18,6 +18,7 @@ import { SessionStatus } from "./status" import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" +import { isRecord } from "@/util/record" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -175,12 +176,22 @@ export namespace SessionProcessor { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - const match = ctx.toolcalls[value.toolCallId] - if (!match) return + const pointer = ctx.toolcalls[value.toolCallId] + const match = yield* session.getPart({ + partID: pointer.id, + messageID: pointer.messageID, + sessionID: pointer.sessionID, + }) + if (!match || match.type !== "tool") return ctx.toolcalls[value.toolCallId] = yield* session.updatePart({ ...match, tool: value.toolName, - state: { status: "running", input: value.input, time: { start: Date.now() } }, + state: { + ...match.state, + status: "running", + input: value.input, + time: { start: Date.now() }, + }, metadata: match.metadata?.providerExecuted ? { ...value.providerMetadata, providerExecuted: true } : value.providerMetadata, @@ -236,6 +247,7 @@ export namespace SessionProcessor { case "tool-error": { const match = ctx.toolcalls[value.toolCallId] if (!match || match.state.status !== "running") return + yield* session.updatePart({ ...match, state: { @@ -351,7 +363,10 @@ export namespace SessionProcessor { }, { text: ctx.currentText.text }, )).text - ctx.currentText.time = { start: Date.now(), end: Date.now() } + { + const end = Date.now() + ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } + } if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata yield* session.updatePart(ctx.currentText) ctx.currentText = undefined @@ -398,19 +413,21 @@ export namespace SessionProcessor { } ctx.reasoningMap = {} - const parts = MessageV2.parts(ctx.assistantMessage.id) - for (const part of parts) { - if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue + for (const part of Object.values(ctx.toolcalls)) { + const end = Date.now() + const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {} yield* session.updatePart({ ...part, state: { ...part.state, status: "error", error: "Tool execution aborted", - time: { start: Date.now(), end: Date.now() }, + metadata: { ...metadata, interrupted: true }, + time: { start: "time" in part.state ? part.state.time.start : end, end }, }, }) } + ctx.toolcalls = {} ctx.assistantMessage.time.completed = Date.now() yield* session.updateMessage(ctx.assistantMessage) }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e9bd5bcd56..19f0850ff4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -743,7 +743,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) - const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) { + const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) { const ctx = yield* InstanceState.context const session = yield* sessions.get(input.sessionID) if (session.revert) { @@ -1507,7 +1507,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Effect.promise(() => SystemPrompt.skills(agent)), Effect.promise(() => SystemPrompt.environment(model)), instruction.system().pipe(Effect.orDie), - Effect.promise(() => MessageV2.toModelMessages(msgs, model)), + MessageV2.toModelMessagesEffect(msgs, model), ]) const system = [...env, ...(skills ? [skills] : []), ...instructions] const format = lastUser.format ?? { type: "text" as const } @@ -1577,7 +1577,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the function* (input: ShellInput) { const s = yield* InstanceState.get(state) const runner = getRunner(s.runners, input.sessionID) - return yield* runner.startShell((signal) => shellImpl(input, signal)) + return yield* runner.startShell(shellImpl(input)) }, ) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 73b55a2fba..b97b53bb9f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,6 +9,7 @@ import { SessionPrompt } from "../session/prompt" import { Config } from "../config/config" import { Permission } from "@/permission" import { Effect } from "effect" +import { Log } from "@/util/log" const id = "task" diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 9dc395876e..a91df76ebf 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -250,7 +250,7 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) + const result = yield* runner.startShell(Effect.succeed("shell-done")) expect(result).toBe("shell-done") expect(runner.busy).toBe(false) }), @@ -264,7 +264,7 @@ describe("Runner", () => { const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel @@ -279,12 +279,10 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* Deferred.succeed(gate, undefined) @@ -302,37 +300,26 @@ describe("Runner", () => { }, }) - const sh = yield* runner - .startShell((signal) => - Effect.promise( - () => - new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - }), - ), - ) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel const done = yield* Fiber.await(sh) - expect(Exit.isSuccess(done)).toBe(true) + expect(Exit.isFailure(done)).toBe(true) }), ) it.live( - "cancel interrupts shell that ignores abort signal", + "cancel interrupts shell", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ignored"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const stop = yield* runner.cancel.pipe(Effect.forkChild) @@ -356,9 +343,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.state._tag).toBe("Shell") @@ -384,9 +369,7 @@ describe("Runner", () => { const calls = yield* Ref.make(0) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const work = Effect.gen(function* () { @@ -414,16 +397,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((signal) => - Effect.promise( - () => - new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - }), - ), - ) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) @@ -478,7 +452,7 @@ describe("Runner", () => { const runner = Runner.make(s, { onBusy: Ref.update(count, (n) => n + 1), }) - yield* runner.startShell((_signal) => Effect.succeed("done")) + yield* runner.startShell(Effect.succeed("done")) expect(yield* Ref.get(count)).toBe(1) }), ) @@ -509,9 +483,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const fiber = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok"))) - .pipe(Effect.forkChild) + const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.busy).toBe(true) diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts new file mode 100644 index 0000000000..58a4fa8c86 --- /dev/null +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -0,0 +1,34 @@ +import { test, expect, describe, afterEach } from "bun:test" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { parseRedirectUri } from "../../src/mcp/oauth-provider" + +describe("parseRedirectUri", () => { + test("returns defaults when no URI provided", () => { + const result = parseRedirectUri() + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) + + test("parses port and path from URI", () => { + const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") + expect(result.port).toBe(8080) + expect(result.path).toBe("/oauth/callback") + }) + + test("returns defaults for invalid URI", () => { + const result = parseRedirectUri("not-a-valid-url") + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) +}) + +describe("McpOAuthCallback.ensureRunning", () => { + afterEach(async () => { + await McpOAuthCallback.stop() + }) + + test("starts server with custom redirectUri port and path", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 88e9ea64c9..9cadc391a1 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" import { Plugin } from "../../src/plugin/index" +import { ModelsDev } from "../../src/provider/models" import { Provider } from "../../src/provider/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" @@ -1823,6 +1824,73 @@ test("custom model inherits api.url from models.dev provider", async () => { }) }) +test("mode cost preserves over-200k pricing from base model", () => { + const provider = { + id: "openai", + name: "OpenAI", + env: [], + api: "https://api.openai.com/v1", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + release_date: "2026-03-05", + attachment: true, + reasoning: true, + temperature: false, + tool_call: true, + cost: { + input: 2.5, + output: 15, + cache_read: 0.25, + context_over_200k: { + input: 5, + output: 22.5, + cache_read: 0.5, + }, + }, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + experimental: { + modes: { + fast: { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + }, + provider: { + body: { + service_tier: "priority", + }, + }, + }, + }, + }, + }, + }, + } as ModelsDev.Provider + + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] + expect(model.cost.input).toEqual(5) + expect(model.cost.output).toEqual(30) + expect(model.cost.cache.read).toEqual(0.5) + expect(model.cost.cache.write).toEqual(0) + expect(model.options["serviceTier"]).toEqual("priority") + expect(model.cost.experimentalOver200K).toEqual({ + input: 5, + output: 22.5, + cache: { + read: 0.5, + write: 0, + }, + }) +}) + test("model variants are generated for reasoning models", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 3634d6fb7e..64a5d3e4b2 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -570,6 +570,81 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("forwards partial bash output for aborted tool calls", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + const output = [ + "31403", + "12179", + "4575", + "", + "", + "User aborted the command", + "", + ].join("\n") + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "error", + input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" }, + error: "Tool execution aborted", + metadata: { interrupted: true, output }, + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "text", value: output }, + }, + ], + }, + ]) + }) + test("filters assistant messages with non-abort errors", async () => { const assistantID = "m-assistant" diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 86149272bc..a3b335b6da 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -21,7 +21,7 @@ import { Log } from "../../src/util/log" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { reply, TestLLMServer } from "../lib/llm-server" +import { raw, reply, TestLLMServer } from "../lib/llm-server" Log.init({ print: false }) @@ -218,6 +218,93 @@ it.live("session.processor effect tests capture llm input cleanly", () => ), ) +it.live("session.processor effect tests preserve text start time", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const gate = defer() + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ + head: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + }, + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { content: "hello" } }], + }, + ], + wait: gate.promise, + tail: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "stop" }], + }, + ], + }), + ) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "hi") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const run = yield* handle + .process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "hi" }], + tools: {}, + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(async () => { + const stop = Date.now() + 500 + while (Date.now() < stop) { + const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text") + if (text?.time?.start) return + await Bun.sleep(10) + } + throw new Error("timed out waiting for text part") + }) + yield* Effect.sleep("20 millis") + gate.resolve() + + const exit = yield* Fiber.await(run) + const text = MessageV2.parts(msg.id).find((part): part is MessageV2.TextPart => part.type === "text") + + expect(Exit.isSuccess(exit)).toBe(true) + expect(text?.text).toBe("hello") + expect(text?.time?.start).toBeDefined() + expect(text?.time?.end).toBeDefined() + if (!text?.time?.start || !text.time.end) return + expect(text.time.start).toBeLessThan(text.time.end) + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests stop after token overflow requests compaction", () => provideTmpdirServer( ({ dir, llm }) => @@ -604,6 +691,7 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup expect(call?.state.status).toBe("error") if (call?.state.status === "error") { expect(call.state.error).toBe("Tool execution aborted") + expect(call.state.metadata?.interrupted).toBe(true) expect(call.state.time.end).toBeDefined() } }), diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0b52bbd47c..adfee9c65c 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a3aa709a71..3dbab2c54a 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4f226e60cf..38f4db09a3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1375,6 +1375,10 @@ export type McpOAuthConfig = { * OAuth scopes to request during authorization */ scope?: string + /** + * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). + */ + redirectUri?: string } export type McpRemoteConfig = { diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts index d514784bc2..9615eacc7a 100644 --- a/packages/sdk/js/src/v2/index.ts +++ b/packages/sdk/js/src/v2/index.ts @@ -6,7 +6,6 @@ import { createOpencodeServer } from "./server.js" import type { ServerOptions } from "./server.js" export * as data from "./data.js" -import * as data from "./data.js" export async function createOpencode(options?: ServerOptions) { const server = await createOpencodeServer({ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a0672df2d7..366bc1fc79 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10882,6 +10882,10 @@ "scope": { "description": "OAuth scopes to request during authorization", "type": "string" + }, + "redirectUri": { + "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", + "type": "string" } }, "additionalProperties": false diff --git a/packages/slack/package.json b/packages/slack/package.json index 4e3e54800b..d23b740cda 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 8de6ea0d43..01c6fae869 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 105098595e..53c170f144 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.4.1", + "version": "1.4.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index de36ca6574..d7f627c57a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.4.1", + "version": "1.4.2", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index afe506d0ea..1d0472d787 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.4.1", + "version": "1.4.2", "publisher": "sst-dev", "repository": { "type": "git",