Compare commits

..

13 Commits

Author SHA1 Message Date
opencode-agent[bot] f929856cdf chore: update nix node_modules hashes 2026-08-20 07:59:11 +00:00
Luke Parker 2a7d0729d0 feat(desktop): optimize cold development startup (#42722) 2026-08-20 17:40:50 +10:00
Aiden Cline e2d9376614 fix(ai): preserve response item ids (#43590) 2026-08-20 01:50:04 -05:00
Major Hayden f288d7e107 fix(ai): isolate Gemini function-response turns (#43479)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-20 01:31:36 -05:00
Dmitry Nefedov afe4c5d23a fix(tui): utilize TUI cursor style config in opencode2 mini (#43529) 2026-08-20 01:24:33 -05:00
Aiden Cline 98ad4465f8 fix(core): preserve discovered model limits (#43589) 2026-08-20 01:11:34 -05:00
Aiden Cline 5b1e8450e7 feat(ai): preserve streamed refusals as text (#43343) 2026-08-20 00:47:18 -05:00
Aiden Cline 99490f289b refactor(ai): remove model limits (#43581) 2026-08-20 00:36:35 -05:00
opencode-agent[bot] e37c7be434 fix(app): resolve branch from active directory (#43579)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-20 05:01:01 +00:00
Aiden Cline 39f4adb4dc fix(ai): settle pending Responses tool calls (#43575) 2026-08-19 23:53:27 -05:00
Dax Raad 393b43a881 Revert "feat(stats): combine free and go usage (#43577)"
This reverts commit 394b5ac5fd.
2026-08-20 00:49:46 -04:00
opencode-agent[bot] 394b5ac5fd feat(stats): combine free and go usage (#43577)
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-20 00:43:44 -04:00
opencode-agent[bot] dbc7d0ee09 chore: update nix node_modules hashes 2026-08-20 04:01:43 +00:00
176 changed files with 3129 additions and 3415 deletions
+1392 -827
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-9IJxoe/MdL6nmoeKvxZop+77HJ/b3HbmpytNUvLqYkc=",
"aarch64-linux": "sha256-KR1J102RDTRDZIkAD3jQfXeP1G2DN9Es7KkdKo+daec=",
"aarch64-darwin": "sha256-HCgSoq1W6XU6m73Ck3wV8gjB687caUTM3w/EO+V7wsE=",
"x86_64-darwin": "sha256-GrKTDvjg0XeDIWbOvayWQjj0SdJd8WPm2+q3IVS17Jg="
"x86_64-linux": "sha256-JEqi00PCle+o5OfBlJJaZtXd+4sYB3o+rvYiESlN4dY=",
"aarch64-linux": "sha256-zk3Uk1SQyeRrQ7BuFwlOnQAptUHIkq+oPdfd+sTEq5U=",
"aarch64-darwin": "sha256-3BOd3EcqimoG3rTI6lTHe91YVlCoEi8/68eT1lbOi0c=",
"x86_64-darwin": "sha256-X7wGmjiMloF5Zhuc20kAxLC+tl613YNXRgA+dQjP2WM="
}
}
+1
View File
@@ -15,6 +15,7 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
+1 -2
View File
@@ -309,7 +309,7 @@ Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and
### Package-like entrypoints
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
@@ -317,7 +317,6 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
})
```
@@ -31,6 +31,7 @@ import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
export type ThinkingInput =
| {
@@ -624,7 +625,6 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
@@ -663,7 +663,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools,
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? outputLimit,
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
temperature: generation?.temperature,
top_p: generation?.topP,
top_k: generation?.topK,
+3 -1
View File
@@ -289,7 +289,9 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
if (previous?.role === "user")
// Gemini rejects a continuation whose function-response turn carries extra
// parts, so an update after a tool result starts its own user turn.
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] })
continue
+86 -45
View File
@@ -93,6 +93,8 @@ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
@@ -101,6 +103,7 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
arguments: Schema.String,
@@ -115,6 +118,8 @@ type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem =
| OpenResponsesInputItem
| {
readonly type: "message"
readonly id?: string
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null
@@ -128,8 +133,6 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({
type: Schema.tag("function"),
name: Schema.String,
@@ -298,6 +301,20 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
const RefusalEvent = Schema.Union([
Schema.Struct({
type: Schema.tag("response.refusal.delta"),
item_id: Schema.String,
delta: Schema.String,
}),
Schema.Struct({
type: Schema.tag("response.refusal.done"),
item_id: Schema.String,
refusal: Schema.String,
}),
])
const isRefusalEvent = Schema.is(RefusalEvent)
export interface Extension {
readonly id: string
readonly name: string
@@ -363,34 +380,42 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
type: "function_call",
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
})
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(id ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
if (!ProviderShared.isRecord(metadata) || !id) return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: metadata.itemId,
id,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
return itemID(part.providerMetadata, providerMetadataKey)
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
@@ -475,24 +500,26 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
return groups
}, [])
input.push(
...groups.map((group) => ({
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
@@ -521,19 +548,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
existing.encrypted_content = reasoning.encrypted_content
continue
}
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
continue
}
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -713,7 +735,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
@@ -942,7 +964,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
),
messageItems,
messagePhases,
@@ -955,7 +977,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
providerMetadata: providerMetadata(state, { itemId: item.id }),
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -1006,11 +1032,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
const events: LLMEvent[] = []
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, state.hasFunctionCall),
normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
@@ -1022,8 +1056,8 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
})
: undefined,
})
return [{ ...state, lifecycle }, events]
}
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
@@ -1065,6 +1099,14 @@ export const step = (state: ParserState, event: Event) => {
: onOutputTextDone(state, event, event.item_id),
)
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
@@ -1092,8 +1134,7 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
+14 -2
View File
@@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat"
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions"
@@ -194,6 +194,7 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
refusal: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
@@ -709,6 +710,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent =
Boolean(delta?.content) ||
Boolean(delta?.refusal) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
@@ -728,7 +730,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || toolDeltas.length > 0)
(Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
@@ -743,6 +745,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
for (const [position, tool] of toolDeltas.entries()) {
@@ -42,6 +42,8 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
-5
View File
@@ -4,11 +4,6 @@ export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
export interface Definition<
@@ -90,7 +90,6 @@ const config = (settings: Settings): Config => {
credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
region: settings.region,
}
@@ -62,7 +62,6 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
region: settings.region,
}).model(modelID)
}
@@ -68,7 +68,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
-1
View File
@@ -63,7 +63,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
}
-1
View File
@@ -126,7 +126,6 @@ const config = (settings: Settings): Config => {
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
@@ -75,7 +75,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
@@ -110,7 +110,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
@@ -80,7 +80,6 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
@@ -119,7 +119,6 @@ export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsIn
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
-1
View File
@@ -63,7 +63,6 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
@@ -55,7 +55,6 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
@@ -74,7 +74,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
-1
View File
@@ -124,7 +124,6 @@ const config = (settings: Settings): Config => {
baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
}
-1
View File
@@ -200,6 +200,5 @@ export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptio
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
-1
View File
@@ -101,7 +101,6 @@ export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const responses = provider.responses
-4
View File
@@ -16,7 +16,6 @@ import {
LLMRequest,
LLMResponse,
LanguageModel,
LanguageModelLimits,
LLMEvent,
InvalidProviderOutputReason,
ProviderID,
@@ -74,7 +73,6 @@ export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults {
readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits
readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions
@@ -82,7 +80,6 @@ export interface RouteDefaults {
export interface RouteDefaultsInput {
readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -119,7 +116,6 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base,
...patch,
headers,
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(
-17
View File
@@ -114,22 +114,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
export namespace LanguageModelLimits {
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
export const make = (input: Input | undefined) =>
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
}
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(LanguageModelLimits),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
@@ -139,7 +124,6 @@ export namespace LanguageModelDefaults {
export type Input =
| LanguageModelDefaults
| {
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -149,7 +133,6 @@ export namespace LanguageModelDefaults {
export const make = (input: Input) => {
if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
+5 -6
View File
@@ -270,21 +270,20 @@ describe("request option precedence", () => {
),
)
it.effect("uses model output limits after route limits and before call maxTokens", () =>
it.effect("uses the Anthropic default before call maxTokens", () =>
Effect.gen(function* () {
const route = AnthropicMessages.route.with({
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
auth: Auth.header("x-api-key", "test"),
limits: { output: 128 },
})
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
const model = route.model({ id: "claude-sonnet-4-5" })
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
const withMaxTokens = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }),
)
expect(withoutMaxTokens.body.max_tokens).toBe(64)
expect(withMaxTokens.body.max_tokens).toBe(32)
expect(withoutMaxTokens.body.max_tokens).toBe(32_000)
expect(withMaxTokens.body.max_tokens).toBe(8_000)
}),
)
})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
@@ -121,7 +121,6 @@ describe("llm constructors", () => {
const model = chatRoute.model({
id: "kimi-k2",
defaults: {
limits: { context: 128_000, output: 8_192 },
generation: { maxTokens: 1_024, stop: ["END"] },
providerOptions: { parallelToolCalls: false },
http: { body: { extra_body: true } },
@@ -130,7 +129,6 @@ describe("llm constructors", () => {
})
const request = LLM.request({ model, prompt: "Say hello." })
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
-14
View File
@@ -43,7 +43,6 @@ describe("provider package entrypoints", () => {
baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
@@ -58,7 +57,6 @@ describe("provider package entrypoints", () => {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true })
expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false })
@@ -70,14 +68,12 @@ describe("provider package entrypoints", () => {
baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
unrelatedInheritedSetting: true,
})
expect(selected.route.id).toBe("openai-responses")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
})
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
@@ -88,7 +84,6 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
providerOptions: { reasoningEffort: "low", store: true },
})
@@ -100,7 +95,6 @@ describe("provider package entrypoints", () => {
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
})
@@ -112,7 +106,6 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
providerOptions: { effort: "low" },
})
@@ -124,7 +117,6 @@ describe("provider package entrypoints", () => {
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
})
@@ -185,7 +177,6 @@ describe("provider package entrypoints", () => {
resourceName: "opencode-test",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const responses = AzureResponses.model("deployment", settings)
@@ -196,7 +187,6 @@ describe("provider package entrypoints", () => {
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(chat.route.id).toBe("azure-openai-chat")
})
@@ -228,7 +218,6 @@ describe("provider package entrypoints", () => {
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
})
@@ -236,7 +225,6 @@ describe("provider package entrypoints", () => {
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
})
@@ -250,7 +238,6 @@ describe("provider package entrypoints", () => {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
})
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
accessToken: "fixture",
@@ -274,7 +261,6 @@ describe("provider package entrypoints", () => {
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(
GoogleVertex.model("gemini-3.5-flash", {
accessToken: "fixture",
@@ -322,7 +322,7 @@ describe("Anthropic Messages route", () => {
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
],
stream: true,
max_tokens: 4096,
max_tokens: 32_000,
})
}),
)
+42
View File
@@ -139,6 +139,48 @@ describe("Gemini route", () => {
}),
)
it.effect("keeps system updates separate from function responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
Message.system("Update."),
Message.system("Later update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
{
role: "user",
parts: [
{ text: "<system-update>\nUpdate.\n</system-update>" },
{ text: "<system-update>\nLater update.\n</system-update>" },
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -664,6 +664,74 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves streamed refusals as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ role: "assistant", refusal: "I can't" }),
deltaChunk({ refusal: " help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }])
}),
)
it.effect("orders metadata-only reasoning before refusal output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
deltaChunk({ refusal: "I can't help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } },
{
type: "text",
text: "I can't help with that.",
},
])
}),
)
it.effect("joins content and refusal deltas into ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ refusal: "No." }),
deltaChunk({ content: " Alternative." }),
deltaChunk({ refusal: " Still no." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("No. Alternative. Still no.")
expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"])
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"])
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
@@ -73,7 +73,7 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -113,11 +113,67 @@ describe("Open Responses-compatible route", () => {
)
expect(prepared.body).toMatchObject({
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
})
}),
)
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
refusal: "I can't help with that.",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
}),
)
it.effect("reads standard Open Responses options", () =>
Effect.gen(function* () {
const model = configure({
@@ -263,7 +263,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -485,7 +485,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
it.effect("continues store-false reasoning while retaining the output item ID", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
@@ -515,6 +515,7 @@ describe("OpenAI Responses route", () => {
...firstInput,
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
@@ -1484,7 +1485,7 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1" },
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-end", id: "msg_1" },
@@ -1505,6 +1506,108 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.content_part.added",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "" },
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: "I can't",
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: " help with that.",
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
refusal: "I can't help with that.",
},
{
type: "response.content_part.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "I can't help with that." },
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
phase: "final_answer",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined })
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openai: { itemId: "msg_refusal", phase: "final_answer" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
])
}),
)
it.effect("rejects malformed refusal events", () =>
Effect.gen(function* () {
const events = [
{ type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" },
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 },
{ type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1543,33 +1646,39 @@ describe("OpenAI Responses route", () => {
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } },
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
providerMetadata: { openai: { phase: null } },
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_commentary",
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
type: "message",
id: "msg_final",
role: "assistant",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
type: "message",
id: "msg_null",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
@@ -1663,12 +1772,12 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1" },
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
])
}),
)
@@ -1700,7 +1809,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
)
@@ -1861,6 +1970,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
},
@@ -1868,7 +1978,6 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).not.toHaveProperty("id")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1912,13 +2021,14 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -2050,6 +2160,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
@@ -2182,6 +2293,50 @@ describe("OpenAI Responses route", () => {
usage,
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("finalizes a pending function call at response completion", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => LLMEvent.is.toolInputEnd(event) || LLMEvent.is.toolCall(event))).toEqual(
[
{
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
],
)
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
@@ -2248,6 +2403,35 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retains function call item metadata when output_item.added is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "item_1" } },
})
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
+22
View File
@@ -2,12 +2,34 @@
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
The `devex` category is the explicit exception to the production-build rule. It measures development commands from submission through a user-visible ready state and has its own Playwright configuration.
Run the suite explicitly from `packages/app`:
```sh
bun run test:bench
```
Run the desktop development startup benchmark from the repository root:
```sh
bun run bench:devex
```
It runs five serial samples of the exact `bun dev:desktop` command. Each sample uses a fresh desktop profile, database, service configuration, service registration, and service process; the desktop selects an isolated ephemeral loopback endpoint. It removes desktop build output and the desktop Vite cache before every run; dependencies, Bun's package cache, and Electron remain installed. The harness stops only that sample's service; it does not stop or change the elected global OpenCode service. The measured endpoint is a visible Home page whose empty-state controls pass Playwright actionability checks. The command's Electron installation check remains inside the measured interval.
Set `DESKTOP_STARTUP_RUNS` only for focused diagnostics:
```sh
DESKTOP_STARTUP_RUNS=1 bun run bench:devex
```
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to capture the renderer's CDP trace from attachment through actionable Home:
```sh
DESKTOP_STARTUP_RUNS=1 OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-desktop-traces bun run bench:devex
```
PowerShell:
```powershell
+2 -2
View File
@@ -18,9 +18,9 @@ const categories = [
"disabled-by-default-v8.cpu_profiler",
]
export async function startChromeTrace(page: Page, name: string) {
export async function startChromeTrace(page: Page, name: string): Promise<undefined | (() => Promise<string>)> {
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
if (!directory) return
if (!directory) return undefined
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
const file = await prepareChromeTrace(directory, name, selectors)
@@ -0,0 +1,26 @@
import { benchmark } from "../benchmark"
import {
desktopBenchmarkContext,
runDesktopStartup,
summarizeDesktopStartup,
type DesktopStartupSample,
} from "./desktop-startup"
benchmark.describe("devex: desktop startup", () => {
benchmark("opens a cold desktop on Home", async ({ report }, testInfo) => {
benchmark.setTimeout(15 * 60_000)
const runs = Number(process.env.DESKTOP_STARTUP_RUNS ?? 5)
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("DESKTOP_STARTUP_RUNS must be a positive integer")
const samples: DesktopStartupSample[] = []
const context = await desktopBenchmarkContext(runs)
for (let run = 1; run <= runs; run++) {
const sample = await runDesktopStartup(run, testInfo).catch((error) => {
report(samples.length ? { samples, summary: summarizeDesktopStartup(samples) } : { samples }, context)
throw error
})
samples.push(sample)
}
report({ samples, summary: summarizeDesktopStartup(samples) }, context)
})
})
@@ -0,0 +1,526 @@
import { Service } from "@opencode-ai/client/service"
import { chromium, expect, type Browser, type Page, type TestInfo } from "@playwright/test"
import { spawn, spawnSync, type ChildProcess } from "node:child_process"
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { startChromeTrace } from "../chrome-trace"
const repository = resolve(import.meta.dirname, "../../../../..")
const milestones = [
"bunRootScript",
"bunDesktopScript",
"desktopPrepared",
"mainBundleReady",
"preloadBundleReady",
"rendererDevServerReady",
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
"desktopPreparation",
"viteMainBundle",
"vitePreloadBundle",
"rendererServerStartup",
"electronStartup",
"serviceSpawnWait",
"serviceProcessStartup",
"rendererStartup",
"visibleWindowToHome",
] as const
type Milestone = (typeof milestones)[number]
type Phase = (typeof phases)[number]
type ServiceInfo = { id: string; version: string; url: string; pid: number }
export type DesktopStartupSample = {
run: number
commandToHomeReadyMs: number
milestonesMs: Record<Milestone, number>
phasesMs: Record<Phase, number>
service: Omit<ServiceInfo, "id">
}
export async function runDesktopStartup(run: number, testInfo: TestInfo) {
const profile = await createColdProfile()
const desktop = await Promise.resolve()
.then(() => startDesktop(profile))
.catch(async (error) => {
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
throw error
})
try {
const page = await desktop.open()
const stopTrace = await startChromeTrace(page, `desktop-startup-${run}`)
try {
await startThemeObservation(page)
await waitForHome(page, desktop.mark)
await requireStableTheme(page)
return await desktop.result(run)
} finally {
await stopTrace?.()
}
} finally {
await desktop.close(testInfo, run)
}
}
export async function desktopBenchmarkContext(runs: number) {
const pkg = JSON.parse(await readFile(join(repository, "packages/desktop/package.json"), "utf8"))
const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repository })
if (revision.status !== 0) throw new Error("Failed to read the benchmark Git revision")
const status = spawnSync("git", ["status", "--porcelain"], { cwd: repository })
if (status.status !== 0) throw new Error("Failed to read the benchmark Git status")
const bun = spawnSync("bun", ["--version"], { cwd: repository })
if (bun.status !== 0) throw new Error("Failed to read the benchmark Bun version")
return {
arch: process.arch,
command: "bun dev:desktop",
runs,
profile: "fresh",
service: "isolated-cold",
install: "complete",
viteCache: "cold",
electronInstall: "present",
bunVersion: bun.stdout.toString().trim(),
electronVersion: pkg.devDependencies.electron,
electronViteVersionRange: pkg.devDependencies["electron-vite"],
gitCommit: revision.stdout.toString().trim(),
gitDirty: status.stdout.length > 0,
trace: Boolean(process.env.OPENCODE_PERFORMANCE_TRACE_DIR),
}
}
export function summarizeDesktopStartup(samples: DesktopStartupSample[]) {
return {
commandToHomeReadyMs: statistics(samples.map((sample) => sample.commandToHomeReadyMs)),
milestonesMs: Object.fromEntries(
milestones.map((name) => [name, statistics(samples.map((sample) => sample.milestonesMs[name]))]),
),
phasesMs: Object.fromEntries(
phases.map((name) => [name, statistics(samples.map((sample) => sample.phasesMs[name]))]),
),
}
}
export function milestoneForLine(line: string): Milestone | undefined {
const text = stripAnsi(line)
return milestonePatterns.find((item) => text.includes(item.text))?.name
}
const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "bunRootScript", text: "$ bun --cwd packages/desktop dev" },
{ name: "bunDesktopScript", text: "$ bun ./scripts/dev.ts" },
{ name: "desktopPrepared", text: "Copied dev icons from" },
{ name: "mainBundleReady", text: "electron main process built successfully" },
{ name: "preloadBundleReady", text: "electron preload scripts built successfully" },
{ name: "rendererDevServerReady", text: "dev server running for the electron renderer process at:" },
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
await Promise.all(
["packages/desktop/node_modules/.vite", "packages/desktop/out"].map((path) =>
rm(join(repository, path), { recursive: true, force: true }),
),
)
const root = await mkdtemp(join(tmpdir(), "opencode-desktop-startup-"))
return initializeColdProfile(root).catch(async (error) => {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
throw error
})
}
async function initializeColdProfile(root: string) {
await Promise.all(
["data", "config", "cache", "state", "desktop", "session", "home"].map((dir) =>
mkdir(join(root, dir), { recursive: true }),
),
)
await Promise.all([
writeFile(
join(root, "desktop", "opencode.settings"),
JSON.stringify({ firstLaunchOnboardingComplete: true }),
),
writeFile(join(root, "desktop", "opencode.global.dat"), JSON.stringify({ language: '{"locale":"en"}' })),
])
const registration = join(root, "desktop", "opencode", "service-local.json")
await Service.stop({ file: registration })
return { root, registration }
}
function startDesktop(profile: Awaited<ReturnType<typeof createColdProfile>>) {
const started = performance.now()
const child = spawn("bun", ["dev:desktop"], {
cwd: repository,
detached: process.platform !== "win32",
env: {
...process.env,
OPENCODE_CONFIG_DIR: join(profile.root, "config"),
OPENCODE_DB: join(profile.root, "data", "opencode.db"),
OPENCODE_TEST_HOME: join(profile.root, "home"),
OPENCODE_TEST_ONBOARDING: "0",
OPENCODE_DESKTOP_TEST_ROOT: profile.root,
OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT: "0",
OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION: "1",
},
stdio: ["ignore", "pipe", "pipe"],
})
if (!child.pid || !child.stdout || !child.stderr) throw new Error("Failed to start the desktop command")
const exited = childExit(child)
const observed: Partial<Record<Milestone, number>> = {}
const endpoint = Promise.withResolvers<string>()
const pageErrors: string[] = []
let browser: Browser | undefined
let service: ServiceInfo | undefined
const mark = (name: Milestone) => {
observed[name] ??= elapsed(started)
}
const record = (line: string) => {
const milestone = milestoneForLine(line)
if (milestone) mark(milestone)
const match = stripAnsi(line).match(/DevTools listening on (ws:\/\/\S+)/)
if (match?.[1]) endpoint.resolve(match[1])
}
const stdout = observeOutput(child.stdout, record)
const stderr = observeOutput(child.stderr, record)
return {
mark,
async open() {
const url = await Promise.race([
endpoint.promise,
exited.then((code) => {
throw new Error(`Desktop command exited with code ${code} before opening its debug endpoint`)
}),
sleep(120_000).then(() => {
throw new Error("Timed out waiting for the desktop debug endpoint")
}),
])
browser = await chromium.connectOverCDP(url, { timeout: 120_000 })
const context = browser.contexts()[0]
if (!context) throw new Error("Electron did not expose a browser context")
await expect.poll(() => context.pages().length, { timeout: 120_000 }).toBeGreaterThan(0)
const page = context.pages()[0]
if (!page) throw new Error("Electron did not expose a renderer page")
page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message))
return page
},
async result(run: number): Promise<DesktopStartupSample> {
if (pageErrors.length) throw new Error(`Desktop renderer reported errors:\n\n${pageErrors.join("\n\n")}`)
service = await readService(profile)
const milestonesMs = requireMilestones(observed)
return {
run,
commandToHomeReadyMs: milestonesMs.homeReady,
milestonesMs,
phasesMs: calculatePhases(milestonesMs),
service: {
version: service.version,
url: service.url,
pid: service.pid,
},
}
},
async close(testInfo: TestInfo, run: number) {
const errors: unknown[] = []
await browser?.close().catch(() => undefined)
await stopProcessTree(child, exited).catch((error) => {
errors.push(error)
child.stdout?.destroy()
child.stderr?.destroy()
})
const [stdoutText, stderrText] = await Promise.all([stdout, stderr]).catch((error) => {
errors.push(error)
return ["", ""]
})
await Promise.all([
testInfo.attach(`desktop-startup-${run}-stdout`, { body: stdoutText, contentType: "text/plain" }),
testInfo.attach(`desktop-startup-${run}-stderr`, { body: stderrText, contentType: "text/plain" }),
pageErrors.length
? testInfo.attach(`desktop-startup-${run}-page-errors`, {
body: pageErrors.join("\n\n"),
contentType: "text/plain",
})
: Promise.resolve(),
]).catch((error) => errors.push(error))
await Service.stop({ file: profile.registration }).catch((error) => errors.push(error))
if (service && processAlive(service.pid))
errors.push(new Error(`Desktop service process ${service.pid} did not stop`))
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch((error) =>
errors.push(error),
)
if (errors.length) throw new AggregateError(errors, "Desktop benchmark cleanup failed")
},
}
}
async function waitForHome(page: Page, mark: (name: Milestone) => void) {
await expect.poll(() => page.evaluate(() => document.visibilityState), { timeout: 120_000 }).toBe("visible")
const projects = page.getByRole("complementary", { name: "Projects", exact: true })
const sessions = page.getByRole("region", { name: "Recent sessions", exact: true })
const search = page.getByRole("textbox", { name: "Search sessions", exact: true })
const addProject = projects.locator('button[data-action="home-add-project-row"]')
await expect(projects).toBeVisible({ timeout: 120_000 })
await expect(sessions).toBeVisible()
await expect(search).toBeEditable()
await expect(sessions.getByText("Nothing here yet", { exact: true })).toBeVisible()
await expect(addProject).toHaveCount(1)
await addProject.click({ trial: true })
mark("homeReady")
}
type ThemeWindow = Window & {
__OPENCODE_THEME_STATES__?: string[]
__OPENCODE_THEME_OBSERVER__?: MutationObserver
}
async function startThemeObservation(page: Page) {
await page.addInitScript(installThemeObservation)
await page.evaluate(installThemeObservation)
}
async function requireStableTheme(page: Page) {
const states = await page.evaluate(() => {
const target = window as ThemeWindow
target.__OPENCODE_THEME_OBSERVER__?.disconnect()
return target.__OPENCODE_THEME_STATES__ ?? []
})
if (states.length !== 1) throw new Error(`Desktop theme changed during startup: ${states.join(" -> ")}`)
}
function installThemeObservation() {
const target = window as ThemeWindow
const observeRoot = () => {
const root = document.documentElement
if (!root) return false
const state = () => {
const theme = root.dataset.theme
const scheme = root.dataset.colorScheme
return theme && scheme ? `${theme}:${scheme}` : undefined
}
const initial = state()
target.__OPENCODE_THEME_STATES__ = initial ? [initial] : []
target.__OPENCODE_THEME_OBSERVER__ = new MutationObserver(() => {
const next = state()
if (!next) return
if (target.__OPENCODE_THEME_STATES__?.at(-1) !== next) target.__OPENCODE_THEME_STATES__?.push(next)
})
target.__OPENCODE_THEME_OBSERVER__.observe(root, {
attributes: true,
attributeFilter: ["data-theme", "data-color-scheme"],
})
return true
}
if (observeRoot()) return
const documentObserver = new MutationObserver(() => {
if (!observeRoot()) return
documentObserver.disconnect()
})
target.__OPENCODE_THEME_OBSERVER__ = documentObserver
documentObserver.observe(document, { childList: true })
}
async function observeOutput(stream: NodeJS.ReadableStream, record: (line: string) => void) {
const decoder = new TextDecoder()
const output: string[] = []
let pending = ""
for await (const chunk of stream) {
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
output.push(text)
pending += text
const lines = pending.split(/\r?\n/)
pending = lines.pop() ?? ""
lines.forEach(record)
}
const final = decoder.decode()
output.push(final)
pending += final
if (pending) record(pending)
return output.join("")
}
async function readService(profile: Awaited<ReturnType<typeof createColdProfile>>) {
const value: unknown = JSON.parse(await readFile(profile.registration, "utf8"))
if (!isServiceInfo(value)) throw new Error("Desktop service registration is invalid")
const url = new URL(value.url)
const port = Number(url.port)
if (url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port <= 0)
throw new Error(`Desktop service used unexpected endpoint ${value.url}`)
if (!value.version.startsWith("2.0.0-local-"))
throw new Error(`Desktop service used unexpected version ${value.version}`)
return value
}
function isServiceInfo(value: unknown): value is ServiceInfo {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string" &&
"version" in value &&
typeof value.version === "string" &&
"url" in value &&
typeof value.url === "string" &&
"pid" in value &&
typeof value.pid === "number"
)
}
function requireMilestones(observed: Partial<Record<Milestone, number>>) {
const get = (name: Milestone) => {
const value = observed[name]
if (value === undefined) throw new Error(`Desktop startup did not report milestone: ${name}`)
return round(value)
}
return {
bunRootScript: get("bunRootScript"),
bunDesktopScript: get("bunDesktopScript"),
desktopPrepared: get("desktopPrepared"),
mainBundleReady: get("mainBundleReady"),
preloadBundleReady: get("preloadBundleReady"),
rendererDevServerReady: get("rendererDevServerReady"),
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
function calculatePhases(value: Record<Milestone, number>): Record<Phase, number> {
return {
desktopPreparation: value.desktopPrepared,
viteMainBundle: round(value.mainBundleReady - value.desktopPrepared),
vitePreloadBundle: round(value.preloadBundleReady - value.mainBundleReady),
rendererServerStartup: round(value.rendererDevServerReady - value.preloadBundleReady),
electronStartup: round(value.electronStarted - value.electronSpawnStarted),
serviceSpawnWait: round(value.serviceSpawnRequested - value.serviceEnsureStarted),
serviceProcessStartup: round(value.serviceReady - value.serviceSpawnRequested),
rendererStartup: round(value.homeReady - value.rendererViteConnected),
visibleWindowToHome: round(value.homeReady - value.windowVisible),
}
}
function statistics(values: number[]) {
if (!values.length) throw new Error("Cannot summarize an empty benchmark")
const sorted = values.toSorted((left, right) => left - right)
const median = medianOf(sorted)
return {
min: round(sorted[0]),
median: round(median),
max: round(sorted.at(-1)!),
medianAbsoluteDeviation: round(medianOf(sorted.map((value) => Math.abs(value - median)).toSorted((a, b) => a - b))),
}
}
function medianOf(sorted: number[]) {
const middle = Math.floor(sorted.length / 2)
if (sorted.length % 2) return sorted[middle]
return (sorted[middle - 1] + sorted[middle]) / 2
}
async function stopProcessTree(child: ChildProcess, exited: Promise<number | null>) {
if (!child.pid) throw new Error("Desktop command has no process ID")
if (process.platform !== "win32") return stopProcessGroup(child.pid, exited)
if (child.exitCode !== null || (await exitsWithin(child, exited, 2_000))) return
const kill = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
stdio: "ignore",
})
await childExit(kill)
if (await exitsWithin(child, exited, 10_000)) return
if (!(await exitsWithin(child, exited, 5_000))) throw new Error(`Desktop command process ${child.pid} did not stop`)
}
async function stopProcessGroup(pid: number, exited: Promise<number | null>) {
await Promise.race([exited, sleep(2_000)])
if (!processGroupAlive(pid)) return
process.kill(-pid, "SIGTERM")
if (await processGroupStopsWithin(pid, 10_000)) return
process.kill(-pid, "SIGKILL")
if (!(await processGroupStopsWithin(pid, 5_000))) throw new Error(`Desktop command process group ${pid} did not stop`)
}
async function processGroupStopsWithin(pid: number, timeout: number) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
if (!processGroupAlive(pid)) return true
await sleep(50)
}
return !processGroupAlive(pid)
}
function processGroupAlive(pid: number) {
try {
process.kill(-pid, 0)
return true
} catch {
return false
}
}
async function exitsWithin(child: ChildProcess, exited: Promise<number | null>, timeout: number) {
if (child.exitCode !== null) return true
const result = await Promise.race([exited.then(() => true), sleep(timeout).then(() => false)])
return result
}
function childExit(child: ChildProcess) {
return new Promise<number | null>((resolve, reject) => {
child.once("error", reject)
child.once("exit", (code) => resolve(code))
})
}
function sleep(milliseconds: number) {
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
}
function processAlive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function stripAnsi(value: string) {
return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
}
function elapsed(started: number) {
return round(performance.now() - started)
}
function round(value: number) {
return Math.round(value * 100) / 100
}
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test"
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default defineConfig({
testDir: ".",
testMatch: "desktop-startup-benchmark.spec.ts",
outputDir: "../../test-results/performance-devex",
timeout: 15 * 60_000,
expect: {
timeout: 120_000,
},
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../../playwright-report/performance-devex", open: "never" }], ["line"]],
projects: [{ name: "desktop" }],
})
@@ -7,7 +7,7 @@ process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(
export default {
...config,
testDir: ".",
testIgnore: "unit/**",
testIgnore: ["unit/**", "devex/**"],
outputDir: "../test-results/performance",
fullyParallel: false,
workers: 1,
@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test"
import { inlineThemePreload } from "../../../vite.js"
import { milestoneForLine, summarizeDesktopStartup, type DesktopStartupSample } from "../devex/desktop-startup"
describe("desktop startup benchmark", () => {
test.each(["/oc-theme-preload.js", "./oc-theme-preload.js"])("inlines %s before the renderer runs", (path) => {
const html = inlineThemePreload(`<script id="oc-theme-preload-script" src="${path}"></script>`)
expect(html).not.toContain(" src=")
expect(html).toContain("opencode-color-scheme")
})
test("recognizes startup milestones in colored output", () => {
const cases = [
["bunRootScript", "$ bun --cwd packages/desktop dev"],
["bunDesktopScript", "$ bun ./scripts/dev.ts"],
["desktopPrepared", "Copied dev icons from"],
["mainBundleReady", "electron main process built successfully"],
["preloadBundleReady", "electron preload scripts built successfully"],
["rendererDevServerReady", "dev server running for the electron renderer process at:"],
["electronSpawnStarted", "starting electron app..."],
["debugEndpointReady", "DevTools listening on ws://"],
["electronStarted", "app starting"],
["serviceEnsureStarted", "starting v2 background service"],
["serviceSpawnRequested", "v2 CLI background service starting"],
["serviceReady", "v2 CLI background service ready"],
["backgroundLoadingReady", "loading task finished"],
["rendererViteConnected", "[vite] connected."],
["rendererInitializationStarted", "awaiting server ready"],
["rendererInitializationReady", "server ready"],
["windowVisible", "main window visible"],
] as const
cases.forEach(([milestone, line]) => {
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
})
expect(milestoneForLine("12:30:00.000 v2 CLI background service ready {")).toBe("serviceReady")
expect(milestoneForLine("unrelated output")).toBeUndefined()
})
test("keeps raw samples and reports median absolute deviation", () => {
const samples = [24, 20, 22, 28, 26].map((commandToHomeReadyMs, index) => sample(index + 1, commandToHomeReadyMs))
expect(summarizeDesktopStartup(samples).commandToHomeReadyMs).toEqual({
min: 20,
median: 24,
max: 28,
medianAbsoluteDeviation: 2,
})
})
})
function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample {
const milestonesMs = {
bunRootScript: 1,
bunDesktopScript: 2,
desktopPrepared: 3,
mainBundleReady: 4,
preloadBundleReady: 5,
rendererDevServerReady: 6,
electronSpawnStarted: 7,
debugEndpointReady: 8,
electronStarted: 9,
serviceEnsureStarted: 10,
serviceSpawnRequested: 11,
serviceReady: 12,
backgroundLoadingReady: 13,
rendererViteConnected: 14,
rendererInitializationStarted: 15,
rendererInitializationReady: 16,
windowVisible: 17,
homeReady: commandToHomeReadyMs,
}
return {
run,
commandToHomeReadyMs,
milestonesMs,
phasesMs: {
desktopPreparation: 3,
viteMainBundle: 1,
vitePreloadBundle: 1,
rendererServerStartup: 1,
electronStartup: 2,
serviceSpawnWait: 1,
serviceProcessStartup: 1,
rendererStartup: commandToHomeReadyMs - 14,
visibleWindowToHome: commandToHomeReadyMs - 17,
},
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
}
}
@@ -36,6 +36,9 @@ test("preserves the draft when a populated command menu triggers a built-in", as
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expect.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content)).toBe(
`"${String.fromCodePoint(0x200b)}"`,
)
await expectAppVisible(composer)
await input.fill("keep me")
@@ -19,7 +19,7 @@ test("session settings use the remote server context", async ({ page }) => {
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-v2-dialog")
@@ -61,7 +61,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
@@ -78,7 +78,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await transport.waitForConnection()
await transport.send({
@@ -208,7 +208,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
canonical: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
@@ -216,7 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -237,7 +237,7 @@ function session(id: string, directory: string, title: string) {
id,
slug: id,
projectID: `project-${id}`,
directory,
location: { directory },
title,
version: "dev",
time: { created: 1, updated: 1 },
+7 -5
View File
@@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop": "./src/desktop.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts",
@@ -28,14 +29,15 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -44,9 +46,9 @@
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite": "8.2.1",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:"
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@corvu/drawer": "catalog:",
@@ -85,6 +87,6 @@
"solid-js": "catalog:",
"solid-list": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "catalog:"
"tailwindcss": "4.3.3"
}
}
+20 -64
View File
@@ -1,12 +1,10 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { type BaseRouterProps, Route, Router, useParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import {
type Component,
@@ -20,30 +18,37 @@ import {
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServersProvider } from "@/context/servers"
import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { LocationProvider } from "@/context/location"
import { TabsProvider } from "@/context/tabs"
import { WslServersProvider } from "@/wsl/context"
import { SessionUIProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { requireServerKey } from "./utils/session-route"
import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session"))
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/pages/session"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
@@ -60,45 +65,6 @@ function TargetServerRoute(props: ParentProps) {
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={tabs.ready() && <Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
)
}
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</ServerProvider>
)}
</Show>
</Show>
)
}
declare global {
interface Window {
__OPENCODE__?: {
@@ -167,22 +133,11 @@ function AppLayout(props: ParentProps) {
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
return (
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
)
}
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
onThemeApplied?: () => void
}>,
) {
return (
@@ -191,13 +146,14 @@ export function AppBaseProviders(
<ThemeProvider
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
props.onThemeApplied?.()
}}
>
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
<UiI18nBridge>
<ErrorBoundary
fallback={(error) => {
Sentry.captureException(error)
void import("@sentry/solid").then(({ captureException }) => captureException(error))
return <ErrorPage error={error} />
}}
>
+14 -7
View File
@@ -241,7 +241,11 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
void tabs.newDraft(
{ server: sessionTab.server, directory: activeSession.location.directory },
"",
model,
)
return
}
case "draft": {
@@ -249,7 +253,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
if (activeTab?.type !== "draft") return
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
case "home": {
@@ -263,7 +267,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
projects?.list().find((item) => item.worktree === projects.last()) ??
projects?.list()[0]
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
void tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
}
}
@@ -467,11 +471,14 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
)
}
const label = channel && ["local", "beta", "dev"].includes(channel) ? channel.toUpperCase() : undefined
return (
<Show when={["local", "beta", "dev"].includes(channel)}>
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()}
</div>
<Show when={label}>
{(value) => (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{value()}
</div>
)}
</Show>
)
}
+9
View File
@@ -0,0 +1,9 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language"
export { type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection, useServers } from "./context/servers"
export { useTabs } from "./context/tabs"
export { createDraftStore } from "./utils/draft-store"
export { useWslServers } from "./wsl/context"
+1 -1
View File
@@ -1,4 +1,4 @@
export { AppBaseProviders, AppInterface } from "./app"
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { useLayout } from "./context/layout"
export { useServerSDK } from "./context/server-sdk"
export { useServers as useServers } from "./context/servers"
+64
View File
@@ -0,0 +1,64 @@
import { Navigate, useSearchParams } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { useGlobal } from "@/context/global"
import { LocationProvider } from "@/context/location"
import { ModelsProvider } from "@/context/models"
import { PromptProvider } from "@/context/prompt"
import { ServerProvider } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { useTabs, type DraftTab } from "@/context/tabs"
import { SessionUIProvider } from "@/pages/directory-layout"
import NewSession from "@/pages/new-session"
export function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={tabs.ready() && <Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
)
}
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</ServerProvider>
)}
</Show>
</Show>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
return (
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
)
}
@@ -1,10 +1,8 @@
import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerActionsController } from "@/components/server/server-management-controller"
import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { type LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/servers"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
@@ -63,7 +61,11 @@ export function createHomeProjectsController(home: HomeController) {
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
edit: (conn: ServerConnection.Http) => {
void import("@/components/settings-v2/dialog-server-v2").then(({ DialogServerV2 }) => {
void dialog.show(() => <DialogServerV2 mode="edit" server={conn} />)
})
},
focus: home.selection.focusServer,
},
project: {
+7 -4
View File
@@ -1,13 +1,14 @@
import { Show, Suspense, type ParentProps } from "solid-js"
import { lazy, Show, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { ToastRegion } from "@/utils/toast"
const DebugBar = lazy(() => import("@/components/debug-bar").then((module) => ({ default: module.DebugBar })))
export default function Layout(props: ParentProps) {
const platform = usePlatform()
const [state, setState] = createStore({ debugTools: true })
const [state, setState] = createStore({ debugTools: false })
const update: TitlebarUpdate = {
get version() {
@@ -41,7 +42,9 @@ export default function Layout(props: ParentProps) {
<Suspense>{props.children}</Suspense>
</main>
<Show when={import.meta.env.DEV && state.debugTools}>
<DebugBar inline />
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
</div>
@@ -39,14 +39,20 @@ describe("new session workspace selection", () => {
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
})
test("falls back to the local branch for main, create, and unknown worktrees", () => {
test("resolves the branch from the active location", () => {
const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev")
expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev")
expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe(
expect(resolveNewSessionBranch({ worktree: "main", directory: "/project/feature", worktreeBranch: branch })).toBe(
"feature",
)
expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
expect(
resolveNewSessionBranch({ worktree: "create", directory: "/project/feature", worktreeBranch: branch }),
).toBe("feature")
expect(
resolveNewSessionBranch({ worktree: "/project/feature", directory: "/project", worktreeBranch: branch }),
).toBe("feature")
expect(resolveNewSessionBranch({ worktree: "/missing", directory: "/project/feature", worktreeBranch: branch })).toBe(
undefined,
)
})
test("uses location VCS state when the project inventory is stale", () => {
@@ -32,11 +32,11 @@ export function normalizeNewSessionWorktree(value: string, directory: string, pr
export function resolveNewSessionBranch(input: {
worktree: string
local?: string
directory: string
worktreeBranch: (worktree: string) => string | undefined
}) {
if (input.worktree === "main" || input.worktree === "create") return input.local
return input.worktreeBranch(input.worktree) ?? input.local
const directory = input.worktree === "main" || input.worktree === "create" ? input.directory : input.worktree
return input.worktreeBranch(directory)
}
export function resolveNewSessionGit(input: { projectVcs?: string; branch?: string }) {
@@ -95,11 +95,10 @@ export function createNewSessionWorkspaceController(input: {
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
})
const localBranch = createMemo(() => data.location.vcs.info({ directory: projectRoot() })?.branch.current)
const branch = createMemo(() =>
resolveNewSessionBranch({
worktree: value(),
local: localBranch(),
directory: sdk().directory,
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
}),
)
+52 -7
View File
@@ -3,22 +3,56 @@ import { beforeEach, describe, expect, test } from "bun:test"
const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text()
const run = () => Function(src)()
const setSystemDark = (matches: boolean) =>
Object.defineProperty(window, "matchMedia", {
value: () => ({ matches }) as MediaQueryList,
configurable: true,
})
beforeEach(() => {
document.head.innerHTML = ""
document.documentElement.removeAttribute("data-theme")
document.documentElement.removeAttribute("data-color-scheme")
document.documentElement.style.removeProperty("background-color")
localStorage.clear()
Object.defineProperty(window, "matchMedia", {
value: () =>
({
matches: false,
}) as MediaQueryList,
configurable: true,
})
setSystemDark(false)
})
describe("theme preload", () => {
test("uses default theme and system light mode when settings are absent", () => {
run()
expect(document.documentElement.dataset.theme).toBe("oc-2")
expect(document.documentElement.dataset.colorScheme).toBe("light")
expect(document.documentElement.style.backgroundColor).toBe("#fafafa")
})
test("restores explicit dark mode on a light system", () => {
localStorage.setItem("opencode-color-scheme", "dark")
run()
expect(document.documentElement.dataset.colorScheme).toBe("dark")
expect(document.documentElement.style.backgroundColor).toBe("#080808")
})
test("restores explicit light mode on a dark system", () => {
setSystemDark(true)
localStorage.setItem("opencode-color-scheme", "light")
run()
expect(document.documentElement.dataset.colorScheme).toBe("light")
expect(document.documentElement.style.backgroundColor).toBe("#fafafa")
})
test("resolves persisted system mode before paint", () => {
setSystemDark(true)
localStorage.setItem("opencode-color-scheme", "system")
run()
expect(document.documentElement.dataset.colorScheme).toBe("dark")
expect(document.documentElement.style.backgroundColor).toBe("#080808")
})
test("keeps cached css for non-default themes", () => {
localStorage.setItem("opencode-theme-id", "nightowl")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
@@ -28,4 +62,15 @@ describe("theme preload", () => {
expect(document.documentElement.dataset.theme).toBe("nightowl")
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;")
})
test("restores the cached variant for a persisted custom dark theme", () => {
localStorage.setItem("opencode-theme-id", "nightowl")
localStorage.setItem("opencode-color-scheme", "dark")
localStorage.setItem("opencode-theme-css-dark", "--background-base:#010203;")
run()
expect(document.documentElement.dataset.theme).toBe("nightowl")
expect(document.documentElement.dataset.colorScheme).toBe("dark")
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#010203;")
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "vite"
import desktopPlugin from "./vite"
import desktopPlugin from "./vite.js"
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
+23 -6
View File
@@ -4,6 +4,18 @@ import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
const themeScript = readFileSync(theme, "utf8")
const tailwind = tailwindcss()
const tailwindGenerate = tailwind.find((plugin) => plugin.name === "@tailwindcss/vite:generate:serve")
const tailwindHotUpdate = tailwindGenerate?.hotUpdate
// Tailwind 4.3.3 expects a server that Vite's bundled dev hook does not provide.
if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
tailwindGenerate.hotUpdate = function (context) {
if (!context.server) return
return tailwindHotUpdate.call(this, context)
}
}
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@@ -40,13 +52,18 @@ export default [
},
{
name: "opencode-desktop:theme-preload",
transformIndexHtml(html) {
return html.replace(
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
)
transformIndexHtml: {
order: "pre",
handler: inlineThemePreload,
},
},
tailwindcss(),
...tailwind,
solidPlugin(),
]
export function inlineThemePreload(html) {
return html.replace(
/<script id="oc-theme-preload-script" src="(?:\.\/|\/)oc-theme-preload\.js"><\/script>/,
`<script id="oc-theme-preload-script">${themeScript}</script>`,
)
}
+2 -2
View File
@@ -12,8 +12,8 @@ export type EnsureTiming = {
const timings = new WeakMap<object, EnsureTiming>()
export const defaultEnsureTiming: EnsureTiming = {
pollInterval: 1_000,
attempts: 120,
pollInterval: 100,
attempts: 1_200,
requestTimeout: 2_000,
spawnDelay: 5_000,
maxSpawnDelay: 30_000,
@@ -2,6 +2,7 @@ import { withEnsureTiming } from "../../src/service-timing"
const timing = {
pollInterval: 20,
attempts: 120,
requestTimeout: 100,
spawnDelay: 200,
maxSpawnDelay: 1_200,
-1
View File
@@ -328,7 +328,6 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
body: projected.body === undefined ? undefined : { ...projected.body },
headers: info.headers,
},
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
providerOptions: projected.settings,
},
body: {
@@ -6,7 +6,6 @@ import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Provider } from "../../provider.js"
import { VariantPlugin } from "../../plugin/variant.js"
export const Plugin = define({
id: "opencode.config.provider",
@@ -35,8 +34,6 @@ export const Plugin = define({
})
yield* ctx.catalog.transform((catalog) => {
const fallback = new Map<string, { providerID: string; modelID: string }>()
const explicit = new Map<string, { providerID: string; modelID: string }>()
const configuredDefault = Config.latest(loaded.entries, "model")
if (configuredDefault !== undefined)
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
@@ -51,13 +48,6 @@ export const Plugin = define({
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
const key = `${providerID}\0${id}`
if (!catalog.model.get(providerID, id) && config.variants === undefined)
fallback.set(key, { providerID, modelID: id })
if (config.variants !== undefined) {
fallback.delete(key)
explicit.set(key, { providerID, modelID: id })
}
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
@@ -77,7 +67,6 @@ export const Plugin = define({
}
if (config.variants !== undefined) {
model.variants ??= []
if (config.variants.length === 0) model.variants = []
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
@@ -107,15 +96,6 @@ export const Plugin = define({
})
}
}
for (const item of fallback.values()) {
const model = catalog.model.get(item.providerID, item.modelID)
if (!model || model.variants.length > 0) continue
VariantPlugin.markFallback(model)
}
for (const item of explicit.values()) {
const model = catalog.model.get(item.providerID, item.modelID)
if (model) VariantPlugin.suppressFallback(model)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
-1
View File
@@ -164,7 +164,6 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
@@ -70,13 +70,11 @@ export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
output: ["text"],
}
model.limit = {
context:
item.loaded_instances.length === 0
? item.max_context_length
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)),
output: 0,
}
const context =
item.loaded_instances.length === 0
? item.max_context_length
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length))
if (context > 0) model.limit.context = context
})
}
})
+4 -7
View File
@@ -96,13 +96,10 @@ export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input
input: ["text", ...(item.show.capabilities?.includes("vision") ? ["image"] : [])],
output: ["text"],
}
model.limit = {
context:
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
)[0] ?? 0,
output: 0,
}
const context = Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
)[0]
if (context !== undefined) model.limit.context = context
})
}
})
+2 -1
View File
@@ -55,7 +55,8 @@ export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input
model.name = item.id
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
model.limit = { context: item.max_model_len ?? 0, output: 0 }
if (typeof item.max_model_len === "number" && item.max_model_len > 0)
model.limit.context = item.max_model_len
})
}
})
+1 -166
View File
@@ -12,8 +12,7 @@ export const Plugin = define({
for (const record of catalog.provider.list()) {
for (const model of record.models.values()) {
catalog.model.update(model.providerID, model.id, (draft) => {
if (suppressed.has(draft)) return
const generated = fallbacks.has(draft) ? fallback(draft, record.provider) : generate(draft, record.provider)
const generated = generate(draft, record.provider)
if (generated.length === 0) return
const variants = draft.variants ?? []
@@ -43,167 +42,3 @@ export function generate(
settings: { reasoningEffort: id },
}))
}
const OPENAI_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"]
const COMMON_EFFORTS = ["low", "medium", "high"]
const ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
const CLAUDE_MANUAL_THINKING_MAX = { haiku: [4, 5], sonnet: [4, 5], opus: [4, 5] } as const
// Config runs immediately before this plugin over the same materialized model objects.
// Weak markers retain omitted versus explicit empty variants without exposing provenance publicly.
const fallbacks = new WeakSet<object>()
const suppressed = new WeakSet<object>()
export function markFallback(model: object) {
suppressed.delete(model)
fallbacks.add(model)
}
export function suppressFallback(model: object) {
fallbacks.delete(model)
suppressed.add(model)
}
export function fallback(
model: {
readonly modelID: string
readonly package?: string
readonly settings?: Readonly<Record<string, unknown>>
readonly limit: { readonly output: number }
},
provider?: { readonly package: string },
): NonNullable<Model.Info["variants"]> {
const packageName = model.package ?? provider?.package
if (openAIResponses(packageName, model.settings))
return OPENAI_EFFORTS.map((id) => ({
id: Model.VariantID.make(id),
settings: settings(packageName, {
reasoningEffort: id,
reasoningSummary: "auto",
include: ENCRYPTED_REASONING,
}),
}))
if (openAIChat(packageName, model.settings)) return efforts(packageName, COMMON_EFFORTS)
if (google(packageName)) return googleVariants(packageName, model.modelID, model.limit.output)
if (anthropic(packageName)) return anthropicVariants(packageName, model.modelID, model.limit.output)
return []
}
function openAIResponses(packageName: string | undefined, settings: Readonly<Record<string, unknown>> | undefined) {
if (Provider.isAISDK(packageName))
return (
Provider.packageName(packageName) === "@ai-sdk/openai" ||
(Provider.packageName(packageName) === "@ai-sdk/azure" && settings?.useCompletionUrls !== true)
)
return [
"@opencode-ai/ai/providers/openai",
"@opencode-ai/ai/providers/openai/responses",
"@opencode-ai/ai/providers/azure",
"@opencode-ai/ai/providers/azure/responses",
"@opencode-ai/ai/providers/google-vertex/responses",
].includes(packageName ?? "")
}
function openAIChat(packageName: string | undefined, settings: Readonly<Record<string, unknown>> | undefined) {
if (Provider.isAISDK(packageName))
return (
Provider.packageName(packageName) === "@ai-sdk/openai-compatible" ||
(Provider.packageName(packageName) === "@ai-sdk/azure" && settings?.useCompletionUrls === true)
)
return [
"@opencode-ai/ai/providers/openai/chat",
"@opencode-ai/ai/providers/openai-compatible",
"@opencode-ai/ai/providers/azure/chat",
"@opencode-ai/ai/providers/google-vertex/chat",
].includes(packageName ?? "")
}
function google(packageName: string | undefined) {
if (Provider.isAISDK(packageName))
return ["@ai-sdk/google", "@ai-sdk/google-vertex"].includes(Provider.packageName(packageName))
return [
"@opencode-ai/ai/providers/google",
"@opencode-ai/ai/providers/google-vertex",
"@opencode-ai/ai/providers/google-vertex/gemini",
].includes(packageName ?? "")
}
function anthropic(packageName: string | undefined) {
if (Provider.isAISDK(packageName))
return ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(Provider.packageName(packageName))
return [
"@opencode-ai/ai/providers/anthropic",
"@opencode-ai/ai/providers/anthropic-compatible",
"@opencode-ai/ai/providers/google-vertex/messages",
].includes(packageName ?? "")
}
function settings(packageName: string | undefined, value: Readonly<Record<string, unknown>>) {
return Provider.isAISDK(packageName) ? value : { providerOptions: value }
}
function efforts(packageName: string | undefined, ids: readonly string[]) {
return ids.map((id) => ({ id: Model.VariantID.make(id), settings: settings(packageName, { reasoningEffort: id }) }))
}
function googleVariants(
packageName: string | undefined,
modelID: string,
output: number,
): NonNullable<Model.Info["variants"]> {
if (!/(?:^|[/.:_-])gemini-2[.-]5(?:[/.:_-]|$)/i.test(modelID))
return COMMON_EFFORTS.map((effort) => ({
id: Model.VariantID.make(effort),
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }),
}))
const variants = [
{ id: "high", budget: 16_000 },
{ id: "max", budget: /(?:^|[/.:_-])pro(?:[/.:_-]|$)/i.test(modelID) ? 32_768 : 24_576 },
]
const maximum = output - 1
if (maximum <= 0) return []
return variants.map((item) => ({
id: Model.VariantID.make(item.id),
settings: settings(packageName, {
thinkingConfig: { includeThoughts: true, thinkingBudget: Math.min(item.budget, maximum) },
}),
}))
}
function anthropicVariants(
packageName: string | undefined,
modelID: string,
output: number,
): NonNullable<Model.Info["variants"]> {
const model = claudeModel(modelID)
const version = model && CLAUDE_MANUAL_THINKING_MAX[model.family]
const manual = version && (model.major < version[0] || (model.major === version[0] && model.minor <= version[1]))
if (!manual) {
const ids =
!model || model.major > 4 || model.minor >= 7 ? [...COMMON_EFFORTS, "xhigh", "max"] : [...COMMON_EFFORTS, "max"]
return ids.map((id) => ({
id: Model.VariantID.make(id),
settings: settings(packageName, { thinking: { type: "adaptive", display: "summarized" }, effort: id }),
}))
}
const maximum = Math.min(31_999, output - 1)
if (maximum <= 0) return []
return [
{ id: "high", budget: Math.min(16_000, maximum) },
{ id: "max", budget: maximum },
].map((item) => ({
id: Model.VariantID.make(item.id),
settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: item.budget } }),
}))
}
function claudeModel(modelID: string) {
const familyFirst = /(?:^|[/.:_-])(opus|sonnet|haiku)-([1-9]\d*)(?:[.-](\d{1,2}))?(?:[/.:_-]|$)/i.exec(modelID)
const versionFirst = /(?:^|[/.:_-])claude-([1-9]\d*)(?:[.-](\d{1,2}))?-(opus|sonnet|haiku)(?:[/.:_-]|$)/i.exec(
modelID,
)
const family = (["haiku", "sonnet", "opus"] as const).find((item) => item === (familyFirst?.[1] ?? versionFirst?.[3]))
const major = Number(familyFirst?.[2] ?? versionFirst?.[1])
const minor = Number(familyFirst?.[3] ?? versionFirst?.[2] ?? 0)
if (!family || !Number.isFinite(major) || !Number.isFinite(minor)) return
return { family, major, minor }
}
-29
View File
@@ -399,38 +399,10 @@ describe("PluginSupervisor config", () => {
}),
)
it.live("lets an explicit empty config array clear generated variants", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts")],
providers: {
configured: {
models: {
"glm-5.2": { variants: [] },
},
},
},
},
Effect.gen(function* () {
yield* ready()
const catalog = yield* Catalog.Service
expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual(
[],
)
}),
),
)
it.live("allows variant generation to be disabled", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { reasoner: {} },
},
},
},
Effect.gen(function* () {
yield* ready()
@@ -441,7 +413,6 @@ describe("PluginSupervisor config", () => {
expect((yield* catalog.model.get(Provider.ID.make("configured"), Model.ID.make("glm-5.2")))?.variants).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
])
expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("reasoner")))?.variants).toEqual([])
}),
),
)
+1 -172
View File
@@ -9,18 +9,16 @@ import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* (entries: Entry[], variants = false) {
const addPlugin = Effect.fn(function* (entries: Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
if (variants) yield* VariantPlugin.Plugin.effect(host)
})
function required<T>(value: T | undefined): T {
@@ -106,175 +104,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("adds fallback variants to new configured models when variants are omitted", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("gpt-next")
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { "gpt-next": {} },
},
},
}),
}),
]
yield* addPlugin(entries, true)
const variants = required(yield* catalog.model.get(providerID, modelID)).variants
expect(variants.map((variant) => String(variant.id))).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
expect(variants[3]?.settings).toEqual({
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
}),
)
it.effect("keeps explicit empty and custom configured variants", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: {
disabled: { variants: [] },
explicit: { variants: [{ id: "deep", settings: { reasoningEffort: "max" } }] },
},
},
},
}),
}),
]
yield* addPlugin(entries, true)
expect((yield* catalog.model.get(providerID, Model.ID.make("disabled")))?.variants).toEqual([])
expect(
(yield* catalog.model.get(providerID, Model.ID.make("explicit")))?.variants.map((variant) => ({
...variant,
id: String(variant.id),
})),
).toEqual([{ id: "deep", settings: { reasoningEffort: "max" } }])
}),
)
it.effect("does not add config fallbacks to existing catalog models", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("known")
yield* catalog.transform((draft) => {
draft.model.update(providerID, modelID, () => {})
})
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { known: { name: "Known" } },
},
},
}),
}),
]
yield* addPlugin(entries, true)
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
}),
)
it.effect("lets an explicit empty array clear inherited variants", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const modelID = Model.ID.make("known")
yield* catalog.transform((draft) => {
draft.model.update(providerID, modelID, (model) => {
model.variants = [{ id: Model.VariantID.make("high"), settings: { reasoningEffort: "high" } }]
})
})
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: { known: { variants: [] } },
},
},
}),
}),
]
yield* addPlugin(entries, true)
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
}),
)
it.effect("respects layered variant intent and the final package flavor", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("custom")
const entries = [
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
models: {
cleared: {},
disabled: { variants: [] },
flavor: {},
},
},
},
}),
}),
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai",
models: {
cleared: { variants: [] },
disabled: { name: "Disabled" },
flavor: { name: "OpenAI" },
},
},
},
}),
}),
]
yield* addPlugin(entries, true)
expect((yield* catalog.model.get(providerID, Model.ID.make("cleared")))?.variants).toEqual([])
expect((yield* catalog.model.get(providerID, Model.ID.make("disabled")))?.variants).toEqual([])
expect(
(yield* catalog.model.get(providerID, Model.ID.make("flavor")))?.variants.map((variant) => String(variant.id)),
).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
}),
)
it.effect("preserves catalog capabilities unless config overrides them", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+11 -34
View File
@@ -9,7 +9,6 @@ import { Integration } from "@opencode-ai/core/integration"
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
import { Catalog } from "@opencode-ai/core/catalog"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Npm } from "@opencode-ai/util/npm"
@@ -237,7 +236,6 @@ describe("ModelResolver", () => {
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, input: 80, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
@@ -510,34 +508,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("applies native OpenAI fallback settings to Responses requests", () =>
Effect.gen(function* () {
const packageName = "@opencode-ai/ai/providers/openai"
const base = model(packageName, { modelID: "gpt-next", limit: { context: 100, output: 32_000 } })
const catalog = model(packageName, {
modelID: "gpt-next",
limit: { context: 100, output: 32_000 },
variants: VariantPlugin.fallback(base),
})
const resolved = yield* ModelResolver.resolveModel(
catalog,
VariantID.make("high"),
Credential.Key.make({ type: "key", key: "secret" }),
)
expect(resolved.route.defaults.providerOptions).toMatchObject({
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body).toMatchObject({
include: ["reasoning.encrypted_content"],
reasoning: { effort: "high", summary: "auto" },
})
}),
)
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
@@ -814,7 +784,6 @@ describe("ModelResolver", () => {
region: "test",
headers: { "x-package": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
})
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
},
@@ -914,7 +883,12 @@ describe("ModelResolver", () => {
{ reasoning: { effort: "high" } },
{ reasoning: { effort: "high" } },
],
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", { reasoningEffort: "high" }, { reasoningEffort: "high" }],
[
"@ai-sdk/xai",
"@opencode-ai/ai/providers/xai",
{ reasoningEffort: "high" },
{ reasoningEffort: "high" },
],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
@@ -937,7 +911,6 @@ describe("ModelResolver", () => {
baseURL: "https://provider.example/v1",
headers: { "x-provider": "header" },
body: { custom: true },
limits: { context: 100, output: 20 },
providerOptions,
})
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
@@ -964,7 +937,11 @@ describe("ModelResolver", () => {
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
[
"@ai-sdk/google-vertex/anthropic",
"@opencode-ai/ai/providers/google-vertex/messages",
"claude-sonnet-4-6",
],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
@@ -71,6 +71,13 @@ describe("LMStudioPlugin", () => {
max_context_length: 131_072,
capabilities: { vision: false, trained_for_tool_use: false },
},
{
type: "llm",
key: "unknown-context",
display_name: "Unknown Context",
loaded_instances: [],
max_context_length: 0,
},
{
type: "embedding",
key: "nomic-embed",
@@ -104,11 +111,14 @@ describe("LMStudioPlugin", () => {
family: "gemma4",
name: "Gemma 4 26B A4B",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
limit: { context: 16_384, output: 0 },
limit: { context: 16_384, output: 32_000 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
capabilities: { tools: false, input: ["text"], output: ["text"] },
limit: { context: 131_072, output: 0 },
limit: { context: 131_072, output: 32_000 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("unknown-context"))).toMatchObject({
limit: { context: 200_000, output: 32_000 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
}),
@@ -54,6 +54,7 @@ describe("OllamaPlugin", () => {
return Response.json({
models: [
summary("gemma3:4b", "gemma-digest", "gemma3"),
summary("unknown-context", "unknown-digest"),
summary("nomic-embed", "embed-digest"),
summary("removed-model", "removed-digest"),
],
@@ -68,6 +69,8 @@ describe("OllamaPlugin", () => {
capabilities: ["completion", "tools", "vision"],
model_info: { "gemma3.context_length": 131_072 },
}
: body.model === "unknown-context"
? show({ family: "unknown", capabilities: ["completion"], context: 0 })
: show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }),
)
},
@@ -98,7 +101,10 @@ describe("OllamaPlugin", () => {
name: "gemma3:4b",
family: "gemma3",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
limit: { context: 131_072, output: 0 },
limit: { context: 131_072, output: 32_000 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("unknown-context"))).toMatchObject({
limit: { context: 200_000, output: 32_000 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
expect(requests).toContainEqual({ method: "GET", path: "/api/tags" })
@@ -63,7 +63,11 @@ describe("VLLMPlugin", () => {
state.models++
return Response.json({
object: "list",
data: [remoteModel("Qwen/Qwen3-Coder", 65_536), remoteModel("foreign-model", 4096, "other")],
data: [
remoteModel("Qwen/Qwen3-Coder", 65_536),
remoteModel("unknown-limit", 0),
remoteModel("foreign-model", 4096, "other"),
],
})
},
}),
@@ -97,7 +101,10 @@ describe("VLLMPlugin", () => {
modelID: "Qwen/Qwen3-Coder",
name: "Qwen/Qwen3-Coder",
capabilities: { tools: false, input: ["text"], output: ["text"] },
limit: { context: 65_536, output: 0 },
limit: { context: 65_536, output: 32_000 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("unknown-limit"))).toMatchObject({
limit: { context: 200_000, output: 32_000 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("foreign-model"))).toBeUndefined()
}),
+1 -151
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -59,153 +59,3 @@ describe("VariantPlugin", () => {
}),
)
})
describe("VariantPlugin.fallback", () => {
const model = (
modelID: string,
packageName: string,
output = 32_000,
settings?: Readonly<Record<string, unknown>>,
) => ({
modelID,
package: packageName,
settings,
limit: { output },
})
const plain = (variants: Model.Info["variants"]) =>
variants.map((variant) => ({ ...variant, id: String(variant.id) }))
const settings = (packageName: string, value: Readonly<Record<string, unknown>>) =>
Provider.isAISDK(packageName) ? value : { providerOptions: value }
test.each([
Provider.aisdk("@ai-sdk/openai"),
Provider.aisdk("@ai-sdk/azure"),
"@opencode-ai/ai/providers/openai",
"@opencode-ai/ai/providers/openai/responses",
"@opencode-ai/ai/providers/azure",
"@opencode-ai/ai/providers/azure/responses",
"@opencode-ai/ai/providers/google-vertex/responses",
])("adds OpenAI Responses variants for %s", (packageName) => {
const variants = VariantPlugin.fallback(model("gpt-next", packageName))
expect(variants.map((variant) => String(variant.id))).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
expect(variants[0]?.settings).toEqual(
settings(packageName, {
reasoningEffort: "none",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
}),
)
})
test.each([
Provider.aisdk("@ai-sdk/openai-compatible"),
"@opencode-ai/ai/providers/openai/chat",
"@opencode-ai/ai/providers/openai-compatible",
"@opencode-ai/ai/providers/azure/chat",
"@opencode-ai/ai/providers/google-vertex/chat",
])("adds conservative chat variants for %s", (packageName) => {
expect(plain(VariantPlugin.fallback(model("reasoner", packageName)))).toEqual([
{ id: "low", settings: settings(packageName, { reasoningEffort: "low" }) },
{ id: "medium", settings: settings(packageName, { reasoningEffort: "medium" }) },
{ id: "high", settings: settings(packageName, { reasoningEffort: "high" }) },
])
})
test("uses chat fallbacks for AI SDK Azure completion URLs", () => {
const variants = VariantPlugin.fallback(
model("deployment", Provider.aisdk("@ai-sdk/azure"), 32_000, { useCompletionUrls: true }),
)
expect(plain(variants)).toEqual([
{ id: "low", settings: { reasoningEffort: "low" } },
{ id: "medium", settings: { reasoningEffort: "medium" } },
{ id: "high", settings: { reasoningEffort: "high" } },
])
})
test.each([
Provider.aisdk("@ai-sdk/google"),
Provider.aisdk("@ai-sdk/google-vertex"),
"@opencode-ai/ai/providers/google",
"@opencode-ai/ai/providers/google-vertex",
"@opencode-ai/ai/providers/google-vertex/gemini",
])("adds Google level and legacy budget variants for %s", (packageName) => {
expect(plain(VariantPlugin.fallback(model("gemini-next", packageName)))).toEqual([
{
id: "low",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } }),
},
{
id: "medium",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" } }),
},
{
id: "high",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }),
},
])
expect(plain(VariantPlugin.fallback(model("gemini-2.5-pro", packageName, 64_000)))).toEqual([
{
id: "high",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }),
},
{
id: "max",
settings: settings(packageName, { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }),
},
])
expect(VariantPlugin.fallback(model("gemini-12.5-pro", packageName)).map((variant) => String(variant.id))).toEqual([
"low",
"medium",
"high",
])
})
test.each([
Provider.aisdk("@ai-sdk/anthropic"),
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
"@opencode-ai/ai/providers/anthropic",
"@opencode-ai/ai/providers/anthropic-compatible",
"@opencode-ai/ai/providers/google-vertex/messages",
])("adds Anthropic adaptive and legacy budget variants for %s", (packageName) => {
expect(VariantPlugin.fallback(model("claude-opus-4-7", packageName)).map((variant) => String(variant.id))).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
])
expect(VariantPlugin.fallback(model("claude-opus-4-7", packageName))[0]?.settings).toEqual(
settings(packageName, {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
}),
)
expect(plain(VariantPlugin.fallback(model("claude-haiku-4-5", packageName, 20_000)))).toEqual([
{ id: "high", settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: 16_000 } }) },
{ id: "max", settings: settings(packageName, { thinking: { type: "enabled", budgetTokens: 19_999 } }) },
])
for (const family of ["haiku", "sonnet", "opus"]) {
expect(VariantPlugin.fallback(model(`claude-${family}-4-5`, packageName))[0]?.settings).toEqual(
settings(packageName, { thinking: { type: "enabled", budgetTokens: 16_000 } }),
)
expect(VariantPlugin.fallback(model(`claude-${family}-4-6`, packageName))[0]?.settings).toEqual(
settings(packageName, {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
}),
)
}
expect(VariantPlugin.fallback(model("claude-mythos-4-5", packageName))[0]?.settings).toEqual(
settings(packageName, {
thinking: { type: "adaptive", display: "summarized" },
effort: "low",
}),
)
})
test("does not add fallbacks for unknown packages", () => {
expect(VariantPlugin.fallback(model("reasoner", "custom"))).toEqual([])
})
})
+1 -1
View File
@@ -28,7 +28,7 @@ const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const model = LanguageModel.make({
id: "summary-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
route: OpenAIChat.route,
})
let requests: LLMRequest[] = []
const client = Layer.mock(LLMClient.Service)({
@@ -30,7 +30,7 @@ let requests: LLMRequest[] = []
const model = LanguageModel.make({
id: "summary-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
route: OpenAIChat.route,
})
const cost = [
{
+10 -4
View File
@@ -2857,11 +2857,17 @@ describe("SessionRunnerLLM", () => {
yield* TestLLM.push(
TestLLM.stop(
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
LLMEvent.textStart({
id: "commentary",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
}),
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
LLMEvent.textEnd({
id: "commentary",
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
providerMetadata: {
openai: { itemId: "msg_commentary", phase: "commentary" },
anthropic: { ignored: true },
},
}),
),
)
@@ -2872,7 +2878,7 @@ describe("SessionRunnerLLM", () => {
{ type: "user", text: "Check first" },
{
type: "assistant",
content: [{ type: "text", text: "Checking.", state: { phase: "commentary" } }],
content: [{ type: "text", text: "Checking.", state: { itemId: "msg_commentary", phase: "commentary" } }],
},
])
@@ -2884,7 +2890,7 @@ describe("SessionRunnerLLM", () => {
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } },
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
},
])
}),
+1 -1
View File
@@ -27,7 +27,7 @@ let requests: LLMRequest[] = []
const model = LanguageModel.make({
id: "title-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
route: OpenAIChat.route,
})
const cost = [
{
+11 -7
View File
@@ -1,6 +1,4 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@@ -11,9 +9,10 @@ const channel = (() => {
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
const appPlugin = (await import("@opencode-ai/app/vite")).default
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
? sentryVitePlugin({
? (await import("@sentry/vite-plugin")).sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
@@ -34,11 +33,12 @@ export default defineConfig({
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
},
build: {
rollupOptions: {
rolldownOptions: {
input: { index: "src/main/index.ts" },
// Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can
// corrupt bundled TypeScript, while a Rollup banner places the shim safely.
// corrupt bundled TypeScript, while an output banner places the shim safely.
output: {
format: "es",
banner: `
// -- CommonJS Shims --
import __cjs_mod__ from 'node:module';
@@ -56,13 +56,14 @@ const require = __cjs_mod__.createRequire(import.meta.url);
enforce: "pre",
resolveId(s) {
if (s === "@lydell/node-pty") return nodePtyPkg
return undefined
},
},
],
},
preload: {
build: {
rollupOptions: {
rolldownOptions: {
input: { index: "src/preload/index.ts" },
output: {
format: "cjs",
@@ -72,6 +73,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
},
},
renderer: {
experimental: {
bundledDev: true,
},
define: {
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
@@ -81,7 +85,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
root: "src/renderer",
build: {
sourcemap: true,
rollupOptions: {
rolldownOptions: {
input: {
main: "src/renderer/index.html",
},
+2 -3
View File
@@ -40,7 +40,6 @@
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
@@ -50,11 +49,11 @@
"@valibot/to-json-schema": "1.6.0",
"electron": "42.3.3",
"electron-builder": "26.15.2",
"electron-vite": "^5",
"electron-vite": "6.0.0-beta.1",
"solid-js": "catalog:",
"sury": "11.0.0-alpha.4",
"typescript": "~5.6.2",
"vite": "catalog:",
"vite": "8.2.1",
"zod-openapi": "5.4.6"
},
"optionalDependencies": {
+5 -3
View File
@@ -11,14 +11,17 @@ async function main() {
process.env.OPENCODE_DISABLE_CHANNEL_DB = "0"
const options = selectOptions()
if (options.server.type === "build") process.env.OPENCODE_DESKTOP_SERVER_CHANNEL = "local"
process.env.OPENCODE_DESKTOP_ISOLATED_SERVER = "1"
await prepareDesktop()
await prepareServer(options.server)
await startDesktop(options.electron)
}
async function prepareDesktop() {
await $`bun run install-electron`
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
await Promise.all([
$`bun run install-electron`,
$`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`,
])
}
function selectOptions(): DevOptions {
@@ -46,7 +49,6 @@ async function prepareServer(source: ServerSource) {
}
async function startDesktop(args: string[]) {
process.env.OPENCODE_DESKTOP_ISOLATED_SERVER = "1"
await $`electron-vite dev ${args}`
}
+27 -10
View File
@@ -2,7 +2,12 @@ import { app } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import type { ServerReadyData } from "../shared/ipc-contract"
import { checkAppExists, resolveAppPath } from "./files/apps"
import { registerIpcHandlers, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
import {
registerIpcHandlers,
registerUpdaterIpcHandlers,
registerWslInitialization,
registerWslIpcHandlers,
} from "./ipc"
import {
acquireApplicationLock,
configureApplication,
@@ -26,13 +31,17 @@ const main = Effect.gen(function* () {
const logger = configureApplication()
if (!acquireApplicationLock()) return
preferApplicationEnvironment(logger)
loadProxyEnvironment(logger)
const lifecycle = createApplicationLifecycle(logger)
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
const wslReady = Promise.withResolvers<void>()
logger.log("starting v2 background service")
const backgroundTask = yield* Effect.promise(() => startBackgroundCli(logger)).pipe(Effect.forkChild)
yield* Effect.promise(() => app.whenReady())
yield* prepareDesktop(logger)
const updater = setupAutoUpdater(lifecycle.prepareToRestart)
const updater = yield* Effect.promise(() => setupAutoUpdater(lifecycle.prepareToRestart))
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
@@ -68,27 +77,35 @@ const main = Effect.gen(function* () {
},
})
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
registerWslInitialization(wslReady.promise)
startAutoUpdater(updater)
yield* Effect.promise(() => startNetworkLogging())
const loadingTask = yield* Effect.gen(function* () {
loadProxyEnvironment(logger)
logger.log("starting v2 background service")
const background = yield* Effect.promise(() => startBackgroundCli(logger))
const wsl = yield* Effect.promise(() => startWsl(background, logger))
registerWslIpcHandlers(wsl.ipc)
wsl.start()
lifecycle.setWslShutdown(wsl.stop)
const background = yield* Fiber.join(backgroundTask)
yield* Deferred.succeed(serverReady, {
url: background.url,
username: background.username,
password: background.password,
})
logger.log("loading task finished")
void startWsl(background, logger).then(
(wsl) => {
registerWslIpcHandlers(wsl.ipc)
lifecycle.setWslShutdown(wsl.stop)
wsl.start()
wslReady.resolve()
},
(error) => {
logger.error("failed to start WSL manager", { error })
wslReady.reject(error)
},
)
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
yield* Fiber.await(loadingTask)
if (lifecycle.restoreWindows().length) createMenu(menu)
yield* Fiber.await(loadingTask)
})
Effect.runFork(main)
+18 -1
View File
@@ -15,7 +15,14 @@ import { createFileCapabilities, openExternalURL, openLocalFileURL } from "./fil
import { setForceFocus } from "./native/debug"
import { runDesktopMenuAction } from "./native/menu-actions"
import { createDesktopStorage } from "./storage"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import {
getPinchZoomEnabled,
getWindowID,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "./windows"
import type { UpdaterIpc } from "./updater"
import type { WslIpc } from "./wsl/ipc"
@@ -112,6 +119,12 @@ export function registerIpcHandlers(deps: Deps) {
return id
})
handle(Ipc.window.themeReady, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")
setWindowThemeReady(win)
})
handle(Ipc.window.getFocused, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
@@ -167,6 +180,10 @@ export function registerUpdaterIpcHandlers(updater: UpdaterIpc) {
handle(Ipc.updater.install, () => updater.install())
}
export function registerWslInitialization(ready: Promise<void>) {
handle(Ipc.wsl.awaitInitialization, () => ready)
}
export function registerWslIpcHandlers(wsl: WslIpc) {
handle(Ipc.wsl.subscribe, (event) => wsl.subscribe(event.sender))
handle(Ipc.wsl.unsubscribe, (event) => wsl.unsubscribe(event.sender.id))
@@ -35,11 +35,11 @@ export function configureApplication() {
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
const onboardingRoot = createOnboardingTestRoot()
const testRoot = createTestRoot()
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
app.setAppUserModelId(appID)
app.setPath("userData", onboardingRoot ? join(onboardingRoot, "desktop") : join(app.getPath("appData"), appID))
if (onboardingRoot) app.setPath("sessionData", join(onboardingRoot, "session"))
app.setPath("userData", testRoot ? join(testRoot, "desktop") : join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", join(testRoot, "session"))
initializeFirstLaunchOnboarding(app.getPath("userData"))
const logger = initLogging()
@@ -48,14 +48,15 @@ export function configureApplication() {
logger.log("app starting", {
version: VERSION,
packaged: app.isPackaged,
onboardingTest: Boolean(onboardingRoot),
onboardingTest: testOnboarding,
})
loadProxyEnvironment(logger)
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
if (!app.isPackaged)
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
return logger
}
@@ -88,7 +89,8 @@ export function prepareDesktop(logger: DesktopLogger) {
),
Effect.catch((error) => Effect.sync(() => logger.warn("failed to clean scoped store files", error))),
)
app.setAsDefaultProtocolClient("opencode")
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
})
@@ -105,14 +107,18 @@ export function loadProxyEnvironment(logger: DesktopLogger) {
}
}
function createOnboardingTestRoot() {
if (!testOnboarding) return undefined
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
rmSync(root, { recursive: true, force: true })
function createTestRoot() {
const root = testOnboarding
? join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
: app.isPackaged
? undefined
: process.env.OPENCODE_DESKTOP_TEST_ROOT
if (!root) return undefined
if (testOnboarding) rmSync(root, { recursive: true, force: true })
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
mkdirSync(join(root, dir), { recursive: true }),
)
process.env.OPENCODE_DB = ":memory:"
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
process.env.XDG_DATA_HOME = join(root, "data")
process.env.XDG_CONFIG_HOME = join(root, "config")
process.env.XDG_CACHE_HOME = join(root, "cache")
+1 -1
View File
@@ -2,7 +2,6 @@ import { MainLogger } from "electron-log"
import log from "electron-log/main.js"
import { app, crashReporter, netLog, shell } from "electron"
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
import { dirname, join } from "node:path"
import { homedir } from "node:os"
import { VERSION } from "../constants"
@@ -185,6 +184,7 @@ function collect(dir: string, prefix: string): Entry[] {
}
async function writeZip(output: string, entries: Entry[]) {
const { BlobReader, BlobWriter, ZipWriter } = await import("@zip.js/zip.js")
const writer = new ZipWriter(new BlobWriter("application/zip"))
for (const entry of entries) {
const data = entry.data ?? readFileSync(entry.path!)
@@ -3,4 +3,5 @@ export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const BACKGROUND_COLOR_KEY = "backgroundColor"
export const WINDOW_IDS_KEY = "windowIds"
+5 -4
View File
@@ -6,21 +6,22 @@ import { getLogger } from "../native/logging"
import { nativeT } from "../native/translations"
import { getStore } from "../storage/store"
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./controller"
import { createUpdaterPlatform } from "./platform"
const key = "ready"
export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
export async function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
const logger = getLogger()
const store = getStore("opencode.updater")
const platform = UPDATER_ENABLED ? (await import("./platform")).createUpdaterPlatform(logger) : undefined
return createUpdaterController({
currentVersion: app.getVersion(),
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
platform,
lifecycle: { prepareToRestart },
persistence: {
get() {
const value = store.get(key)
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string")
return undefined
return { version: value.version } satisfies UpdaterReadyRecord
},
set: (value) => store.set(key, value),
@@ -5,7 +5,7 @@ import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
import { join } from "node:path"
import { Ipc, sendIpcEvent, type TitlebarTheme } from "../../shared/ipc-contract"
import { developmentResourcesRoot, preloadPath } from "../paths"
import { PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
const oc2Theme = oc2ThemeJson as DesktopTheme
@@ -22,10 +22,12 @@ let backgroundColor: string | undefined
export function windowAppearance() {
const mode = tone()
const storedBackground = getStore().get(BACKGROUND_COLOR_KEY)
return {
title: "OpenCode",
icon: iconPath(),
backgroundColor: backgroundColor ?? oc2Background[mode],
backgroundColor:
backgroundColor ?? (typeof storedBackground === "string" ? storedBackground : undefined) ?? oc2Background[mode],
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
@@ -56,6 +58,7 @@ export function setDockIcon() {
export function setBackgroundColor(color: string) {
backgroundColor = color
getStore().set(BACKGROUND_COLOR_KEY, color)
BrowserWindow.getAllWindows().forEach((win) => {
win.setBackgroundColor(color)
if (process.platform === "darwin") win.invalidateShadow()
@@ -63,7 +66,8 @@ export function setBackgroundColor(color: string) {
}
export function getBackgroundColor() {
return backgroundColor
const stored = getStore().get(BACKGROUND_COLOR_KEY)
return backgroundColor ?? (typeof stored === "string" ? stored : undefined)
}
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
+22 -4
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs"
import { join } from "node:path"
import { app, BrowserWindow } from "electron"
import { writeLog } from "../native/logging"
import { removeStoreFile, getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import {
@@ -23,6 +24,7 @@ import { wireWindowRecovery } from "./recovery"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
const windowIDs = new WeakMap<BrowserWindow, string>()
const themeReady = new WeakMap<BrowserWindow, () => void>()
const registry = createWindowRegistry<BrowserWindow>({
read: () => getStore().get(WINDOW_IDS_KEY),
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
@@ -68,6 +70,10 @@ export function getLastFocusedWindow() {
return win
}
export function setWindowThemeReady(win: BrowserWindow) {
themeReady.get(win)?.()
}
export function restoreMainWindows() {
const ids = registry.persisted()
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
@@ -92,16 +98,28 @@ export function createMainWindow(id: string = randomUUID()) {
state.manage(win)
registerWindow(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
let revealed = false
const reveal = () => {
if (revealed || win.isDestroyed()) return
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
revealed = true
win.show()
writeLog("window", "main window visible", { window: id })
}
win.once("ready-to-show", reveal)
if (process.platform === "linux") win.webContents.once("did-finish-load", reveal)
const ready = () => {
contentReady = true
reveal()
}
themeReady.set(win, () => {
appliedTheme = true
reveal()
})
win.once("ready-to-show", ready)
if (process.platform === "linux") win.webContents.once("did-finish-load", ready)
win.once("closed", () => themeReady.delete(win))
loadWindow(win, "index.html")
return win
}
+21 -13
View File
@@ -34,29 +34,36 @@ const updaterHandler = (state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
type WslInvoke = Exclude<
(typeof Ipc.wsl)[keyof typeof Ipc.wsl],
typeof Ipc.wsl.awaitInitialization | typeof Ipc.wsl.event
>
function invokeWsl<Channel extends WslInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
return invoke(Ipc.wsl.awaitInitialization).then(() => invoke(channel, ...args))
}
const api: ElectronAPI = {
awaitInitialization: () => invoke(Ipc.app.awaitInitialization),
wslServers: {
getState: () => invoke(Ipc.wsl.getState),
getState: () => invokeWsl(Ipc.wsl.getState),
subscribe: (cb) => {
const dispose = listen(Ipc.wsl.event, cb)
void invoke(Ipc.wsl.subscribe)
const subscribed = invokeWsl(Ipc.wsl.subscribe)
return () => {
dispose()
void invoke(Ipc.wsl.unsubscribe)
void subscribed.then(() => invokeWsl(Ipc.wsl.unsubscribe))
}
},
probeRuntime: () => invoke(Ipc.wsl.probeRuntime),
refreshDistros: () => invoke(Ipc.wsl.refreshDistros),
installWsl: () => invoke(Ipc.wsl.installWsl),
installDistro: (name) => invoke(Ipc.wsl.installDistro, name),
probeAddable: (distros) => invoke(Ipc.wsl.probeAddable, distros),
installOpencode: (name) => invoke(Ipc.wsl.installOpencode, name),
openTerminal: (name) => invoke(Ipc.wsl.openTerminal, name),
addServer: (distro) => invoke(Ipc.wsl.addServer, distro),
removeServer: (id) => invoke(Ipc.wsl.removeServer, id),
startServer: (id) => invoke(Ipc.wsl.startServer, id),
probeRuntime: () => invokeWsl(Ipc.wsl.probeRuntime),
refreshDistros: () => invokeWsl(Ipc.wsl.refreshDistros),
installWsl: () => invokeWsl(Ipc.wsl.installWsl),
installDistro: (name) => invokeWsl(Ipc.wsl.installDistro, name),
probeAddable: (distros) => invokeWsl(Ipc.wsl.probeAddable, distros),
installOpencode: (name) => invokeWsl(Ipc.wsl.installOpencode, name),
openTerminal: (name) => invokeWsl(Ipc.wsl.openTerminal, name),
addServer: (distro) => invokeWsl(Ipc.wsl.addServer, distro),
removeServer: (id) => invokeWsl(Ipc.wsl.removeServer, id),
startServer: (id) => invokeWsl(Ipc.wsl.startServer, id),
},
updater: {
subscribe: async (cb) => {
@@ -100,6 +107,7 @@ const api: ElectronAPI = {
draftBlobGet: (id) => invoke(Ipc.drafts.getBlob, id),
getWindowID: () => invoke(Ipc.window.getId),
themeReady: () => invoke(Ipc.window.themeReady),
onMenuCommand: (cb) => listen(Ipc.menu.command, cb),
onDeepLink: (cb) => listen(Ipc.app.deepLink, cb),
+1
View File
@@ -38,6 +38,7 @@ export type ElectronAPI = {
draftBlobGet: IpcInvokeMethod<typeof Ipc.drafts.getBlob>
getWindowID: IpcInvokeMethod<typeof Ipc.window.getId>
themeReady: IpcInvokeMethod<typeof Ipc.window.themeReady>
onMenuCommand: IpcEventSubscription<typeof Ipc.menu.command>
onDeepLink: IpcEventSubscription<typeof Ipc.app.deepLink>
+14 -9
View File
@@ -5,6 +5,7 @@ import {
AppBaseProviders,
AppInterface,
PlatformProvider,
preloadRoute,
ServerConnection,
useCommand,
useLanguage,
@@ -13,9 +14,8 @@ import {
} from "@opencode-ai/app"
import { useTheme } from "@opencode-ai/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import type { ElectronAPI } from "../preload/types"
import { MigrationStatus } from "./migration-status"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
@@ -26,6 +26,8 @@ import { getLastActiveUrl } from "./window/route-storage"
import { DesktopMemoryRouter } from "./window/router"
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
const MigrationStatus = lazy(() => import("./migration-status").then((module) => ({ default: module.MigrationStatus })))
export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
const [windowState] = createResource(() => props.api.getWindowID().then((id) => ({ id, version: props.version })))
return (
@@ -37,9 +39,11 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; windowState: DesktopWindowState }) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const initialUrl = getLastActiveUrl(props.windowState.id)
const [sidecar] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [route] = createResource(() => preloadRoute(initialUrl))
const router = (routerProps: BaseRouterProps) => (
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
)
@@ -47,9 +51,7 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
function ReadyApp() {
const wslServers = useWslServers()
const language = useLanguage()
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
)
const ready = createMemo(() => !defaultServer.loading && !sidecar.loading && !locale.loading && !route.loading)
const servers = createMemo(() => {
const data = initializationData(sidecar)
const list: ServerConnection.Any[] = []
@@ -79,13 +81,15 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={getLastActiveUrl(props.windowState.id)}
initialUrl={initialUrl}
serverKey={key}
/>
<DesktopEffects api={props.api} />
<Show when={initializationData(sidecar)} keyed>
{(server) => <MigrationStatus server={server} />}
</Show>
<Suspense fallback={null}>
<Show when={initializationData(sidecar)} keyed>
{(server) => <MigrationStatus server={server} />}
</Show>
</Suspense>
</AppInterface>
)}
</Show>
@@ -98,6 +102,7 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
<AppBaseProviders
locale={locale.latest}
onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)}
onThemeApplied={() => void props.api.themeReady()}
>
<Show when={true}>{(_) => <ReadyApp />}</Show>
</AppBaseProviders>
-21
View File
@@ -1,21 +0,0 @@
export const dict = {
"desktop.menu.checkForUpdates": "ዝማኔዎችን ይመልከቱ...",
"desktop.menu.reloadWebview": "ዳግም ጫን Webview",
"desktop.menu.restart": "ዳግም አስጀምር",
"desktop.dialog.chooseFolder": "አቃፊ ምረጥ",
"desktop.dialog.chooseFile": "ፋይል ምረጥ",
"desktop.dialog.saveFile": "ፋይሉን አስቀምጥ",
"desktop.updater.checkFailed.title": "ማዘመን ቼክ አልተሳካም",
"desktop.updater.checkFailed.message": "ዝማኔዎችን ማረጋገጥ አልተሳካም",
"desktop.updater.none.title": "ምንም ማሻሻያ የለም",
"desktop.updater.none.message": "አሁን የቅርብ ጊዜውን የOpenCode ስሪት እየተጠቀሙ ነው",
"desktop.updater.downloadFailed.title": "ዝማኔ አልተሳካም",
"desktop.updater.downloadFailed.message": "ዝማኔን ማውረድ አልተሳካም",
"desktop.updater.downloaded.title": "ዝማኔው ወርዷል",
"desktop.updater.downloaded.prompt": "ስሪት {{version}} ከOpenCode ወርዷል፣ መጫን እና እንደገና ማስጀመር ይፈልጋሉ?",
"desktop.updater.installFailed.title": "ዝማኔ አልተሳካም",
"desktop.updater.installFailed.message": "ዝማኔን መጫን አልተሳካም",
"desktop.error.dev.rootNotFound":
"ሥርወ አካል አልተገኘም። ወደ የእርስዎ index.html ማከልን ረስተዋል? ወይም የመታወቂያ ባህሪው የተሳሳተ ፊደል ተጽፎ ሊሆን ይችላል?",
}
-24
View File
@@ -1,24 +0,0 @@
export const dict = {
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
"desktop.menu.reloadWebview": "إعادة تحميل عرض الويب",
"desktop.menu.restart": "إعادة تشغيل",
"desktop.dialog.chooseFolder": "اختيار مجلد",
"desktop.dialog.chooseFile": "اختيار ملف",
"desktop.dialog.saveFile": "حفظ ملف",
"desktop.updater.checkFailed.title": "فشل التحقق من التحديثات",
"desktop.updater.checkFailed.message": "فشل التحقق من وجود تحديثات",
"desktop.updater.none.title": "لا توجد تحديثات متاحة",
"desktop.updater.none.message": "أنت تستخدم بالفعل أحدث إصدار من OpenCode",
"desktop.updater.downloadFailed.title": "فشل التحديث",
"desktop.updater.downloadFailed.message": "فشل تنزيل التحديث",
"desktop.updater.downloaded.title": "تم تنزيل التحديث",
"desktop.updater.downloaded.prompt":
"تم تنزيل الإصدار {{version}} من OpenCode. هل ترغب في تثبيته وإعادة تشغيل التطبيق؟",
"desktop.updater.installFailed.title": "فشل التحديث",
"desktop.updater.installFailed.message": "فشل تثبيت التحديث",
"desktop.error.dev.rootNotFound":
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
}
-24
View File
@@ -1,24 +0,0 @@
export const dict = {
"desktop.menu.checkForUpdates": "Yeniləmələri yoxla...",
"desktop.menu.reloadWebview": "Webview-u yenidən yüklə",
"desktop.menu.restart": "Yenidən başlat",
"desktop.dialog.chooseFolder": "Qovluq seçin",
"desktop.dialog.chooseFile": "Fayl seçin",
"desktop.dialog.saveFile": "Faylı saxla",
"desktop.updater.checkFailed.title": "Yeniləmə yoxlaması uğursuz oldu",
"desktop.updater.checkFailed.message": "Yeniləmələr yoxlana bilmədi",
"desktop.updater.none.title": "Yeniləmə mövcud deyil",
"desktop.updater.none.message": "Artıq OpenCode-un ən son versiyasından istifadə edirsiniz",
"desktop.updater.downloadFailed.title": "Yeniləmə uğursuz oldu",
"desktop.updater.downloadFailed.message": "Yeniləmə yüklənə bilmədi",
"desktop.updater.downloaded.title": "Yeniləmə yükləndi",
"desktop.updater.downloaded.prompt":
"OpenCode-un {{version}} versiyası yüklənib. Onu quraşdırıb tətbiqi yenidən başlatmaq istəyirsiniz?",
"desktop.updater.installFailed.title": "Yeniləmə uğursuz oldu",
"desktop.updater.installFailed.message": "Yeniləmə quraşdırıla bilmədi",
"desktop.error.dev.rootNotFound":
"Kök element tapılmadı. index.html-ə əlavə etməyi unutmusunuz? Yoxsa id atributu səhv yazılıb?",
}
-22
View File
@@ -1,22 +0,0 @@
export const dict = {
"desktop.menu.checkForUpdates": "Проверете за актуализации...",
"desktop.menu.reloadWebview": "Презареди Webview",
"desktop.menu.restart": "Рестартирайте",
"desktop.dialog.chooseFolder": "Изберете папка",
"desktop.dialog.chooseFile": "Изберете файл",
"desktop.dialog.saveFile": "Запазете файла",
"desktop.updater.checkFailed.title": "Проверката на актуализацията е неуспешна",
"desktop.updater.checkFailed.message": "Неуспешна проверка за актуализации",
"desktop.updater.none.title": "Няма налична актуализация",
"desktop.updater.none.message": "Вече използвате най-новата версия на OpenCode",
"desktop.updater.downloadFailed.title": "Неуспешна актуализация",
"desktop.updater.downloadFailed.message": "Неуспешно изтегляне на актуализация",
"desktop.updater.downloaded.title": "Актуализацията е изтеглена",
"desktop.updater.downloaded.prompt":
"Версия {{version}} от OpenCode е изтеглена. Искате ли да я инсталирате и рестартирате?",
"desktop.updater.installFailed.title": "Неуспешна актуализация",
"desktop.updater.installFailed.message": "Неуспешно инсталиране на актуализация",
"desktop.error.dev.rootNotFound":
"Основният елемент не е намерен. Забравихте ли да го добавите към вашия index.html? Или може би атрибутът id е изписан неправилно?",
}
-22
View File
@@ -1,22 +0,0 @@
export const dict: Record<string, string> = {
"desktop.menu.checkForUpdates": "আপডেটের জন্য চেক করুন...",
"desktop.menu.reloadWebview": "Webview পুনরায় লোড করুন",
"desktop.menu.restart": "রিস্টার্ট করুন",
"desktop.dialog.chooseFolder": "একটি ফোল্ডার নির্বাচন করুন",
"desktop.dialog.chooseFile": "একটি ফাইল নির্বাচন করুন",
"desktop.dialog.saveFile": "ফাইল সংরক্ষণ করুন",
"desktop.updater.checkFailed.title": "আপডেট চেক ব্যর্থ হয়েছে",
"desktop.updater.checkFailed.message": "আপডেটের জন্য চেক করতে ব্যর্থ",
"desktop.updater.none.title": "কোন আপডেট উপলব্ধ নেই",
"desktop.updater.none.message": "আপনি ইতিমধ্যেই OpenCode এর সর্বশেষ সংস্করণ ব্যবহার করছেন৷",
"desktop.updater.downloadFailed.title": "আপডেট ব্যর্থ হয়েছে৷",
"desktop.updater.downloadFailed.message": "আপডেট ডাউনলোড করতে ব্যর্থ হয়েছে",
"desktop.updater.downloaded.title": "আপডেট ডাউনলোড হয়েছে",
"desktop.updater.downloaded.prompt":
"OpenCode-এর {{version}} সংস্করণ ডাউনলোড করা হয়েছে, আপনি কি এটি ইনস্টল করে পুনরায় চালু করতে চান?",
"desktop.updater.installFailed.title": "আপডেট ব্যর্থ হয়েছে৷",
"desktop.updater.installFailed.message": "আপডেট ইনস্টল করতে ব্যর্থ হয়েছে",
"desktop.error.dev.rootNotFound":
"মূল উপাদান পাওয়া যায়নি. আপনি কি আপনার index.html এ যোগ করতে ভুলে গেছেন? অথবা হয়তো আইডি অ্যাট্রিবিউট ভুল বানান হয়েছে?",
}
-24
View File
@@ -1,24 +0,0 @@
export const dict = {
"desktop.menu.checkForUpdates": "Verificar atualizações...",
"desktop.menu.reloadWebview": "Recarregar Webview",
"desktop.menu.restart": "Reiniciar",
"desktop.dialog.chooseFolder": "Escolher uma pasta",
"desktop.dialog.chooseFile": "Escolher um arquivo",
"desktop.dialog.saveFile": "Salvar arquivo",
"desktop.updater.checkFailed.title": "Falha ao verificar atualizações",
"desktop.updater.checkFailed.message": "Falha ao verificar atualizações",
"desktop.updater.none.title": "Nenhuma atualização disponível",
"desktop.updater.none.message": "Você já está usando a versão mais recente do OpenCode",
"desktop.updater.downloadFailed.title": "Falha na atualização",
"desktop.updater.downloadFailed.message": "Falha ao baixar a atualização",
"desktop.updater.downloaded.title": "Atualização baixada",
"desktop.updater.downloaded.prompt":
"A versão {{version}} do OpenCode foi baixada. Você gostaria de instalá-la e reiniciar?",
"desktop.updater.installFailed.title": "Falha na atualização",
"desktop.updater.installFailed.message": "Falha ao instalar a atualização",
"desktop.error.dev.rootNotFound":
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
}

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