Compare commits

..

19 Commits

Author SHA1 Message Date
Brendan Allan c4d283d23d chore(desktop): merge v2 into effect rpc 2026-08-20 16:51:48 +08:00
Brendan Allan 41b4e1e8e2 moar effect 2026-08-20 16:20:50 +08:00
opencode-agent[bot] ab77fb080a chore: update nix node_modules hashes 2026-08-20 08:06:55 +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
Aiden Cline 08f26a2d2e feat(ai): support Responses request options (#43360) 2026-08-19 22:45:26 -05:00
Kit Langton a51622a0e7 chore: upgrade Effect to rc.110 (#43567) 2026-08-20 03:43:23 +00:00
Kit Langton 20ff543ff2 fix(tui): honest reconnect overlay copy without a managed service (#43561) 2026-08-20 03:33:57 +00:00
Brendan Allan 12d3824212 refactor(desktop): move IPC to Effect RPC 2026-08-18 11:13:53 +00:00
250 changed files with 6291 additions and 4641 deletions
+1420 -842
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="
}
}
+6 -5
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",
@@ -37,10 +38,10 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.107",
"@effect/platform-node": "4.0.0-beta.107",
"@effect/platform-node-shared": "4.0.0-beta.107",
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
"@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -71,7 +72,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.107",
"effect": "4.0.0-rc.110",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
+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
+104 -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,
@@ -159,6 +162,14 @@ export const coreFields = {
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safety_identifier: Schema.optional(Schema.String),
stream_options: Schema.optional(
Schema.Struct({
include_obfuscation: Schema.optional(Schema.Boolean),
}),
),
top_logprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String),
@@ -179,6 +190,8 @@ export const coreFields = {
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
}
const OpenResponsesBody = Schema.Struct({
@@ -288,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
@@ -353,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* (
@@ -465,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 }),
@@ -511,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) {
@@ -578,6 +610,12 @@ const lowerOptions = (request: LLMRequest) => {
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
...(options.streamOptions?.includeObfuscation !== undefined
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
@@ -627,6 +665,8 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request),
}
})
@@ -695,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]
}
@@ -924,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,
@@ -937,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)
@@ -988,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),
@@ -1004,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
@@ -1047,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))
@@ -1074,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)),
@@ -47,9 +47,17 @@ export const AllowedTools = Schema.Struct({
})
export type AllowedTools = typeof AllowedTools.Type
export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
-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({
@@ -126,6 +182,10 @@ describe("Open Responses-compatible route", () => {
providerOptions: {
reasoningEffort: "low",
store: true,
metadata: { environment: "test" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
@@ -136,6 +196,7 @@ describe("Open Responses-compatible route", () => {
LLM.request({
model,
prompt: "Think.",
generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 },
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
@@ -143,6 +204,12 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
metadata: { environment: "test" },
safety_identifier: "user_123",
stream_options: { include_obfuscation: false },
top_logprobs: 3,
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
@@ -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",
},
@@ -1284,6 +1285,7 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
promptCacheKey: "session_123",
generation: { presencePenalty: 0.25, frequencyPenalty: -0.25 },
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
@@ -1293,6 +1295,10 @@ describe("OpenAI Responses route", () => {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
metadata: { environment: "test", tenant: "acme" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 5,
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
@@ -1306,6 +1312,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
expect(prepared.body.safety_identifier).toBe("user_123")
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
expect(prepared.body.top_logprobs).toBe(5)
expect(prepared.body.presence_penalty).toBe(0.25)
expect(prepared.body.frequency_penalty).toBe(-0.25)
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
@@ -1473,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" },
@@ -1494,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(
@@ -1532,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,
@@ -1652,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" } } },
])
}),
)
@@ -1689,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" } } },
])
}),
)
@@ -1850,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." }],
},
@@ -1857,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." },
@@ -1901,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." }] },
])
}),
)
@@ -2039,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" },
@@ -2171,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")
}),
)
@@ -2237,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>`,
)
}
+1 -1
View File
@@ -37,7 +37,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.107",
"effect": "4.0.0-rc.110",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+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 -4
View File
@@ -111,7 +111,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
const durable = events[1]
if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
expect(durable.created).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
@@ -219,9 +219,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(logQueries[0]).toEqual({ after: "0" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
1_717_171_717_000,
)
expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
expect(logged.at(-1)).toEqual(synced)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
})
@@ -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: {
-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
})
}
})
@@ -236,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 } } },
},
})
@@ -785,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 })
},
@@ -913,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 })
@@ -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 -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",
},
+3 -3
View File
@@ -23,6 +23,7 @@
},
"main": "./out/main/index.js",
"dependencies": {
"@effect/platform-node": "catalog:",
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -40,7 +41,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 +50,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}`
}
+63 -62
View File
@@ -1,103 +1,100 @@
import { execFile } from "node:child_process"
import { access, readFile, readdir } from "node:fs/promises"
import { dirname, extname, join } from "node:path"
import util from "node:util"
import { Effect, FileSystem, Path } from "effect"
const execFilePromise = util.promisify(execFile)
const exists = (path: string) =>
access(path)
.then(() => true)
.catch(() => false)
export function checkAppExists(appName: string) {
export const checkAppExists = Effect.fn("DesktopFiles.checkAppExists")(function* (appName: string) {
if (process.platform === "win32") return true
if (process.platform === "linux") return true
return checkMacosApp(appName)
}
return yield* checkMacosApp(appName)
})
export function resolveAppPath(appName: string) {
export const resolveAppPath = Effect.fn("DesktopFiles.resolveAppPath")(function* (appName: string) {
if (process.platform !== "win32") return appName
return resolveWindowsAppPath(appName)
}
return yield* resolveWindowsAppPath(appName)
})
async function checkMacosApp(appName: string) {
const checkMacosApp = Effect.fn("DesktopFiles.checkMacosApp")(function* (appName: string) {
const fs = yield* FileSystem.FileSystem
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
const home = process.env.HOME
if (home) locations.push(`${home}/Applications/${appName}.app`)
for (const location of locations) {
if (await exists(location)) return true
if (yield* exists(fs, location)) return true
}
return execFilePromise("which", [appName])
.then(() => true)
.catch(() => false)
}
return yield* Effect.tryPromise(() => execFilePromise("which", [appName])).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
let output: string
try {
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
} catch {
return null
}
const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(function* (appName: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const result = yield* Effect.tryPromise(() => execFilePromise("where", [appName])).pipe(
Effect.catch(() => Effect.succeed(undefined)),
)
if (!result) return null
const paths = output
const paths = result.stdout
.toString()
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}`
const hasExt = (value: string, ext: string) => path.extname(value).toLowerCase() === `.${ext}`
const exe = paths.find((path) => hasExt(path, "exe"))
if (exe) return exe
const resolveCmd = async (path: string) => {
const content = await readFile(path, "utf8")
const resolveCmd = Effect.fnUntraced(function* (file: string) {
const content = yield* fs.readFileString(file)
for (const token of content.split('"').map((value: string) => value.trim())) {
const lower = token.toLowerCase()
if (!lower.includes(".exe")) continue
const index = lower.indexOf("%~dp0")
if (index >= 0) {
const base = dirname(path)
const base = path.dirname(file)
const suffix = token.slice(index + 5)
const resolved = suffix
.replace(/\//g, "\\")
.split("\\")
.filter((part: string) => part && part !== ".")
.reduce((current: string, part: string) => {
if (part === "..") return dirname(current)
return join(current, part)
if (part === "..") return path.dirname(current)
return path.join(current, part)
}, base)
if (await exists(resolved)) return resolved
if (yield* exists(fs, resolved)) return resolved
}
if (await exists(token)) return token
if (yield* exists(fs, token)) return token
}
return null
}
})
for (const path of paths) {
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
const resolved = await resolveCmd(path)
for (const file of paths) {
if (hasExt(file, "cmd") || hasExt(file, "bat")) {
const resolved = yield* resolveCmd(file)
if (resolved) return resolved
}
if (!extname(path)) {
const cmd = `${path}.cmd`
if (await exists(cmd)) {
const resolved = await resolveCmd(cmd)
if (!path.extname(file)) {
const cmd = `${file}.cmd`
if (yield* exists(fs, cmd)) {
const resolved = yield* resolveCmd(cmd)
if (resolved) return resolved
}
const bat = `${path}.bat`
if (await exists(bat)) {
const resolved = await resolveCmd(bat)
const bat = `${file}.bat`
if (yield* exists(fs, bat)) {
const resolved = yield* resolveCmd(bat)
if (resolved) return resolved
}
}
@@ -110,27 +107,31 @@ async function resolveWindowsAppPath(appName: string): Promise<string | null> {
.join("")
if (key) {
for (const path of paths) {
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
for (const file of paths) {
const dirs = [path.dirname(file), path.dirname(path.dirname(file)), path.dirname(path.dirname(path.dirname(file)))]
for (const dir of dirs) {
try {
for (const entry of await readdir(dir)) {
const candidate = join(dir, entry)
if (!hasExt(candidate, "exe")) continue
const stem = entry.replace(/\.exe$/i, "")
const name = stem
.split("")
.filter((value: string) => /[a-z0-9]/i.test(value))
.map((value: string) => value.toLowerCase())
.join("")
if (name.includes(key) || key.includes(name)) return candidate
}
} catch {
continue
const entries = yield* fs.readDirectory(dir).pipe(Effect.catch(() => Effect.succeed([])))
for (const entry of entries) {
const candidate = path.join(dir, entry)
if (!hasExt(candidate, "exe")) continue
const stem = entry.replace(/\.exe$/i, "")
const name = stem
.split("")
.filter((value: string) => /[a-z0-9]/i.test(value))
.map((value: string) => value.toLowerCase())
.join("")
if (name.includes(key) || key.includes(name)) return candidate
}
}
}
}
return paths[0] ?? null
})
function exists(fs: FileSystem.FileSystem, path: string) {
return fs.access(path).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
}
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, FileSystem } from "effect"
import {
assertAttachmentBudget,
createPickedFileAuthorizations,
@@ -9,6 +11,9 @@ import {
readAttachment,
} from "./attachment-picker"
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) =>
Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
describe("assertAttachmentBudget", () => {
test("accepts selections within the media ingest limit", () => {
expect(() =>
@@ -25,7 +30,7 @@ describe("assertAttachmentBudget", () => {
const file = join(directory, "example.txt")
try {
await writeFile(file, "lorem ipsum")
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
expect(new TextDecoder().decode(await run(readAttachment(file)))).toBe("lorem ipsum")
} finally {
await rm(directory, { recursive: true, force: true })
}
@@ -37,7 +42,7 @@ describe("assertAttachmentBudget", () => {
try {
await writeFile(file, "")
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
await expect(run(readAttachment(file))).rejects.toThrow("20 MB limit")
} finally {
await rm(directory, { recursive: true, force: true })
}
@@ -45,16 +50,16 @@ describe("assertAttachmentBudget", () => {
})
describe("picked file authorizations", () => {
const read = async (path: string) => new TextEncoder().encode(path).buffer
const read = (path: string) => Effect.sync(() => new TextEncoder().encode(path).buffer)
test("keeps concurrent picker selections isolated", async () => {
const authorizations = createPickedFileAuthorizations(read)
const first = authorizations.add(1, ["a.txt", "b.txt"])
const second = authorizations.add(1, ["c.txt"])
expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
expect(new TextDecoder().decode(await run(authorizations.read(1, first, "a.txt")))).toBe("a.txt")
expect(new TextDecoder().decode(await run(authorizations.read(1, second, "c.txt")))).toBe("c.txt")
expect(new TextDecoder().decode(await run(authorizations.read(1, first, "b.txt")))).toBe("b.txt")
})
test("releases unread files for one picker without affecting another", async () => {
@@ -63,25 +68,29 @@ describe("picked file authorizations", () => {
const second = authorizations.add(1, ["b.txt"])
authorizations.release(1, first)
await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
await expect(run(authorizations.read(1, first, "a.txt"))).rejects.toThrow("not selected")
expect(new TextDecoder().decode(await run(authorizations.read(1, second, "b.txt")))).toBe("b.txt")
})
test("keeps picker tokens scoped to their renderer", async () => {
const authorizations = createPickedFileAuthorizations(read)
const token = authorizations.add(1, ["a.txt"])
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
await expect(run(authorizations.read(2, token, "a.txt"))).rejects.toThrow("not selected")
})
test("charges actual reads against the selection budget", async () => {
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
if (6 > maxBytes) throw new Error("budget exceeded")
return new ArrayBuffer(6)
}, 10)
const authorizations = createPickedFileAuthorizations(
(_path, maxBytes) =>
Effect.sync(() => {
if (6 > maxBytes) throw new Error("budget exceeded")
return new ArrayBuffer(6)
}),
10,
)
const token = authorizations.add(1, ["a.txt", "b.txt"])
await authorizations.read(1, token, "a.txt")
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
await run(authorizations.read(1, token, "a.txt"))
await expect(run(authorizations.read(1, token, "b.txt"))).rejects.toThrow("budget exceeded")
})
})
@@ -1,11 +1,11 @@
import { randomUUID } from "node:crypto"
import { open } from "node:fs/promises"
import { Effect, FileSystem } from "effect"
import { nativeT } from "../native/translations"
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
export function createPickedFileAuthorizations(
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
read: (path: string, maxBytes: number) => Effect.Effect<ArrayBuffer, unknown>,
budget = MAX_ATTACHMENT_BYTES,
) {
const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
@@ -16,15 +16,15 @@ export function createPickedFileAuthorizations(
selections.set(token, { sender, paths: new Set(paths), remaining: budget })
return token
},
async read(sender: number, token: string, path: string) {
read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) {
const selection = selections.get(token)
if (selection?.sender !== sender || !selection.paths.delete(path))
throw new Error(nativeT("desktop.picker.error.notSelected"))
const bytes = await read(path, selection.remaining)
const bytes = yield* read(path, selection.remaining)
selection.remaining -= bytes.byteLength
if (selection.paths.size === 0) selections.delete(token)
return bytes
},
}),
release(sender: number, token: string) {
if (selections.get(token)?.sender === sender) selections.delete(token)
},
@@ -37,21 +37,23 @@ export function assertAttachmentBudget(files: { size: number }[]) {
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
}
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
const file = await open(filePath, "r")
try {
const info = await file.stat()
if (info.size > maxBytes)
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
const bytes = Buffer.allocUnsafe(info.size)
let offset = 0
while (offset < info.size) {
const result = await file.read(bytes, offset, info.size - offset, offset)
if (result.bytesRead === 0) break
offset += result.bytesRead
}
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
} finally {
await file.close()
}
export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
return Effect.scoped(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const file = yield* fs.open(filePath, { flag: "r" })
const info = yield* file.stat
if (info.size > FileSystem.Size(maxBytes))
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
const bytes = new Uint8Array(Number(info.size))
let offset = 0
while (offset < bytes.byteLength) {
const read = Number(yield* file.read(bytes.subarray(offset)))
if (read === 0) break
offset += read
}
return bytes.buffer.slice(0, offset)
}),
)
}
+85 -57
View File
@@ -1,69 +1,98 @@
export * as DesktopFiles from "./index"
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { clipboard, dialog, shell } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import type { DirectoryPickerOptions, FilePickerOptions, SaveFilePickerOptions } from "../../shared/ipc-contract"
import { writeLog } from "../native/logging"
import { scoped } from "../native/logging"
import { nativeT } from "../native/translations"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { assertAttachmentBudget, createPickedFileAuthorizations, readAttachment } from "./attachment-picker"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
export function createFileCapabilities() {
const pickedFiles = createPickedFileAuthorizations()
export type Interface = ReturnType<typeof make>
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopFiles") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
return Service.of(make(fs, path))
}),
)
function make(fs: FileSystem.FileSystem, path: Path.Path) {
const pickedFiles = createPickedFileAuthorizations((file, maxBytes) =>
readAttachment(file, maxBytes).pipe(Effect.provideService(FileSystem.FileSystem, fs)),
)
return {
async openDirectoryPicker(options?: DirectoryPickerOptions) {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: options?.title ?? nativeT("desktop.dialog.chooseFolder"),
defaultPath: options?.defaultPath,
})
openDirectoryPicker: Effect.fn("DesktopFiles.openDirectoryPicker")(function* (options?: DirectoryPickerOptions) {
const result = yield* Effect.promise(() =>
dialog.showOpenDialog({
properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: options?.title ?? nativeT("desktop.dialog.chooseFolder"),
defaultPath: options?.defaultPath,
}),
)
if (result.canceled) return null
return options?.multiple ? result.filePaths : result.filePaths[0]
},
async openFilePicker(sender: number, options?: FilePickerOptions) {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])],
title: options?.title ?? nativeT("desktop.dialog.chooseFile"),
defaultPath: options?.defaultPath,
filters: pickerFilters(options?.extensions),
})
}),
openFilePicker: Effect.fn("DesktopFiles.openFilePicker")(function* (sender: number, options?: FilePickerOptions) {
const result = yield* Effect.promise(() =>
dialog.showOpenDialog({
properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])],
title: options?.title ?? nativeT("desktop.dialog.chooseFile"),
defaultPath: options?.defaultPath,
filters: pickerFilters(options?.extensions),
}),
)
if (result.canceled) return null
const files = await Promise.all(
result.filePaths.map(async (path) => ({ path, name: basename(path), size: (await stat(path)).size })),
const files = yield* Effect.forEach(
result.filePaths,
Effect.fnUntraced(function* (file) {
const info = yield* fs.stat(file)
return { path: file, name: path.basename(file), size: Number(info.size) }
}),
{ concurrency: "unbounded" },
)
assertAttachmentBudget(files)
return { token: pickedFiles.add(sender, result.filePaths), files }
},
readPickedFile: (sender: number, token: string, path: string) => pickedFiles.read(sender, token, path),
releasePickedFiles: (sender: number, token: string) => pickedFiles.release(sender, token),
async saveFilePicker(options?: SaveFilePickerOptions) {
const result = await dialog.showSaveDialog({
title: options?.title ?? nativeT("desktop.dialog.saveFile"),
defaultPath: options?.defaultPath,
})
}),
readPickedFile: pickedFiles.read,
releasePickedFiles: pickedFiles.release,
saveFilePicker: Effect.fn("DesktopFiles.saveFilePicker")(function* (options?: SaveFilePickerOptions) {
const result = yield* Effect.promise(() =>
dialog.showSaveDialog({
title: options?.title ?? nativeT("desktop.dialog.saveFile"),
defaultPath: options?.defaultPath,
}),
)
if (result.canceled) return null
return result.filePath ?? null
},
async openPath(path: string, application?: string) {
if (!application) return shell.openPath(path)
await new Promise<void>((resolve, reject) => {
const command =
process.platform === "darwin"
? { file: "open", arguments: ["-a", application, path] }
: { file: application, arguments: [path] }
execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve()))
})
},
async revealPath(path: string) {
const exists = await stat(path).then(
() => true,
() => false,
}),
openPath: Effect.fn("DesktopFiles.openPath")(function* (target: string, application?: string) {
if (!application) return yield* Effect.promise(() => shell.openPath(target))
yield* Effect.tryPromise(() =>
new Promise<void>((resolve, reject) => {
const command =
process.platform === "darwin"
? { file: "open", arguments: ["-a", application, target] }
: { file: application, arguments: [target] }
execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve()))
}),
)
}),
revealPath: Effect.fn("DesktopFiles.revealPath")(function* (target: string) {
const exists = yield* fs.stat(target).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
if (!exists) return false
shell.showItemInFolder(path)
shell.showItemInFolder(target)
return true
},
}),
readClipboardImage() {
const image = clipboard.readImage()
if (image.isEmpty()) return null
@@ -73,25 +102,24 @@ export function createFileCapabilities() {
}
}
export function openExternalURL(value: string) {
export const openExternalURL = Effect.fn("DesktopFiles.openExternalURL")(function* (value: string) {
const url = resolveExternalURL(value)
if (!url) {
writeLog("window", "blocked external target", { url: value }, "warn")
yield* scoped("window", Effect.logWarning("blocked external target", { url: value }))
return
}
void shell.openExternal(url)
}
yield* Effect.promise(() => shell.openExternal(url))
})
export function openLocalFileURL(value: string) {
export const openLocalFileURL = Effect.fn("DesktopFiles.openLocalFileURL")(function* (value: string) {
const path = resolveLocalFilePath(value)
if (!path) {
writeLog("window", "blocked local file target", { url: value }, "warn")
yield* scoped("window", Effect.logWarning("blocked local file target", { url: value }))
return
}
void shell.openPath(path).then((error) => {
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
})
}
const error = yield* Effect.promise(() => shell.openPath(path))
if (error) yield* scoped("window", Effect.logError("failed to open local file", { path, error }))
})
function pickerFilters(extensions?: string[]) {
if (!extensions?.length) return undefined
+61 -80
View File
@@ -1,94 +1,75 @@
import { NodeFileSystem, NodePath, NodeRuntime } from "@effect/platform-node"
import { app } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import { Effect, Exit, Layer } from "effect"
import type { ServerReadyData } from "../shared/ipc-contract"
import { checkAppExists, resolveAppPath } from "./files/apps"
import { registerIpcHandlers, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
import { Ipc } from "./ipc"
import {
acquireApplicationLock,
configureApplication,
loadProxyEnvironment,
preferApplicationEnvironment,
prepareApplicationEnvironment,
prepareDesktop,
} from "./lifecycle/environment"
import { createApplicationLifecycle } from "./lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./lifecycle/onboarding"
import { exportDebugLogs, startNetworkLogging, writeLog } from "./native/logging"
import { createMenu, sendMenuCommand } from "./native/menu"
import { setNativeTranslations } from "./native/translations"
import { ApplicationLifecycle } from "./lifecycle"
import { initializeFirstLaunchOnboarding } from "./lifecycle/onboarding"
import { Shutdown } from "./lifecycle/shutdown"
import { DesktopLogging } from "./native/logging"
import { startBackgroundCli } from "./service/background-service"
import { forwardInitializationFailure } from "./service/initialization"
import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-settings"
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater"
import { getLastFocusedWindow, setBackgroundColor } from "./windows"
import { startWsl } from "./wsl/start"
const main = Effect.gen(function* () {
const logger = configureApplication()
if (!acquireApplicationLock()) return
preferApplicationEnvironment(logger)
const lifecycle = createApplicationLifecycle(logger)
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
import { Updater } from "./updater"
const runApplication = Effect.gen(function* () {
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
yield* Effect.promise(() => app.whenReady())
yield* prepareDesktop(logger)
const updater = setupAutoUpdater(lifecycle.prepareToRestart)
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch: lifecycle.relaunch,
}
registerIpcHandlers({
relaunch: lifecycle.relaunch,
awaitInitialization: Effect.fnUntraced(
function* () {
logger.log("awaiting server ready")
const result = yield* Deferred.await(serverReady)
logger.log("server ready", { url: result.url })
return result
},
(effect) => Effect.runPromise(effect),
),
consumeInitialDeepLinks: lifecycle.consumeInitialDeepLinks,
getDefaultServerUrl,
setDefaultServerUrl,
isFirstLaunchOnboardingPending,
finishFirstLaunchOnboarding,
checkAppExists,
resolveAppPath: async (appName) => resolveAppPath(appName),
showUpdater: () => showUpdaterDialog(updater),
setBackgroundColor,
exportDebugLogs,
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
setNativeTranslations: (bundle) => {
if (setNativeTranslations(bundle)) createMenu(menu)
},
})
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
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)
yield* Deferred.succeed(serverReady, {
url: background.url,
username: background.username,
password: background.password,
})
logger.log("loading task finished")
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
yield* Fiber.await(loadingTask)
if (lifecycle.restoreWindows().length) createMenu(menu)
yield* prepareDesktop
yield* runDesktop.pipe(Effect.provide(Updater.layer))
})
Effect.runFork(main)
const runDesktop = Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* logging.startNetwork
yield* loadProxyEnvironment
yield* Effect.logInfo("starting v2 background service")
const loading = yield* startBackgroundCli().pipe(Effect.exit)
const initialization = Exit.isSuccess(loading)
? Effect.succeed({
url: loading.value.url,
username: loading.value.username,
password: loading.value.password,
} satisfies ServerReadyData)
: Effect.failCause(loading.cause).pipe(Effect.orDie)
yield* runIpc(Exit.isSuccess(loading)).pipe(
Effect.provide(Ipc.layer(initialization, Exit.isSuccess(loading) ? loading.value : undefined)),
)
})
const runIpc = Effect.fn("Desktop.runIpc")(function* (loaded: boolean) {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (loaded) yield* Effect.logInfo("loading task finished")
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
return Effect.sync(() => app.off("will-quit", quit))
})
})
const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer).pipe(
Layer.provideMerge(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
)
const main = Effect.gen(function* () {
if (!acquireApplicationLock()) return
yield* configureApplication()
yield* runApplication
})
main.pipe(
Effect.provide(ApplicationLifecycle.layer.pipe(Layer.provideMerge(platform))),
Effect.scoped,
NodeRuntime.runMain,
)
+26
View File
@@ -0,0 +1,26 @@
import type { WebContents } from "electron"
import { Effect, Queue, Stream } from "effect"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
const queues = new Map<number, Queue.Queue<DesktopEvent>>()
export const bindIpcEvents = Effect.fn("IpcEvents.bind")(function* (senderId: number) {
const queue = yield* Queue.unbounded<DesktopEvent>()
const previous = queues.get(senderId)
queues.set(senderId, queue)
if (previous) yield* Queue.shutdown(previous)
return Effect.fnUntraced(function* () {
if (queues.get(senderId) === queue) queues.delete(senderId)
yield* Queue.shutdown(queue)
})()
})
export function ipcEventStream(senderId: number) {
const queue = queues.get(senderId)
return queue ? Stream.fromQueue(queue) : Stream.empty
}
export function emitIpcEvent(sender: WebContents, event: DesktopEvent) {
const queue = queues.get(sender.id)
if (queue) Queue.offerUnsafe(queue, event)
}
@@ -0,0 +1,72 @@
import { BrowserWindow } from "electron"
import { parseDesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import { Effect } from "effect"
import { AppRpcs } from "../../shared/ipc-rpc"
import { openExternalURL } from "../files"
import { checkAppExists, resolveAppPath } from "../files/apps"
import { setForceFocus } from "../native/debug"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
import { setNativeTranslations } from "../native/translations"
import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
import { Initialization } from "../service/initialization"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
import { sender } from "./context"
export const appHandlers = AppRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const initialization = yield* Initialization.Service
const updater = yield* Updater.Service
const logging = yield* DesktopLogging.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
return AppRpcs.of({
AppAwaitInitialization: () => initialization.await,
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
AppIsFirstLaunchOnboardingPending: isFirstLaunchOnboardingPending,
AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) =>
finishFirstLaunchOnboarding(createDefaultProject).pipe(Effect.orDie),
AppCheckAppExists: ({ appName }) => checkAppExists(appName).pipe(Effect.orDie),
AppResolveAppPath: ({ appName }) => resolveAppPath(appName).pipe(Effect.orDie),
AppSetBackgroundColor: ({ color }) => Effect.sync(() => setBackgroundColor(color)),
AppExportDebugLogs: () => logging.exportDebug,
AppSetForceFocus: ({ enabled }, context) => promise(() => setForceFocus(sender(handoff, context), enabled)),
AppRecordFatalRendererError: ({ error }) =>
scoped("renderer", Effect.logError("fatal renderer error", { ...error })),
AppSetNativeTranslations: ({ value }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
if (!setNativeTranslations(bundle)) return
createMenu({
trigger: (id) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
createWindow: lifecycle.createWindow,
openExternal: (url) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
})
}),
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
})
}),
)
function promise<A>(evaluate: () => A | Promise<A>) {
return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie)
}
@@ -0,0 +1,9 @@
import type { IpcPortHandoff } from "../ipc-transport"
export type RpcContext = { readonly client: { readonly id: number } }
export function sender(handoff: IpcPortHandoff["Service"], context: RpcContext) {
const contents = handoff.sender(context.client.id)
if (!contents || contents.isDestroyed()) throw new Error("Renderer connection not found")
return contents
}
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
})
}),
)
@@ -0,0 +1,42 @@
import { Effect } from "effect"
import { FileRpcs } from "../../shared/ipc-rpc"
import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export const fileHandlers = FileRpcs.toLayer(
Effect.gen(function* () {
const files = yield* DesktopFiles.Service
const handoff = yield* IpcPortHandoff
return FileRpcs.of({
FilesOpenDirectoryPicker: ({ options }) => files.openDirectoryPicker(options),
FilesOpenFilePicker: ({ options }, context) =>
files
.openFilePicker(
sender(handoff, context).id,
options ? { ...options, extensions: options.extensions && [...options.extensions] } : undefined,
)
.pipe(Effect.orDie),
FilesReadPickedFile: ({ token, path }, context) =>
files
.readPickedFile(sender(handoff, context).id, token, path)
.pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie),
FilesReleasePickedFiles: ({ token }, context) =>
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
FilesSaveFilePicker: ({ options }) => files.saveFilePicker(options),
FilesOpenExternal: ({ url }) => openExternalURL(url),
FilesOpenLocalFile: ({ url }) => openLocalFileURL(url),
FilesOpenPath: ({ path, application }) =>
files.openPath(path, application).pipe(
Effect.map((result) => result ?? null),
Effect.orDie,
),
FilesRevealPath: ({ path }) => files.revealPath(path),
FilesReadClipboardImage: () =>
Effect.sync(() => {
const image = files.readClipboardImage()
return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null
}),
})
}),
)
@@ -0,0 +1,28 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { MenuRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
import { runDesktopMenuAction } from "../native/menu-actions"
import { Updater } from "../updater"
import { sender } from "./context"
export const menuHandlers = MenuRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const updater = yield* Updater.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
return MenuRpcs.of({
MenuRunAction: ({ action }, context) =>
Effect.sync(() =>
runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, {
checkForUpdates: () => runFork(updater.show),
createWindow: lifecycle.createWindow,
relaunch: lifecycle.relaunch,
}),
),
})
}),
)
@@ -0,0 +1,29 @@
import { Effect } from "effect"
import { StorageRpcs } from "../../shared/ipc-rpc"
import { DesktopStorage } from "../storage"
export const storageHandlers = StorageRpcs.toLayer(
Effect.gen(function* () {
const storage = yield* DesktopStorage.Service
return StorageRpcs.of({
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
StorageDelete: ({ name, key }) => storage.deleteValue(name, key).pipe(Effect.orDie),
StorageClear: ({ name }) => storage.clear(name).pipe(Effect.orDie),
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
DraftsPutBlob: ({ data }) =>
Effect.sync(() =>
storage.drafts.putBlob(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer),
),
DraftsGetBlob: ({ id }) =>
Effect.sync(() => {
const data = storage.drafts.getBlob(id)
return data ? new Uint8Array(data) : null
}),
})
}),
)
@@ -0,0 +1,18 @@
import { Effect } from "effect"
import { UpdaterRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { Updater } from "../updater"
import { sender } from "./context"
export const updaterHandlers = UpdaterRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const updater = yield* Updater.Service
return UpdaterRpcs.of({
UpdaterSubscribe: (_args, context) => updater.subscribe(sender(handoff, context)),
UpdaterUnsubscribe: (_args, context) => updater.unsubscribe(sender(handoff, context).id),
UpdaterCheck: () => updater.check,
UpdaterInstall: () => updater.install,
})
}),
)
@@ -0,0 +1,58 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import {
getPinchZoomEnabled,
getWindowID,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return WindowRpcs.of({
WindowGetId: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
}),
WindowThemeReady: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
setWindowThemeReady(win)
}),
WindowGetFocused: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFocused() ?? false),
WindowGetFullscreen: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFullScreen() ?? false),
WindowSetFocus: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.focus()),
WindowShow: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.show()),
WindowGetZoomFactor: (_args, context) => Effect.sync(() => sender(handoff, context).getZoomFactor()),
WindowSetZoomFactor: ({ factor }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
contents.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(contents)
if (win) updateTitlebar(win)
}),
WindowGetPinchZoomEnabled: () => Effect.sync(getPinchZoomEnabled),
WindowSetPinchZoomEnabled: ({ enabled }) => Effect.sync(() => setPinchZoomEnabled(enabled)),
WindowSetTitlebar: ({ theme }, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (win) setTitlebar(win, theme)
}),
})
}),
)
@@ -0,0 +1,27 @@
import { Effect } from "effect"
import { WslRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { Wsl } from "../wsl/start"
import { sender } from "./context"
export const wslHandlers = WslRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const wsl = yield* Wsl.Service
return WslRpcs.of({
WslSubscribe: (_args, context) => wsl.subscribe(sender(handoff, context)),
WslUnsubscribe: (_args, context) => wsl.unsubscribe(sender(handoff, context).id),
WslGetState: () => wsl.getState(),
WslProbeRuntime: () => wsl.probeRuntime(),
WslRefreshDistros: () => wsl.refreshDistros(),
WslInstallWsl: () => wsl.installWsl(),
WslInstallDistro: ({ name }) => wsl.installDistro(name),
WslProbeAddable: ({ distros }) => wsl.probeAddable([...distros]),
WslInstallOpencode: ({ name }) => wsl.installOpencode(name),
WslOpenTerminal: ({ name }) => wsl.openTerminal(name),
WslAddServer: ({ distro }) => wsl.addServer(distro),
WslRemoveServer: ({ id }) => wsl.removeServer(id),
WslStartServer: ({ id }) => wsl.startServer(id),
})
}),
)
@@ -0,0 +1,181 @@
import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import { MessageChannel } from "node:worker_threads"
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
describe("desktop RPC transport", () => {
test("keeps multiple renderer ports independent", async () => {
const handlers = TestRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return TestRpcs.of({
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
})
}),
)
const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
const runtime = ManagedRuntime.make(live)
const handoff = await runtime.runPromise(IpcPortHandoff)
const first = new MessageChannel()
const second = new MessageChannel()
handoff.bind(sender(1), serverPort(first.port1))
handoff.bind(sender(2), serverPort(second.port1))
const firstClient = makeClient(first.port2)
const secondClient = makeClient(second.port2)
const [focused, unfocused] = await Promise.all([callFocused(firstClient), callFocused(secondClient)])
expect(focused).toBe(true)
expect(unfocused).toBe(false)
expect(await putBlob(firstClient, new Uint8Array([2, 7, 1]))).toBe("2,7,1")
expect(await getBlob(firstClient)).toEqual(new Uint8Array([3, 1, 4]))
expect(await firstEvent(firstClient)).toEqual(new TestEvent({ value: "session.new" }))
const reloaded = new MessageChannel()
handoff.bind(sender(1), serverPort(reloaded.port1))
const reloadedClient = makeClient(reloaded.port2)
const [reloadedFocused, stillUnfocused] = await Promise.all([
callFocused(reloadedClient),
callFocused(secondClient),
])
expect(reloadedFocused).toBe(true)
expect(stillUnfocused).toBe(false)
await Promise.all([firstClient.dispose(), secondClient.dispose(), reloadedClient.dispose()])
await runtime.dispose()
})
})
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
const TestRpcs = RpcGroup.make(
Rpc.make("test.focused", { success: Schema.Boolean }),
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
Rpc.make("test.events", { success: TestEvent, stream: true }),
)
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
class TestClient extends Context.Service<TestClient, TestRpcClient>()("opencode/desktop/TestClient") {}
function makeClient(port: MessagePort) {
return ManagedRuntime.make(
Layer.effect(TestClient, RpcClient.make(TestRpcs)).pipe(Layer.provide(clientProtocol(port))),
)
}
function callFocused(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.focused"]()
}),
)
}
function putBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>, data: Uint8Array) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.blob.put"]({ data })
}),
)
}
function getBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.blob.get"]()
}),
)
}
function firstEvent(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.events"]().pipe(Stream.runHead, Effect.map(Option.getOrThrow))
}),
)
}
function clientProtocol(port: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const serialization = yield* RpcSerialization.RpcSerialization
const parser = serialization.makeUnsafe()
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) =>
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
port.addEventListener("message", onMessage)
port.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
port.removeEventListener("message", onMessage)
port.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
Effect.sync(() => {
const encoded = parser.encode(request)
if (encoded !== undefined) port.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
function sender(id: number) {
const events = new EventEmitter()
return {
id,
isDestroyed: () => false,
once: events.once.bind(events),
off: events.off.bind(events),
} as unknown as WebContents
}
function serverPort(port: import("node:worker_threads").MessagePort) {
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: unknown) => void>()
return {
on(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.on(event, listener)
return
}
const wrapped = (data: unknown) => listener({ data } as Electron.MessageEvent)
listeners.set(listener, wrapped)
port.on("message", wrapped)
},
off(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.off(event, listener)
return
}
const wrapped = listeners.get(listener)
if (wrapped) port.off("message", wrapped)
},
postMessage: port.postMessage.bind(port),
start: port.start.bind(port),
close: port.close.bind(port),
} as unknown as MessagePortMain
}
+125
View File
@@ -0,0 +1,125 @@
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { bindIpcEvents } from "./ipc-events"
type PortBinding = {
readonly id: number
readonly sender: WebContents
readonly port: MessagePortMain
readonly parser: RpcSerialization.Parser
readonly onMessage: (event: Electron.MessageEvent) => void
readonly onClose: () => void
readonly unbindEvents: Effect.Effect<void>
}
type Handoff = {
readonly bind: (sender: WebContents, port: MessagePortMain) => void
readonly sender: (clientId: number) => WebContents | undefined
}
export class IpcPortHandoff extends Context.Service<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
export const IpcServerProtocolLive = Layer.unwrap(
Effect.gen(function* () {
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
const bindings = new Map<number, PortBinding>()
const senderBindings = new Map<number, number>()
const protocol = Layer.effect(
RpcServer.Protocol,
RpcServer.Protocol.make(
Effect.fnUntraced(function* (writeRequest) {
const serialization = yield* RpcSerialization.RpcSerialization
const disconnects = yield* Queue.unbounded<number>()
const inbound = yield* Queue.unbounded<readonly [number, RpcMessage.FromClientEncoded]>()
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
let nextClientId = 0
const disconnect = Effect.fnUntraced(function* (id: number) {
const binding = bindings.get(id)
if (!binding) return
bindings.delete(id)
if (senderBindings.get(binding.sender.id) === id) senderBindings.delete(binding.sender.id)
binding.port.off("message", binding.onMessage)
binding.port.off("close", binding.onClose)
binding.sender.off("destroyed", binding.onClose)
yield* binding.unbindEvents
binding.port.close()
Queue.offerUnsafe(disconnects, id)
})
const bind = Effect.fnUntraced(function* (sender: WebContents, port: MessagePortMain) {
const previous = senderBindings.get(sender.id)
if (previous !== undefined) yield* disconnect(previous)
if (sender.isDestroyed()) {
port.close()
return
}
const id = nextClientId++
const parser = serialization.makeUnsafe()
const onMessage = (event: Electron.MessageEvent) => {
try {
parser
.decode(event.data)
.forEach((message) =>
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
)
} catch {
return
}
}
const onClose = () => runFork(disconnect(id))
const unbindEvents = yield* bindIpcEvents(sender.id)
const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents }
bindings.set(id, binding)
senderBindings.set(sender.id, id)
port.on("message", onMessage)
port.on("close", onClose)
sender.once("destroyed", onClose)
port.start()
})
yield* Stream.fromQueue(handoffs).pipe(
Stream.runForEach(([sender, port]) => bind(sender, port)),
Effect.forkScoped,
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach(([id, message]) => (bindings.has(id) ? writeRequest(id, message) : Effect.void)),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() => Effect.forEach([...bindings.keys()], disconnect, { discard: true }))
return {
disconnects,
send: (clientId, response) =>
Effect.sync(() => {
const binding = bindings.get(clientId)
if (!binding) return
const encoded = binding.parser.encode(response)
if (encoded !== undefined) binding.port.postMessage(encoded)
}),
end: disconnect,
clientIds: Effect.sync(() => new Set(bindings.keys())),
initialMessage: Effect.succeed(Option.none()),
supportsAck: true,
supportsTransferables: false,
supportsSpanPropagation: false,
}
}),
),
)
return Layer.merge(
protocol,
Layer.succeed(IpcPortHandoff)({
bind: (sender, port) => {
Queue.offerUnsafe(handoffs, [sender, port])
},
sender: (clientId) => bindings.get(clientId)?.sender,
}),
)
}),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
+74 -178
View File
@@ -1,184 +1,80 @@
import { BrowserWindow, ipcMain } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
export * as Ipc from "./ipc"
import {
Ipc,
type FatalRendererError,
type IpcInvoke,
type IpcInvokeArgs,
type IpcInvokeResult,
type IpcSend,
type ServerReadyData,
} from "../shared/ipc-contract"
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "./files"
import { setForceFocus } from "./native/debug"
import { runDesktopMenuAction } from "./native/menu-actions"
import { createDesktopStorage } from "./storage"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterIpc } from "./updater"
import type { WslIpc } from "./wsl/ipc"
import { app, BrowserWindow, MessageChannelMain } from "electron"
import { Effect, Layer } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import type { ServerReadyData } from "../shared/ipc-contract"
import { DesktopRpcs } from "../shared/ipc-rpc"
import { IpcTransportPort } from "../shared/ipc-transport"
import { DesktopFiles, openExternalURL } from "./files"
import { appHandlers } from "./ipc-handlers/app"
import { eventHandlers } from "./ipc-handlers/events"
import { fileHandlers } from "./ipc-handlers/files"
import { menuHandlers } from "./ipc-handlers/menu"
import { storageHandlers } from "./ipc-handlers/storage"
import { updaterHandlers } from "./ipc-handlers/updater"
import { windowHandlers } from "./ipc-handlers/window"
import { wslHandlers } from "./ipc-handlers/wsl"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { ApplicationLifecycle } from "./lifecycle"
import { createMenu, sendMenuCommand } from "./native/menu"
import { Initialization } from "./service/initialization"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { Wsl } from "./wsl/start"
type MaybePromise<Value> = Value | Promise<Value>
function handle<Channel extends keyof IpcInvoke>(
channel: Channel,
listener: (event: IpcMainInvokeEvent, ...args: IpcInvokeArgs<Channel>) => MaybePromise<IpcInvokeResult<Channel>>,
) {
ipcMain.handle(channel, listener)
}
function on<Channel extends keyof IpcSend>(
channel: Channel,
listener: (event: IpcMainEvent, ...args: IpcSend[Channel]) => void,
) {
ipcMain.on(channel, listener)
}
type Deps = {
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
showUpdater: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
setNativeTranslations: (bundle: DesktopNativeBundle) => void
}
export function registerIpcHandlers(deps: Deps) {
const files = createFileCapabilities()
const storage = createDesktopStorage()
handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization())
handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks())
handle(Ipc.app.getDefaultServerUrl, () => deps.getDefaultServerUrl())
handle(Ipc.app.setDefaultServerUrl, (_event, url) => deps.setDefaultServerUrl(url))
handle(Ipc.app.isFirstLaunchOnboardingPending, () => deps.isFirstLaunchOnboardingPending())
handle(Ipc.app.finishFirstLaunchOnboarding, (_event, createDefaultProject) =>
deps.finishFirstLaunchOnboarding(createDefaultProject),
export function layer(initialization: Effect.Effect<ServerReadyData>, cli?: Wsl.Cli) {
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer(cli)).pipe(
Layer.provideMerge(Initialization.layer(initialization)),
)
handle(Ipc.app.checkAppExists, (_event, appName) => deps.checkAppExists(appName))
handle(Ipc.app.resolveAppPath, (_event, appName) => deps.resolveAppPath(appName))
handle(Ipc.app.setBackgroundColor, (_event, color) => deps.setBackgroundColor(color))
handle(Ipc.app.exportDebugLogs, () => deps.exportDebugLogs())
handle(Ipc.app.setForceFocus, (event, enabled) => setForceFocus(event.sender, enabled))
handle(Ipc.app.recordFatalRendererError, (_event, error) => deps.recordFatalRendererError(error))
handle(Ipc.app.setNativeTranslations, (event, value) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
deps.setNativeTranslations(bundle)
})
handle(Ipc.storage.get, (_event, name, key) => {
return storage.get(name, key)
})
handle(Ipc.storage.set, (_event, name, key, value) => storage.set(name, key, value))
handle(Ipc.storage.delete, (_event, name, key) => storage.deleteValue(name, key))
handle(Ipc.storage.clear, (_event, name) => storage.clear(name))
handle(Ipc.storage.keys, (_event, name) => storage.keys(name))
handle(Ipc.storage.length, (_event, name) => storage.length(name))
handle(Ipc.drafts.get, (_event, key) => storage.drafts.get(key))
handle(Ipc.drafts.set, (_event, key, value) => storage.drafts.set(key, value))
handle(Ipc.drafts.delete, (_event, key) => storage.drafts.set(key, null))
handle(Ipc.drafts.putBlob, (_event, data) => storage.drafts.putBlob(data))
handle(Ipc.drafts.getBlob, (_event, id) => storage.drafts.getBlob(id))
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
fileHandlers,
windowHandlers,
menuHandlers,
updaterHandlers,
wslHandlers,
eventHandlers,
)
return RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
Layer.provide(handlers),
Layer.provideMerge(IpcServerProtocolLive),
Layer.provideMerge(services),
)
}
handle(Ipc.files.openDirectoryPicker, (_event, options) => files.openDirectoryPicker(options))
handle(Ipc.files.openFilePicker, (event, options) => files.openFilePicker(event.sender.id, options))
handle(Ipc.files.readPickedFile, (event, token, path) => files.readPickedFile(event.sender.id, token, path))
handle(Ipc.files.releasePickedFiles, (event, token) => files.releasePickedFiles(event.sender.id, token))
handle(Ipc.files.saveFilePicker, (_event, options) => files.saveFilePicker(options))
on(Ipc.files.openExternal, (_event, url) => openExternalURL(url))
on(Ipc.files.openLocalFile, (_event, url) => openLocalFileURL(url))
handle(Ipc.files.openPath, (_event, path, app) => files.openPath(path, app))
handle(Ipc.files.revealPath, (_event, path) => files.revealPath(path))
handle(Ipc.files.readClipboardImage, () => files.readClipboardImage())
handle(Ipc.window.getId, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
})
handle(Ipc.window.getFocused, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
})
handle(Ipc.window.getFullscreen, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFullScreen() ?? false
})
handle(Ipc.window.setFocus, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.focus()
})
handle(Ipc.window.show, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.show()
})
on(Ipc.app.relaunch, () => {
deps.relaunch()
})
handle(Ipc.window.getZoomFactor, (event) => event.sender.getZoomFactor())
handle(Ipc.window.setZoomFactor, (event, factor) => {
event.sender.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
updateTitlebar(win)
})
handle(Ipc.window.getPinchZoomEnabled, () => getPinchZoomEnabled())
handle(Ipc.window.setPinchZoomEnabled, (_event, enabled) => {
setPinchZoomEnabled(enabled)
})
handle(Ipc.window.setTitlebar, (event, theme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)
})
handle(Ipc.menu.runAction, (event, action) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
export const registerIpcHandlers = Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const updater = yield* Updater.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
createWindow: lifecycle.createWindow,
openExternal: (url: string) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
}
const wire = (_event: Electron.Event, win: BrowserWindow) => {
win.webContents.on("did-finish-load", () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
})
}
export function registerUpdaterIpcHandlers(updater: UpdaterIpc) {
handle(Ipc.updater.subscribe, (event) => updater.subscribe(event.sender))
handle(Ipc.updater.unsubscribe, (event) => updater.unsubscribe(event.sender.id))
handle(Ipc.updater.check, () => updater.check())
handle(Ipc.updater.install, () => updater.install())
}
export function registerWslIpcHandlers(wsl: WslIpc) {
handle(Ipc.wsl.subscribe, (event) => wsl.subscribe(event.sender))
handle(Ipc.wsl.unsubscribe, (event) => wsl.unsubscribe(event.sender.id))
handle(Ipc.wsl.getState, () => wsl.getState())
handle(Ipc.wsl.probeRuntime, () => wsl.probeRuntime())
handle(Ipc.wsl.refreshDistros, () => wsl.refreshDistros())
handle(Ipc.wsl.installWsl, () => wsl.installWsl())
handle(Ipc.wsl.installDistro, (_event, value) => wsl.installDistro(value))
handle(Ipc.wsl.probeAddable, (_event, value) => wsl.probeAddable(value))
handle(Ipc.wsl.installOpencode, (_event, value) => wsl.installOpencode(value))
handle(Ipc.wsl.openTerminal, (_event, value) => wsl.openTerminal(value))
handle(Ipc.wsl.addServer, (_event, value) => wsl.addServer(value))
handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value))
handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value))
}
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
return {
installMenu: () => createMenu(menu),
}
})
@@ -1,18 +1,15 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import http from "node:http"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import { app } from "electron"
import contextMenu from "electron-context-menu"
import { Effect } from "effect"
import { CHANNEL, VERSION } from "../constants"
import { initCrashReporter, initLogging, type DesktopLogger } from "../native/logging"
import { Effect, FileSystem, Path } from "effect"
import { CHANNEL } from "../constants"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { cleanupStoreFiles } from "../storage/cleanup"
import { registerRendererProtocol, setDockIcon } from "../windows"
import { initializeFirstLaunchOnboarding } from "./onboarding"
const appNames: Record<string, string> = {
dev: "OpenCode Dev",
@@ -27,7 +24,8 @@ const appIDs: Record<string, string> = {
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
export function configureApplication() {
export const configureApplication = Effect.fn("Application.configure")(function* () {
const path = yield* Path.Path
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
try {
process.chdir(homedir())
@@ -35,29 +33,18 @@ export function configureApplication() {
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
const onboardingRoot = createOnboardingTestRoot()
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"))
initializeFirstLaunchOnboarding(app.getPath("userData"))
const logger = initLogging()
initCrashReporter()
loadSystemCertificates(logger)
logger.log("app starting", {
version: VERSION,
packaged: app.isPackaged,
onboardingTest: Boolean(onboardingRoot),
})
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")
return logger
}
if (!app.isPackaged)
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
const testRoot = yield* createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", path.join(testRoot, "session"))
})
export function acquireApplicationLock() {
if (app.requestSingleInstanceLock()) return true
@@ -65,68 +52,81 @@ export function acquireApplicationLock() {
return false
}
export function preferApplicationEnvironment(logger: DesktopLogger) {
export const prepareApplicationEnvironment = Effect.gen(function* () {
yield* loadSystemCertificates
yield* loadProxyEnvironment
})
export const preferApplicationEnvironment = Effect.gen(function* () {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, logger) : null
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
const shellEnv = shell ? yield* loadShellEnv(shell) : null
yield* Effect.sync(() => {
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
})
})
}
})
export function prepareDesktop(logger: DesktopLogger) {
return Effect.gen(function* () {
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
Effect.tap((result) =>
Effect.sync(() => {
if (result.deleted.length === 0) return
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
}),
),
Effect.catch((error) => Effect.sync(() => logger.warn("failed to clean scoped store files", error))),
)
export const prepareDesktop = Effect.gen(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const context = yield* Effect.context()
yield* cleanupStoreFiles(app.getPath("userData")).pipe(
Effect.tap((result) =>
result.deleted.length === 0
? Effect.void
: Effect.logInfo("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }),
),
Effect.catch((error) => Effect.logWarning("failed to clean scoped store files", { error })),
)
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
})
}
registerRendererProtocol(path, paths.rendererRoot, Effect.runForkWith(context))
setDockIcon(path, paths)
})
export function loadProxyEnvironment(logger: DesktopLogger) {
ensureLoopbackNoProxy()
try {
export const loadProxyEnvironment = Effect.gen(function* () {
yield* Effect.try(() => {
ensureLoopbackNoProxy()
// Electron 41.2 has a newer Node API than the current @types/node package.
const proxyAwareHttp = http as typeof http & { setGlobalProxyFromEnv(): void }
proxyAwareHttp.setGlobalProxyFromEnv()
} catch (error) {
logger.warn("failed to load proxy environment", error)
}
}
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
})
function createOnboardingTestRoot() {
if (!testOnboarding) return undefined
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
rmSync(root, { recursive: true, force: true })
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
mkdirSync(join(root, dir), { recursive: true }),
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const root = testOnboarding
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
: app.isPackaged
? undefined
: process.env.OPENCODE_DESKTOP_TEST_ROOT
if (!root) return undefined
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
yield* Effect.forEach(
["data", "config", "cache", "state", "desktop", "session"],
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
{ discard: true },
)
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")
process.env.XDG_STATE_HOME = join(root, "state")
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
process.env.XDG_DATA_HOME = path.join(root, "data")
process.env.XDG_CONFIG_HOME = path.join(root, "config")
process.env.XDG_CACHE_HOME = path.join(root, "cache")
process.env.XDG_STATE_HOME = path.join(root, "state")
return root
}
})
function loadSystemCertificates(logger: DesktopLogger) {
try {
const loadSystemCertificates = Effect.try({
try: () => {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
} catch (error) {
logger.warn("failed to load system certificates", error)
}
}
},
catch: (error) => error,
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load system certificates", { error })))
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]

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