Compare commits

...
14 changed files with 182 additions and 8 deletions
@@ -359,6 +359,8 @@ const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): strin
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
}
const hasText = (part: { readonly text: string }) => part.text.trim().length > 0
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
name: tool.name,
description: tool.description,
@@ -534,6 +536,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicUserBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (!hasText(part)) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -543,7 +546,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
}
messages.push({ role: "user", content })
if (content.length > 0) messages.push({ role: "user", content })
continue
}
@@ -551,6 +554,11 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (!hasText(part)) {
if (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
return yield* invalid("Anthropic Messages cannot discard provider state attached to empty assistant text")
continue
}
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -579,7 +587,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
)
}
messages.push({ role: "assistant", content })
if (content.length > 0) messages.push({ role: "assistant", content })
continue
}
@@ -58,6 +58,79 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("filters empty user and assistant text while preserving replay state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user(" \n\t"),
Message.user([
{ type: "text", text: "" },
{ type: "text", text: " Use the tool. " },
{ type: "text", text: " \n\t" },
]),
Message.assistant([
{ type: "text", text: "" },
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({
id: "call_1",
name: "lookup",
resultType: "text",
result: "Tool result.",
}),
Message.assistant(" \n\t"),
Message.user("Continue."),
],
cache: "none",
}),
)
expect(prepared.body).toMatchObject({
messages: [
{ role: "user", content: [{ type: "text", text: " Use the tool. " }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "", signature: "sig_1" },
{ type: "tool_use", id: "call_1", name: "lookup", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: "Tool result.",
},
],
},
{ role: "user", content: [{ type: "text", text: "Continue." }] },
],
})
}),
)
it.effect("rejects empty assistant text carrying provider state", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "", providerMetadata: { anthropic: { encryptedContent: "opaque" } } },
]),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("cannot discard provider state attached to empty assistant text")
}),
)
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -236,6 +236,7 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.window": "Window",
"desktop.menu.help": "Help",
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.settings": "Settings",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
@@ -283,6 +284,11 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.updater.dialog.restart": "Restart",
"desktop.updater.dialog.later": "Later",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode2' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
"desktop.recovery.action.relaunch": "Relaunch",
"desktop.recovery.action.exportLogs": "Export Logs",
"desktop.recovery.action.keepWaiting": "Keep Waiting",
@@ -2,6 +2,13 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("installs the CLI from the macOS application menu", () => {
const appMenu = DESKTOP_MENU.find((menu) => menu.id === "app")
const item = appMenu?.items?.find((entry) => entry.type === "item" && entry.action === "app.installCli")
expect(item).toEqual({ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" })
})
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
@@ -4,6 +4,7 @@ export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.installCli"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
@@ -84,6 +85,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
action: "app.checkForUpdates",
enabled: "updater",
},
{ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" },
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
@@ -7,6 +7,9 @@ import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
const hasProviderMetadata = (metadata: ProviderMetadata | undefined) =>
metadata !== undefined && Object.keys(metadata).length > 0
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
@@ -188,9 +191,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
return result ? [call, result] : [call]
})
const meaningful = content.filter((part) => {
if (part.type === "text") return part.text !== ""
if (part.type === "text") return part.text !== "" || hasProviderMetadata(part.providerMetadata)
if (part.type !== "reasoning") return true
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
return part.text !== "" || hasProviderMetadata(part.providerMetadata)
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
@@ -1031,7 +1031,7 @@ Recent work
content: [
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
text: "",
state: { phase: "commentary" },
}),
],
@@ -1045,7 +1045,7 @@ Recent work
expect(messages[0]?.content).toEqual([
{
type: "text",
text: "Checking.",
text: "",
providerMetadata: { provider: { phase: "commentary" } },
},
])
@@ -5,6 +5,7 @@ import { AppRpcs } from "../../shared/ipc-rpc"
import { openExternalURL } from "../files"
import { checkAppExists, resolveAppPath } from "../files/apps"
import { setForceFocus } from "../native/debug"
import { showCliInstaller } from "../native/install-cli"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
import { setNativeTranslations } from "../native/translations"
@@ -12,6 +13,7 @@ import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
import { BackgroundService } from "../service/background-service"
import { DesktopCli } from "../service/desktop-cli"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
@@ -22,6 +24,7 @@ export const appHandlers = AppRpcs.toLayer(
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const background = yield* BackgroundService.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const logging = yield* DesktopLogging.Service
const runFork = Effect.runForkWith(yield* Effect.context())
@@ -56,6 +59,7 @@ export const appHandlers = AppRpcs.toLayer(
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
installCli: () => runFork(showCliInstaller(desktopCli)),
createWindow: lifecycle.createWindow,
openExternal: (url) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
+4
View File
@@ -16,7 +16,9 @@ import { windowHandlers } from "./ipc-handlers/window"
import { wslHandlers } from "./ipc-handlers/wsl"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
@@ -42,6 +44,7 @@ export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true })
export const registerIpcHandlers = Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const menu = {
@@ -50,6 +53,7 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
installCli: () => runFork(showCliInstaller(desktopCli)),
createWindow: lifecycle.createWindow,
openExternal: (url: string) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
@@ -0,0 +1,28 @@
import { dialog } from "electron"
import { Effect } from "effect"
import { DesktopCli } from "../service/desktop-cli"
import { nativeT } from "./translations"
export function showCliInstaller(desktopCli: DesktopCli.Interface) {
return desktopCli.install.pipe(
Effect.tap((path) =>
Effect.promise(() =>
dialog.showMessageBox({
type: "info",
message: nativeT("desktop.cli.installed.message", { path }),
title: nativeT("desktop.cli.installed.title"),
}),
),
),
Effect.catch((error) =>
Effect.promise(() =>
dialog.showMessageBox({
type: "error",
message: nativeT("desktop.cli.failed.message", { error: error.message }),
title: nativeT("desktop.cli.failed.title"),
}),
),
),
Effect.asVoid,
)
}
@@ -4,6 +4,7 @@ import { updateTitlebar } from "../windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
installCli: () => void
createWindow: () => void
relaunch: () => void
}>
@@ -17,6 +18,9 @@ export function runDesktopMenuAction(
case "app.checkForUpdates":
handlers.checkForUpdates?.()
return
case "app.installCli":
handlers.installCli?.()
return
case "app.relaunch":
handlers.relaunch?.()
return
+2
View File
@@ -16,6 +16,7 @@ import { nativeT } from "./translations"
type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
installCli: () => void
createWindow: () => void
openExternal: (url: string) => void
relaunch: () => void
@@ -60,6 +61,7 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
item.click = () =>
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
checkForUpdates: deps.checkForUpdates,
installCli: deps.installCli,
createWindow: deps.createWindow,
relaunch: deps.relaunch,
})
@@ -1,9 +1,10 @@
export * as DesktopCli from "./desktop-cli"
import { execFile } from "node:child_process"
import { execFile, spawn } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { parseCliVersion } from "./cli-version"
@@ -18,6 +19,7 @@ export interface Resolved {
export interface Interface {
readonly resolve: Effect.Effect<Resolved>
readonly install: Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopCli") {}
@@ -25,10 +27,19 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const path = yield* Path.Path
const resolve = yield* Effect.cached(
make().pipe(Effect.provide(yield* Effect.context<FileSystem.FileSystem | Path.Path>()), Effect.orDie),
)
return Service.of({ resolve })
const install = Effect.gen(function* () {
if (process.platform !== "darwin") return yield* Effect.fail(new Error("CLI installation requires macOS"))
const cli = yield* resolve
if (!cli.binary) return yield* Effect.fail(new Error("Bundled CLI executable is unavailable"))
const home = app.getPath("home")
yield* runInstaller(cli.binary, home)
return path.join(home, ".opencode", "bin", "opencode2")
})
return Service.of({ resolve, install })
}),
)
@@ -134,6 +145,27 @@ const run = Effect.fn("DesktopCli.run")(function* (binary: string, args: string[
return stdout
})
const runInstaller = Effect.fn("DesktopCli.installForUser")(function* (binary: string, home: string) {
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn("/bin/bash", ["-s", "--", "--binary", binary], {
env: { ...process.env, HOME: home },
stdio: ["pipe", "ignore", "pipe"],
})
let stderr = ""
child.stderr.on("data", (chunk) => (stderr += chunk))
child.on("error", reject)
child.on("close", (code) => {
if (code === 0) return resolve()
reject(new Error(stderr.trim() || `CLI installer exited with code ${code}`))
})
child.stdin.end(installer)
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
})
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
@@ -3,6 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
const DesktopMenuAction = Schema.Literals([
"app.checkForUpdates",
"app.installCli",
"app.relaunch",
"edit.undo",
"edit.redo",