From 22b162e9c40155d38382848da72e05cdf3b5db0e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 00:35:36 +0800 Subject: [PATCH 01/13] fix(provider): sync current reasoning variants --- packages/opencode/src/provider/transform.ts | 130 +++++++++++--- .../opencode/test/provider/transform.test.ts | 160 ++++++++++++++++-- 2 files changed, 251 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 9401e2b01..bf08da228 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -569,17 +569,55 @@ export function topK(model: Provider.Model) { const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS] +const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"] +const OPENAI_GPT5_PRO_EFFORTS = ["high"] +const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"] +const OPENAI_GPT5_CHAT_EFFORTS = ["medium"] +const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS] const OPENAI_NONE_EFFORT_RELEASE_DATE = "2025-11-13" const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04" const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ +const GPT5_VERSION_RE = /(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/ +const GPT5_PRO_RE = /(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/ +const GPT5_VERSIONED_PRO_RE = /(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/ -function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | null { +function gpt5Version(apiId: string) { + return Number(GPT5_VERSION_RE.exec(apiId)?.[1]) || undefined +} + +function versionedGpt5ReasoningEfforts(apiId: string) { + if (GPT5_VERSIONED_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_2_PLUS_EFFORTS + const version = gpt5Version(apiId) + if (version === undefined) return undefined + if (version === 1) return OPENAI_GPT5_1_EFFORTS + return OPENAI_GPT5_2_PLUS_EFFORTS +} + +function gpt5CodexReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("codex")) return undefined + const version = gpt5Version(apiId) + if (version !== undefined && version >= 3) return OPENAI_GPT5_CODEX_3_PLUS_EFFORTS + if (apiId.includes("codex-max") || (version !== undefined && version >= 2)) return OPENAI_GPT5_CODEX_XHIGH_EFFORTS + return WIDELY_SUPPORTED_EFFORTS +} + +function gpt5ChatReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("-chat")) return undefined + return gpt5Version(apiId) === undefined ? [] : OPENAI_GPT5_CHAT_EFFORTS +} + +function openaiReasoningEfforts(apiId: string, releaseDate: string) { const id = apiId.toLowerCase() - if (id === "gpt-5-pro" || id === "openai/gpt-5-pro") return null - if (id.includes("codex")) { - if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - return [...WIDELY_SUPPORTED_EFFORTS] - } + if (id.includes("deep-research")) return ["medium"] + const chatEfforts = gpt5ChatReasoningEfforts(id) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS + const codexEfforts = gpt5CodexReasoningEfforts(id) + if (codexEfforts) return codexEfforts + const versionedEfforts = versionedGpt5ReasoningEfforts(id) + if (versionedEfforts) return versionedEfforts const efforts = [...WIDELY_SUPPORTED_EFFORTS] if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none") @@ -587,6 +625,14 @@ function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | return efforts } +function openaiCompatibleReasoningEfforts(id: string) { + const apiId = id.toLowerCase() + const chatEfforts = gpt5ChatReasoningEfforts(apiId) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_EFFORTS + return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { return ["low", "medium", "high", "xhigh", "max"] @@ -603,6 +649,29 @@ function deepseekMajorVersion(apiId: string): number | undefined { return Number.parseInt(match[2]!, 10) } +function googleThinkingLevelEfforts(apiId: string) { + const id = apiId.toLowerCase() + if (!id.includes("gemini-3")) return ["low", "high"] + if (id.includes("flash-image")) return ["minimal", "high"] + if (id.includes("pro-image")) return ["high"] + if (id.includes("flash")) return ["minimal", "low", "medium", "high"] + return ["low", "medium", "high"] +} + +function googleThinkingBudgetMax(apiId: string) { + const id = apiId.toLowerCase() + if (id.includes("2.5") && id.includes("pro") && !id.includes("flash")) return 32_768 + return 24_576 +} + +function googleSmallThinkingConfig(apiId: string) { + const levels = googleThinkingLevelEfforts(apiId) + if (apiId.toLowerCase().includes("gemini-3")) { + return { thinkingLevel: levels.includes("minimal") ? "minimal" : levels.includes("low") ? "low" : "high" } + } + return { thinkingBudget: googleThinkingBudgetMax(apiId) === 32_768 ? 128 : 0 } +} + export function variants(model: Provider.Model): Record> { if (!model.capabilities.reasoning) return {} @@ -637,8 +706,13 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }])) + if (!id.includes("gpt") && !id.includes("gemini-3") && !id.includes("claude")) return {} + return Object.fromEntries( + (id.includes("gpt") ? openaiCompatibleReasoningEfforts(id) : OPENAI_EFFORTS).map((effort) => [ + effort, + { reasoning: { effort } }, + ]), + ) case "@ai-sdk/gateway": if (model.id.includes("anthropic")) { @@ -702,7 +776,6 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) } return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) @@ -750,18 +823,22 @@ export function variants(model: Provider.Model): Record= 4) { openaiCompatibleEfforts.push("max") } - return Object.fromEntries(openaiCompatibleEfforts.map((effort) => [effort, { reasoningEffort: effort }])) + return Object.fromEntries( + (GPT5_FAMILY_RE.test(model.api.id.toLowerCase()) + ? openaiCompatibleReasoningEfforts(model.api.id) + : openaiCompatibleEfforts + ).map((effort) => [effort, { reasoningEffort: effort }]), + ) } case "@ai-sdk/azure": { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure if (id === "o1-mini") return {} - const azureEfforts = ["low", "medium", "high"] - if (GPT5_FAMILY_RE.test(id)) { - azureEfforts.unshift("minimal") - } return Object.fromEntries( - azureEfforts.map((effort) => [ + (GPT5_FAMILY_RE.test(id) && gpt5Version(id) === undefined + ? ["minimal", ...WIDELY_SUPPORTED_EFFORTS] + : WIDELY_SUPPORTED_EFFORTS + ).map((effort) => [ effort, { reasoningEffort: effort, @@ -774,7 +851,6 @@ export function variants(model: Provider.Model): Record [ effort, @@ -814,6 +890,10 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }])) + } + return { high: { thinking: { @@ -890,18 +970,14 @@ export function variants(model: Provider.Model): Record [ + googleThinkingLevelEfforts(id).map((effort) => [ effort, { thinkingConfig: { @@ -1156,6 +1232,11 @@ export function smallOptions(model: Provider.Model) { model.api.npm === "@ai-sdk/github-copilot" ) { if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("-chat")) { + if (gpt5Version(model.api.id) === undefined) return { store: false } + return { store: false, reasoningEffort: "medium" } + } + if (model.api.id.includes("search-api")) return { store: false } if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) { return { store: false, reasoningEffort: "low" } } @@ -1165,10 +1246,7 @@ export function smallOptions(model: Provider.Model) { } if (model.providerID === "google") { // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget - if (model.api.id.includes("gemini-3")) { - return { thinkingConfig: { thinkingLevel: "minimal" } } - } - return { thinkingConfig: { thinkingBudget: 0 } } + return { thinkingConfig: googleSmallThinkingConfig(model.api.id) } } if (model.providerID === "openrouter" || model.providerID === "llmgateway") { if (model.api.id.includes("google")) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e0acf87c3..68dd25c02 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3308,6 +3308,36 @@ describe("ProviderTransform.variants", () => { expect(result.high).toEqual({ reasoning: { effort: "high" } }) }) + test("gpt-5.4 returns only supported OpenAI reasoning efforts", () => { + const model = createMockModel({ + id: "openrouter/openai/gpt-5.4", + providerID: "openrouter", + api: { + id: "openai/gpt-5.4", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + expect(result.xhigh).toEqual({ reasoning: { effort: "xhigh" } }) + }) + + test("gpt-5.2-chat-latest returns only medium effort", () => { + const model = createMockModel({ + id: "openrouter/openai/gpt-5.2-chat-latest", + providerID: "openrouter", + api: { + id: "openai/gpt-5.2-chat-latest", + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["medium"]) + expect(result.medium).toEqual({ reasoning: { effort: "medium" } }) + }) + test("gemini-3 returns OPENAI_EFFORTS with reasoning", () => { const model = createMockModel({ id: "openrouter/gemini-3-5-pro", @@ -3768,6 +3798,21 @@ describe("ProviderTransform.variants", () => { max: { reasoningEffort: "max" }, }) }) + + test("gpt-5.5-pro returns only supported OpenAI-compatible reasoning efforts", () => { + const model = createMockModel({ + id: "custom-provider/gpt-5.5-pro", + providerID: "custom-provider", + api: { + id: "gpt-5.5-pro", + url: "https://api.custom.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["medium", "high", "xhigh"]) + expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) + }) }) describe("@ai-sdk/azure", () => { @@ -3818,7 +3863,7 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) }) - test("dotted gpt-5 family ids add minimal effort", () => { + test("dotted gpt-5 family ids do not add unsupported minimal effort", () => { const model = createMockModel({ id: "gpt-5.4", providerID: "azure", @@ -3829,12 +3874,12 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) }) }) describe("@ai-sdk/openai", () => { - test("gpt-5-pro returns empty object", () => { + test("gpt-5-pro returns high effort", () => { const model = createMockModel({ id: "gpt-5-pro", providerID: "openai", @@ -3845,7 +3890,12 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["high"]) + expect(result.high).toEqual({ + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) }) test("standard openai models return custom efforts with reasoningSummary", () => { @@ -3883,22 +3933,27 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high"]) }) - test("models after 2025-12-04 include 'xhigh' effort", () => { + test("gpt-5 chat models expose only supported reasoning efforts", () => { const model = createMockModel({ - id: "openai/gpt-5-chat", + id: "openai/gpt-5.2-chat-latest", providerID: "openai", api: { - id: "gpt-5-chat", + id: "gpt-5.2-chat-latest", url: "https://api.openai.com", npm: "@ai-sdk/openai", }, - release_date: "2025-12-05", + release_date: "2025-12-11", }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) + expect(Object.keys(result)).toEqual(["medium"]) + expect(result.medium).toEqual({ + reasoningEffort: "medium", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) }) - test("dotted gpt-5.x ids include minimal effort", () => { + test("dotted gpt-5.x ids use supported versioned efforts", () => { const model = createMockModel({ id: "gpt-5.4", providerID: "openai", @@ -3910,7 +3965,37 @@ describe("ProviderTransform.variants", () => { release_date: "2026-03-05", }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + }) + + test("gpt-5.3-codex includes none and xhigh", () => { + const model = createMockModel({ + id: "gpt-5.3-codex", + providerID: "openai", + api: { + id: "gpt-5.3-codex", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: "2026-01-22", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + }) + + test("deep-research models expose only medium effort", () => { + const model = createMockModel({ + id: "o3-deep-research", + providerID: "openai", + api: { + id: "o3-deep-research", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: "2025-06-26", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["medium"]) }) test("gpt-50 lookalike does not get gpt-5 family treatment", () => { @@ -3944,9 +4029,9 @@ describe("ProviderTransform.variants", () => { test("openai gpt-5.4 includes xhigh effort", () => { const result = ProviderTransform.variants(cfModel("openai/gpt-5.4", "2026-03-05")) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) expect(result.high).toEqual({ reasoningEffort: "high" }) - expect(Object.keys(result)).toContain("minimal") }) test("openai gpt-5.2-codex includes xhigh", () => { @@ -3971,6 +4056,21 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/anthropic", () => { + test("opus 4.5 returns standard effort options", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-5", + providerID: "anthropic", + api: { + id: "claude-opus-4.5-20251101", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.high).toEqual({ effort: "high" }) + }) + test("sonnet 4.6 returns adaptive thinking options", () => { const model = createMockModel({ id: "anthropic/claude-sonnet-4-6", @@ -4157,11 +4257,45 @@ describe("ProviderTransform.variants", () => { expect(result.max).toEqual({ thinkingConfig: { includeThoughts: true, - thinkingBudget: 24576, + thinkingBudget: 32768, + }, + }) + }) + + test("gemini-3 flash models include minimal thinking level", () => { + const model = createMockModel({ + id: "google/gemini-3-flash-preview", + providerID: "google", + api: { + id: "gemini-3-flash-preview", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) + expect(result.minimal).toEqual({ + thinkingConfig: { + includeThoughts: true, + thinkingLevel: "minimal", }, }) }) + test("gemini-3 pro image models only expose high thinking level", () => { + const model = createMockModel({ + id: "google/gemini-3-pro-image-preview", + providerID: "google", + api: { + id: "gemini-3-pro-image-preview", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["high"]) + }) + test("other gemini models return low and high with thinkingLevel", () => { const model = createMockModel({ id: "google/gemini-2.0-pro", From cce1bc2943b91b0e56cd8b067dcaa0890e17f106 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 00:35:41 +0800 Subject: [PATCH 02/13] fix(server): preserve cors on auth failures --- packages/opencode/src/server/server.ts | 4 +- .../test/server/cors-middleware.test.ts | 38 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index edd3c493e..572eb1773 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -33,10 +33,10 @@ export const Default = lazy(() => create({})) function create(opts: { cors?: string[] }) { const app = new Hono() .onError(ErrorMiddleware) - .use(AuthMiddleware) + .use(CorsMiddleware(opts)) .use(LoggerMiddleware) + .use(AuthMiddleware) .use(CompressionMiddleware) - .use(CorsMiddleware(opts)) .route("/global", GlobalRoutes()) const runtime = adapter.create(app) diff --git a/packages/opencode/test/server/cors-middleware.test.ts b/packages/opencode/test/server/cors-middleware.test.ts index dc84b153c..609978f57 100644 --- a/packages/opencode/test/server/cors-middleware.test.ts +++ b/packages/opencode/test/server/cors-middleware.test.ts @@ -1,12 +1,27 @@ -import { describe, expect } from "bun:test" +import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Server } from "../../src/server/server" import { Log } from "@opencode-ai/core/util/log" +import { Flag } from "@opencode-ai/core/flag/flag" import { testEffect } from "../lib/effect" Log.init({ print: false }) const it = testEffect(Layer.empty) +const mutableFlag = Flag as { + OPENCODE_SERVER_PASSWORD?: string + OPENCODE_SERVER_USERNAME?: string +} + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +afterEach(() => { + mutableFlag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + mutableFlag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME +}) describe("CORS middleware", () => { it.live("allows localhost browser origins", () => @@ -48,4 +63,25 @@ describe("CORS middleware", () => { } }), ) + + it.live("adds CORS headers to unauthorized browser responses", () => + Effect.gen(function* () { + mutableFlag.OPENCODE_SERVER_PASSWORD = "secret" + mutableFlag.OPENCODE_SERVER_USERNAME = "alice" + + const app = Server.Default().app + const response = yield* Effect.promise(() => + Promise.resolve( + app.request("/global/health", { + headers: { + Origin: "https://app.opencode.ai", + }, + }), + ), + ) + + expect(response.status).toBe(401) + expect(response.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai") + }), + ) }) From 9b0110a46fc7c6061144792577d112220dbb8178 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 00:35:48 +0800 Subject: [PATCH 03/13] fix(desktop): harden app shell edge cases --- .../src/main/ipc-window-config.test.ts | 11 ++++++ packages/desktop-electron/src/main/ipc.ts | 12 ++++--- .../desktop-electron/src/main/logging.test.ts | 14 ++++++++ packages/desktop-electron/src/main/logging.ts | 17 +++++++++ .../src/renderer/webview-zoom.test.ts | 22 ++++++++++++ .../src/renderer/webview-zoom.ts | 35 ++++++++++++++----- 6 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 packages/desktop-electron/src/main/logging.test.ts create mode 100644 packages/desktop-electron/src/renderer/webview-zoom.test.ts diff --git a/packages/desktop-electron/src/main/ipc-window-config.test.ts b/packages/desktop-electron/src/main/ipc-window-config.test.ts index d9fb257af..e881ff45e 100644 --- a/packages/desktop-electron/src/main/ipc-window-config.test.ts +++ b/packages/desktop-electron/src/main/ipc-window-config.test.ts @@ -24,4 +24,15 @@ describe("desktop startup IPC", () => { expect(source).toContain("exportRendererDiagnostics") expect(source).toContain("rendererDiagnosticsSlice") }) + + test("store-get returns null when persisted store reads fail", () => { + const start = source.indexOf('ipcMain.handle("store-get"') + const end = source.indexOf('ipcMain.handle("store-set"', start) + const handler = source.slice(start, end) + + expect(handler).toContain("try {") + expect(handler).toContain("getStore(name)") + expect(handler).toContain("catch") + expect(handler).toContain("return null") + }) }) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 38757ef74..a27218cea 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -170,10 +170,14 @@ export function registerIpcHandlers(deps: Deps) { return deps.setDesktopContext(context, win) }) ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { - const store = getStore(name) - const value = store.get(key) - if (value === undefined || value === null) return null - return typeof value === "string" ? value : JSON.stringify(value) + try { + const store = getStore(name) + const value = store.get(key) + if (value === undefined || value === null) return null + return typeof value === "string" ? value : JSON.stringify(value) + } catch { + return null + } }) ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { getStore(name).set(key, value) diff --git a/packages/desktop-electron/src/main/logging.test.ts b/packages/desktop-electron/src/main/logging.test.ts new file mode 100644 index 000000000..d61156202 --- /dev/null +++ b/packages/desktop-electron/src/main/logging.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const source = readFileSync(resolve(import.meta.dir, "logging.ts"), "utf8") + +describe("desktop logging", () => { + test("disables the console transport after a broken pipe", () => { + expect(source).toContain("initConsoleTransport()") + expect(source).toContain("log.transports.console.writeFn") + expect(source).toContain('err.code === "EPIPE"') + expect(source).toContain("log.transports.console.level = false") + }) +}) diff --git a/packages/desktop-electron/src/main/logging.ts b/packages/desktop-electron/src/main/logging.ts index c517d0f18..cf902f88d 100644 --- a/packages/desktop-electron/src/main/logging.ts +++ b/packages/desktop-electron/src/main/logging.ts @@ -7,6 +7,7 @@ const TAIL_LINES = 1000 export function initLogging() { log.transports.file.maxSize = 5 * 1024 * 1024 + initConsoleTransport() cleanup() return log } @@ -41,3 +42,19 @@ function cleanup() { } } } + +function initConsoleTransport() { + const write = log.transports.console.writeFn.bind(log.transports.console) + log.transports.console.writeFn = (options) => { + try { + write(options) + } catch (err) { + if (!isBrokenPipe(err)) throw err + log.transports.console.level = false + } + } +} + +function isBrokenPipe(err: unknown) { + return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE" +} diff --git a/packages/desktop-electron/src/renderer/webview-zoom.test.ts b/packages/desktop-electron/src/renderer/webview-zoom.test.ts new file mode 100644 index 000000000..46a1952bf --- /dev/null +++ b/packages/desktop-electron/src/renderer/webview-zoom.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const source = readFileSync(resolve(import.meta.dir, "webview-zoom.ts"), "utf8") + +describe("desktop renderer webview zoom", () => { + test("only consumes keydown events that actually change zoom", () => { + expect(source).toContain('if (event.key === "-") {') + expect(source).toContain('if (event.key === "=" || event.key === "+") {') + expect(source).toContain('if (event.key === "0") {') + expect(source.match(/event\.preventDefault\(\)/g)?.length).toBe(3) + expect(source).not.toContain("let newZoom = webviewZoom()") + }) + + test("keeps requested zoom separate until Electron accepts the zoom change", () => { + expect(source).toContain("let requestedZoom = 1") + expect(source).toContain("requestedZoom = next") + expect(source).toContain("if (requestedZoom !== next) return") + expect(source).toContain("setWebviewZoom(next)") + }) +}) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 9c0a3a3a3..cb4b5a448 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -12,6 +12,7 @@ const OS_NAME = (() => { })() const [webviewZoom, setWebviewZoom] = createSignal(1) +let requestedZoom = 1 const MAX_ZOOM_LEVEL = 10 const MIN_ZOOM_LEVEL = 0.2 @@ -19,20 +20,36 @@ const MIN_ZOOM_LEVEL = 0.2 const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) const applyZoom = (next: number) => { - setWebviewZoom(next) - void window.api.setZoomFactor(next) + requestedZoom = next + void window.api + .setZoomFactor(next) + .then(() => { + if (requestedZoom !== next) return + setWebviewZoom(next) + }) + .catch(() => { + if (requestedZoom !== next) return + requestedZoom = webviewZoom() + }) } window.addEventListener("keydown", (event) => { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return - let newZoom = webviewZoom() - - if (event.key === "-") newZoom -= 0.2 - if (event.key === "=" || event.key === "+") newZoom += 0.2 - if (event.key === "0") newZoom = 1 - - applyZoom(clamp(newZoom)) + if (event.key === "-") { + event.preventDefault() + applyZoom(clamp(requestedZoom - 0.2)) + return + } + if (event.key === "=" || event.key === "+") { + event.preventDefault() + applyZoom(clamp(requestedZoom + 0.2)) + return + } + if (event.key === "0") { + event.preventDefault() + applyZoom(1) + } }) export { webviewZoom } From 6943f6b41361cda0657eaaa6ba21f95e5c547eb1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 00:43:52 +0800 Subject: [PATCH 04/13] test(desktop): harden ipc source contract --- packages/desktop-electron/src/main/ipc-window-config.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/desktop-electron/src/main/ipc-window-config.test.ts b/packages/desktop-electron/src/main/ipc-window-config.test.ts index e881ff45e..34fb8fb04 100644 --- a/packages/desktop-electron/src/main/ipc-window-config.test.ts +++ b/packages/desktop-electron/src/main/ipc-window-config.test.ts @@ -28,6 +28,8 @@ describe("desktop startup IPC", () => { test("store-get returns null when persisted store reads fail", () => { const start = source.indexOf('ipcMain.handle("store-get"') const end = source.indexOf('ipcMain.handle("store-set"', start) + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) const handler = source.slice(start, end) expect(handler).toContain("try {") From a223a7f5bfb0ad44da583920f9ed14de520801f2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 00:45:58 +0800 Subject: [PATCH 05/13] fix(desktop): return after zoom reset --- packages/desktop-electron/src/renderer/webview-zoom.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index cb4b5a448..0602c3a2a 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -49,6 +49,7 @@ window.addEventListener("keydown", (event) => { if (event.key === "0") { event.preventDefault() applyZoom(1) + return } }) From fd62177ac263b716bd80c7e7d273c55d1f9d2101 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 00:45:58 +0800 Subject: [PATCH 06/13] fix(provider): align azure gpt chat efforts --- packages/opencode/src/provider/transform.ts | 12 ++++--- .../opencode/test/provider/transform.test.ts | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index bf08da228..23a01c29f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -834,11 +834,15 @@ export function variants(model: Provider.Model): Record [ + : WIDELY_SUPPORTED_EFFORTS) + return Object.fromEntries( + azureEfforts.map((effort) => [ effort, { reasoningEffort: effort, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 68dd25c02..83d6c11f7 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3876,6 +3876,39 @@ describe("ProviderTransform.variants", () => { const result = ProviderTransform.variants(model) expect(Object.keys(result)).toEqual(["low", "medium", "high"]) }) + + test("versioned gpt-5 chat models expose only medium effort", () => { + const model = createMockModel({ + id: "gpt-5.2-chat-latest", + providerID: "azure", + api: { + id: "gpt-5.2-chat-latest", + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["medium"]) + expect(result.medium).toEqual({ + reasoningEffort: "medium", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + + test("unversioned gpt-5 chat models do not expose effort variants", () => { + const model = createMockModel({ + id: "gpt-5-chat", + providerID: "azure", + api: { + id: "gpt-5-chat", + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) }) describe("@ai-sdk/openai", () => { From 7b6ad1f88824a2854a5d5f9ac75665782cb096bf Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 01:04:41 +0800 Subject: [PATCH 07/13] fix(desktop): avoid double wrapping console transport --- .../desktop-electron/src/main/logging.test.ts | 117 ++++++++++++++++-- packages/desktop-electron/src/main/logging.ts | 13 +- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/packages/desktop-electron/src/main/logging.test.ts b/packages/desktop-electron/src/main/logging.test.ts index d61156202..522094efd 100644 --- a/packages/desktop-electron/src/main/logging.test.ts +++ b/packages/desktop-electron/src/main/logging.test.ts @@ -1,14 +1,113 @@ -import { describe, expect, test } from "bun:test" -import { readFileSync } from "node:fs" -import { resolve } from "node:path" +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" -const source = readFileSync(resolve(import.meta.dir, "logging.ts"), "utf8") +let logDir = "" +const fakeLog: { + transports: { + file: { + maxSize: number + getFile: () => { path: string } + } + console: { + level: string | false + wrapCount: number + writeFn: (options: unknown) => void + } + } +} = { + transports: { + file: { + maxSize: 0, + getFile: () => ({ path: join(tmpdir(), "desktop.log") }), + }, + console: { + level: "info", + wrapCount: 0, + writeFn: () => undefined, + }, + }, +} + +mock.module("electron-log/main.js", () => ({ + default: fakeLog, +})) + +afterEach(() => { + if (logDir) rmSync(logDir, { recursive: true, force: true }) + logDir = "" +}) + +afterAll(() => { + mock.restore() +}) + +function setupLog(writeFn: (options: unknown) => void) { + logDir = mkdtempSync(join(tmpdir(), "pawwork-logging-test-")) + let currentWriteFn = writeFn + let wrapCount = 0 + fakeLog.transports.file = { + maxSize: 0, + getFile: () => ({ path: join(logDir, "desktop.log") }), + } + fakeLog.transports.console = { + level: "info", + get wrapCount() { + return wrapCount + }, + get writeFn() { + return currentWriteFn + }, + set writeFn(next) { + wrapCount++ + currentWriteFn = next + }, + } + return fakeLog.transports.console +} + +function brokenPipe() { + return Object.assign(new Error("broken pipe"), { code: "EPIPE" }) +} + +function otherWriteError() { + return Object.assign(new Error("write failed"), { code: "ENOENT" }) +} describe("desktop logging", () => { - test("disables the console transport after a broken pipe", () => { - expect(source).toContain("initConsoleTransport()") - expect(source).toContain("log.transports.console.writeFn") - expect(source).toContain('err.code === "EPIPE"') - expect(source).toContain("log.transports.console.level = false") + test("disables the console transport after a broken pipe", async () => { + const consoleTransport = setupLog(() => { + throw brokenPipe() + }) + const { initLogging } = await import(`./logging?logging-test=${crypto.randomUUID()}`) + + initLogging() + consoleTransport.writeFn({}) + + expect(consoleTransport.level).toBe(false) + }) + + test("rethrows non-broken-pipe console transport errors", async () => { + const err = otherWriteError() + const consoleTransport = setupLog(() => { + throw err + }) + const { initLogging } = await import(`./logging?logging-test=${crypto.randomUUID()}`) + + initLogging() + + expect(() => consoleTransport.writeFn({})).toThrow(err) + expect(consoleTransport.level).toBe("info") + }) + + test("does not wrap the console transport more than once", async () => { + const consoleTransport = setupLog(() => undefined) + const { initLogging } = await import(`./logging?logging-test=${crypto.randomUUID()}`) + + initLogging() + initLogging() + + expect(consoleTransport.wrapCount).toBe(1) }) }) diff --git a/packages/desktop-electron/src/main/logging.ts b/packages/desktop-electron/src/main/logging.ts index cf902f88d..8a4713397 100644 --- a/packages/desktop-electron/src/main/logging.ts +++ b/packages/desktop-electron/src/main/logging.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path" const MAX_LOG_AGE_DAYS = 7 const TAIL_LINES = 1000 +const CONSOLE_TRANSPORT_INITIALIZED = Symbol("pawwork.consoleTransportInitialized") export function initLogging() { log.transports.file.maxSize = 5 * 1024 * 1024 @@ -44,13 +45,19 @@ function cleanup() { } function initConsoleTransport() { - const write = log.transports.console.writeFn.bind(log.transports.console) - log.transports.console.writeFn = (options) => { + const transport = log.transports.console as typeof log.transports.console & { + [CONSOLE_TRANSPORT_INITIALIZED]?: boolean + } + if (transport[CONSOLE_TRANSPORT_INITIALIZED]) return + transport[CONSOLE_TRANSPORT_INITIALIZED] = true + + const write = transport.writeFn.bind(transport) + transport.writeFn = (options) => { try { write(options) } catch (err) { if (!isBrokenPipe(err)) throw err - log.transports.console.level = false + transport.level = false } } } From 8812a804cce93a2450924e01d6138692f10c3022 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 01:04:44 +0800 Subject: [PATCH 08/13] test(desktop): cover webview zoom behavior --- .../src/renderer/webview-zoom.test.ts | 131 +++++++++++++++--- 1 file changed, 114 insertions(+), 17 deletions(-) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.test.ts b/packages/desktop-electron/src/renderer/webview-zoom.test.ts index 46a1952bf..63938853a 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.test.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.test.ts @@ -1,22 +1,119 @@ -import { describe, expect, test } from "bun:test" -import { readFileSync } from "node:fs" -import { resolve } from "node:path" +import { afterEach, describe, expect, mock, test } from "bun:test" -const source = readFileSync(resolve(import.meta.dir, "webview-zoom.ts"), "utf8") +type KeydownHandler = (event: { + key: string + ctrlKey: boolean + metaKey: boolean + preventDefault: () => void +}) => void + +const originalNavigator = globalThis.navigator +const originalWindow = globalThis.window + +afterEach(() => { + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + configurable: true, + }) + Object.defineProperty(globalThis, "window", { + value: originalWindow, + configurable: true, + }) +}) + +function deferred() { + let resolve!: () => void + let reject!: (err: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +async function loadZoomModule(options?: { userAgent?: string; setZoomFactor?: (factor: number) => Promise }) { + const handlers: KeydownHandler[] = [] + const setZoomFactor = mock(options?.setZoomFactor ?? (() => Promise.resolve())) + + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: options?.userAgent ?? "Windows" }, + configurable: true, + }) + Object.defineProperty(globalThis, "window", { + value: { + api: { setZoomFactor }, + addEventListener: (type: string, handler: KeydownHandler) => { + if (type === "keydown") handlers.push(handler) + }, + }, + configurable: true, + }) + + const module = await import(`./webview-zoom?webview-zoom-test=${crypto.randomUUID()}`) + return { + handler: handlers[0]!, + setZoomFactor, + webviewZoom: module.webviewZoom, + } +} + +function keyEvent(key: string, overrides?: Partial[0]>) { + return { + key, + ctrlKey: true, + metaKey: false, + preventDefault: mock(() => undefined), + ...overrides, + } +} describe("desktop renderer webview zoom", () => { - test("only consumes keydown events that actually change zoom", () => { - expect(source).toContain('if (event.key === "-") {') - expect(source).toContain('if (event.key === "=" || event.key === "+") {') - expect(source).toContain('if (event.key === "0") {') - expect(source.match(/event\.preventDefault\(\)/g)?.length).toBe(3) - expect(source).not.toContain("let newZoom = webviewZoom()") - }) - - test("keeps requested zoom separate until Electron accepts the zoom change", () => { - expect(source).toContain("let requestedZoom = 1") - expect(source).toContain("requestedZoom = next") - expect(source).toContain("if (requestedZoom !== next) return") - expect(source).toContain("setWebviewZoom(next)") + test("only consumes keydown events that actually change zoom", async () => { + const { handler, setZoomFactor } = await loadZoomModule() + const unrelated = keyEvent("a") + const zoomOut = keyEvent("-") + + handler(unrelated) + handler(zoomOut) + + expect(unrelated.preventDefault).toHaveBeenCalledTimes(0) + expect(zoomOut.preventDefault).toHaveBeenCalledTimes(1) + expect(setZoomFactor).toHaveBeenCalledTimes(1) + expect(setZoomFactor).toHaveBeenCalledWith(0.8) + }) + + test("keeps requested zoom separate until Electron accepts the zoom change", async () => { + const zoom = deferred() + const { handler, webviewZoom } = await loadZoomModule({ setZoomFactor: () => zoom.promise }) + + handler(keyEvent("-")) + + expect(webviewZoom()).toBe(1) + zoom.resolve() + await zoom.promise + await Promise.resolve() + expect(webviewZoom()).toBe(0.8) + }) + + test("keeps the current zoom when Electron rejects the zoom change", async () => { + const zoom = deferred() + const { handler, webviewZoom } = await loadZoomModule({ setZoomFactor: () => zoom.promise }) + + handler(keyEvent("-")) + + zoom.reject(new Error("zoom failed")) + await zoom.promise.catch(() => undefined) + await Promise.resolve() + expect(webviewZoom()).toBe(1) + }) + + test("uses requested zoom for rapid repeated shortcuts", async () => { + const { handler, setZoomFactor } = await loadZoomModule() + + handler(keyEvent("-")) + handler(keyEvent("-")) + handler(keyEvent("0")) + + expect(setZoomFactor.mock.calls.map(([factor]) => factor)).toEqual([0.8, 0.6000000000000001, 1]) }) }) From 59cbcf4247468f2849b4596a9a233934eaa46331 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 01:04:48 +0800 Subject: [PATCH 09/13] test(provider): cover small option variants --- .../opencode/test/provider/transform.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 83d6c11f7..7d953fd8a 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3069,6 +3069,132 @@ describe("ProviderTransform.message - cache control on gateway", () => { }) }) +describe("ProviderTransform.smallOptions", () => { + const createSmallModel = (overrides: Partial = {}): any => ({ + id: "test/test-model", + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.test.com", + npm: "@ai-sdk/openai", + }, + capabilities: { + reasoning: true, + }, + ...overrides, + }) + + test("uses medium effort for versioned gpt-5 chat models", () => { + const model = createSmallModel({ + id: "openai/gpt-5.2-chat-latest", + api: { + id: "gpt-5.2-chat-latest", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + }) + + expect(ProviderTransform.smallOptions(model)).toEqual({ store: false, reasoningEffort: "medium" }) + }) + + test("does not send reasoning effort for unversioned gpt-5 chat models", () => { + const model = createSmallModel({ + id: "openai/gpt-5-chat-latest", + api: { + id: "gpt-5-chat-latest", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + }) + + expect(ProviderTransform.smallOptions(model)).toEqual({ store: false }) + }) + + test("does not send reasoning effort for gpt-5 search api models", () => { + const model = createSmallModel({ + id: "openai/gpt-5-search-api", + api: { + id: "gpt-5-search-api", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + }) + + expect(ProviderTransform.smallOptions(model)).toEqual({ store: false }) + }) + + test("uses small thinking budgets for gemini 2.5 models", () => { + expect( + ProviderTransform.smallOptions( + createSmallModel({ + id: "google/gemini-2.5-pro", + providerID: "google", + api: { + id: "gemini-2.5-pro", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }), + ), + ).toEqual({ thinkingConfig: { thinkingBudget: 128 } }) + expect( + ProviderTransform.smallOptions( + createSmallModel({ + id: "google/gemini-2.5-flash", + providerID: "google", + api: { + id: "gemini-2.5-flash", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }), + ), + ).toEqual({ thinkingConfig: { thinkingBudget: 0 } }) + }) + + test("uses small thinking levels for gemini 3 models", () => { + expect( + ProviderTransform.smallOptions( + createSmallModel({ + id: "google/gemini-3-flash-preview", + providerID: "google", + api: { + id: "gemini-3-flash-preview", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }), + ), + ).toEqual({ thinkingConfig: { thinkingLevel: "minimal" } }) + expect( + ProviderTransform.smallOptions( + createSmallModel({ + id: "google/gemini-3-pro-preview", + providerID: "google", + api: { + id: "gemini-3-pro-preview", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }), + ), + ).toEqual({ thinkingConfig: { thinkingLevel: "low" } }) + expect( + ProviderTransform.smallOptions( + createSmallModel({ + id: "google/gemini-3-pro-image-preview", + providerID: "google", + api: { + id: "gemini-3-pro-image-preview", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }), + ), + ).toEqual({ thinkingConfig: { thinkingLevel: "high" } }) + }) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", From a92f7190599812d2940a1dbcd8cda53d330df8a2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 01:10:42 +0800 Subject: [PATCH 10/13] test(desktop): restore global window descriptor --- .../src/renderer/webview-zoom.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.test.ts b/packages/desktop-electron/src/renderer/webview-zoom.test.ts index 63938853a..34f8b133e 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.test.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.test.ts @@ -7,18 +7,14 @@ type KeydownHandler = (event: { preventDefault: () => void }) => void -const originalNavigator = globalThis.navigator -const originalWindow = globalThis.window +const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator") +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") afterEach(() => { - Object.defineProperty(globalThis, "navigator", { - value: originalNavigator, - configurable: true, - }) - Object.defineProperty(globalThis, "window", { - value: originalWindow, - configurable: true, - }) + if (originalNavigator) Object.defineProperty(globalThis, "navigator", originalNavigator) + else delete (globalThis as { navigator?: Navigator }).navigator + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else delete (globalThis as { window?: Window }).window }) function deferred() { @@ -38,6 +34,7 @@ async function loadZoomModule(options?: { userAgent?: string; setZoomFactor?: (f Object.defineProperty(globalThis, "navigator", { value: { userAgent: options?.userAgent ?? "Windows" }, configurable: true, + writable: true, }) Object.defineProperty(globalThis, "window", { value: { @@ -47,6 +44,7 @@ async function loadZoomModule(options?: { userAgent?: string; setZoomFactor?: (f }, }, configurable: true, + writable: true, }) const module = await import(`./webview-zoom?webview-zoom-test=${crypto.randomUUID()}`) From 380dfd03ca1726c49c9540f42a3aff39661a9a9a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 10:07:13 +0800 Subject: [PATCH 11/13] fix(provider): restrict gemini pro thinking levels --- packages/opencode/src/provider/transform.ts | 2 +- packages/opencode/test/provider/transform.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 23a01c29f..779c6bad5 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -655,7 +655,7 @@ function googleThinkingLevelEfforts(apiId: string) { if (id.includes("flash-image")) return ["minimal", "high"] if (id.includes("pro-image")) return ["high"] if (id.includes("flash")) return ["minimal", "low", "medium", "high"] - return ["low", "medium", "high"] + return ["low", "high"] } function googleThinkingBudgetMax(apiId: string) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 7d953fd8a..929382430 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4441,6 +4441,20 @@ describe("ProviderTransform.variants", () => { }) }) + test("gemini-3 pro models only expose low and high thinking levels", () => { + const model = createMockModel({ + id: "google/gemini-3-pro-preview", + providerID: "google", + api: { + id: "gemini-3-pro-preview", + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "high"]) + }) + test("gemini-3 pro image models only expose high thinking level", () => { const model = createMockModel({ id: "google/gemini-3-pro-image-preview", From 50d0a326c703c0e7f95191839e80d6297c9796f1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 10:07:17 +0800 Subject: [PATCH 12/13] test(desktop): cover logging fresh imports --- packages/desktop-electron/src/main/logging.test.ts | 11 +++++++++++ packages/desktop-electron/src/main/logging.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/desktop-electron/src/main/logging.test.ts b/packages/desktop-electron/src/main/logging.test.ts index 522094efd..da73950b7 100644 --- a/packages/desktop-electron/src/main/logging.test.ts +++ b/packages/desktop-electron/src/main/logging.test.ts @@ -110,4 +110,15 @@ describe("desktop logging", () => { expect(consoleTransport.wrapCount).toBe(1) }) + + test("does not wrap the console transport again across fresh module imports", async () => { + const consoleTransport = setupLog(() => undefined) + const first = await import(`./logging?logging-test=${crypto.randomUUID()}`) + const second = await import(`./logging?logging-test=${crypto.randomUUID()}`) + + first.initLogging() + second.initLogging() + + expect(consoleTransport.wrapCount).toBe(1) + }) }) diff --git a/packages/desktop-electron/src/main/logging.ts b/packages/desktop-electron/src/main/logging.ts index 8a4713397..11beea5ab 100644 --- a/packages/desktop-electron/src/main/logging.ts +++ b/packages/desktop-electron/src/main/logging.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path" const MAX_LOG_AGE_DAYS = 7 const TAIL_LINES = 1000 -const CONSOLE_TRANSPORT_INITIALIZED = Symbol("pawwork.consoleTransportInitialized") +const CONSOLE_TRANSPORT_INITIALIZED = Symbol.for("pawwork.consoleTransportInitialized") export function initLogging() { log.transports.file.maxSize = 5 * 1024 * 1024 From b1856a6a51d65d1c089acf17d646cad206b03528 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 9 May 2026 10:16:50 +0800 Subject: [PATCH 13/13] test(desktop): avoid zoom float artifact --- packages/desktop-electron/src/renderer/webview-zoom.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.test.ts b/packages/desktop-electron/src/renderer/webview-zoom.test.ts index 34f8b133e..63883e84b 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.test.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.test.ts @@ -112,6 +112,10 @@ describe("desktop renderer webview zoom", () => { handler(keyEvent("-")) handler(keyEvent("0")) - expect(setZoomFactor.mock.calls.map(([factor]) => factor)).toEqual([0.8, 0.6000000000000001, 1]) + const factors = setZoomFactor.mock.calls.map(([factor]) => factor) + expect(factors).toHaveLength(3) + expect(factors[0]).toBeCloseTo(0.8, 10) + expect(factors[1]).toBeCloseTo(0.6, 10) + expect(factors[2]).toBe(1) }) })