mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 01:26:11 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a26c3a0b8b | ||
|
|
72bd1a45be | ||
|
|
0197af4806 | ||
|
|
ea3e0dde19 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user