Compare commits

..
Author SHA1 Message Date
usrnk1 b1a3fad739 test(desktop): cover Console onboarding 2026-09-11 16:38:56 +02:00
usrnk1 e249302f2c feat(desktop): focus window after Console auth 2026-09-11 16:38:55 +02:00
usrnk1 5245303289 feat(app): group Console providers in settings 2026-09-11 16:38:55 +02:00
usrnk1 d027b93b19 feat(desktop): add Console onboarding flow 2026-09-11 16:38:55 +02:00
Shoubhit Dash 2df00955cb fix(cli): preserve ACP reasoning boundaries and effort selections (#48493) 2026-09-11 18:46:54 +05:30
opencode-agent[bot]andnexxeln e0deffa083 docs(plugin): fix Effect system part example (#48492)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-11 18:12:11 +05:30
Wu Shuwen 4e97b78d98 docs: fix v2 plugin system example (#48452) 2026-09-11 18:12:01 +05:30
Brendan Allan 6bb4b35399 fix(app): preserve loopback server host (#44296) 2026-09-11 06:41:38 +00:00
Brendan Allan 0ce1383030 fix(app): preserve file search results while loading (#43832) 2026-09-11 06:13:32 +00:00
Brendan AllanandBrendonovich 010cd6131e fix(app): merge adjacent file-changing tools (#44977)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-09-11 05:58:02 +00:00
Brendan Allan c2348f8f69 fix(app): coordinate notifications across windows (#44613) 2026-09-11 05:28:13 +00:00
Aiden Cline 71317ec7c9 fix(core): report provider initialization failures (#48433) 2026-09-11 00:26:55 -05:00
opencode-agent[bot]andBrendonovich 4261fe749d fix(desktop): repair SSH server updates (#48442)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-11 13:24:38 +08:00
Aiden Cline 9dd7149e75 feat(core): append comment guidance to Anthropic system prompt (#48444) 2026-09-11 00:05:05 -05:00
Brendan Allan 0c0a431c9f fix(ui): make comment cancel action ghost (#46742) 2026-09-11 13:03:49 +08:00
Brendan Allan 3368e049d2 fix(app): refine home row hover actions (#47270) 2026-09-11 04:39:26 +00:00
Aiden Cline f0b5da1c11 feat(ai): type provider configuration errors (#48440) 2026-09-10 23:29:55 -05:00
Aiden Cline 15c525dcfb fix(merman): support & node groups in flowchart statements (#48425) 2026-09-10 22:24:59 -05:00
James Long 4d12e01824 refactor(tui): project production subgroups through tree engine (#48399) 2026-09-10 23:15:58 -04:00
James Long 70afbac80c docs(www): add core CLI theme concepts (#48426) 2026-09-10 23:13:53 -04:00
James Long 1c723c56fa feat(tui): add recursive grouping tree (#48394) 2026-09-10 21:40:23 -04:00
Aiden Cline 181428a2f3 fix(codemode): use Bun's wording for the missing atob/btoa argument (#48381) 2026-09-10 20:08:36 -05:00
Dax Raad 9b1891fb7e docs(cli): expand provider connections 2026-09-10 20:43:17 -04:00
Dax Raad d4bf78b348 docs: add websearch guides 2026-09-10 20:30:58 -04:00
Dax Raad d4ceffe787 docs: refresh v2 guides 2026-09-10 20:12:08 -04:00
Aiden Cline 872e38055e fix(ai): normalize flat Responses stream errors (#48376) 2026-09-10 18:23:32 -05:00
James Long 0c1dfa9186 refactor(tui): extract shared session rendering primitives (#48393) 2026-09-10 18:07:14 -04:00
Dax Raad 8f4d706647 feat(cli): add command docs and simplify session list 2026-09-10 16:27:12 -04:00
Aiden Cline 929374cdfd feat(core): parse JSON text results from MCP tools without an output schema (#48357) 2026-09-10 15:20:13 -05:00
opencode-agent[bot] cfa5ba700e fix(stats): canonicalize DeepSeek Flash usage (#48373) 2026-09-10 13:58:51 -05:00
Shoubhit Dash 45a2ed9a97 feat(core): per-session permission rules (#48351) 2026-09-10 22:30:53 +05:30
opencode-agent[bot]andnexxeln f6333546f8 fix(tui): disambiguate plugin actions (#48354)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-10 22:10:01 +05:30
opencode-agent[bot]andnexxeln 9e153ce7b3 fix(core): raise instruction entry limit (#48350)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-09-10 20:59:38 +05:30
Simon Klee eb357f17cf fix(tui): strip NUL characters before clipboard writes (#48337) 2026-09-10 15:47:03 +02:00
Shoubhit Dash 573d76933f fix(ai): make xAI Responses websockets work and enable them (#48318) 2026-09-10 17:27:43 +05:30
usrnk1andBrendonovich bb8194395a feat(desktop): respect follow-up behavior for slash commands (#48169)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-10 19:24:23 +08:00
200 changed files with 8466 additions and 4019 deletions
@@ -18,7 +18,6 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
@@ -27,6 +26,7 @@ export interface Options {
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly continuation?: OpenResponsesContinuation.Shape
}
export interface Prepared {
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,6 +163,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -6,7 +6,6 @@ import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "open-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
@@ -15,12 +14,19 @@ interface CheckpointValue {
readonly output: ReadonlyArray<unknown>
}
/**
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
*/
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
readonly continuation?: Shape
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
@@ -127,22 +133,26 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
const fields = previous ? shape(request) : undefined
const delta = previous && fields ? incremental(request, previous) : undefined
if (!previous || !fields || !delta)
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -195,4 +205,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export const OpenResponsesContinuation = { driver } as const
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
+32 -4
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -325,9 +325,8 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
// event-level `error` envelope, so accept all three shapes here.
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -401,10 +400,39 @@ export const Event = Schema.StructWithRest(
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
),
)
export interface ProviderAdapter {
readonly id: string
readonly name: string
+8 -3
View File
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("alibaba")
@@ -82,8 +82,13 @@ export const configure = (input: Config) => {
? hosts.get(region)
: `${workspaceID}.${region}.maas.aliyuncs.com`
if (baseURL === undefined) {
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
if (region === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
if (host === undefined)
throw new ProviderConfigurationError({
provider: id,
message: `Alibaba region ${region} requires workspaceID or baseURL`,
})
}
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -79,9 +79,12 @@ const defaults = (input: Config) => {
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
throw new ProviderConfigurationError({
provider: id,
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
})
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
+4 -3
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
@@ -39,8 +39,9 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
if (auth === "sigv4" && apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
@@ -36,8 +36,12 @@ const auth = (input: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config) => {
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
const provider = input.provider ?? "anthropic-compatible"
if (!input.baseURL)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
...rest,
@@ -61,8 +65,13 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
// Read before the exclusivity check narrows a conflicting settings object to `never`.
const provider = ProviderID.make(settings.provider ?? id)
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
throw new ProviderConfigurationError({
provider,
message: "Anthropic-compatible apiKey cannot be combined with authToken",
})
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+5 -2
View File
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -57,7 +57,10 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
throw new ProviderConfigurationError({
provider: id,
message: "Anthropic apiKey cannot be combined with authToken",
})
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+2 -2
View File
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { ProviderShared } from "../protocols/shared.js"
@@ -163,7 +163,7 @@ const config = (settings: Settings): Config => {
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
throw new Error("Azure requires resourceName or baseURL")
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
}
export const responsesModel: ProviderPackage.Definition<
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-ai-gateway")
@@ -35,7 +35,11 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
})
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-workers-ai")
@@ -28,7 +28,11 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
})
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -37,7 +37,8 @@ const route = Route.make({
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -74,7 +75,8 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
@@ -67,7 +67,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new Error("Google Vertex Messages does not support API keys")
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -107,7 +107,8 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
@@ -39,7 +39,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new Error("Google Vertex Responses does not support API keys")
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -79,7 +79,8 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -1,8 +1,10 @@
import type { AnyAuthClient } from "google-auth-library"
import { Effect, Redacted } from "effect"
import { Auth, MissingCredentialError } from "../route/auth.js"
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
const id = ProviderID.make("google-vertex")
export type OAuthOptions =
| { readonly accessToken?: string; readonly auth?: never }
@@ -35,12 +37,18 @@ export const host = (location: string) => {
export const requireProject = (value: string | undefined) => {
if (value) return value
throw new Error("Google Vertex requires a project when baseURL is not configured")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex requires a project when baseURL is not configured",
})
}
export const apiKey = (input: ApiKeyOptions) => {
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
}
@@ -68,7 +76,10 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new Error("Google Vertex accessToken cannot be combined with auth")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex accessToken cannot be combined with auth",
})
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
+9 -3
View File
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export interface GeminiOptionsInput extends Gemini.OptionsInput {
@@ -93,7 +93,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
const apiKey = GoogleVertexShared.apiKey(input)
const endpointModel = String(modelID).startsWith("endpoints/")
if (apiKey !== undefined && endpointModel)
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex tuned models do not support Express Mode API keys",
})
const location = GoogleVertexShared.location(inputLocation, "us-central1")
const project = GoogleVertexShared.project(inputProject)
const endpoint =
@@ -123,7 +126,10 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
+4
View File
@@ -41,6 +41,10 @@ const responsesRoute = Route.make({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
}),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
+5 -1
View File
@@ -23,6 +23,7 @@ import {
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
ProviderConfigurationError,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -128,7 +129,10 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
})
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
+13
View File
@@ -50,6 +50,19 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
route: Schema.optional(RouteID),
}) {}
/**
* Provider settings that are missing, conflicting, or unsupported, such as
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
* provider facade or package entrypoint configures a model, before any
* request exists, so it is not an `AIError` reason.
*/
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
"AI.Error.ProviderConfiguration",
)("ProviderConfiguration", {
provider: ProviderID,
message: Schema.String,
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
+16 -11
View File
@@ -3,6 +3,9 @@ import { model } from "@opencode/ai/providers/openai"
import { LLM } from "../src/index.js"
import { Endpoint } from "../src/route/endpoint.js"
const configuration = (provider: string, message: string) =>
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -322,7 +325,7 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow("Anthropic-compatible providers require a baseURL")
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
})
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
@@ -337,10 +340,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://messages.example.test/v1",
},
]),
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow("Anthropic apiKey cannot be combined with authToken")
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
})
test("maps legacy OpenAI organization and project settings to headers", () => {
@@ -490,43 +493,45 @@ describe("provider package entrypoints", () => {
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
{ accessToken: "token", auth: {}, project: "vertex-project" },
])
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() => configured.model("gemini-3.5-flash")).toThrow(
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
)
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Messages does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Messages does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Chat does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Chat does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Responses does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Responses does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
})
})
+7 -1
View File
@@ -76,7 +76,13 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
expect(() => Alibaba.configure({ region })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "alibaba",
message: `Alibaba region ${region} requires workspaceID or baseURL`,
}),
)
for (const config of [
{ baseURL: "https://gateway.example/prefix" },
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
@@ -1458,7 +1458,13 @@ describe("Bedrock Converse route", () => {
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "amazon-bedrock",
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
}),
)
}).pipe(
withProcessEnv({
...noAmbientAWS,
@@ -378,7 +378,11 @@ describe("Google Vertex providers", () => {
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "google-vertex",
message: "Google Vertex tuned models do not support Express Mode API keys",
}),
)
})
})
@@ -0,0 +1,78 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
"type": "error",
"sequence_number": 4,
"code": "server_shutting_down",
"message": "Server is shutting down. Please retry your request.",
"param": null,
"diagnostic": "retain-original-frame"
}`
for (const model of [
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
),
]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
expect(error.reason.body).toBe(raw)
expect(error.reason.http?.status).toBe(200)
}
}),
)
@@ -90,7 +90,11 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -98,6 +102,7 @@ const continuationDriver = (request: Readonly<Record<string, unknown>>, base = b
request,
message,
base: base(message),
continuation,
})
}
@@ -921,6 +926,58 @@ describe("OpenAI Responses route", () => {
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
const internal = ProviderShared.encodeJson({
type: "error",
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
})
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "ProviderInternal" } },
})
}),
)
it.effect("shapes the incremental send with the route continuation", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "grok-4.6",
store: true,
instructions: "You are terse.",
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const secondRequest = {
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
yield* continuationDriver(firstRequest).create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const trimmed = yield* continuationDriver(
secondRequest,
baseChannelDriver,
({ instructions: _, ...rest }) => rest,
).create(saved)
expect(trimmed.mode).toBe("incremental")
expect(JSON.parse(trimmed.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// Declining the continuation sends the step in full and never sends a previous_response_id.
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
expect(declined.mode).toBe("full")
expect(JSON.parse(declined.message)).toEqual(secondRequest)
}),
)
+110 -2
View File
@@ -1,11 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer, Stream } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import { LLMClient } from "../../src/route.js"
import {
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -13,6 +20,35 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
Effect.gen(function* () {
let driver: WebSocketChannelDriver | undefined
yield* LLMClient.generate(request, {
webSocket: {
execute: (exchange) =>
Effect.sync(() => {
driver = exchange.driver
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
if (!driver) throw new Error("Expected a WebSocket channel driver")
return driver
})
const completed = (driver: WebSocketChannelDriver, id: string) =>
Effect.gen(function* () {
const create = yield* driver.create(undefined)
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
const observation = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
)
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
return observation.checkpoint
})
describe("xAI Responses route", () => {
it.effect("composes the Open Responses baseline with xAI extensions", () =>
Effect.gen(function* () {
@@ -162,6 +198,78 @@ describe("xAI Responses route", () => {
}),
)
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
Effect.gen(function* () {
// xAI answers a rejected response.create with an error envelope that carries no event type.
const envelope = ProviderShared.encodeJson({
error: {
message:
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
type: "api_error",
},
})
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
})
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
expect(error.reason.body).toBe(envelope)
}),
)
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
Effect.gen(function* () {
const step = (store: boolean, ...prompts: string[]) =>
LLM.request({
model,
system: "You are terse.",
messages: prompts.map((prompt) => Message.user(prompt)),
providerOptions: { store },
})
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
expect(stored.mode).toBe("incremental")
expect(JSON.parse(stored.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
include: ["reasoning.encrypted_content"],
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// The connection cache only serves stored responses, so the default store: false never chains.
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
expect(unstored.mode).toBe("full")
expect(JSON.parse(unstored.message)).toMatchObject({
instructions: "You are terse.",
store: false,
input: [
{ role: "user", content: [{ type: "input_text", text: "First" }] },
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
],
})
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
@@ -19,11 +19,13 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("alert")).toBeVisible()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(dialog.getByRole("alert")).toBeVisible()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
@@ -43,6 +45,17 @@ story("adding a server keeps all SSH challenges in the original connection dialo
await expect(dialog).toHaveCount(0)
})
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
await mount("app-dialog-ssh--incompatible-host")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
})
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
@@ -0,0 +1,491 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/console-auth-project"
const location = { directory, project: { id: "proj_console", directory, canonical: directory } }
const provider = {
id: "opencode",
integrationID: "opencode",
name: "Anomaly / OpenCode",
activation: "enabled",
package: "@ai-sdk/openai-compatible",
}
const secondProvider = {
...provider,
id: "console-google",
name: "Anomaly / Google",
package: "@ai-sdk/google",
}
const model = {
id: "sonnet",
modelID: "sonnet",
providerID: provider.id,
name: "Console Sonnet",
enabled: true,
status: "active",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
cost: [],
time: { released: 1700000000000 },
limit: { context: 200000, output: 32000 },
}
const models = [
model,
...Array.from({ length: 18 }, (_, index) => ({
...model,
id: `model-${index + 2}`,
modelID: `model-${index + 2}`,
name: `Console Model ${index + 2}`,
})),
{ ...model, id: "gemini", modelID: "gemini", providerID: secondProvider.id, name: "Console Gemini" },
]
const integration = {
id: "opencode",
name: "OpenCode",
connections: [],
methods: [
{ id: "device", type: "oauth", label: "OpenCode Console account" },
{ type: "key", label: "API key (service account)" },
],
}
async function fixture(
page: Page,
remote = false,
options: {
draft?: boolean
browserFailed?: boolean
slowStart?: Promise<void>
existingConnection?: boolean
singleProvider?: boolean
} = {},
) {
const state = {
status: "pending",
starts: 0,
cancelled: [] as string[],
models: true,
modelError: false,
statusError: false,
}
const server = remote ? "http://production.example:4096" : undefined
const currentIntegration = {
...integration,
connections: options.existingConnection ? [{ type: "env", name: "OPENCODE_API_KEY" }] : [],
}
await mockOpenCodeServer(page, {
server,
directory,
provider: [],
sessions: [],
project: {
id: "proj_console",
canonical: directory,
name: "Console test",
time: { created: 1700000000000, updated: 1700000000000 },
},
pageMessages: () => ({ items: [] }),
})
await page
.context()
.route("https://console.example/**", (route) =>
route.fulfill({ contentType: "text/html", body: "<title>Console fixture</title><p>Authorize access</p>" }),
)
await page.route("**/api/integration**", async (route) => {
const request = route.request()
const path = new URL(request.url()).pathname
if (request.method() === "OPTIONS") return route.fallback()
const headers = { "access-control-allow-origin": "*" }
const json = (data: unknown) => route.fulfill({ headers, json: { location, data } })
if (path === "/api/integration") return json([currentIntegration])
if (path === "/api/integration/opencode") return json(currentIntegration)
if (path === "/api/integration/opencode/connect/oauth") {
expect(request.postDataJSON()).toEqual({ methodID: "device" })
state.starts++
if (options.slowStart) await options.slowStart
return json({
attemptID: `con_${state.starts}`,
mode: "auto",
instructions: "Confirmation code: TFXS-STXG",
url: "https://console.example/device?user_code=TFXS-STXG&client_id=opencode-cli",
time: { created: Date.now(), expires: Date.now() + 60000 },
})
}
if (path.includes("/connect/oauth/con_")) {
if (request.method() === "DELETE") {
state.cancelled.push(path.split("/").pop()!)
return route.fulfill({ status: 204, headers })
}
if (state.statusError) return route.fulfill({ status: 503, headers })
return json({
status: state.status,
...(state.status === "failed" ? { message: "Device authorization failed: access_denied" } : {}),
time: { created: 0, expires: Date.now() + 60000 },
})
}
return route.fallback()
})
await page.route("**/api/provider**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: {
location,
data: state.status === "complete" ? [provider, ...(options.singleProvider ? [] : [secondProvider])] : [],
},
})
})
await page.route("**/api/model**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.modelError) return route.fulfill({ status: 503, headers: { "access-control-allow-origin": "*" } })
const available = state.status === "complete" && state.models
return route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: {
location,
data: new URL(route.request().url()).pathname.endsWith("/default")
? available
? model
: null
: available
? options.singleProvider
? models.filter((model) => model.providerID === provider.id)
: models
: [],
},
})
})
await page.addInitScript(
({ directory, server }) => {
if (server) localStorage.setItem("opencode.settings.dat:defaultServerUrl", server)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: server ? [{ type: "http", displayName: "Production server", http: { url: server } }] : [],
projects: { [server ?? "local"]: [{ worktree: directory, expanded: true }] },
}),
)
},
{ directory, server },
)
const params = new URLSearchParams()
if (server) params.set("server", server)
if (options.browserFailed) params.set("browserFailed", "1")
await page.goto(`/e2e/desktop/index.html?${params}`)
const dialog = page.locator('[data-component="dialog-v2"]').getByRole("dialog")
if (options.draft) {
await page.keyboard.press("Control+t")
const composer = page.locator('[data-component="composer-editor"]')
await expect(composer).toBeEditable()
await composer.fill("Keep this draft throughout sign-in")
await expect(page.locator('[data-component="provider-setup"]')).toBeVisible()
await expect(page.locator('[data-component="new-session-tip"]')).toContainText("Connect to 75+ providers")
await page.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
return { state, dialog }
}
await page.getByRole("button", { name: "Settings", exact: true }).click()
await page.getByRole("tab", { name: "Providers", exact: true }).click()
// Use the picker so this also exercises the existing Settings entry point.
await page.getByRole("button", { name: "Show more providers", exact: true }).click()
await page
.getByRole("dialog")
.getByRole("button", { name: /^OpenCode / })
.click()
await expect(dialog.getByRole("button", { name: "Continue with OpenCode Console" })).toBeEnabled()
return { state, dialog }
}
test("Console account is primary and the code is displayed without a copy-code step", async ({ page }) => {
const { state, dialog } = await fixture(page)
await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console", exact: true })).toBeVisible()
await expect(dialog.getByText("Service account?", { exact: true })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Use API key", exact: true })).toBeVisible()
const shell = await dialog.boundingBox()
const back = await dialog.getByRole("button", { name: "Navigate back" }).boundingBox()
const heading = await dialog.getByRole("heading", { name: "Connect OpenCode Console" }).boundingBox()
const logo = await dialog.locator('[data-component="opencode-logo"]').boundingBox()
const description = await dialog
.getByText("Sign in once to use the models available through your OpenCode account.")
.boundingBox()
const primary = await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).boundingBox()
const service = await dialog.locator('[data-component="console-service-account"]').boundingBox()
if (!shell || !back || !heading || !logo || !description || !primary || !service)
throw new Error("Missing dialog layout")
expect(shell.height).toBe(512)
expect(back.x - shell.x).toBe(20)
expect(back.y - shell.y).toBe(16)
expect(heading.y - (back.y + back.height)).toBe(12)
expect(logo.y + logo.height / 2).toBe(heading.y + heading.height / 2)
expect(description.y - (heading.y + heading.height)).toBe(24)
expect(primary.y - (description.y + description.height)).toBe(20)
expect(service.y - (primary.y + primary.height)).toBe(20)
await page.screenshot({ path: test.info().outputPath("connect-console-light.png") })
const popup = page.waitForEvent("popup")
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
const consolePage = await popup
await expect(consolePage).toHaveURL(/user_code=TFXS-STXG/)
await expect(consolePage).toHaveURL(/client_id=opencode-desktop/)
await expect(consolePage).toHaveURL(/return_window=console-auth-fixture/)
await expect(
dialog.getByText("Continue in your browser. Confirm the code shown there matches the one below."),
).toBeVisible()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Copy sign-in link" })).toBeVisible()
const authHeading = await dialog.getByRole("heading", { name: "Connect OpenCode Console account" }).boundingBox()
const authDescription = await dialog
.getByText("Continue in your browser. Confirm the code shown there matches the one below.")
.boundingBox()
const label = await dialog.getByText("Device code", { exact: true }).boundingBox()
const code = await dialog.getByRole("group", { name: "Device code: TFXS-STXG" }).boundingBox()
const waiting = await dialog.getByRole("status").boundingBox()
const fallback = await dialog.locator('[data-component="console-browser-fallback"]').boundingBox()
const authShell = await dialog.boundingBox()
if (!authHeading || !authDescription || !label || !code || !waiting || !fallback || !authShell)
throw new Error("Missing authorization layout")
expect(authDescription.y - (authHeading.y + authHeading.height)).toBe(24)
expect(label.y - (authDescription.y + authDescription.height)).toBe(20)
expect(code.y - (label.y + label.height)).toBe(8)
expect(code.height).toBe(48)
expect(waiting.y - (code.y + code.height)).toBe(8)
expect(fallback.y - (waiting.y + waiting.height)).toBe(20)
expect(authShell.height).toBeLessThan(512)
expect(authShell.y + authShell.height - (fallback.y + fallback.height)).toBe(16)
await page.screenshot({ path: test.info().outputPath("console-auth-light.png") })
await page.emulateMedia({ colorScheme: "dark" })
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
await page.screenshot({ path: test.info().outputPath("console-auth-dark.png") })
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
const list = dialog.getByRole("radiogroup", { name: "Models available from OpenCode" })
await expect(dialog.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Anomaly / Google", exact: true })).toBeVisible()
await expect(list.getByRole("radio")).toHaveCount(models.length)
await page.mouse.move(0, 0)
const first = list.getByRole("radio", { name: "Console Sonnet" })
await expect(first).toBeChecked()
await expect(first).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await expect(dialog.locator('[data-component="settings-list"]')).toHaveCount(2)
await expect(first.locator('[data-slot="settings-row-title"]')).toHaveCSS("font-weight", "440")
await expect(first).toHaveCSS("border-radius", "0px")
const providerHeading = dialog.locator(".settings-models-group-header").filter({ hasText: "Anomaly / OpenCode" })
await expect(providerHeading).toHaveCSS("position", "sticky")
await expect(providerHeading).toHaveCSS("padding-bottom", "0px")
const google = dialog.getByRole("button", { name: "Anomaly / Google", exact: true })
const googleHeading = dialog.locator(".settings-models-group-header").filter({ hasText: "Anomaly / Google" })
await google.click()
await expect(googleHeading).toHaveCSS("padding-bottom", "8px")
await google.click()
await expect(googleHeading).toHaveCSS("padding-bottom", "0px")
await expect(dialog.locator('[data-slot="dialog-header"]')).toHaveCSS("padding-top", "20px")
const hovered = list.getByRole("radio", { name: "Console Model 3" })
await hovered.hover()
await expect(hovered).toHaveCSS("border-bottom-color", "rgba(0, 0, 0, 0)")
await expect(list.getByRole("radio", { name: "Console Model 2" })).toHaveCSS(
"border-bottom-color",
"rgba(0, 0, 0, 0)",
)
await page.screenshot({ path: test.info().outputPath("first-provider-models-dark.png") })
await list.getByRole("radio", { name: "Console Model 2" }).click()
await expect(list.getByRole("radio", { name: "Console Model 2" })).toBeChecked()
const scroll = dialog.locator('[data-component="first-provider-model-scroll"]')
const footer = dialog.locator('[data-component="first-provider-model-footer"]')
const footerBefore = await footer.boundingBox()
expect(await scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await scroll.evaluate((element) => element.scrollTo({ top: element.scrollHeight }))
await expect(list.getByRole("radio", { name: models.at(-1)!.name })).toBeInViewport()
expect(await footer.boundingBox()).toEqual(footerBefore)
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Model 2")
expect(state.starts).toBe(1)
expect(state.cancelled).toEqual([])
})
test("Manage models opens the Models settings page", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await dialog.getByRole("button", { name: "Manage models", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(page.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
})
test("a single connected provider has a non-collapsible heading", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { singleProvider: true })
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toHaveCount(0)
await expect(dialog.getByText("Anomaly / OpenCode", { exact: true })).toBeVisible()
})
test("model choice is skipped after a provider has already been connected", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { existingConnection: true })
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog).toBeHidden()
await expect(page.getByRole("tab", { name: "Providers", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByText("OpenCode Console connected", { exact: true })).toBeVisible()
})
test("service-account API key form matches the Console dialog layout", async ({ page }) => {
const { dialog } = await fixture(page)
const initialShell = await dialog.boundingBox()
await dialog.getByRole("button", { name: "Use API key", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console", exact: true })).toBeVisible()
const description = dialog.getByText("Connect using a service-account API key from OpenCode Console.")
const label = dialog.locator('[data-component="provider-api-key-label"]')
const input = dialog.getByLabel("OpenCode Console API key", { exact: true })
const button = dialog.getByRole("button", { name: "Continue", exact: true })
await expect(input).toBeFocused()
const shell = await dialog.boundingBox()
const heading = await dialog.getByRole("heading", { name: "Connect OpenCode Console" }).boundingBox()
const descriptionBox = await description.boundingBox()
const labelBox = await label.boundingBox()
const fieldBox = await input.locator("..").locator("..").boundingBox()
const buttonBox = await button.boundingBox()
if (!shell || !heading || !descriptionBox || !labelBox || !fieldBox || !buttonBox)
throw new Error("Missing API key dialog layout")
if (!initialShell) throw new Error("Missing initial Console dialog layout")
expect(shell.height).toBe(512)
expect(shell.height).toBe(initialShell.height)
expect(descriptionBox.y - (heading.y + heading.height)).toBe(24)
expect(labelBox.y - (descriptionBox.y + descriptionBox.height)).toBe(20)
expect(fieldBox.y - (labelBox.y + labelBox.height)).toBe(8)
expect(buttonBox.y - (fieldBox.y + fieldBox.height)).toBe(20)
await page.screenshot({ path: test.info().outputPath("console-api-key-light.png") })
})
test("setup preserves the draft and Continue restores composer focus", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { draft: true })
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
const composer = page.locator('[data-component="composer-editor"]')
await expect(composer).toHaveText("Keep this draft throughout sign-in")
await expect(composer).toBeFocused()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Sonnet")
await expect(page.locator('[data-component="provider-setup"]')).toBeHidden()
})
test("catalog refresh failure retries without asking for authorization again", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.modelError = true
state.status = "complete"
await expect(dialog.getByRole("alert")).toContainText("Your account is connected, but we couldn't load your models")
state.modelError = false
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
})
test("status request failure resumes the existing attempt", async ({ page }) => {
const { state, dialog } = await fixture(page)
state.statusError = true
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("alert")).toBeVisible()
state.statusError = false
state.status = "complete"
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
expect(state.cancelled).toEqual([])
})
test("closing during authorization startup cancels the late server attempt", async ({ page }) => {
const start = Promise.withResolvers<void>()
const { state, dialog } = await fixture(page, false, { slowStart: start.promise })
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect.poll(() => state.starts).toBe(1)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
await expect(dialog).toBeHidden()
start.resolve()
await expect.poll(() => state.cancelled).toEqual(["con_1"])
})
test("authorization startup stays on the Continue button until the device code is ready", async ({ page }) => {
const start = Promise.withResolvers<void>()
const { dialog } = await fixture(page, false, { slowStart: start.promise })
const button = dialog.getByRole("button", { name: "Continue with OpenCode Console" })
await button.click()
await expect(dialog.getByRole("button", { name: "Opening browser…" })).toHaveAttribute("aria-busy", "true")
await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console" })).toBeVisible()
await expect(dialog.getByRole("group", { name: /Device code/ })).toHaveCount(0)
start.resolve()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
})
test("browser failure offers a copyable sign-in link in a narrow RTL window", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"])
const { dialog } = await fixture(page, false, { browserFailed: true })
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByText(/We couldn't open your browser/)).toBeVisible()
await page.setViewportSize({ width: 380, height: 650 })
await page.evaluate(() => {
document.documentElement.dir = "rtl"
})
const code = dialog.getByRole("group", { name: "Device code: TFXS-STXG" })
await expect(code).toHaveCSS("direction", "ltr")
await expect(code).toBeInViewport()
await dialog.getByRole("button", { name: "Copy sign-in link" }).click()
await expect(dialog.getByRole("button", { name: "Sign-in link copied" })).toBeVisible()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
"https://console.example/device?user_code=TFXS-STXG&client_id=opencode-desktop&return_window=console-auth-fixture",
)
await expect(dialog.getByRole("button", { name: "Open Console again" })).toBeInViewport()
await page.screenshot({ path: test.info().outputPath("console-auth-narrow-rtl.png") })
})
test("cancel releases the server attempt and retrying expiration creates a new attempt", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "expired"
await expect(dialog.getByRole("alert")).toContainText("has expired")
state.status = "pending"
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
await expect.poll(() => state.starts).toBe(2)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
await expect.poll(() => state.cancelled).toEqual(["con_1", "con_2"])
})
test("an authorized workspace without models stays connected and can refresh", async ({ page }) => {
const { state, dialog } = await fixture(page)
state.models = false
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByText(/this Console workspace has no available models/)).toBeVisible()
state.models = true
await dialog.getByRole("button", { name: "Refresh models" }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
})
test("remote disclosure precedes authorization and all auth requests target that server", async ({ page }) => {
const { state, dialog } = await fixture(page, true)
await expect(dialog.getByRole("note")).toContainText("Connecting on “Production server”")
await expect(dialog.getByRole("note")).toContainText("credentials will be stored on this server")
expect(state.starts).toBe(0)
const request = page.waitForRequest(
(request) => request.method() === "POST" && request.url().includes("/connect/oauth"),
)
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
expect(new URL((await request).url()).origin).toBe("http://production.example:4096")
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
const cancelled = page.waitForRequest(
(request) => request.method() === "DELETE" && request.url().includes("/connect/oauth"),
)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
expect(new URL((await cancelled).url()).origin).toBe("http://production.example:4096")
})
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body class="overflow-hidden bg-v2-background-bg-deep">
<div id="root" class="flex h-dvh flex-col p-px"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
import { render } from "solid-js/web"
import { MemoryRouter } from "@solidjs/router"
import { AppBaseProviders, AppInterface } from "@/app"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
import { ServerConnection } from "@/runtime/server/registry"
// Exercise the real Desktop renderer with local browser/clipboard adapters and
// an HTTP fixture. No Electron service or account credentials are touched.
const params = new URLSearchParams(window.location.search)
const remote = params.get("server")
const server: ServerConnection.Any = remote
? { type: "http", displayName: "Production server", http: { url: remote } }
: { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
render(
() => (
<PlatformProvider
value={{
platform: "desktop",
windowID: "console-auth-fixture",
os: "linux",
draftStore: createBrowserDraftStore(),
openExternal: () => {},
restart: async () => {},
notify: async () => {},
openDirectoryPickerDialog: async () => null,
writeClipboardText: (text) => navigator.clipboard.writeText(text),
openBrowser: async (url) => {
if (params.has("browserFailed")) return false
const browser = window.open("about:blank", "_blank")
if (!browser) return false
browser.opener = null
browser.location.replace(url)
return true
},
}}
>
<AppBaseProviders locale="en">
<AppInterface servers={[server]} defaultServer={ServerConnection.key(server)} router={MemoryRouter} />
</AppBaseProviders>
</PlatformProvider>
),
root,
)
@@ -0,0 +1,19 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 4454)
export default defineConfig({
testDir: ".",
outputDir: "../test-results/desktop",
timeout: 60000,
expect: { timeout: 10000 },
workers: 1,
retries: 0,
use: { baseURL: `http://127.0.0.1:${port}`, screenshot: "only-on-failure", serviceWorkers: "block" },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: true,
timeout: 120000,
},
})
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
await timeline.send(
partUpdated(
toolPart(
@@ -134,7 +134,6 @@ test("combines follow-up patches into one three-file stack inside Used", async (
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
+4 -1
View File
@@ -6,6 +6,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
server?: string
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
@@ -47,7 +48,9 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const server =
config.server ??
`http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ server, retry }) => {
+1
View File
@@ -19,6 +19,7 @@ const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0
export default defineConfig({
testDir: "./e2e",
testIgnore: [
"desktop/**",
"service-worker/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
+5 -10
View File
@@ -126,15 +126,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -326,7 +320,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
+23 -3
View File
@@ -18,11 +18,14 @@ import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { ProviderSetup } from "@/providers/connect/setup"
import { usePlatform } from "@/runtime/platform/platform"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -41,6 +44,10 @@ export function NewSessionView(props: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
}) {
const platform = usePlatform()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const setup = () => platform.platform === "desktop" && providers.ready() && !providers.usable()
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
WorkspaceOnboardingSchema,
@@ -61,11 +68,20 @@ export function NewSessionView(props: {
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div
class="absolute inset-x-0 top-[25.375%] flex justify-center px-6"
classList={{ "bottom-12 overflow-y-auto": setup() }}
>
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} />
<ProviderSetup
visible={setup()}
directory={sdk().directory}
selection={props.composer.model.selection}
onDone={props.composer.restoreFocus}
/>
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
@@ -133,8 +149,9 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const integrations = useIntegrations(() => sdk().directory)
const [providerState, setProviderState, , providerReady] = persisted(
Persist.global("new-session.provider-tip"),
Persist.global("new-session.provider-tip-v3"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
@@ -153,7 +170,10 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
() =>
providers.ready() &&
providerReady() &&
providers.paid().length === 0 &&
!integrations.list().some((integration) => integration.connections.length > 0) &&
!providers
.connected()
.some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0) &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
@@ -45,6 +45,10 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
all: () => providers().all,
default: () => providers().default,
usable: () =>
(data.location.model.list(location()) ?? []).some(
(model) => model.enabled && model.status !== "deprecated" && providers().connected.includes(model.providerID),
),
// V2 servers list only available providers, so the connectable catalog
// comes from the integration list, with the provider catalog as fallback.
popular: () => {
@@ -72,15 +76,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return paid
const paid = new Set(
(data.location.model.list(location()) ?? [])
.filter((model) => model.enabled && model.cost.some((cost) => cost.input > 0))
.map((model) => model.providerID),
)
return [...Iterable.filter(providers().all, ([id]) => connected.has(id) && paid.has(id))]
},
}
}
@@ -0,0 +1,68 @@
import { For, Show } from "solid-js"
import { Button } from "@opencode/ui/button"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { useLanguage } from "@/runtime/i18n/language"
export function ConsoleAuthorization(props: {
code: string
browserFailed: boolean
copied: boolean
copyFailed: boolean
onOpen: () => void
onCopy: () => void
}) {
const language = useLanguage()
return (
<div
data-component="console-authorization"
class="flex flex-col gap-5 text-[13px] leading-5 text-v2-text-text-muted"
>
<p>
{language.t(
props.browserFailed ? "provider.connect.console.browserFailed" : "provider.connect.console.instructions",
)}
</p>
<div class="flex flex-col gap-2">
<div class="font-medium text-v2-text-text-base">{language.t("provider.connect.console.deviceCode")}</div>
<div
dir="ltr"
role="group"
aria-label={`${language.t("provider.connect.console.deviceCode")}: ${props.code}`}
class="flex max-w-full gap-1 self-start font-mono text-xl font-[530] text-v2-text-text-base tabular-nums"
>
<For each={props.code.split("")}>
{(character) => (
<span
aria-hidden="true"
class={
character === "-"
? "mx-1 flex h-12 items-center text-v2-text-text-muted"
: "flex h-12 w-8 items-center justify-center rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02"
}
>
{character}
</span>
)}
</For>
</div>
<p role="status">
<TextShimmer text={language.t("provider.connect.console.waiting")} active />
</p>
</div>
<div data-component="console-browser-fallback" class="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-1">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.browserHint")}</span>
<Button variant="ghost-muted" onClick={props.onCopy}>
{language.t(props.copied ? "provider.connect.console.linkCopied" : "provider.connect.console.copyLink")}
</Button>
<Show when={props.browserFailed || props.copyFailed}>
<Button variant="ghost" onClick={props.onOpen}>
{language.t("provider.connect.console.openAgain")}
</Button>
</Show>
</div>
<Show when={props.copyFailed}>
<p role="alert">{language.t("provider.connect.console.copyFailed")}</p>
</Show>
</div>
)
}
+144 -30
View File
@@ -13,18 +13,20 @@ export function createProviderConnectionController(options: {
provider: () => string
directory: () => string | undefined
onComplete: () => void
initialMethod?: string
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
const directory = options.directory()
return directory ? { directory } : undefined
}
// An authorization belongs to the server and Location where it began.
const directory = options.directory()
const integrationID = options.provider()
const desktopConsole = platform.platform === "desktop" && integrationID === "opencode"
const location = () => (directory ? { directory } : undefined)
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
() => ({ provider: integrationID, directory }),
(input) =>
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
@@ -41,8 +43,12 @@ export function createProviderConnectionController(options: {
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
state: "pending" as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
error: undefined as string | undefined,
connected: false,
browserFailed: false,
statusFailed: false,
selectingIndex: undefined as number | undefined,
})
const polling = {
generation: 0,
@@ -59,7 +65,7 @@ export function createProviderConnectionController(options: {
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.authorized"; index: number; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
@@ -71,6 +77,10 @@ export function createProviderConnectionController(options: {
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
draft.connected = false
draft.browserFailed = false
draft.statusFailed = false
draft.selectingIndex = undefined
return
}
if (action.type === "method.reset") {
@@ -79,6 +89,10 @@ export function createProviderConnectionController(options: {
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
draft.connected = false
draft.browserFailed = false
draft.statusFailed = false
draft.selectingIndex = undefined
return
}
if (action.type === "auth.form") {
@@ -95,12 +109,15 @@ export function createProviderConnectionController(options: {
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
draft.selectingIndex = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
if (action.type === "auth.authorized") {
draft.methodIndex = action.index
draft.state = "waiting"
draft.authorization = action.authorization
draft.error = undefined
draft.selectingIndex = undefined
return
}
draft.state = "error"
@@ -115,24 +132,59 @@ export function createProviderConnectionController(options: {
clearTimeout(polling.timer)
polling.timer = undefined
}
const cancelAttempt = (authorization = store.authorization) => {
if (!desktopConsole) return
if (!authorization || (authorization.attemptID === store.authorization?.attemptID && store.connected)) return
void serverSDK.api.integration.oauth
.cancel({
integrationID,
attemptID: authorization.attemptID,
location: location(),
})
.catch(() => undefined)
}
const openBrowser = async () => {
const authorization = store.authorization
if (!authorization) return
const generation = polling.generation
const opened = await Promise.resolve()
.then(async () => {
if (platform.openBrowser) return platform.openBrowser(authorization.url)
platform.openExternal(authorization.url)
return true
})
.then((result) => result !== false)
.catch(() => false)
if (polling.disposed || generation !== polling.generation) return
setStore("browserFailed", !opened)
}
const finish = async () => {
cancelPolling()
const generation = polling.generation
setStore({ connected: true, state: "refreshing", error: undefined })
const ref = location()
data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
data.location.model.invalidate(ref)
await Promise.all([
const refreshed = await Promise.all([
data.location.integration.sync(ref),
data.location.provider.sync(ref),
data.location.model.sync(ref),
]).catch(() => undefined)
if (polling.disposed) return
])
.then(() => true)
.catch(() => false)
if (polling.disposed || generation !== polling.generation) return
if (!refreshed && desktopConsole) {
dispatch({ type: "auth.error", error: language.t("provider.connect.console.refreshFailed") })
return
}
setStore("state", "ready")
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK.api.integration.oauth
.status({
integrationID: options.provider(),
integrationID,
attemptID: authorization.attemptID,
location: location(),
})
@@ -140,9 +192,14 @@ export function createProviderConnectionController(options: {
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
setStore("statusFailed", true)
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
error: desktopConsole
? language.t("provider.connect.console.statusFailed")
: result.error instanceof Error
? result.error.message
: String(result.error),
})
return
}
@@ -151,20 +208,37 @@ export function createProviderConnectionController(options: {
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
const message = result.status.message
dispatch({
type: "auth.error",
error:
desktopConsole && message.includes("expired_token")
? language.t("provider.connect.console.expired")
: desktopConsole && message.includes("access_denied")
? language.t("provider.connect.console.denied")
: message,
})
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
dispatch({
type: "auth.error",
error: language.t(desktopConsole ? "provider.connect.console.expired" : "common.requestFailed"),
})
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
polling.timer = setTimeout(
() => void poll(authorization, generation),
options.pollInterval ?? (desktopConsole ? 500 : 1_000),
)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
cancelAttempt()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
const awaitAuthorization = desktopConsole && selected.type === "oauth" && selected.id === "device"
if (!awaitAuthorization) dispatch({ type: "method.select", index })
if (selected.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
@@ -175,41 +249,62 @@ export function createProviderConnectionController(options: {
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
dispatch({ type: "auth.error", error: language.t("provider.connect.form.unsupported") })
return
}
dispatch({ type: "auth.pending" })
if (awaitAuthorization) {
setStore({
selectingIndex: index,
authorization: undefined,
state: undefined,
error: undefined,
browserFailed: false,
statusFailed: false,
})
} else {
dispatch({ type: "auth.pending" })
}
const result = await serverSDK.api.integration.oauth
.connect({
integrationID: options.provider(),
integrationID,
methodID: selected.id,
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
if (integrationID === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
url.searchParams.set("return_window", platform.windowID)
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: String(result.error) })
if (polling.disposed || generation !== polling.generation) {
if (result.ok) cancelAttempt(result.authorization)
return
}
dispatch({ type: "auth.complete", authorization: result.authorization })
if (!result.ok) {
if (awaitAuthorization) dispatch({ type: "method.select", index })
dispatch({
type: "auth.error",
error: desktopConsole ? language.t("provider.connect.console.startFailed") : String(result.error),
})
return
}
dispatch({ type: "auth.authorized", index, authorization: result.authorization })
if (desktopConsole && selected.id === "device") void openBrowser()
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const reset = () => {
cancelPolling()
cancelAttempt()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await serverSDK.api.integration.connect.key({
integrationID: options.provider(),
integrationID,
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
@@ -221,7 +316,7 @@ export function createProviderConnectionController(options: {
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK.api.integration.oauth
.complete({
integrationID: options.provider(),
integrationID,
attemptID: authorization.attemptID,
location: location(),
code,
@@ -238,13 +333,20 @@ export function createProviderConnectionController(options: {
let auto = false
createEffect(() => {
if (auto || integration.loading || methods().length !== 1) return
if (auto || integration.loading) return
const index = options.initialMethod
? methods().findIndex((method) => method.type === "oauth" && method.id === options.initialMethod)
: methods().length === 1
? 0
: -1
if (index < 0) return
auto = true
void select(0)
void select(index)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
cancelAttempt()
})
return {
@@ -254,6 +356,9 @@ export function createProviderConnectionController(options: {
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
browserFailed: () => store.browserFailed,
selecting: (index: number) => store.selectingIndex === index,
openBrowser,
auth: {
state: () => store.state,
error: () => store.error,
@@ -261,6 +366,15 @@ export function createProviderConnectionController(options: {
reset,
connectKey,
completeCode,
refresh: finish,
retry: () => {
if (store.connected) return finish()
if (store.statusFailed && store.authorization) {
setStore({ state: "waiting", error: undefined, statusFailed: false })
return poll(store.authorization, polling.generation)
}
return store.methodIndex === undefined ? Promise.resolve() : select(store.methodIndex, store.formAnswer)
},
},
}
}
+645 -144
View File
@@ -4,11 +4,23 @@ import { Icon } from "@opencode/ui/icon"
import { List } from "@opencode/ui/list"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Spinner } from "@opencode/ui/spinner"
import { Loader } from "@opencode/ui/loader"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import {
type Component,
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Match,
onMount,
Show,
Switch,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/runtime/platform/external-link"
@@ -18,6 +30,18 @@ import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
import { ConsoleAuthorization } from "./console"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { authServerName, RemoteAuthNotice } from "./remote"
import type { ModelSelection } from "@/providers/models/selection"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { useSettingsSurface } from "@/settings/surface"
import { SettingsList } from "@/settings/list"
import "./models.css"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
@@ -37,9 +61,21 @@ export function useProviderConnectController(options: { onBack?: () => void } =
export const DialogConnectProvider: Component<{
directory?: string
controller?: ReturnType<typeof useProviderConnectController>
provider?: string
initialMethod?: string
selection?: ModelSelection
onDone?: () => void
onConnected?: (provider: string) => void
}> = (props) => {
const fallback = useProviderConnectController()
if (props.provider) fallback.select(props.provider)
const controller = props.controller ?? fallback
const platform = usePlatform()
const [state, setState] = createStore({
completed: false,
modelProvider: undefined as { id: string; name: string } | undefined,
authorization: false,
})
const language = useLanguage()
const reset = controller.back
const back = { current: reset }
@@ -56,13 +92,22 @@ export const DialogConnectProvider: Component<{
<Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm autofocus={false} />
</Match>
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
<Match
keyed
when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}
>
{(provider) => (
<ProviderConnection
provider={provider()}
provider={provider}
directory={props.directory}
onBack={reset}
setBack={(handler) => (back.current = handler)}
initialMethod={props.initialMethod}
selection={props.selection}
onDone={props.onDone ? () => setState("completed", true) : undefined}
onConnected={() => props.onConnected?.(provider)}
onFirstConnection={(provider) => setState("modelProvider", provider)}
onAuthorization={(authorization) => setState("authorization", authorization)}
/>
)}
</Match>
@@ -75,25 +120,58 @@ export const DialogConnectProvider: Component<{
return (
<Dialog
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
containerClass={
state.modelProvider
? "!h-[min(calc(100vh_-_16px),560px)] !w-[min(calc(100vw_-_16px),640px)]"
: platform.platform === "desktop" && controller.selected() === "opencode" && state.authorization
? "!h-auto !max-h-[min(calc(100vh_-_16px),560px)] !w-[min(calc(100vw_-_16px),640px)]"
: "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
}
onCloseAutoFocus={(event) => {
if (!state.completed || !props.onDone) return
event.preventDefault()
props.onDone()
}}
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
classList={{
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3":
platform.platform === "desktop" && controller.selected() === "opencode" && !state.modelProvider,
"[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider,
}}
>
<DialogHeader closeLabel={language.t("common.close")}>
<Show
when={controller.selected()}
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
>
<Icon name="arrow-left" size="small" />
</button>
</Show>
<Switch>
<Match when={state.modelProvider}>
{(provider) => (
<div class="flex items-center gap-2">
<Show
when={provider().id === "opencode"}
fallback={<ProviderIcon id={provider().id} class="size-4 shrink-0" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<DialogTitle>{language.t("provider.connect.models.title", { provider: provider().name })}</DialogTitle>
</div>
)}
</Match>
<Match when={controller.selected()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
>
<Icon name="arrow-left" size="small" />
</button>
</Match>
<Match when={true}>
<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>
</Match>
</Switch>
</DialogHeader>
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
<DialogBody
class={`min-h-0 flex-1 overflow-hidden px-2 ${state.modelProvider || (platform.platform === "desktop" && controller.selected() === "opencode") ? "pb-0" : "pb-2"}`}
>
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
<Content />
</div>
@@ -115,7 +193,9 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...integrations.list()]
const values = [custom(), ...integrations.list()].map((provider) =>
provider.id === "opencode" ? { ...provider, name: language.t("provider.connect.console.name") } : provider,
)
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
@@ -205,7 +285,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
aria-busy={store.connecting === provider.id}
onClick={() => connect(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
<Show
when={provider.id === "opencode"}
fallback={<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
@@ -254,18 +339,62 @@ function ProviderConnection(props: {
directory?: string
onBack: () => void
setBack: (handler: () => void) => void
initialMethod?: string
selection?: ModelSelection
onDone?: () => void
onConnected?: () => void
onFirstConnection: (provider: { id: string; name: string }) => void
onAuthorization: (authorization: boolean) => void
}) {
const dialog = useDialog()
const params = useParams()
const language = useLanguage()
const providers = useProviders(() => props.directory)
const directory = () => props.directory ?? decode64(params.dir)
const initialDirectory = props.directory ?? decode64(params.dir)
const directory = () => initialDirectory
const platform = usePlatform()
const sdk = useServerSDK()
const data = useData()
const tabs = useTabs()
const surface = useSettingsSurface()
const integrations = useIntegrations(directory)
const desktopConsole = platform.platform === "desktop" && props.provider === "opencode"
const remote = desktopConsole && authServerName(sdk.server) !== undefined
const initialModel = props.selection?.current()
const [consoleState, setConsoleState] = createStore({
copied: false,
copyFailed: false,
firstConnection: undefined as boolean | undefined,
models: false,
selectedModel: "",
collapsed: {} as Record<string, boolean>,
})
const consoleMethod = () => {
const method = controller.currentMethod()
return desktopConsole && method?.type === "oauth" && method.id === "device"
}
const done = () => {
props.onDone?.()
dialog.close()
}
const controller = createProviderConnectionController({
provider: () => props.provider,
directory,
initialMethod: remote ? undefined : props.initialMethod,
onComplete: () => {
props.onConnected?.()
if (consoleState.firstConnection) {
if (connectionModels().length > 0) {
const first = connectionGroups()[0]?.models[0]
setConsoleState({ models: true, selectedModel: first ? modelKey(first) : "" })
props.onFirstConnection({ id: props.provider, name: connectedProviderName() })
return
}
if (consoleMethod()) return
}
dialog.close()
surface.open("providers")
showToast({
variant: "success",
icon: "circle-check",
@@ -274,18 +403,98 @@ function ProviderConnection(props: {
})
},
})
createEffect(() => {
const current = controller.integration()
if (!current || consoleState.firstConnection !== undefined) return
const existingConnection = integrations.list().some((integration) => integration.connections.length > 0)
const existingProvider = providers
.connected()
.some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0)
setConsoleState("firstConnection", !existingConnection && !existingProvider)
})
const [defaultModel] = createResource(
() => controller.auth.state() === "ready" && consoleMethod(),
() =>
sdk.api.model
.default({ location: initialDirectory ? { directory: initialDirectory } : undefined })
.then((response) => response.data)
.catch(() => undefined),
)
const consoleModels = createMemo(() => {
const location = initialDirectory ? { directory: initialDirectory } : undefined
const connected = new Set(
(data.location.provider.list(location) ?? [])
.filter((provider) => provider.integrationID === "opencode")
.map((provider) => provider.id),
)
return (data.location.model.list(location) ?? [])
.filter((model) => connected.has(model.providerID) && model.enabled && model.status !== "deprecated")
.toSorted((a, b) => a.providerID.localeCompare(b.providerID) || a.id.localeCompare(b.id))
})
const connectionModels = createMemo(() => {
const location = initialDirectory ? { directory: initialDirectory } : undefined
const ids = new Set(
(data.location.provider.list(location) ?? [])
.filter((provider) => provider.id === props.provider || provider.integrationID === props.provider)
.map((provider) => provider.id),
)
return (data.location.model.list(location) ?? []).filter(
(model) => ids.has(model.providerID) && model.enabled && model.status !== "deprecated",
)
})
const connectionGroups = createMemo(() => {
const location = initialDirectory ? { directory: initialDirectory } : undefined
const models = connectionModels()
return (data.location.provider.list(location) ?? [])
.filter((provider) => provider.id === props.provider || provider.integrationID === props.provider)
.map((provider) => ({ provider, models: models.filter((model) => model.providerID === provider.id) }))
.filter((group) => group.models.length > 0)
})
const modelKey = (model: { providerID: string; id: string }) => `${model.providerID}:${model.id}`
const selectedModel = () => connectionModels().find((model) => modelKey(model) === consoleState.selectedModel)
const connectedProviderName = () => (props.provider === "opencode" ? "OpenCode" : provider().name)
const recommended = () => {
const current = props.selection?.current()
if (initialModel && current?.id === initialModel.id && current.provider.id === initialModel.provider.id)
return current
const preferred = defaultModel()
return (
consoleModels().find((model) => model.providerID === preferred?.providerID && model.id === preferred.id) ??
consoleModels()[0]
)
}
const copyLink = async () => {
const url = controller.authorization()?.url
if (!url) return
const copied = await Promise.resolve()
.then(() => (platform.writeClipboardText ? platform.writeClipboardText(url) : navigator.clipboard.writeText(url)))
.then(() => true)
.catch(() => false)
if (controller.authorization()?.url !== url) return
setConsoleState({ copied, copyFailed: !copied })
}
createEffect(() => {
controller.authorization()?.attemptID
setConsoleState({ copied: false, copyFailed: false })
})
createEffect(() => props.onAuthorization(controller.authorization() !== undefined))
const provider = createMemo(() => ({
id: props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
name:
props.provider === "opencode"
? language.t("provider.connect.console.name")
: (providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider),
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey")
if (value.type === "key")
return language.t(desktopConsole ? "provider.connect.console.serviceKey" : "provider.connect.method.apiKey")
return value.label ?? ""
}
const methodDetails = (value?: { type?: string; label?: string }) => {
const label = methodLabel(value)
if (desktopConsole && value?.type === "key") return { label }
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
const hint = suffix?.[1]
return {
@@ -432,33 +641,76 @@ function ProviderConnection(props: {
props.setBack(goBack)
function MethodSelection() {
const primary = () =>
desktopConsole
? controller.methods().findIndex((method) => method.type === "oauth" && method.id === "device")
: -1
const serviceAccount = () => controller.methods().findIndex((method) => method.type === "key")
return (
<div class="flex flex-col gap-2">
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={controller.methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void controller.auth.select(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
</span>
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
<Show when={details().hint}>
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
<Show when={primary() >= 0}>
<div class="flex flex-col items-start gap-5">
<p class="text-[13px] leading-5 text-v2-text-text-muted">{language.t("provider.connect.console.intro")}</p>
<Button
size="large"
class="!px-3"
variant="contrast"
disabled={controller.selecting(primary())}
aria-busy={controller.selecting(primary())}
onClick={() => void controller.auth.select(primary())}
>
<Show when={controller.selecting(primary())}>
<span class="absolute inset-0 flex items-center justify-center gap-1.5">
<Loader />
<span>{language.t("provider.connect.console.openingBrowser")}</span>
</span>
</Show>
<span
aria-hidden={controller.selecting(primary()) ? "true" : undefined}
classList={{ "opacity-0": controller.selecting(primary()) }}
>
{language.t("provider.connect.console.continue")}
</span>
</Button>
<Show when={serviceAccount() >= 0}>
<div data-component="console-service-account" class="flex h-7 items-center gap-1">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
<Button variant="ghost-muted" onClick={() => void controller.auth.select(serviceAccount())}>
{language.t("provider.connect.console.useApiKey")}
</Button>
</div>
</Show>
</div>
</Show>
<Show when={primary() < 0}>
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={controller.methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<Show when={index() !== primary()}>
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void controller.auth.select(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
</span>
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
<Show when={details().hint}>
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
</Show>
</button>
</Show>
</button>
)
}}
</For>
</div>
)
}}
</For>
</div>
</Show>
</div>
)
}
@@ -492,29 +744,38 @@ function ProviderConnection(props: {
}
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
<div
class={`flex flex-col gap-5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted ${desktopConsole ? "pb-1" : "px-3"}`}
>
<Show when={desktopConsole}>
<p>{language.t("provider.connect.console.serviceKeyDescription")}</p>
</Show>
<Show when={!desktopConsole}>
<Show
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</div>
</Show>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
<span data-component="provider-api-key-label">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
</span>
<TextInput
ref={apiKey}
class="!w-full"
@@ -536,7 +797,12 @@ function ProviderConnection(props: {
</div>
)}
</Show>
<Button type="submit" variant="contrast" data-action="provider-connect-submit">
<Button
type="submit"
class={desktopConsole ? "!px-3" : undefined}
variant="contrast"
data-action="provider-connect-submit"
>
{language.t("common.continue")}
</Button>
</form>
@@ -582,7 +848,7 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
<TextInput
ref={codeInput}
@@ -622,93 +888,328 @@ function ProviderConnection(props: {
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
<Show
when={consoleMethod()}
fallback={
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
}
>
<ConsoleAuthorization
code={new URL(controller.authorization()!.url).searchParams.get("user_code") ?? code() ?? ""}
browserFailed={controller.browserFailed()}
copied={consoleState.copied}
copyFailed={consoleState.copyFailed}
onCopy={() => void copyLink()}
onOpen={() => void controller.openBrowser()}
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</Show>
)
}
const startWithModel = async () => {
const model = selectedModel()
if (!model) return
const selection = { providerID: model.providerID, modelID: model.id }
if (props.selection) {
props.selection.set(selection)
done()
return
}
dialog.close()
await tabs.newDraft(
{
server: ServerConnection.key(sdk.server),
directory: initialDirectory ?? data.location.default().directory,
},
undefined,
selection,
)
}
function FirstConnectionModels() {
return (
<div data-component="first-provider-models" class="flex min-h-0 flex-1 flex-col px-3">
<p class="shrink-0 pb-5 text-[13px] leading-5 text-v2-text-text-muted">
{language.t("provider.connect.models.description")}
</p>
<div
data-component="first-provider-model-scroll"
class="settings-panel settings-models min-h-0 flex-1 overflow-y-auto pb-4"
>
<div
role="radiogroup"
aria-label={language.t("provider.connect.models.list", { provider: connectedProviderName() })}
>
<For each={connectionGroups()}>
{(group) => {
const collapsible = () => connectionGroups().length > 1
const expanded = () => !collapsible() || !consoleState.collapsed[group.provider.id]
const label = () => (
<span class="settings-models-group-label">
<Show
when={group.provider.id === "opencode"}
fallback={<ProviderIcon id={group.provider.id} class="size-4 shrink-0" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<span class="settings-section-title">{group.provider.name}</span>
</span>
)
return (
<section class="settings-section" data-expanded={expanded() ? "" : undefined}>
<h3
class="settings-models-group-header sticky top-0 z-[1] box-content bg-v2-background-bg-layer-01"
classList={{ "pb-2": collapsible() && !expanded() }}
>
<Show when={collapsible()} fallback={<div class="settings-models-group-trigger">{label()}</div>}>
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
onClick={() => setConsoleState("collapsed", group.provider.id, expanded())}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
/>
</span>
{label()}
</button>
</Show>
</h3>
<Show when={expanded()}>
<SettingsList>
<For each={group.models}>
{(model) => {
const selected = () => consoleState.selectedModel === modelKey(model)
return (
<button
type="button"
role="radio"
data-component="settings-row"
data-first-provider-model=""
data-selected={selected() ? "" : undefined}
aria-checked={selected()}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => setConsoleState("selectedModel", modelKey(model))}
>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">
<span class="min-w-0 truncate">{model.name}</span>
</div>
</div>
<div data-slot="settings-row-control" class="size-4">
<Show when={selected()}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</div>
</button>
)
}}
</For>
</SettingsList>
</Show>
</section>
)
}}
</For>
</div>
</div>
<div
data-component="first-provider-model-footer"
class="-mx-5 flex h-15 shrink-0 items-center justify-between border-t border-v2-border-border-muted px-4"
>
<Button
variant="ghost-muted"
icon="outline-sliders"
onClick={() => {
dialog.close()
surface.open("models")
}}
>
{language.t("dialog.model.manage")}
</Button>
<Button variant="contrast" disabled={!selectedModel()} onClick={() => void startWithModel()}>
{language.t("common.continue")}
</Button>
</div>
</div>
)
}
return (
<div class="flex min-h-0 flex-1 flex-col">
<div class="flex h-10 shrink-0 items-start gap-2 px-3">
<ProviderIcon id={props.provider} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
<Switch>
<Match
when={props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")}
>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</div>
</div>
<Show when={!consoleState.models} fallback={<FirstConnectionModels />}>
<div class="flex min-h-0 flex-1 flex-col">
<div>
<Switch>
<Match when={controller.loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
</div>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<div
class={
desktopConsole ? "flex shrink-0 items-center gap-2 px-3 pb-6" : "flex h-10 shrink-0 items-start gap-2 px-3"
}
>
<Show
when={props.provider === "opencode"}
fallback={<ProviderIcon id={props.provider} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
<DialogTitle>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
<Match when={consoleMethod()}>{language.t("provider.connect.console.title")}</Match>
<Match
when={
props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")
}
>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</Match>
</Switch>
</DialogTitle>
</div>
</div>
<div
class={
desktopConsole ? "flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4" : "flex min-h-0 flex-1 flex-col"
}
>
<Show when={remote}>
<div class="mb-5">
<RemoteAuthNotice server={sdk.server} />
</div>
</Show>
<div>
<Switch>
<Match when={controller.auth.state() === "ready" && consoleMethod()}>
<div class="flex flex-col items-start gap-5 text-[13px] leading-5 text-v2-text-text-muted">
<div role="status">
<p class="flex items-center gap-2 font-medium text-v2-text-text-base">
<Icon name="circle-check" />
{language.t("provider.connect.console.connected")}
</p>
<p>
{language.t(
consoleModels().length ? "provider.connect.console.ready" : "provider.connect.console.noModels",
)}
</p>
</div>
<Show when={recommended()}>
{(model) => (
<p>
{language.t("provider.connect.console.model")}{" "}
<span class="font-medium text-v2-text-text-base">{model().name}</span>
</p>
)}
</Show>
<Show
when={consoleModels().length > 0}
fallback={
<>
<Button onClick={() => platform.openExternal("https://opencode.ai/console")}>
{language.t("provider.connect.console.openAgain")}
</Button>
<Button onClick={() => void controller.auth.refresh()}>
{language.t("provider.connect.console.refresh")}
</Button>
</>
}
>
<Button
variant="contrast"
disabled={defaultModel.loading}
onClick={() => {
const model = recommended()
if (props.selection && model)
props.selection.set({ providerID: model.providerID, modelID: model.id })
done()
}}
>
{language.t(props.onDone ? "provider.connect.console.start" : "provider.connect.console.done")}
</Button>
</Show>
</div>
</Match>
<Match when={desktopConsole && controller.auth.state() === "refreshing"}>
<p role="status" class="text-[13px] leading-5 text-v2-text-text-muted">
{language.t("provider.connect.console.refreshing")}
</p>
</Match>
<Match when={controller.loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base" role="alert">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>
{desktopConsole
? controller.auth.error()
: language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Show when={desktopConsole}>
<Button class="mt-4" onClick={() => void controller.auth.retry()}>
{language.t("provider.connect.console.retry")}
</Button>
</Show>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,17 @@
[data-first-provider-model]:has(+ [data-first-provider-model]:hover),
[data-first-provider-model]:hover {
border-block-end-color: transparent;
}
[data-first-provider-model]:hover {
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-component="first-provider-models"] [data-component="settings-list"] {
background-color: var(--v2-background-bg-layer-02);
overflow: hidden;
}
[data-component="first-provider-models"] .settings-section[data-expanded] {
padding-bottom: 0;
}
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import { authServerName } from "./remote"
test("SSH disclosure uses the remote identity even with a loopback proxy", () => {
expect(authServerName({ type: "ssh", host: "production.example", http: { url: "http://127.0.0.1:4096" } })).toBe(
"production.example",
)
expect(
authServerName({
type: "ssh",
host: "production.example",
displayName: "Production server",
http: { url: "http://127.0.0.1:4096" },
}),
).toBe("Production server")
})
test("local Desktop and loopback HTTP connections do not show remote disclosure", () => {
expect(authServerName({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBeUndefined()
for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
expect(authServerName({ type: "http", http: { url: `http://${host}:4096` } })).toBeUndefined()
}
})
test("WSL and remote HTTP connections show their server identity", () => {
expect(
authServerName({ type: "sidecar", variant: "wsl", distro: "Ubuntu", http: { url: "http://127.0.0.1:4096" } }),
).toBe("Ubuntu")
expect(authServerName({ type: "http", http: { url: "https://production.example" } })).toBe("production.example")
})
@@ -0,0 +1,30 @@
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName } from "@/runtime/server/registry"
export function authServerName(server: ServerConnection.Any) {
if (ServerConnection.builtin(server)) return undefined
if (server.type === "http" && ["localhost", "127.0.0.1", "[::1]"].includes(new URL(server.http.url).hostname))
return undefined
if (server.type === "sidecar" && server.variant === "wsl") return server.displayName ?? server.distro
return serverName(server)
}
export function RemoteAuthNotice(props: { server: ServerConnection.Any }) {
const language = useLanguage()
return (
<Show when={authServerName(props.server)}>
{(name) => (
<div
class="rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02 p-3 text-[13px] leading-5"
role="note"
>
<p class="font-medium text-v2-text-text-base">
{language.t("provider.connect.remote.title", { server: name() })}
</p>
<p class="text-v2-text-text-muted">{language.t("provider.connect.remote.description")}</p>
</div>
)}
</Show>
)
}
@@ -0,0 +1,51 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import type { ModelSelection } from "@/providers/models/selection"
import { RemoteAuthNotice } from "./remote"
import { Show } from "solid-js"
export function ProviderSetup(props: {
visible: boolean
directory: string
selection: ModelSelection
onDone: () => void
}) {
const language = useLanguage()
const dialog = useDialog()
const sdk = useServerSDK()
const open = async (console: boolean) => {
const { DialogConnectProvider } = await import("./dialog")
void dialog.show(() => (
<DialogConnectProvider
directory={props.directory}
provider={console ? "opencode" : undefined}
initialMethod={console ? "device" : undefined}
selection={props.selection}
onDone={props.onDone}
/>
))
}
return (
<Show when={props.visible}>
<section
data-component="provider-setup"
class="flex flex-col gap-4 rounded-lg border border-v2-border-border-base bg-v2-background-bg-layer-01 p-5 text-[13px] leading-5"
>
<div class="flex flex-col gap-1">
<h2 class="text-[15px] font-medium text-v2-text-text-base">{language.t("provider.setup.title")}</h2>
<p class="text-v2-text-text-muted">{language.t("provider.connect.console.intro")}</p>
</div>
<RemoteAuthNotice server={sdk.server} />
<div class="flex flex-wrap gap-2">
<Button variant="contrast" onClick={() => void open(true)}>
{language.t("provider.connect.console.continue")}
</Button>
<Button onClick={() => void open(false)}>{language.t("provider.setup.other")}</Button>
</div>
<p class="text-v2-text-text-muted">{language.t("provider.setup.settings")}</p>
</section>
</Show>
)
}
@@ -0,0 +1,17 @@
export function OpenCodeLogo(props: { class?: string }) {
return (
<svg
data-component="opencode-logo"
aria-hidden="true"
class={props.class}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(1.2 1.2) scale(0.85)">
<path opacity="0.2" d="M11.1999 12.8H4.79993V6.40002H11.1999V12.8Z" fill="currentColor" />
<path d="M11.2 3.2H4.79998V12.8H11.2V3.2ZM14.4 16H1.59998V0H14.4V16Z" fill="currentColor" />
</g>
</svg>
)
}
+49
View File
@@ -189,6 +189,53 @@ export const dict = {
"dialog.provider.viewAll": "Show more providers",
"provider.connect.title": "Connect {{provider}}",
"provider.connect.console.title": "Connect OpenCode Console account",
"provider.connect.console.name": "OpenCode Console",
"provider.connect.console.instructions":
"Continue in your browser. Confirm the code shown there matches the one below.",
"provider.connect.console.deviceCode": "Device code",
"provider.connect.console.waiting": "Waiting for confirmation…",
"provider.connect.console.browserHint": "Browser didn't open?",
"provider.connect.console.copyLink": "Copy sign-in link",
"provider.connect.console.linkCopied": "Sign-in link copied",
"provider.connect.console.copyFailed": "Couldn't copy the sign-in link. Open Console again to continue.",
"provider.connect.console.openAgain": "Open Console again",
"provider.connect.console.browserFailed":
"We couldn't open your browser. Try again or copy the sign-in link to continue.",
"provider.connect.console.expired": "This sign-in request has expired. Start again to get a new device code.",
"provider.connect.console.denied": "Access was denied in Console. Try again when you're ready to connect.",
"provider.connect.console.statusFailed": "Couldn't check authorization. Check your server connection and try again.",
"provider.connect.console.startFailed": "Couldn't start sign-in. Check your server connection and try again.",
"provider.connect.models.title": "Connected to {{provider}}",
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
"provider.connect.models.list": "Models available from {{provider}}",
"provider.connect.console.retry": "Try again",
"provider.connect.console.refreshing": "OpenCode connected. Loading your models...",
"provider.connect.console.refreshFailed":
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
"provider.connect.console.connected": "OpenCode connected",
"provider.connect.console.ready": "Your models are ready.",
"provider.connect.console.noModels":
"Your account is connected, but this Console workspace has no available models. Check its setup in Console, then refresh.",
"provider.connect.console.refresh": "Refresh models",
"provider.connect.console.model": "Model",
"provider.connect.console.start": "Start coding",
"provider.connect.console.done": "Done",
"provider.connect.console.continue": "Continue with OpenCode Console",
"provider.connect.console.openingBrowser": "Opening browser…",
"provider.connect.console.serviceAccount": "Service account?",
"provider.connect.console.useApiKey": "Use API key",
"provider.connect.console.otherMethods": "Other methods",
"provider.connect.console.serviceKey": "API key (service account)",
"provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
"provider.connect.console.intro": "Sign in once to use the models available through your OpenCode account.",
"provider.connect.remote.title": "Connecting on “{{server}}”",
"provider.connect.remote.description":
"Your OpenCode credentials will be stored on this server. Models will be available through this server.",
"provider.connect.form.unsupported": "This authentication form contains unsupported fields",
"provider.setup.title": "Start with OpenCode",
"provider.setup.other": "Other providers",
"provider.setup.settings": "You can change this later in Settings → Providers.",
"provider.connect.title.anthropicProMax": "Login with Claude Pro/Max",
"provider.connect.selectMethod": "Select login method for {{provider}}.",
"provider.connect.method.apiKey": "API key",
@@ -1269,6 +1316,8 @@ export const dict = {
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
"settings.providers.console.available.one": "{{count}} provider available",
"settings.providers.console.available.other": "{{count}} providers available",
"settings.providers.section.popular": "Popular providers",
"settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.",
"settings.providers.tag.environment": "Environment",
@@ -37,6 +37,9 @@ type PlatformBase = {
/** Open a web or mail URL in the default system application */
openExternal(url: string): void
/** Open an authentication page, reporting whether the browser could be launched. */
openBrowser?(url: string): Promise<boolean>
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
+6 -2
View File
@@ -54,8 +54,12 @@ export function createWebPlatform(version: string) {
function getCurrentServerUrl() {
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
if (import.meta.env.DEV) {
const loopback =
location.hostname === "localhost" || location.hostname === "[::1]" || location.hostname.startsWith("127.")
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? (loopback ? location.hostname : "localhost")
return `http://${host}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
}
return location.origin
}
+5 -2
View File
@@ -10,6 +10,7 @@ import { createData } from "@opencode/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
@@ -33,6 +34,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
const models = createGlobalModels()
const notificationCoordinator = createNotificationCoordinator()
const settingsServer = createMemo(() => {
const list = server.list
@@ -57,7 +59,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
return createServerController(conn, server.scope(key), server.projects.forServer(key))
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
}, owner)
serverCtxs.set(key, serverCtx)
return serverCtx
@@ -131,6 +133,7 @@ function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
) {
const language = useLanguage()
const settings = useSettings()
@@ -159,7 +162,7 @@ function createServerController(
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -278,6 +278,7 @@ function Open(props: { initial?: string }) {
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
+14 -2
View File
@@ -120,7 +120,13 @@ export function DialogSsh(props: {
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
<Show
when={
!props.promptOnly &&
item()?.stage !== "incompatible" &&
(!state.prompted || (!!error() && !prompt()))
}
>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label" for="ssh-target">
{language.t("ssh.target")}
@@ -160,6 +166,12 @@ export function DialogSsh(props: {
/>
</div>
</Show>
<Show when={item()?.stage === "incompatible"}>
<div class="flex w-full min-w-0 flex-col gap-2" role="status" aria-live="polite">
<span class="text-14-medium text-v2-text-text-base">{language.t("ssh.stage.incompatible")}</span>
<span class="text-13-regular text-v2-text-text-muted">{language.t("ssh.error.version")}</span>
</div>
</Show>
<Show when={prompt()} keyed>
{(prompt) => (
<div class="flex w-full min-w-0 flex-col gap-2">
@@ -195,7 +207,7 @@ export function DialogSsh(props: {
</div>
)}
</Show>
<Show when={error()}>
<Show when={item()?.stage !== "incompatible" && error()}>
{(error) => (
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
{error()}
@@ -1,6 +1,6 @@
import { createMemo, createUniqueId, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createQuery } from "@tanstack/solid-query"
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
import { Icon } from "@opencode/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
@@ -56,6 +56,7 @@ export function SessionFileBrowserTab(props: {
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
placeholderData: keepPreviousData,
}
})
const files = createMemo(() => {
+26 -2
View File
@@ -4,7 +4,7 @@ import { Switch } from "@opencode/ui/switch"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, For, Show } from "solid-js"
import { createEffect, type Component, For, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
@@ -25,7 +25,11 @@ export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component = () => {
export const SettingsModels: Component<{
active?: boolean
provider?: string
onReveal?: () => void
}> = (props) => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
@@ -34,6 +38,7 @@ export const SettingsModels: Component = () => {
ModelProvidersSchema,
{ collapsed: {} },
)
const sections = new Map<string, HTMLElement>()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -57,6 +62,24 @@ export const SettingsModels: Component = () => {
},
})
createEffect(() => {
if (!props.active || !props.provider) return
const provider = props.provider
if (list.filter()) {
list.clear()
return
}
if (!list.grouped.latest.some((group) => group.category === provider)) return
const section = sections.get(provider)
if (!section?.isConnected) return
list.grouped.latest.forEach((group) => setStore("collapsed", group.category, group.category !== provider))
requestAnimationFrame(() => {
section.scrollIntoView({ block: "start" })
section.querySelector<HTMLElement>(".settings-models-group-trigger")?.focus({ preventScroll: true })
props.onReveal?.()
})
})
return (
<>
<div class="settings-tab-header settings-tab-header--stacked">
@@ -121,6 +144,7 @@ export const SettingsModels: Component = () => {
return (
<div
ref={(element) => sections.set(group.category, element)}
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
+188 -34
View File
@@ -1,11 +1,14 @@
import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { showToast } from "@/shell/notifications/toast"
import { popularProviders, useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { createMemo, type Component, For, Show } from "solid-js"
import { createEffect, createMemo, type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
@@ -33,6 +36,7 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsProviders: Component<{
directory: string | undefined
onBack?: () => void
onSelectProvider?: (providerID: string) => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
@@ -40,18 +44,35 @@ export const SettingsProviders: Component<{
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const [state, setState] = createStore({
disconnecting: {} as Record<string, "removing" | "removed" | undefined>,
consoleExpanded: false,
})
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | undefined) =>
setState("disconnecting", (current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, status])),
}))
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
<DialogConnectProvider
directory={props.directory}
controller={providerConnect}
onConnected={(providerID) =>
setState("disconnecting", (current) =>
providerID === "opencode" ? {} : { ...current, [providerID]: undefined },
)
}
/>
</SettingsServerScope>
))
}
const connected = createMemo(() => {
const available = createMemo(() => {
return providers
.connected()
.filter(
@@ -61,6 +82,37 @@ export const SettingsProviders: Component<{
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
})
createEffect(() => {
const ids = new Set(available().map((item) => item.id))
Object.entries(state.disconnecting).forEach(([id, status]) => {
if (status === "removing" && !ids.has(id)) setState("disconnecting", id, "removed")
})
})
const connected = createMemo(() => available().filter((item) => !state.disconnecting[item.id]))
const consoleGroup = createMemo(() => {
const root = available().find((item) => item.id === "opencode")
const suffix = " / OpenCode"
if (!root?.name.endsWith(suffix)) return
const workspace = root.name.slice(0, -suffix.length).trim()
if (!workspace) return
const prefix = `${workspace} / `
return {
root,
workspace,
providers: available().filter((item) => item.name.startsWith(prefix)),
prefix,
}
})
const displayed = createMemo(() => {
const group = consoleGroup()
if (!group) return connected()
const grouped = new Set(group.providers.filter((item) => item.id !== group.root.id).map((item) => item.id))
return connected().filter((item) => !grouped.has(item.id))
})
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
const items = providers
@@ -104,6 +156,10 @@ export const SettingsProviders: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const disconnect = async (providerID: string, name: string) => {
if (state.disconnecting[providerID]) return
const group = consoleGroup()
const ids = group?.root.id === providerID ? group.providers.map((provider) => provider.id) : [providerID]
updateDisconnecting(ids, "removing")
const location = props.directory ? { directory: props.directory } : undefined
await serverSdk.api.integration
.get({ integrationID: providerID, location })
@@ -113,6 +169,7 @@ export const SettingsProviders: Component<{
await Promise.all(
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
)
updateDisconnecting(ids, "removed")
showToast({
variant: "success",
icon: "circle-check",
@@ -121,6 +178,7 @@ export const SettingsProviders: Component<{
})
})
.catch((err: unknown) => {
updateDisconnecting(ids, undefined)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -143,38 +201,127 @@ export const SettingsProviders: Component<{
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsList>
<Show
when={connected().length > 0}
when={displayed().length > 0}
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
>
<For each={connected()}>
{(item) => (
<div class="settings-provider-row group">
<div class="settings-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
<div class="settings-provider-main">
<span class="settings-provider-name truncate">{item.name}</span>
<Badge>{type(item)}</Badge>
</div>
</div>
<For each={displayed()}>
{(item) => {
const console = () => (consoleGroup()?.root.id === item.id ? consoleGroup() : undefined)
return (
<Show
when={canDisconnect(item)}
when={console()}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
<div class="settings-provider-row group">
<div class="settings-provider-lead">
<Show
when={item.id === "opencode"}
fallback={
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
}
>
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
</Show>
<div class="settings-provider-main">
<span class="settings-provider-name truncate">{item.name}</span>
<Badge>{type(item)}</Badge>
</div>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item.id, item.name)}
>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
}
>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
{(group) => (
<div class="settings-provider-console group">
<div class="settings-provider-console-header">
<div class="settings-provider-lead">
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
<div class="settings-provider-console-summary">
<div class="settings-provider-main">
<span class="settings-provider-name truncate">
{language.t("provider.connect.console.name")}
</span>
<Badge>{group().workspace}</Badge>
</div>
<Show when={group().providers.length > 1}>
<button
type="button"
class="settings-provider-console-toggle"
aria-expanded={state.consoleExpanded}
onClick={() => setState("consoleExpanded", (value) => !value)}
>
<span>
{language.plural(
"settings.providers.console.available",
group().providers.length,
{ count: group().providers.length },
)}
</span>
<Icon
name="chevron-right"
size="small"
classList={{
"settings-provider-console-chevron": true,
open: state.consoleExpanded,
}}
/>
</button>
</Show>
</div>
</div>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item.id, language.t("provider.connect.console.name"))}
>
{language.t("common.disconnect")}
</Button>
</div>
<Show when={state.consoleExpanded}>
<div class="settings-provider-console-list">
<div class="settings-provider-console-separator" aria-hidden="true" />
<For each={group().providers}>
{(provider) => (
<button
type="button"
class="settings-provider-console-item"
onClick={() => props.onSelectProvider?.(provider.id)}
>
<span>{provider.name.slice(group().prefix.length)}</span>
<Icon
name="chevron-right"
size="small"
class="settings-provider-console-item-chevron"
/>
</button>
)}
</For>
</div>
</Show>
</div>
)}
</Show>
</div>
)}
)
}}
</For>
</Show>
</SettingsList>
@@ -187,12 +334,19 @@ export const SettingsProviders: Component<{
{(item) => (
<div class="settings-provider-row">
<div class="settings-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
<Show
when={item.id === "opencode"}
fallback={
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
}
>
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
</Show>
<div class="settings-provider-copy">
<div class="settings-provider-main">
<span class="settings-provider-name">{item.name}</span>
+134 -2
View File
@@ -515,6 +515,129 @@
border-bottom: none;
}
.settings-provider-console {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-provider-console:last-child {
border-bottom: none;
}
.settings-provider-console-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 48px;
padding-block: 10px;
}
.settings-provider-console-summary {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.settings-provider-console-toggle {
display: flex;
height: 28px;
align-items: center;
gap: 2px;
padding-inline: 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
cursor: pointer;
}
@media (hover: hover) {
.settings-provider-console-toggle:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-text-text-base);
}
}
.settings-provider-console-chevron {
color: var(--v2-icon-icon-muted);
transition: transform 150ms ease-out;
}
.settings-provider-console-chevron.open {
transform: rotate(90deg);
}
.settings-provider-console-list {
display: flex;
flex-direction: column;
gap: 2px;
padding-bottom: 10px;
}
.settings-provider-console-separator {
height: 0.5px;
margin-bottom: 6px;
background: var(--v2-border-border-base);
}
.settings-provider-console-item {
display: flex;
width: 100%;
min-height: 24px;
align-items: center;
gap: 2px;
padding-inline: 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
text-align: start;
cursor: pointer;
}
.settings-provider-console-item-chevron {
color: var(--v2-icon-icon-base);
opacity: 0;
}
@media (hover: hover) {
.settings-provider-console-item:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-provider-console-item:hover .settings-provider-console-item-chevron {
opacity: 1;
}
}
.settings-provider-console-item:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-provider-console-item:focus-visible .settings-provider-console-item-chevron {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.settings-provider-console-chevron {
transition: none;
}
}
@container settings-panel (max-width: 520px) {
.settings-provider-console-header {
flex-wrap: wrap;
}
}
@media (min-width: 640px) {
.settings-provider-row {
flex-wrap: nowrap;
@@ -536,7 +659,7 @@
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
gap: 8px;
}
.settings-provider-lead:not(:has(.settings-provider-copy)) {
@@ -575,7 +698,7 @@
}
.settings-provider-empty {
padding-block: 20px;
padding-block: 16px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
@@ -633,6 +756,15 @@
margin-top: 16px;
}
.settings-providers [data-component="connected-providers-section"] [data-component="settings-list"] {
padding-inline: 16px;
}
.settings-providers [data-component="connected-providers-section"] .settings-provider-row {
min-height: 48px;
padding-block: 10px;
}
.settings-tab-header.settings-tab-header--stacked {
display: flex;
flex-direction: column;
+17 -3
View File
@@ -55,7 +55,10 @@ export const SettingsScreen: Component = () => {
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const [state, setState] = createStore({
worktreeFilterReset: 0,
modelProvider: undefined as string | undefined,
})
let root: HTMLDivElement | undefined
onMount(() => {
@@ -228,10 +231,21 @@ export const SettingsScreen: Component = () => {
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
<SettingsProviders
directory={directory()}
onBack={showProviders}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.open("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
<SettingsModels
active={surface.tab() === "models"}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
@@ -0,0 +1,93 @@
import { onCleanup } from "solid-js"
const FOCUS_LOCK = "opencode:notification-focus"
const MAX_CLAIMED = 500
export function createNotificationCoordinator() {
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
const claimed = new Set<string>()
const focus = { pending: false, release: undefined as (() => void) | undefined }
const updateFocus = () => {
if (typeof document === "undefined" || !document.hasFocus()) {
focus.release?.()
return
}
if (!locks || focus.pending || focus.release) return
focus.pending = true
void locks
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
focus.pending = false
if (!document.hasFocus()) return
await new Promise<void>((resolve) => {
focus.release = resolve
})
focus.release = undefined
})
.catch(() => {
focus.pending = false
})
}
if (typeof window !== "undefined") {
window.addEventListener("focus", updateFocus)
window.addEventListener("blur", updateFocus)
document.addEventListener("visibilitychange", updateFocus)
updateFocus()
onCleanup(() => {
window.removeEventListener("focus", updateFocus)
window.removeEventListener("blur", updateFocus)
document.removeEventListener("visibilitychange", updateFocus)
focus.release?.()
})
}
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
const key = `${kind}:${eventID}`
const execute = async () => {
if (!claim(kind, key, claimed)) return
await run()
}
if (!locks) return execute()
await locks.request(`opencode:notification:${key}`, execute)
}
return {
sound(eventID: string, run: () => Promise<unknown> | void) {
return once("sound", eventID, run)
},
system(eventID: string, run: () => Promise<unknown> | void) {
return once("system", eventID, async () => {
if (typeof document !== "undefined" && document.hasFocus()) return
if (!locks) return run()
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
if (!lock) return
await run()
})
})
},
}
}
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const storageKey = `opencode:notification-${kind}`
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
@@ -11,7 +11,8 @@ import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
@@ -114,7 +115,12 @@ function buildNotificationIndex(list: Notification[]) {
return index
}
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
export function createServerNotificationState(input: {
sdk: ServerSDK
data: Data
key: ServerConnection.Key
coordinator: ReturnType<typeof createNotificationCoordinator>
}) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -223,7 +229,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
if (session.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
}
append({
@@ -235,8 +241,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
})
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
),
)
}
})
@@ -248,7 +256,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
if (session?.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
}
append({
@@ -263,8 +271,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
),
)
}
})
@@ -74,9 +74,6 @@ function getLoads() {
}
const cache = new Map<SoundID, Promise<string | undefined>>()
const claimed = new Set<string>()
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
const MAX_CLAIMED = 500
export function soundSrc(id: string | undefined) {
const loads = getLoads()
@@ -103,34 +100,3 @@ export function playSound(src: string | undefined) {
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
const play = async () => {
if (!claim(eventID)) return
await playSoundById(id)
}
if (typeof navigator === "undefined" || !navigator.locks) return play()
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
}
function claim(eventID: string) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
+5 -1
View File
@@ -76,7 +76,10 @@ export function buildEffortSelectOption(input: {
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
}
}
@@ -125,6 +128,7 @@ export function formatVariantName(variant: string) {
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
+4 -2
View File
@@ -201,7 +201,7 @@ export async function streamTurn(input: {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID,
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
content: { type: "text", text: event.data.delta },
})
continue
@@ -455,6 +455,8 @@ async function replayMessage(
return
}
if (message.type !== "assistant") return
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
let reasoningOrdinal = 0
for (const part of message.content) {
if (part.type === "text") {
await connection.sessionUpdate({
@@ -472,7 +474,7 @@ async function replayMessage(
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: message.id,
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
content: { type: "text", text: part.text },
},
})
+20 -5
View File
@@ -41,7 +41,12 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import {
buildConfigOptions,
DEFAULT_VARIANT_VALUE,
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
@@ -275,7 +280,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value)
const selected = requireModel(state.catalog, params.value, state.model)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
@@ -284,7 +289,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (!model?.variants.some((variant) => variant.id === params.value))
if (
!model ||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
)
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
@@ -453,7 +461,7 @@ function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
}))
}
function requireModel(catalog: Catalog, modelID: string): ModelRef {
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
@@ -461,7 +469,14 @@ function requireModel(catalog: Catalog, modelID: string): ModelRef {
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
return { providerID: model.providerID, id: model.id, variant: selected.variant }
const variant =
selected.variant ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant }
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
null,
2,
)
: formatTable(page.data)) + EOL
: formatList(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
@@ -96,18 +96,14 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
),
)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
function formatList(sessions: ReadonlyArray<SessionInfo>) {
return sessions
.map((session) =>
[
session.id,
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
new Date(session.time.updated).toLocaleString(),
].join("\t"),
)
.join(EOL)
}
@@ -50,11 +50,11 @@ describe("acp config option subprocess", () => {
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
expect(effort.currentValue).toBe("default")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
}, 60_000)
test("effort switch updates currentValue", async () => {
test("effort survives model synchronization and can be reset to default", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
@@ -70,5 +70,23 @@ describe("acp config option subprocess", () => {
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
const synchronized = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: requireSelectOption(session.configOptions, "model").currentValue,
}),
)
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
const reset = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: "default",
}),
)
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
}, 60_000)
})
+44 -6
View File
@@ -91,7 +91,7 @@ describe("acp event behavior", () => {
}
})
test("preserves text and reasoning order before returning the terminal response", async () => {
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
const firstUpdate = Promise.withResolvers<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
@@ -108,6 +108,14 @@ describe("acp event behavior", () => {
delta: "think-1",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: " continued",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
@@ -120,7 +128,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 2,
ordinal: 1,
delta: "think-2",
}),
)
@@ -144,7 +152,7 @@ describe("acp event behavior", () => {
firstUpdate.resolve()
await releaseUpdate.promise
}
if (updates.length === 3) allUpdates.resolve()
if (updates.length === 4) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
@@ -171,18 +179,48 @@ describe("acp event behavior", () => {
) {
return [
item.update.sessionUpdate,
item.update.messageId,
item.update.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "think-1"],
["agent_message_chunk", "answer"],
["agent_thought_chunk", "think-2"],
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
])
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
const replayed: SessionUpdateParams[] = []
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
{
id: "msg_order",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1 },
content: [
{ type: "reasoning", text: "think-1 continued" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "think-2" },
],
},
])
expect(replayed).toEqual([
{
sessionId: "ses_order",
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "msg_order:reasoning:0",
content: { type: "text", text: "think-1 continued" },
},
},
updates[2],
updates[3],
])
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
@@ -222,7 +222,7 @@ describe("acp service directory behavior", () => {
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
expect(currentValue(selectedModel, "effort")).toBe("low")
expect(currentValue(selectedModel, "effort")).toBe("default")
expect(currentValue(selectedEffort, "effort")).toBe("medium")
expect(currentValue(selectedMode, "mode")).toBe("plan")
expect(
@@ -32,7 +32,7 @@ describe("acp service lifecycle", () => {
model: { providerID: "test", id: "second-model" },
},
})
expect(currentValue(created, "effort")).toBe("none")
expect(currentValue(created, "effort")).toBe("default")
})
test("loads and forks with paginated replay while resume does not replay", async () => {
+20 -12
View File
@@ -11,13 +11,13 @@ import type { RelativePath } from "@opencode/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode/schema/model"
import type { DateTime } from "effect"
import type { Permission } from "@opencode/schema/permission"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -27,7 +27,6 @@ import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { Permission } from "@opencode/schema/permission"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
import type { Command } from "@opencode/schema/command"
@@ -37,6 +36,7 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Reference } from "@opencode/schema/reference"
import type { Worktree } from "@opencode/schema/worktree"
import type { Vcs } from "@opencode/schema/vcs"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
@@ -209,6 +209,7 @@ export type SessionCreateInput = {
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionCreateOutput = Session.Info
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@@ -360,15 +361,6 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly messageID?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -446,6 +438,7 @@ export type SessionLogOutput =
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
readonly version: string
}
}
@@ -498,6 +491,15 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
}
| {
readonly id: Event.ID
readonly created: number
@@ -1148,7 +1150,6 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -1595,6 +1596,12 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1602,6 +1609,7 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
+12 -14
View File
@@ -68,8 +68,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -183,6 +181,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -397,6 +397,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -594,17 +595,6 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -744,7 +734,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -1159,6 +1148,14 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
request: { list: EndpointPermissionRequestList(raw) },
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
@@ -1166,6 +1163,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
+15 -14
View File
@@ -62,8 +62,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -177,6 +175,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -567,6 +567,7 @@ export function make(options: ClientOptions) {
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -844,18 +845,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -1580,6 +1569,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
file: {
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
+127 -93
View File
@@ -147,14 +147,6 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -559,28 +551,6 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
version: string
}
}
export type SessionAgentSelected = {
id: string
created: number
@@ -1659,24 +1629,6 @@ export type SessionInboxMove = {
delivery: SessionInboxDelivery
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
revert?: SessionRevert
}
export type SessionRevertStaged = {
id: string
created: number
@@ -1920,6 +1872,58 @@ export type AgentInfo = {
permissions: PermissionRuleset
}
export type SessionPermissionsUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.permissions.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; permissions: PermissionRuleset }
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
permissions?: PermissionRuleset
revert?: SessionRevert
}
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
permissions?: PermissionRuleset
version: string
}
}
export type ConfigEntry =
| {
type: "document"
@@ -2092,8 +2096,6 @@ export type ConfigEntry =
| { type: "agents"; path: string }
| { type: "claude"; path: string }
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = {
id: string
sessionID: string
@@ -2148,6 +2150,8 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields2 = [FormField1, ...Array<FormField1>]
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionInboxEnqueued = {
@@ -2202,7 +2206,6 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -2242,6 +2245,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionDeleted
| SessionForked
@@ -2301,6 +2305,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
@@ -2813,6 +2818,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["id"]
readonly title?: {
readonly id?: string | null
@@ -2821,6 +2831,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["title"]
readonly agent?: {
readonly id?: string | null
@@ -2829,6 +2844,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["agent"]
readonly model?: {
readonly id?: string | null
@@ -2837,6 +2857,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["model"]
readonly location?: {
readonly id?: string | null
@@ -2845,6 +2870,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["location"]
readonly metadata?: {
readonly id?: string | null
@@ -2853,7 +2883,25 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["metadata"]
readonly permissions?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["permissions"]
}
export type SessionCreateOutput = { data: SessionInfo }["data"]
@@ -2891,6 +2939,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3161,13 +3214,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3203,6 +3249,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3473,13 +3524,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3515,6 +3559,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3785,13 +3834,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4281,27 +4323,6 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["messageID"]
readonly to?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
@@ -5804,6 +5825,19 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void
export type PermissionRulesInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly permissions: {
readonly permissions: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}["permissions"]
}
export type PermissionRulesOutput = void
export type FileReadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+4 -12
View File
@@ -695,6 +695,10 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
@@ -1024,18 +1028,6 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+2 -3
View File
@@ -5,9 +5,8 @@ import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
const base64 = (name: "atob" | "btoa") =>
sync(name, (args, node) => {
if (args.length === 0) {
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
}
if (args.length === 0)
throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node).as("TypeError")
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260910120000_clear_v1_session_permission",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`UPDATE \`session_v2\` SET \`permission\` = NULL;`)
})
},
}
export default migration
@@ -600,7 +600,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
+2
View File
@@ -41,6 +41,8 @@ export const layer = Layer.effect(
[
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.ModelConfigurationError",
"SessionRunnerModel.ModelInitializationError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
"SessionRunnerModel.UnsupportedCompactionError",
],
+64 -75
View File
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -309,7 +308,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
options?: { stdin?: string; env?: Record<string, string> },
) {
const result = yield* proc
.run(
@@ -318,7 +317,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
{ stdin: options?.stdin },
)
.pipe(
Effect.mapError(
@@ -332,8 +331,7 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -387,7 +385,9 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,7 +464,13 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -493,23 +499,19 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -517,57 +519,49 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
"diff",
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -739,11 +733,6 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+73 -5
View File
@@ -1,7 +1,7 @@
export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LanguageModel } from "@opencode/ai"
import { LanguageModel, ProviderConfigurationError } from "@opencode/ai"
import { Auth } from "@opencode/ai/route"
import { Context, Effect, Layer, Schema, Struct } from "effect"
import { AISDK } from "./aisdk.js"
@@ -39,6 +39,40 @@ export class UnsupportedPackageError extends Schema.TaggedError<UnsupportedPacka
}
}
export const InitializationPhase = Schema.Literals(["load", "init", "construct"])
export type InitializationPhase = typeof InitializationPhase.Type
/** Provider settings are missing, conflicting, or unsupported; the provider's own message tells the user what to fix. */
export class ModelConfigurationError extends Schema.TaggedError<ModelConfigurationError>()(
"SessionRunnerModel.ModelConfigurationError",
{
providerID: Provider.ID,
modelID: ID,
package: Schema.String,
detail: Schema.String,
},
) {
override get message() {
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
}
}
/** A supported package failed unexpectedly while loading or constructing the model. */
export class ModelInitializationError extends Schema.TaggedError<ModelInitializationError>()(
"SessionRunnerModel.ModelInitializationError",
{
providerID: Provider.ID,
modelID: ID,
package: Schema.String,
phase: InitializationPhase,
detail: Schema.String,
},
) {
override get message() {
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
}
}
export class UnresolvedProviderVariablesError extends Schema.TaggedError<UnresolvedProviderVariablesError>()(
"SessionRunnerModel.UnresolvedProviderVariablesError",
{
@@ -68,6 +102,8 @@ export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCo
export type Error =
| VariantUnavailableError
| UnsupportedPackageError
| ModelConfigurationError
| ModelInitializationError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
| Integration.AuthorizationError
@@ -135,7 +171,11 @@ export const fromCatalogModel = (
dependencies?: Dependencies,
): Effect.Effect<
LanguageModel,
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
| UnsupportedPackageError
| ModelConfigurationError
| ModelInitializationError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
@@ -178,14 +218,16 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
...configuration,
}) ?? {},
)
return yield* loadAISDK({ ...resolved, settings }).pipe(Effect.mapError(() => unsupported(resolved)))
return yield* loadAISDK({ ...resolved, settings }).pipe(
Effect.mapError((error) => initialization(resolved, "init", error.cause)),
)
}
if (!native) return yield* unsupported(resolved)
const specifier = native
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
Effect.mapError((error) => initialization(resolved, "load", error.cause)),
)
const settings = {
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
@@ -204,7 +246,15 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
catch: (cause) =>
cause instanceof ProviderConfigurationError
? new ModelConfigurationError({
providerID: resolved.providerID,
modelID: resolved.id,
package: resolved.package ?? "unknown",
detail: cause.message,
})
: initialization(resolved, "construct", cause),
})
})
@@ -277,6 +327,24 @@ const unsupported = (model: Info) =>
package: model.package ?? "unknown",
})
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
new ModelInitializationError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
phase,
detail: causeMessage(cause) ?? `${phase} failed for ${model.package ?? "unknown"}`,
})
// Unexpected throws still carry the most useful diagnosis in their message; a stack or an unknown value does not.
const causeMessage = (cause: unknown): string | undefined => {
if (typeof cause === "string") return cause.trim() || undefined
if (!(cause instanceof globalThis.Error)) return undefined
const message = cause.message.trim()
if (message) return message
return causeMessage(cause.cause)
}
export const resolveModel = (
model: Info,
variant: VariantID | undefined,
+1 -1
View File
@@ -154,7 +154,7 @@ const layer = Layer.effect(
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
+3
View File
@@ -404,6 +404,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
rules: sessions.setPermissions,
},
plugin: {
list: () => response(plugin.list()),
@@ -509,6 +510,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
title: input?.title,
agent: input?.agent,
model: input?.model,
metadata: input?.metadata,
permissions: input?.permissions,
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
+19 -2
View File
@@ -11,6 +11,7 @@ import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
import PROMPT_KIMI from "./system-prompt/kimi.txt"
import PROMPT_META from "./system-prompt/meta.txt"
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
const id = model.id.toLowerCase()
@@ -18,6 +19,19 @@ export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
return id.includes("gpt-6") ? PROMPT_ASTRA : PROMPT_GPT
})
export const AnthropicPlugin = make(
"opencode.prompt.anthropic",
(model) => {
const id = model.id.toLowerCase()
if (!id.includes("claude")) return undefined
return PROMPT_ANTHROPIC
},
"append",
)
// Both OpenAIToolsPlugin and AnthropicToolsPlugin are disabled intentionally until we can figure out a good ux for displaying
// heavy grep/glob usage done via shell or other mechanisms
export const OpenAIToolsPlugin = make("opencode.optimize.openai.tools", (model, tools) => {
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
if (!ids.includes("gpt")) return undefined
@@ -45,11 +59,12 @@ export const MetaPlugin = make("opencode.prompt.meta", (model) => {
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
})
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
function make(
id: string,
optimize: (model: Model.Info, tools: SessionHooks["context"]["tools"]) => string | undefined,
mode: "override" | "append" = "override",
) {
return define({
id,
@@ -66,7 +81,9 @@ function make(
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
const rendered = SessionSystemPrompt.render(template, Object.keys(event.tools))
const text = mode === "append" ? `${system.text}\n\n${rendered}` : rendered
event.system[0] = { ...system, text }
}).pipe(Effect.catch(() => Effect.void))
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
+1
View File
@@ -101,6 +101,7 @@ export const XAIPlugin = define({
for (const model of provider.models.values()) {
catalog.model.update(providerID, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
draft.websocket = true
})
}
})
@@ -0,0 +1,2 @@
# Code comments
By default, match the surrounding comment density: where the code has none, add none. Use comments sparingly, only where they are appropriate, such as for behavior that is not obvious from the code itself. Instructions from the user or the project take precedence over this guidance.
+10 -26
View File
@@ -18,6 +18,7 @@ import { SessionMessageTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { RelativePath } from "./schema.js"
import { Agent } from "@opencode/schema/agent"
import type { Permission } from "@opencode/schema/permission"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import path from "path"
@@ -54,11 +55,8 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode/util/fs-util"
import type { EventLog } from "@opencode/schema/event-log"
import type { FileDiff } from "@opencode/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -84,6 +82,7 @@ type CreateBaseInput = {
agent?: Agent.ID
model?: Model.Ref
metadata?: SessionSchema.Metadata
permissions?: Permission.Ruleset
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
@@ -110,7 +109,6 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -137,13 +135,6 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -168,6 +159,10 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly setPermissions: (input: {
sessionID: SessionSchema.ID
permissions: Permission.Ruleset
}) => Effect.Effect<void, NotFoundError>
readonly move: SessionMove.Interface["move"]
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
@@ -232,7 +227,6 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -260,9 +254,10 @@ const layer = Layer.effect(
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
// Children inherit metadata the way they inherit location, so
// host policies that read it treat the family uniformly.
// Children inherit metadata and permissions the way they inherit
// location, so host policies that read them treat the family uniformly.
metadata: input.metadata ?? parent?.metadata,
permissions: input.permissions ?? parent?.permissions,
model: input.model
? {
id: Model.ID.make(input.model.id),
@@ -364,17 +359,6 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
messageID: input.messageID,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -410,6 +394,7 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
@@ -463,7 +448,6 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
+2 -1
View File
@@ -1,6 +1,7 @@
export * as SessionContext from "./context.js"
import { Model } from "@opencode/schema/model"
import { Permission } from "../permission.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
@@ -129,7 +130,7 @@ const layer = Layer.effect(
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(agent.info.permissions),
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
-138
View File
@@ -1,138 +0,0 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["messageID", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+1
View File
@@ -50,6 +50,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
metadata: row.metadata ?? undefined,
permissions: row.permission ?? undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
+4 -20
View File
@@ -60,21 +60,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -131,6 +116,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.permissions.updated": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
@@ -138,11 +124,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
+10
View File
@@ -160,6 +160,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
permission: parent.permission,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -450,6 +451,7 @@ const layer = Layer.effectDiscard(
agent: event.data.agent,
model: event.data.model,
metadata: event.data.metadata,
permission: event.data.permissions,
version: event.data.version,
time_created: event.created,
time_updated: event.created,
@@ -571,6 +573,14 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
db
.update(SessionTable)
.set({ permission: event.data.permissions, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) => {
const idle = event.data.idle
return db
@@ -33,6 +33,10 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const ModelConfigurationError = ModelResolver.ModelConfigurationError
export type ModelConfigurationError = ModelResolver.ModelConfigurationError
export const ModelInitializationError = ModelResolver.ModelInitializationError
export type ModelInitializationError = ModelResolver.ModelInitializationError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
@@ -226,7 +226,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+11
View File
@@ -3,6 +3,7 @@ export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Scope } from "effect"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Permission } from "@opencode/schema/permission"
import { Event } from "@opencode/schema/event"
import { FSUtil } from "@opencode/util/fs-util"
import { Bus } from "../bus.js"
@@ -72,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const setPermissions = Effect.fn("Session.setPermissions")(function* (
sessionID: SessionSchema.ID,
input: { permissions: Permission.Ruleset },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
input: { agent: Agent.ID },
@@ -334,6 +342,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
@@ -356,6 +365,7 @@ export const make = Effect.fn("Session.make")(function* () {
const message = operations.message.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
const inbox = operations.inbox.bind(undefined, sessionID)
@@ -381,6 +391,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
+2 -2
View File
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { PermissionV1 } from "@opencode/schema/permission-v1"
import type { Permission } from "@opencode/schema/permission"
import type { Project } from "@opencode/schema/project"
import type { SessionSchema } from "./schema.js"
import type { Workspace } from "@opencode/schema/workspace"
@@ -49,7 +49,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
@@ -55,6 +55,8 @@ export function toSessionError(cause: unknown): SessionError.Error {
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
cause instanceof SessionRunnerModel.ModelConfigurationError ||
cause instanceof SessionRunnerModel.ModelInitializationError ||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
)
return { type: "provider.no-route", message: cause.message }
+1
View File
@@ -103,6 +103,7 @@ const layer = Layer.effect(
agent: input.data.info.agent,
model: input.data.info.model,
metadata: input.data.info.metadata,
permissions: input.data.info.permissions,
},
{
location: input.location,
+16 -33
View File
@@ -131,55 +131,38 @@ const layer = Layer.effect(
)
})
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
source: repo.source,
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
const comparison = yield* compare("diff", input)
return yield* git.tree
.diff({
repository: compared.repository,
from: compared.from,
to: compared.to,
...comparison.input,
context: input.context,
paths: input.paths,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
+12 -1
View File
@@ -99,8 +99,19 @@ export const layer = Layer.effect(
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const output = () => {
if (result.structured !== undefined) return result.structured
if (text === "") return null
// Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.
if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) {
try {
return JSON.parse(text)
} catch {}
}
return text
}
return {
output: result.structured ?? (text === "" ? null : text),
output: output(),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
-37
View File
@@ -6,7 +6,6 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Git } from "@opencode/core/git"
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { VcsPatch } from "@opencode/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -197,42 +196,6 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+74
View File
@@ -324,6 +324,32 @@ const mcp = Layer.mock(Mcp.Service, {
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "issues",
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "count",
description: "Returns a number as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "typed",
description: "Declares a string output and returns JSON as text",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "string" },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "issues",
codemode: false,
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
@@ -374,6 +400,20 @@ const mcp = Layer.mock(Mcp.Service, {
isError: false,
content: [{ type: "text", text: "hello" }],
})
if (input.name === "issues" || input.name === "typed")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
})
if (input.name === "count")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "42" }],
})
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
@@ -1943,6 +1983,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_issues",
"direct_lookup",
"direct_media",
"execute",
@@ -2033,6 +2074,39 @@ it.effect("returns content-only MCP results through Code Mode", () =>
}),
)
it.effect("parses JSON text results from MCP tools without an output schema", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
const run = (code: string) =>
toolSet
.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
})
.pipe(Effect.map((execution) => execution.output.output))
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
// Outside Code Mode the content the model reads is the original text.
expect(
yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
}),
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service

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