Compare commits

...

12 Commits

49 changed files with 1642 additions and 599 deletions
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
"href",
"#opencode-v2-icon-plus",
)
await panel.getByRole("button", { name: "Open file" }).click()
const openFileTab = panel.getByRole("tab", { name: "Open file" })
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
await expect(openFileTab).toHaveAttribute("data-selected", "")
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
+1 -1
View File
@@ -575,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
/>
<Menu.Portal>
<Menu.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
@@ -170,10 +170,10 @@ export function SessionFileBrowserTab(props: {
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
<Icon name="file-tree" size="large" class="mb-2" />
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
@@ -417,7 +417,7 @@ export function SessionSidePanel(props: {
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" />}
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
@@ -47,7 +47,6 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -83,11 +82,14 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
updater: service
? {
apply: (version) => runPromise(updater.apply(version)),
}
: undefined,
updater: {
monitor: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
{ signal },
),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
+6 -55
View File
@@ -7,14 +7,12 @@ import { Global } from "@opencode-ai/util/global"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
export type Mode = "default" | "service" | "stdio"
@@ -29,7 +27,6 @@ export type Options = {
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
replacements: [
@@ -54,8 +51,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
const next = yield* Effect.scoped(
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
@@ -66,7 +62,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
if (incumbent !== undefined) return
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -163,62 +159,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
}),
)
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
if (server === undefined) return
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater
.monitor({
url,
password,
managed: options.mode === "service",
notify: server.updateAvailable,
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
})
.pipe(Effect.forkScoped)
return yield* options.mode === "service"
? Effect.raceFirst(
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
Deferred.await(replacement).pipe(Effect.map(Option.some)),
)
? server.shutdown
: options.mode === "stdio"
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
? waitForStdinClose()
: Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
if (Option.isNone(next)) return
yield* spawnReplacement(next.value)
})
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
const options = yield* ServiceConfig.options()
const [command, ...args] = options.command
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
// We do not monitor the replacement after spawn. A managed TUI
// recovers with Service.ensure if startup fails; a future client
// restart signal could coordinate that recovery instead.
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
...options.env,
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
},
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => new Error("Failed to start replacement server", { cause }),
})
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
+3 -6
View File
@@ -1,5 +1,5 @@
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "upgrade"
export type Policy = "disable" | "notify"
export type Action = "none" | "notify"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -10,10 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
if (policy === "notify") return "notify"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "notify"
return "upgrade"
return "notify"
}
export function parseReleaseVersion(input: string) {
+19 -28
View File
@@ -6,22 +6,17 @@ describe("updater", () => {
test("reads update policy from JSONC", () => {
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
})
test("maps the v1 update policy", () => {
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
})
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
})
test("reports patches and minors without automatically installing them", () => {
test("reports every available release", () => {
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
@@ -32,25 +27,21 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
test("reports majors instead of automatically installing them", () => {
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
})
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
test("reports when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
})
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify")
expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none")
})
test("preserves strict validity", () => {
@@ -71,21 +62,21 @@ describe("updater", () => {
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify")
})
})
+28 -165
View File
@@ -1,154 +1,36 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OpenCode } from "@opencode-ai/client"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly monitor: (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) => Effect.Effect<void>
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export type Inspection =
| { readonly action: "none" }
| { readonly action: Exclude<Action, "none">; readonly version: string }
type State =
| { readonly type: "current" }
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
| { readonly type: "ready-to-restart"; readonly version: string }
export interface MonitorInput {
readonly url: string
readonly password: string
readonly managed: boolean
readonly inspect: () => Effect.Effect<Inspection, Error>
readonly install: (version: string) => Effect.Effect<boolean, Error>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
readonly interval?: Duration.Input
readonly notificationThreshold?: Duration.Input
export const monitorUpdates = Effect.fnUntraced(function* (input: {
readonly inspect: () => Effect.Effect<string | undefined, Error>
readonly notify: (version: string) => Effect.Effect<void>
}
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
const state = yield* Ref.make<State>({ type: "current" })
const applyLock = yield* Semaphore.make(1)
const client = OpenCode.make({
baseUrl: input.url,
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
})
const applyIfIdle = () =>
applyLock.withPermit(
Effect.gen(function* () {
const pending = yield* Ref.get(state)
if (pending.type !== "available") return
const active = yield* Effect.tryPromise({
try: () => client.session.active(),
catch: (cause) => new Error("Failed to read active sessions", { cause }),
})
if (Object.keys(active).length > 0) return
const latest = yield* input.inspect()
if (latest.action !== "upgrade") {
yield* Ref.set(state, { type: "current" })
return
}
const installed = yield* input
.install(latest.version)
.pipe(
Effect.catch((error) =>
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
),
)
if (!installed) return
const handoff = input.managed
? yield* Effect.tryPromise({
try: () => client.experimental.persistentPty.handoff(),
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
})
: undefined
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
if (handoff) yield* input.restart(handoff.handoff)
}),
)
const checkServer = Effect.gen(function* () {
const result = yield* input.inspect()
if (result.action === "notify") {
yield* input.notify(result.version)
return
}
if (result.action !== "upgrade") {
yield* Ref.update(
state,
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
)
return
}
yield* Ref.update(state, (current): State => {
if (current.type === "ready-to-restart" && current.version === result.version) return current
return {
type: "available",
version: result.version,
availableSince: current.type === "available" ? current.availableSince : Date.now(),
}
})
yield* applyIfIdle()
const pending = yield* Ref.get(state)
if (
pending.type === "available" &&
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
)
yield* input.notify(pending.version)
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
const subscribe = Effect.suspend(() =>
Stream.fromAsyncIterable(
client.event.subscribe(),
(cause) => new Error("Update event stream failed", { cause }),
).pipe(
Stream.runForEach((event) => {
if (event.type === "server.connected") return applyIfIdle()
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.failed" &&
event.type !== "session.execution.interrupted"
)
return Effect.void
return Effect.tryPromise({
try: () => client.session.wait({ sessionID: event.data.sessionID }),
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
}).pipe(Effect.andThen(applyIfIdle()))
}),
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
),
).pipe(Effect.repeat(Schedule.spaced("1 second")))
return yield* Effect.all(
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
{
concurrency: "unbounded",
discard: true,
},
)
readonly initialDelay?: Duration.Input
readonly interval?: Duration.Input
}) {
const interval = input.interval ?? "10 minutes"
const initialDelay = input.initialDelay ?? "90 seconds"
const check = Effect.gen(function* () {
const version = yield* input.inspect()
if (version !== undefined) yield* input.notify(version)
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -161,13 +43,14 @@ export function decodePolicy(text: string): Policy | undefined {
if (errors.length || typeof input !== "object" || input === null) return
if ("update" in input) {
const value = input.update
if (value === "disable" || value === "notify" || value === "auto") return value
if (value === "disable" || value === "notify") return value
if (value === "auto") return "notify"
return
}
if (!("autoupdate" in input)) return
if (input.autoupdate === false) return "disable"
if (input.autoupdate === "notify") return "notify"
if (input.autoupdate === true) return "auto"
if (input.autoupdate === true) return "notify"
}
const make = Effect.gen(function* () {
@@ -192,7 +75,7 @@ const make = Effect.gen(function* () {
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? "auto"
return values.findLast((value) => value !== undefined) ?? "notify"
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
@@ -302,19 +185,19 @@ const make = Effect.gen(function* () {
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
const inspect = Effect.fnUntraced(function* () {
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
yield* Effect.logInfo("update check skipped", {
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
return { action: "none" }
return undefined
}
const policy = yield* readPolicy()
if (policy === "disable") {
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return { action: "none" }
return undefined
}
const version = yield* latest()
@@ -325,19 +208,16 @@ const make = Effect.gen(function* () {
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return { action: "none" }
return undefined
}
if (next === "notify") {
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return { action: next, version }
}
return { action: next, version }
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return version
})
const install = Effect.fnUntraced(function* (version: string) {
const detected = yield* method()
if (!detected) {
yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* Effect.logWarning("update skipped: installation method not found")
return false
}
yield* upgrade(detected, version)
@@ -349,26 +229,9 @@ const make = Effect.gen(function* () {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
const check = Effect.fn("cli.updater.check")(
function* () {
const result = yield* inspect()
if (result.action !== "upgrade") return
yield* install(result.version)
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) {
return yield* monitorServer({ ...input, inspect, install })
})
return Service.of({ check, monitor, apply, method, latest, upgrade })
return Service.of({ monitor, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
+1 -2
View File
@@ -12,9 +12,8 @@ await Effect.runPromise(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
method: () =>
Effect.sync(() => {
record("method")
+27 -94
View File
@@ -1,107 +1,40 @@
import { expect } from "bun:test"
import { Deferred, Effect, Layer, Option } from "effect"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.live("installs and restarts after the final Session settles", () =>
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
const installed = yield* Deferred.make<string>()
const restarted = yield* Deferred.make<void>()
yield* Updater.monitorServer({
url: fixture.url,
password: "test",
managed: true,
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
notify: () => Effect.void,
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed("2.0.0"),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
fixture.settle()
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
expect(
yield* Effect.raceFirst(
Deferred.await(installed),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
),
).toBe("1.1.0")
yield* Effect.raceFirst(
Deferred.await(restarted),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("89 seconds")
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("1 second")
expect(yield* Queue.take(updates)).toBe("2.0.0")
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
expect(yield* Queue.take(updates)).toBe("2.0.0")
}),
)
const wait = (promise: Promise<unknown>, message: () => string) =>
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
it.effect("does not notify when no update is available", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed(undefined),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
function makeServer() {
const encoder = new TextEncoder()
const activeRead = Promise.withResolvers<void>()
const eventOpened = Promise.withResolvers<void>()
const waited = Promise.withResolvers<void>()
let active = true
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/session/active") {
activeRead.resolve()
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
}
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
waited.resolve()
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
return Response.json({ handoff: null })
}
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
eventOpened.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return new Response("Not found", { status: 404 })
},
})
return {
url: server.url.origin,
activeRead: activeRead.promise,
eventOpened: eventOpened.promise,
waited: waited.promise,
settle() {
active = false
events?.enqueue(
encoder.encode(
`data: ${JSON.stringify({
id: "evt_settled",
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
data: { sessionID: "ses_test" },
})}\n\n`,
),
)
events?.close()
events = undefined
},
stop() {
server.stop(true)
},
}
}
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)
@@ -1892,7 +1892,7 @@ export type ConfigEntry =
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
update?: "disable" | "notify" | "auto"
update?: "disable" | "notify"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
+1 -1
View File
@@ -8,7 +8,7 @@ import { CodeModeCatalog } from "./catalog.js"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
## Search
+9 -2
View File
@@ -73,6 +73,11 @@ export function normalize(input: unknown): Result {
const legacyUpdate = own(input, "autoupdate")
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const nativeUpdate = own(input, "update")
? input.update === "auto"
? "notify"
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
? "auto"
@@ -86,7 +91,10 @@ export function normalize(input: unknown): Result {
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
const migratedUpdate =
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
if (update !== undefined) encoded.update = update
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
@@ -196,7 +204,6 @@ export function normalize(input: unknown): Result {
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
update: Info.fields.update,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,
+222 -90
View File
@@ -1,7 +1,7 @@
export * as Git from "./git.js"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath, RelativePath } from "./schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -149,6 +150,7 @@ export interface Interface {
from: TreeID
to: TreeID
context?: number
/** Exact files or directory prefixes; directory selectors return combined diffs. */
paths?: readonly RelativePath[]
}) => Effect.Effect<readonly File.Diff[], OperationError>
readonly restore: (input: {
@@ -296,14 +298,6 @@ const layer = Layer.effect(
yield* operation("reset", repository.worktree, ["reset", "--hard", revision])
})
const repositoryArgs = (repository: Repository, args: string[]) => [
"--git-dir",
repository.gitDirectory,
"--work-tree",
repository.worktree,
...args,
]
const repositoryOperation = Effect.fnUntraced(function* (
operationName: OperationError["operation"],
repository: Repository,
@@ -519,95 +513,169 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
}),
)
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
const text = (yield* repositoryOperation("restore", repository, [
"ls-tree",
"-z",
tree,
const diffs = new Map<RelativePath, File.Diff>()
const directories = new Map<RelativePath, RelativePath[]>()
const binaries = new Map<string, string>()
// Stable headers keep user diff settings from mixing neighboring files' patch chunks.
const diffArgs = (options: string[], paths: readonly RelativePath[]) => [
"--literal-pathspecs",
"diff",
"--no-ext-diff",
"--no-color",
"--no-renames",
"--src-prefix=a/",
"--dst-prefix=b/",
"--submodule=short",
...options,
input.from,
input.to,
"--",
file,
])).text.replace(/\0$/, "")
if (!text) return false
if (!/^\d+\s+\w+\s+[0-9a-f]+\t/.test(text))
return yield* new OperationError({
operation: "restore",
directory: repository.worktree,
message: `Invalid tree entry for ${file}`,
})
return true
...paths,
]
const read = (options: string[], paths: readonly RelativePath[]) =>
repositoryOperation("diff", input.repository, diffArgs(options, paths))
for (const paths of input.paths === undefined
? [[]]
: pathBatches(input.paths.map((file) => repositoryPath(input.repository, file)))) {
const names = (yield* read(["--name-status", "-z"], paths)).text.split("\0")
const statuses = new Map(
names.flatMap((status, index) => (index % 2 === 0 && status ? [[names[index + 1], status] as const] : [])),
)
if (statuses.size === 0) continue
const parents = new Set<string>()
for (const file of statuses.keys()) {
for (let index = file.lastIndexOf("/"); index !== -1; index = file.lastIndexOf("/", index - 1)) {
parents.add(file.slice(0, index))
}
}
for (const selected of paths) {
if (selected !== "." && !parents.has(selected)) continue
const files = Array.from(statuses.keys())
.filter((file) => selected === "." || file === selected || file.startsWith(selected + "/"))
.map((file) => RelativePath.make(file))
if (files.length > 0) directories.set(selected, files)
}
const result = yield* Effect.all(
{
stats: read(["--numstat", "-z"], paths),
patches: readPatches(proc, input.repository, diffArgs([`--unified=${input.context ?? 3}`], paths)),
},
{ concurrency: 2 },
)
for (const entry of result.stats.text.split("\0").filter(Boolean)) {
const first = entry.indexOf("\t")
const second = entry.indexOf("\t", first + 1)
const file = RelativePath.make(entry.slice(second + 1))
const additions = entry.slice(0, first)
const deletions = entry.slice(first + 1, second)
const binary = additions === "-" || deletions === "-"
const status = statuses.get(file)
const patch = result.patches.get(file)
if (!binary && patch === undefined && (Number(additions) > 0 || Number(deletions) > 0))
return yield* new OperationError({
operation: "diff",
directory: input.repository.worktree,
message: `Could not parse Git patch for ${file}`,
})
if (binary) binaries.set(file, patch ?? "")
diffs.set(file, {
file,
status: status === "A" ? "added" : status === "D" ? "deleted" : "modified",
additions: binary ? 0 : Number(additions),
deletions: binary ? 0 : Number(deletions),
patch: binary ? "" : (patch ?? ""),
})
}
}
return input.paths === undefined
? Array.from(diffs.values())
: input.paths.map((file) => {
const normalized = repositoryPath(input.repository, file)
const diff = diffs.get(normalized)
if (diff && !directories.has(normalized)) return { ...diff, file }
const children = (directories.get(normalized) ?? []).flatMap((path) => {
const child = diffs.get(path)
return child ? [child] : []
})
return {
file,
status:
children.length > 0 && children.every((child) => child.status === children[0].status)
? children[0].status
: ("modified" as const),
additions: children.reduce((total, child) => total + child.additions, 0),
deletions: children.reduce((total, child) => total + child.deletions, 0),
patch: children.map((child) => binaries.get(child.file) ?? child.patch).join(""),
}
})
})
const restore = Effect.fn("Git.tree.restore")(
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
locked(
input.repository,
Effect.forEach(
input.files,
([file, tree]) =>
Effect.gen(function* () {
if (yield* hasEntry(input.repository, tree, file)) {
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
return
}
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
Effect.gen(function* () {
// Only group consecutive snapshots: directory restores can overlap paths from another snapshot.
const groups: { tree: TreeID; files: RelativePath[] }[] = []
for (const [inputPath, tree] of input.files) {
const file = repositoryPath(input.repository, inputPath)
const previous = groups.at(-1)
if (previous?.tree === tree) {
previous.files.push(file)
continue
}
groups.push({ tree, files: [file] })
}
for (const group of groups) {
// Checkout uses stdin, so its batch can span multiple argv-limited lookups.
const pending: RelativePath[] = []
const flush = () =>
pending.length === 0
? Effect.void
: repositoryOperation(
"restore",
input.repository,
["--literal-pathspecs", "checkout", group.tree, "--pathspec-from-file=-", "--pathspec-file-nul"],
{ stdin: pending.splice(0).join("\0") + "\0" },
)
for (const paths of pathBatches(group.files)) {
const entries = new Set(
(yield* repositoryOperation("restore", input.repository, [
"--literal-pathspecs",
"ls-tree",
"--name-only",
"-t",
"-z",
group.tree,
"--",
...paths,
])).text
.split("\0")
.filter(Boolean),
)
}),
{ discard: true },
),
for (const file of paths) {
if (entries.has(file) || (file === "." && entries.size > 0)) {
pending.push(file)
continue
}
// Keep deletions in their original position relative to the surrounding checkouts.
yield* flush()
yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "restore",
directory: input.repository.worktree,
message: `Failed to remove ${file}`,
cause,
}),
),
)
}
}
yield* flush()
}
}),
),
)
@@ -702,6 +770,70 @@ const layer = Layer.effect(
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
function readPatches(proc: AppProcess.Interface, repository: Repository, args: string[]) {
return Effect.gen(function* () {
const collector = VcsPatch.collectGitPatch()
const handle = yield* proc.spawn(
ChildProcess.make("git", repositoryArgs(repository, args), {
cwd: repository.worktree,
extendEnv: true,
stdin: "ignore",
}),
)
const result = yield* Effect.all(
{
stdout: handle.stdout.pipe(
Stream.decodeText,
Stream.runForEach((chunk) => Effect.sync(() => collector.write(chunk))),
),
stderr: AppProcess.collectStream(handle.stderr, undefined),
code: handle.exitCode,
},
{ concurrency: "unbounded" },
)
if (result.code !== 0)
return yield* new OperationError({
operation: "diff",
directory: repository.worktree,
message: result.stderr.buffer.toString("utf8").trim() || "Git diff failed",
})
return collector.end()
}).pipe(
Effect.scoped,
Effect.mapError((cause) =>
cause instanceof OperationError
? cause
: new OperationError({ operation: "diff", directory: repository.worktree, message: cause.message, cause }),
),
)
}
function repositoryArgs(repository: Repository, args: string[]) {
return ["--git-dir", repository.gitDirectory, "--work-tree", repository.worktree, ...args]
}
function repositoryPath(repository: Repository, file: RelativePath) {
return RelativePath.make(
path.relative(repository.worktree, path.resolve(repository.worktree, file)).split(path.sep).join("/") || ".",
)
}
// diff and ls-tree have no pathspec-from-file option. Bound argv size, including quoting overhead on Windows.
function pathBatches(paths: readonly RelativePath[]) {
const batches: RelativePath[][] = []
let bytes = 0
for (const file of paths) {
const size = Buffer.byteLength(file) + 3
if (batches.length === 0 || bytes + size > 16_384) {
batches.push([])
bytes = 0
}
batches[batches.length - 1].push(file)
bytes += size
}
return batches
}
interface Result {
readonly exitCode: number
readonly text: string
+6 -2
View File
@@ -18,7 +18,9 @@ const RemoteModel = Schema.Struct({
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
// API version 2026-08-01 renamed cache_price to cache_read_price.
cache_price: Schema.optional(Schema.Number),
cache_read_price: Schema.optional(Schema.Number),
input_price: Schema.Number,
output_price: Schema.Number,
}),
@@ -166,7 +168,9 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
cache: {
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
read: Money.USDPerMillionTokens.make(
(prices?.default.cache_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
),
write: Money.USDPerMillionTokens.zero,
},
},
+5 -8
View File
@@ -55,7 +55,6 @@ type Active = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
scope: Scope.Closeable
token: object
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
@@ -77,7 +76,7 @@ type BackgroundResult = {
backgrounded?: Deferred.Deferred<Info>
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
@@ -184,14 +183,14 @@ export const make = Effect.gen(function* () {
})
})
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.scope !== scope) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
@@ -241,7 +240,6 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(existing) }, jobs]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
const job = {
info: {
id,
@@ -255,18 +253,17 @@ export const make = Effect.gen(function* () {
done,
backgrounded,
scope,
token,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
}),
)
if ("scope" in result)
yield* restore(input.run).pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, result.token, exit)),
Effect.flatMap((exit) => settle(id, result.scope, exit)),
Effect.asVoid,
Effect.forkIn(result.scope, { startImmediately: true }),
)
+141
View File
@@ -0,0 +1,141 @@
export * as ModalModels from "./models.js"
import { Money } from "@opencode-ai/schema/money"
import { Option, Schema } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
const providerID = Provider.ID.make("modal")
const ReasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const RemoteModel = Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
})
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResponse = Schema.decodeUnknownSync(Response)
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
type RemoteModel = typeof RemoteModel.Type
export async function get(baseURL: string, apiKey: string, existing: readonly Model.Info[]) {
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
})
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
// Decode each item tolerantly so one malformed entry cannot discard the
// whole inventory. A malformed envelope still fails the fetch.
const remote = decodeResponse(await response.json()).data.flatMap((raw) => {
const model = Option.getOrUndefined(decodeModel(raw))
return model ? [model] : []
})
const templates = new Map(existing.map((model) => [model.id, model]))
const result = new Map<Model.ID, Model.Info>()
for (const item of remote) {
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
const id = Model.ID.make(item.id)
result.set(id, build(id, item, baseURL, template))
}
return result
}
function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) {
if (value === undefined) return fallback
const parsed = Number(value) * 1_000_000
return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback
}
function limit(value: number | undefined, fallback: number) {
const parsed = value === undefined ? fallback : Math.trunc(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) {
const cost = previous?.cost[0]
const input = previous?.limit.input
return Model.Info.make({
...Model.Info.default(providerID, id),
id,
modelID: Model.ID.make(remote.id),
providerID,
name: remote.name ?? previous?.name ?? remote.id,
family: previous?.family,
compatibility:
remote.interleaved === undefined
? previous?.compatibility
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
headers: previous?.headers,
body: previous?.body,
capabilities: {
tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true,
input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"],
output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"],
},
variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote),
time: previous?.time ?? { released: 0 },
cost: [
{
input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero),
output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero),
cache: {
read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero),
write: cost?.cache.write ?? Money.USDPerMillionTokens.zero,
},
},
],
status: previous?.status ?? "active",
enabled: previous?.enabled ?? true,
limit: {
context: limit(remote.context_length, previous?.limit.context ?? 0),
...(input === undefined ? {} : { input }),
output: limit(remote.max_output_length, previous?.limit.output ?? 0),
},
})
}
function variants(remote: RemoteModel): Model.Info["variants"] {
const seen = new Map<string, Model.Info["variants"][number]>()
for (const option of remote.reasoning_options ?? []) {
for (const value of option.values) {
const effort = value ?? "none"
if (!seen.has(effort))
seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } })
}
}
return [...seen.values()]
}
+2
View File
@@ -15,6 +15,7 @@ import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { MistralPlugin } from "./provider/mistral.js"
import { ModalPlugin } from "./provider/modal.js"
import { NvidiaPlugin } from "./provider/nvidia.js"
import { OllamaPlugin } from "./provider/ollama.js"
import { OpenAIPlugin } from "./provider/openai.js"
@@ -48,6 +49,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
LLMGatewayPlugin,
LMStudioPlugin,
MistralPlugin,
ModalPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
@@ -13,7 +13,7 @@ import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
const apiVersion = "2026-06-01"
const apiVersion = "2026-08-01"
const userApiVersion = "2025-04-01"
const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device")
@@ -0,0 +1,67 @@
import { Effect, Semaphore, Stream } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Bus } from "../../bus.js"
import { Catalog } from "../../catalog.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { ModalModels } from "../../modal/models.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = Provider.ID.make("modal")
export const ModalPlugin = define({
id: "opencode.provider.modal",
effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
models?: Map<Model.ID, Model.Info>
} = {}
const load = Effect.fn("ModalPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("modal")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const apiKey = credential?.type === "key" ? credential.key : process.env.MODAL_PROXY_TOKEN
const provider = yield* catalog.provider.get(providerID)
const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
if (!apiKey || !baseURL) {
loaded.baseURL = undefined
loaded.models = undefined
return
}
loaded.baseURL = baseURL
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === providerID)
loaded.models = yield* Effect.tryPromise({
try: () => ModalModels.get(baseURL, apiKey, existing),
catch: (cause) => cause,
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(undefined))),
)
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
if (!loaded.models) return
for (const id of item.models.keys()) {
if (!loaded.models.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
}),
} satisfies PluginInternal.InternalPlugin)
+15 -24
View File
@@ -28,11 +28,9 @@ const events = Metric.counter("opencode_session_websocket_events_total", {
const metric = (event: string, attributes: Record<string, string> = {}) =>
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
interface Active {
readonly queue: Queue.Queue<string, AIError>
readonly lifecycle: { delivery: Delivery }
delivery: "send-attempted" | "provider-observed" | "terminal"
}
interface Channel {
@@ -130,14 +128,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "close",
phase: "close",
delivery:
channel.active.lifecycle.delivery === "queued" ||
channel.active.lifecycle.delivery === "connecting" ||
channel.active.lifecycle.delivery === "ready"
? "not-sent"
: channel.active.lifecycle.delivery === "provider-observed" ||
channel.active.lifecycle.delivery === "terminal"
? "accepted"
: "ambiguous",
channel.active.delivery === "provider-observed" || channel.active.delivery === "terminal"
? "accepted"
: "ambiguous",
}),
),
)
@@ -198,7 +191,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "idle-data",
phase: "receive",
})
active.lifecycle.delivery = "provider-observed"
active.delivery = "provider-observed"
if (typeof message !== "string")
return yield* transportError("Unsupported binary WebSocket frame", {
url: exchange.connect.url,
@@ -226,8 +219,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
phase:
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery:
channel.active?.lifecycle.delivery === "provider-observed" ||
channel.active?.lifecycle.delivery === "terminal" ||
channel.active?.delivery === "provider-observed" ||
channel.active?.delivery === "terminal" ||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
? "accepted"
: error.reason._tag === "Transport" && error.reason.code === "1009"
@@ -256,7 +249,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
exchange: WebSocketChannelExchange,
lifecycle: { delivery: Delivery },
) {
if (owner.closed)
return yield* transportError("Session WebSocket owner is closed", {
@@ -288,7 +280,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
yield* closeChannel(owner, current)
}
lifecycle.delivery = owner.channel ? "ready" : "connecting"
if (owner.channel)
yield* Effect.logDebug("session websocket reused", {
sessionTransport: "websocket",
@@ -314,7 +305,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
),
)
if (!channel) return fallback(exchange)
lifecycle.delivery = "ready"
if (channel.pending) {
channel.pending = undefined
@@ -326,9 +316,11 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
const active: Active = {
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
delivery: "send-attempted",
}
channel.active = active
lifecycle.delivery = "send-attempted"
const sent = yield* channel.connection.sendText(create.message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
@@ -366,7 +358,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
operation: "read",
code: "idle-timeout",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
}),
),
}),
@@ -375,7 +367,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.sync(() => {
if (!observationTerminal(observation)) return
terminal = observation
lifecycle.delivery = "terminal"
active.delivery = "terminal"
const staged = observation.type === "completed" ? observation.checkpoint : undefined
if (staged) channel.pending = { token, checkpoint: staged }
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
@@ -411,7 +403,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
operation: "read",
code: "incomplete",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
})
yield* poison(owner, channel, error)
}),
@@ -448,7 +440,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
const lifecycle = { delivery: "queued" as Delivery }
let execution: WebSocketChannelExecution | undefined
return Effect.succeed({
get http() {
@@ -456,7 +447,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
},
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange, lifecycle)),
Effect.andThen(start(owner, exchange)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
+1
View File
@@ -28,6 +28,7 @@ export interface CompareInput {
export interface DiffInput extends CompareInput {
readonly context?: number
/** Project-relative files or directories. Each directory selector produces a combined diff. */
readonly paths?: readonly RelativePath[]
}
+2 -4
View File
@@ -29,11 +29,9 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
update:
info.autoupdate === false
? "disable"
: info.autoupdate === "notify"
: info.autoupdate === "notify" || info.autoupdate === true
? "notify"
: info.autoupdate === true
? "auto"
: undefined,
: undefined,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
+62 -10
View File
@@ -31,6 +31,16 @@ export const countPatch = (patch: string) => {
return { additions, deletions }
}
const pathEscapes = new Map([
["a", "\x07"],
["b", "\b"],
["f", "\f"],
["v", "\v"],
["t", "\t"],
["n", "\n"],
["r", "\r"],
])
const parseQuotedPath = (value: string) => {
let out = ""
for (let idx = 1; idx < value.length; idx++) {
@@ -42,12 +52,16 @@ const parseQuotedPath = (value: string) => {
}
const next = value[++idx]
if (next === "t") out += "\t"
else if (next === "n") out += "\n"
else if (next === "r") out += "\r"
else if (next === '"' || next === "\\") out += next
else out += next ?? ""
// Git encodes non-ASCII filenames as octal UTF-8 bytes, not JavaScript string escapes.
const octal = /^[0-7]{1,3}(?:\\[0-7]{1,3})*/.exec(value.slice(idx))?.[0]
if (octal) {
out += Buffer.from(octal.split("\\").map((byte) => Number.parseInt(byte, 8))).toString("utf8")
idx += octal.length - 1
continue
}
out += pathEscapes.get(next) ?? next ?? ""
}
return undefined
}
const parsePathToken = (value: string) => {
@@ -56,7 +70,7 @@ const parsePathToken = (value: string) => {
}
const fileFromDiffPath = (value: string | undefined) => {
if (!value || value === "/dev/null") return
if (!value || value === "/dev/null") return undefined
const file = parsePathToken(value)
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
return file
@@ -66,13 +80,15 @@ const fileFromGitHeader = (header: string) => {
if (header.startsWith('"')) {
const first = parseQuotedPath(header)
const second = first ? header.slice(first.end).trimStart() : undefined
if (!second) return
if (!second.startsWith('"')) return fileFromDiffPath(second)
return fileFromDiffPath(parseQuotedPath(second)?.value)
if (!second) return undefined
return fileFromDiffPath(second)
}
// Mode-only changes have no ---/+++ lines. A filename may itself contain " b/".
const file = header.slice(2, Math.floor(header.length / 2))
if (header === `a/${file} b/${file}`) return file
const separator = header.indexOf(" b/")
if (separator === -1) return
if (separator === -1) return undefined
return fileFromDiffPath(header.slice(separator + 1))
}
@@ -104,3 +120,39 @@ export const chunksByFile = (patch: Patch, fallback: (index: number) => string |
acc.set(file, (acc.get(file) ?? "") + chunk)
return acc
}, new Map<string, string>())
/** Assemble streamed Git output one file at a time, preserving every character. */
export function collectGitPatch() {
const files = new Map<string, string>()
const fragments: string[] = []
const marker = "\ndiff --git "
let tail = ""
const flush = () => {
if (fragments.length === 0) return
const text = fragments.join("")
fragments.length = 0
const file = fileFromPatchChunk(text)
if (file) files.set(file, (files.get(file) ?? "") + text)
}
return {
write(chunk: string) {
const text = tail + chunk
let start = 0
for (let index = text.indexOf(marker); index !== -1; index = text.indexOf(marker, start)) {
fragments.push(text.slice(start, index + 1))
flush()
start = index + 1
}
// Keep only enough lookbehind to recognize a header split between reads.
const end = Math.max(start, text.length - marker.length + 1)
if (end > start) fragments.push(text.slice(start, end))
tail = text.slice(end)
},
end() {
if (tail) fragments.push(tail)
tail = ""
flush()
return files
},
}
}
@@ -47,7 +47,7 @@ describe("CodeModeInstructions", () => {
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
expect(initialized.text).toContain(
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
"This catalog is the complete set of tools callable inside `execute`. It does not affect tools exposed directly outside Code Mode.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
+10 -1
View File
@@ -13,6 +13,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { Global } from "@opencode-ai/util/global"
@@ -665,10 +666,18 @@ describe("Config", () => {
test("migrates the v1 update policy", () => {
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
})
test("normalizes the previous native auto update policy", () => {
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
type: "normalized",
encoded: { update: "notify" },
diagnostics: [],
})
})
test("migrates v1 provider lists to policies", () => {
expect(
ConfigMigrateV1.migrate({
+413
View File
@@ -0,0 +1,413 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { initRepo } from "./fixture/git"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(Git.node))
const write = (directory: string, files: Record<string, string | Uint8Array>) =>
Effect.promise(() =>
Promise.all(
Object.entries(files).map(async ([file, content]) => {
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true })
await Bun.write(path.join(directory, file), content)
}),
),
)
const fixture = Effect.fnUntraced(function* (files: Record<string, string | Uint8Array>) {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await initRepo(directory)
})
yield* write(directory, files)
const git = yield* Git.Service
const source = yield* git.repo.discover(AbsolutePath.make(directory))
if (!source) throw new Error("Repository not found")
const repository = yield* git.repo.create({
worktree: source.worktree,
gitDirectory: AbsolutePath.make(path.join(tmp.path, "snapshot")),
seed: source,
})
const capture = () => git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
return { git, repository, directory, capture, before: yield* capture() }
})
describe("Git tree batches", () => {
it.live(
"preserves per-file patches, requested ordering, binary stats, and empty changes",
() =>
Effect.gen(function* () {
const f = yield* fixture({
"changed.txt": "first\nbefore\nlast\n",
"deleted.txt": "deleted\n",
"unchanged.txt": "unchanged\n",
"binary.bin": new Uint8Array([0, 1, 2]),
"café 🦊.txt": "unicode before\n",
"nested/space name.txt": "space before\n",
})
yield* write(f.directory, {
"changed.txt": "first\nafter\nlast\n",
"added.txt": "added\n",
"empty.txt": "",
"binary.bin": new Uint8Array([0, 3, 4]),
"café 🦊.txt": "unicode after\n",
"nested/space name.txt": "space after\n",
})
yield* Effect.promise(() => fs.unlink(path.join(f.directory, "deleted.txt")))
const after = yield* f.capture()
const paths = [
"nested/space name.txt",
"café 🦊.txt",
"binary.bin",
"empty.txt",
"added.txt",
"deleted.txt",
"changed.txt",
"unchanged.txt",
].map((file) => RelativePath.make(file))
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths, context: 1 })
expect(diffs.map((diff) => [diff.file, diff.status, diff.additions, diff.deletions])).toEqual([
[paths[0], "modified", 1, 1],
[paths[1], "modified", 1, 1],
[paths[2], "modified", 0, 0],
[paths[3], "added", 0, 0],
[paths[4], "added", 1, 0],
[paths[5], "deleted", 0, 1],
[paths[6], "modified", 1, 1],
[paths[7], "modified", 0, 0],
])
for (const diff of diffs) {
if (diff.file === "binary.bin") {
expect(diff.patch).toBe("")
continue
}
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --unified=1 --no-renames ${f.before} ${after} -- ${diff.file}`
.cwd(f.directory)
.text(),
)
expect(diff.patch).toBe(expected)
}
expect(yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths: [] })).toEqual([])
expect(yield* f.git.tree.diff({ repository: f.repository, from: after, to: after })).toEqual([])
}),
{ timeout: 30_000 },
)
;(process.platform === "win32" ? it.live.skip : it.live)(
"keeps C-quoted paths and type changes paired with the correct patch",
() =>
Effect.gen(function* () {
const names = ["tab\tname.txt", "line\nname.txt", 'quote"name.txt', "back\\slash.txt", "bell\x07.txt"]
const f = yield* fixture({
...Object.fromEntries(names.map((file) => [file, "before\n"])),
"kind.txt": "was a file\n",
"path b/nested/mode.txt": "executable\n",
"café-mode.txt": "executable\n",
tab: "before\n",
"tab\tmode.txt": "executable\n",
})
yield* write(f.directory, { ...Object.fromEntries(names.map((file) => [file, "after\n"])), tab: "after\n" })
yield* Effect.promise(async () => {
await fs.unlink(path.join(f.directory, "kind.txt"))
await fs.symlink("café-mode.txt", path.join(f.directory, "kind.txt"))
await fs.chmod(path.join(f.directory, "path b/nested/mode.txt"), 0o755)
await fs.chmod(path.join(f.directory, "café-mode.txt"), 0o755)
await fs.chmod(path.join(f.directory, "tab\tmode.txt"), 0o755)
})
const after = yield* f.capture()
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after })
expect(diffs).toHaveLength(names.length + 5)
for (const diff of diffs) {
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames ${f.before} ${after} -- ${diff.file}`
.cwd(f.directory)
.text(),
)
expect(diff.patch).toBe(expected)
}
yield* f.git.tree.restore({
repository: f.repository,
files: new Map(diffs.map((diff) => [RelativePath.make(diff.file), f.before])),
})
for (const file of names)
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, file)).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "kind.txt")).text())).toBe("was a file\n")
expect((yield* Effect.promise(() => fs.stat(path.join(f.directory, "café-mode.txt")))).mode & 0o111).toBe(0)
}),
{ timeout: 30_000 },
)
it.live(
"streams multi-buffer UTF-8 patches without changing CRLF content",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "large.txt": "before\r\n" })
yield* write(f.directory, { "large.txt": "after 🦊 diff --git is file content\r\n".repeat(10_000) })
const after = yield* f.capture()
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after })
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames ${f.before} ${after} -- large.txt`
.cwd(f.directory)
.text(),
)
expect(diffs).toHaveLength(1)
expect(diffs[0].patch).toBe(expected)
}),
{ timeout: 30_000 },
)
it.live(
"fails instead of returning partial diffs when Git cannot read a changed blob",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "changed.txt": "before\n" })
yield* write(f.directory, { "changed.txt": "after\n" })
const after = yield* f.capture()
const blob = (yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} rev-parse ${`${after}:changed.txt`}`.cwd(f.directory).text(),
)).trim()
yield* Effect.promise(async () => {
const file = path.join(f.repository.gitDirectory, "objects", blob.slice(0, 2), blob.slice(2))
await fs.unlink(file)
await fs.writeFile(file, "broken object")
})
const error = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Git.OperationError)
expect(error.operation).toBe("diff")
}),
{ timeout: 30_000 },
)
it.live(
"keeps Git display configuration from mixing neighboring patches",
() =>
Effect.gen(function* () {
const f = yield* fixture({ foo: "before\n", "a/foo": "before\n" })
yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} update-index --add --cacheinfo ${`160000,${"1".repeat(40)},z-module`}`
.cwd(f.directory)
.quiet(),
)
const before = yield* f.git.tree.write(f.repository)
yield* write(f.directory, { foo: "after\n", "a/foo": "after\n" })
yield* f.capture()
yield* Effect.promise(async () => {
await $`git --git-dir ${f.repository.gitDirectory} update-index --add --cacheinfo ${`160000,${"2".repeat(40)},z-module`}`
.cwd(f.directory)
.quiet()
await $`git --git-dir ${f.repository.gitDirectory} config diff.noprefix true`.cwd(f.directory).quiet()
await $`git --git-dir ${f.repository.gitDirectory} config diff.submodule log`.cwd(f.directory).quiet()
})
const after = yield* f.git.tree.write(f.repository)
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: before, to: after })
expect(diffs.map((diff) => diff.file)).toEqual(["a/foo", "foo", "z-module"])
for (const diff of diffs) {
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames --src-prefix=a/ --dst-prefix=b/ --submodule=short ${before} ${after} -- ${diff.file}`
.cwd(f.directory)
.text(),
)
expect(diff.patch).toBe(expected)
}
}),
{ timeout: 30_000 },
)
it.live(
"preserves restoration order across snapshots and overlapping directory paths",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "alpha.txt": "one\n", "folder/file.txt": "one\n", "untouched.txt": "one\n" })
yield* write(f.directory, { "alpha.txt": "two\n", "folder/file.txt": "two\n" })
const middle = yield* f.capture()
yield* write(f.directory, {
"alpha.txt": "three\n",
"folder/file.txt": "three\n",
"untouched.txt": "keep this edit\n",
"added.txt": "remove this\n",
})
yield* f.git.tree.restore({
repository: f.repository,
files: new Map([
[RelativePath.make("alpha.txt"), f.before],
[RelativePath.make("folder/file.txt"), middle],
[RelativePath.make("folder"), f.before],
[RelativePath.make("added.txt"), f.before],
]),
})
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "alpha.txt")).text())).toBe("one\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "folder/file.txt")).text())).toBe("one\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "untouched.txt")).text())).toBe(
"keep this edit\n",
)
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "added.txt")).exists())).toBe(false)
}),
{ timeout: 30_000 },
)
it.live(
"treats selected bracketed filenames literally",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "app/[slug]/page.tsx": "before\n", "app/s/page.tsx": "original\n" })
yield* write(f.directory, { "app/[slug]/page.tsx": "after\n", "app/s/page.tsx": "keep this edit\n" })
const after = yield* f.capture()
const file = RelativePath.make("app/[slug]/page.tsx")
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths: [file] })
expect(diffs).toHaveLength(1)
expect(diffs[0].patch).toContain("+after")
expect(diffs[0].patch).not.toContain("keep this edit")
yield* f.git.tree.restore({ repository: f.repository, files: new Map([[file, f.before]]) })
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, file)).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "app/s/page.tsx")).text())).toBe(
"keep this edit\n",
)
}),
{ timeout: 30_000 },
)
it.live(
"returns complete directory diffs and totals without including neighboring paths",
() =>
Effect.gen(function* () {
const f = yield* fixture({
"nested/a.txt": "before\n",
"nested/deeper/b.txt": "before\n",
"nested/image.bin": new Uint8Array([0, 1]),
"nested-other/outside.txt": "before\n",
})
yield* write(f.directory, {
"nested/a.txt": "after\n",
"nested/deeper/b.txt": "after\nextra\n",
"nested/image.bin": new Uint8Array([0, 2]),
"nested-other/outside.txt": "outside\n",
})
const after = yield* f.capture()
for (const selected of ["nested/", "./nested", "."]) {
const diffs = yield* f.git.tree.diff({
repository: f.repository,
from: f.before,
to: after,
paths: [RelativePath.make(selected)],
})
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames ${f.before} ${after} -- ${selected}`
.cwd(f.directory)
.text(),
)
expect(diffs).toHaveLength(1)
expect(diffs[0]).toEqual({
file: selected,
status: "modified",
additions: selected === "." ? 4 : 3,
deletions: selected === "." ? 3 : 2,
patch: expected,
})
}
const empty = yield* f.git.tree.diff({
repository: f.repository,
from: f.before,
to: after,
paths: [RelativePath.make("absent/")],
})
expect(empty).toEqual([{ file: "absent/", status: "modified", additions: 0, deletions: 0, patch: "" }])
}),
{ timeout: 30_000 },
)
it.live(
"includes exact and descendant changes in file-directory replacements",
() =>
Effect.gen(function* () {
const f = yield* fixture({ module: "old file\n" })
yield* Effect.promise(() => fs.unlink(path.join(f.directory, "module")))
yield* write(f.directory, { "module/index.ts": "new child\n" })
const after = yield* f.capture()
for (const [from, to] of [
[f.before, after],
[after, f.before],
]) {
const diffs = yield* f.git.tree.diff({
repository: f.repository,
from,
to,
paths: [RelativePath.make("module")],
})
const expected = yield* Effect.promise(() =>
$`git --git-dir ${f.repository.gitDirectory} --work-tree ${f.directory} diff --no-renames ${from} ${to} -- module`
.cwd(f.directory)
.text(),
)
expect(diffs).toEqual([{ file: "module", status: "modified", additions: 1, deletions: 1, patch: expected }])
}
}),
{ timeout: 30_000 },
)
it.live(
"normalizes explicit paths before matching tree entries",
() =>
Effect.gen(function* () {
const f = yield* fixture({ "changed.txt": "before\n", "folder/file.txt": "before\n" })
yield* write(f.directory, { "changed.txt": "after\n", "folder/file.txt": "after\n" })
const after = yield* f.capture()
const file = RelativePath.make("./changed.txt")
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths: [file] })
expect(diffs.map((diff) => [diff.file, diff.additions, diff.deletions])).toEqual([[file, 1, 1]])
yield* f.git.tree.restore({
repository: f.repository,
files: new Map([
[file, f.before],
[RelativePath.make("folder/"), f.before],
[RelativePath.make("folder/file.txt"), f.before],
]),
})
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "changed.txt")).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "folder/file.txt")).text())).toBe("before\n")
}),
{ timeout: 30_000 },
)
it.live(
"handles path lists larger than one command without touching unselected files",
() =>
Effect.gen(function* () {
const names = Array.from(
{ length: 140 },
(_, index) => `${String(index).padStart(3, "0")}-${"x".repeat(130)}.txt`,
)
const f = yield* fixture({
...Object.fromEntries(names.map((file) => [file, "before\n"])),
"unselected.txt": "original\n",
})
yield* write(f.directory, {
...Object.fromEntries(names.map((file) => [file, "after\n"])),
"unselected.txt": "keep this edit\n",
})
const after = yield* f.capture()
const paths = names.toReversed().map((file) => RelativePath.make(file))
const diffs = yield* f.git.tree.diff({ repository: f.repository, from: f.before, to: after, paths })
expect(diffs.map((diff) => diff.file)).toEqual(paths)
expect(
diffs.every((diff) => diff.additions === 1 && diff.deletions === 1 && diff.patch.includes("+after")),
).toBe(true)
yield* f.git.tree.restore({ repository: f.repository, files: new Map(paths.map((file) => [file, f.before])) })
for (const file of names)
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, file)).text())).toBe("before\n")
expect(yield* Effect.promise(() => Bun.file(path.join(f.directory, "unselected.txt")).text())).toBe(
"keep this edit\n",
)
}),
{ timeout: 30_000 },
)
})
@@ -118,3 +118,40 @@ test("defensively syncs advertised Copilot models", async () => {
await server.stop(true)
}
})
test("prices cache reads from either token price spelling", async () => {
// API version 2026-08-01 renamed cache_price to cache_read_price; older payloads still use cache_price.
const item = (id: string, prices: Record<string, number>) => ({
model_picker_enabled: true,
id,
name: id,
version: `${id}-2026-08-01`,
supported_endpoints: ["/chat/completions"],
billing: { token_prices: { batch_size: 1_000_000, default: { input_price: 250, output_price: 1500, ...prices } } },
capabilities: {
family: "gpt",
limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 },
supports: { tool_calls: true },
},
})
const server = Bun.serve({
port: 0,
fetch: () =>
Response.json({
data: [
item("renamed", { cache_read_price: 25, cache_write_price: 0 }),
item("legacy", { cache_price: 25 }),
item("unpriced", {}),
],
}),
})
try {
const models = await CopilotModels.get(server.url.origin, {}, [])
expect(models.get(Model.ID.make("renamed"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
expect(models.get(Model.ID.make("legacy"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
expect(models.get(Model.ID.make("unpriced"))?.cost[0]).toMatchObject({ cache: { read: 0 } })
} finally {
await server.stop(true)
}
})
+65
View File
@@ -64,6 +64,71 @@ describe("Job", () => {
}),
)
it.live("reuses running work when started again with the same ID", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const output = yield* Deferred.make<string>()
const job = yield* jobs.start({ id: "job_reused", type: "test", run: Deferred.await(output) })
expect(
yield* jobs.start({ id: job.id, type: "duplicate", run: Effect.die("Duplicate work must not run") }),
).toEqual(job)
yield* Deferred.succeed(output, "original output")
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
type: "test",
status: "completed",
output: "original output",
})
}),
)
it.live("ignores an obsolete callback after a cancellation waiter starts a same-ID replacement", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const callback = yield* Deferred.make<() => void>()
const output = yield* Deferred.make<string>()
const finalized = yield* Deferred.make<void>()
const job = yield* jobs.start({
id: "job_replaced",
type: "test",
run: Effect.callback<string>((resume) => {
Deferred.doneUnsafe(
callback,
Effect.succeed(() => resume(Effect.succeed("obsolete output"))),
)
}),
})
const complete = yield* Deferred.await(callback)
// Cancellation wakes waiters before closing the old scope, allowing the old callback to race replacement.
const replacement = yield* jobs.wait({ id: job.id }).pipe(
Effect.tap((result) => Effect.sync(() => expect(result.info?.status).toBe("cancelled"))),
Effect.andThen(
jobs.start({
id: job.id,
type: "replacement",
run: Deferred.await(output).pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
}),
),
Effect.andThen(Effect.sync(complete)),
Effect.forkChild({ startImmediately: true }),
)
yield* jobs.cancel(job.id)
yield* Fiber.join(replacement)
expect(yield* jobs.get(job.id)).toMatchObject({ type: "replacement", status: "running" })
expect(yield* Deferred.isDone(finalized)).toBe(false)
yield* Deferred.succeed(output, "replacement output")
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
type: "replacement",
status: "completed",
output: "replacement output",
})
expect(yield* Deferred.isDone(finalized)).toBe(true)
}),
)
it.live("returns finished from a blocking wait when completion wins", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
+137
View File
@@ -0,0 +1,137 @@
import { expect, test } from "bun:test"
import { ModalModels } from "@opencode-ai/core/modal/models"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Money } from "@opencode-ai/schema/money"
const providerID = Provider.ID.make("modal")
test("modal plugin is registered", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.modal")
})
function template(id: string, overrides: Partial<Model.Info> = {}) {
return Model.Info.make({
...Model.Info.default(providerID, Model.ID.make(id)),
name: `${id} catalog`,
family: Model.Family.make("catalog-family"),
...overrides,
})
}
test("maps live Modal models onto catalog templates", async () => {
const server = Bun.serve({
port: 0,
fetch: (request) => {
expect(request.headers.get("Authorization")).toBe("Bearer test-key")
expect(new URL(request.url).pathname).toBe("/v1/models")
return Response.json({
data: [
{
id: "live-model",
base_model_id: "base-model",
name: "Live Model",
input_modalities: ["text", "image"],
output_modalities: ["text"],
context_length: 128000,
max_output_length: 8192,
pricing: { prompt: "0.000001", completion: 0.000002, input_cache_read: "0.0000002" },
supported_sampling_parameters: ["temperature"],
supported_features: ["tools", "reasoning"],
reasoning_options: [{ type: "effort", values: ["low", "high", null] }],
interleaved: { field: "reasoning_content" },
},
{
id: "standalone",
context_length: 64000,
},
{ id: "malformed", context_length: "huge" },
],
})
},
})
try {
const base = template("base-model")
const stale = template("stale")
const models = await ModalModels.get(`${server.url.origin}/v1`, "test-key", [base, stale])
expect(models.has(Model.ID.make("stale"))).toBe(false)
expect(models.has(Model.ID.make("malformed"))).toBe(false)
const model = models.get(Model.ID.make("live-model"))
expect(model?.name).toBe("Live Model")
expect(model?.family).toBe(Model.Family.make("catalog-family"))
expect(model?.providerID).toBe(providerID)
expect(model?.modelID).toBe(Model.ID.make("live-model"))
expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` })
expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" })
expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] })
expect(model?.cost[0]?.input).toBe(Money.USDPerMillionTokens.make(1))
expect(model?.cost[0]?.output).toBe(Money.USDPerMillionTokens.make(2))
expect(Number(model?.cost[0]?.cache.read)).toBeCloseTo(0.2, 10)
expect(model?.cost[0]?.cache.write).toBe(Money.USDPerMillionTokens.zero)
expect(model?.limit).toMatchObject({ context: 128000, output: 8192 })
expect(model?.variants.map((variant) => variant.id)).toEqual([
Model.VariantID.make("low"),
Model.VariantID.make("high"),
Model.VariantID.make("none"),
])
expect(model?.variants[0]?.settings).toMatchObject({ reasoningEffort: "low" })
expect(model?.status).toBe("active")
const fresh = models.get(Model.ID.make("standalone"))
expect(fresh?.name).toBe("standalone")
expect(fresh?.family).toBeUndefined()
expect(fresh?.capabilities).toMatchObject({ tools: true, input: ["text"], output: ["text"] })
expect(fresh?.variants).toEqual([])
expect(fresh?.limit).toMatchObject({ context: 64000, output: 0 })
} finally {
await server.stop(true)
}
})
test("keeps template cost and limits when the proxy omits them", async () => {
const server = Bun.serve({
port: 0,
fetch: () =>
Response.json({
data: [{ id: "sparse", hugging_face_id: "hf-base" }],
}),
})
try {
const base = template("hf-base", {
cost: [
{
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(10),
cache: { read: Money.USDPerMillionTokens.make(1), write: Money.USDPerMillionTokens.make(2) },
},
],
limit: { context: 1000, input: 500, output: 250 },
})
const models = await ModalModels.get(server.url.origin, "test-key", [base])
const model = models.get(Model.ID.make("sparse"))
expect(model?.name).toBe("hf-base catalog")
expect(model?.cost[0]).toMatchObject({ input: 5, output: 10, cache: { read: 1, write: 2 } })
expect(model?.limit).toMatchObject({ context: 1000, input: 500, output: 250 })
} finally {
await server.stop(true)
}
})
test("throws on proxy failure so the plugin can fail soft", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("nope", { status: 500 }),
})
try {
await expect(ModalModels.get(server.url.origin, "test-key", [])).rejects.toThrow()
} finally {
await server.stop(true)
}
})
@@ -122,7 +122,7 @@ describe("GithubCopilotPlugin", () => {
expect(requests[0]?.has("x-api-key")).toBe(false)
expect(requests[0]?.get("x-initiator")).toBe("user")
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-08-01")
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
}),
)
@@ -145,7 +145,7 @@ describe("GithubCopilotPlugin", () => {
expect(event.request.headers.has("x-api-key")).toBe(false)
expect(event.request.headers.get("x-initiator")).toBe("user")
expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-08-01")
}),
)
@@ -445,14 +445,17 @@ describe("SessionModelTransport", () => {
)
})
test("closes an active exchange without waiting for its Session permit", async () => {
test.each([false, true])("classifies active and queued close (observed: %s)", async (observed) => {
const started = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () => Deferred.succeed(started, undefined),
sendText: () =>
observed
? Queue.offer(messages, "frame").pipe(Effect.asVoid)
: Deferred.succeed(started, undefined).pipe(Effect.asVoid),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
@@ -462,17 +465,30 @@ describe("SessionModelTransport", () => {
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
const executor = transport.bind(session)
const item = exchange("active")
const running = yield* collect(executor, {
...item,
driver: {
...item.driver,
observe: (_create, frame) => Deferred.succeed(started, undefined).pipe(Effect.as({ type: "frame", frame })),
},
}).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const queued = yield* collect(executor, exchange("queued")).pipe(
Effect.result,
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* transport.close(session)
const result = yield* Effect.result(Fiber.join(running))
expect(result).toMatchObject({
expect(yield* Fiber.join(running)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
failure: { reason: { _tag: "Transport", code: "close", delivery: observed ? "accepted" : "ambiguous" } },
})
expect(yield* Fiber.join(queued)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "queue", delivery: "not-sent" } },
})
expect(closed).toBe(1)
}),
@@ -545,6 +561,43 @@ describe("SessionModelTransport", () => {
)
})
test("closes a connection returned after its owner closes during setup", async () => {
const connecting = Deferred.makeUnsafe<void>()
const release = Deferred.makeUnsafe<void>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Deferred.succeed(connecting, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({
sendText: () => Effect.die("Unexpected send after owner close"),
messages: Stream.never,
close: Effect.sync(() => closed++).pipe(Effect.asVoid),
}),
),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const running = yield* collect(
transport.bind(session),
exchange("first", { fallback: () => Stream.die("Unexpected fallback after owner close") }),
).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(connecting)
yield* transport.close(session)
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(running)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "connect", delivery: "not-sent" } },
})
expect(closed).toBe(1)
}),
)
})
test("falls back once when connection setup fails before send", async () => {
let fallbacks = 0
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { VcsPatch } from "@opencode-ai/core/vcs/patch"
describe("streamed Git patches", () => {
const first = [
"diff --git a/first.txt b/first.txt",
"index 1111111..2222222 100644",
"--- a/first.txt",
"+++ b/first.txt",
"@@ -1 +1 @@",
"-old\r",
"+diff --git is content, not a header 🦊\r",
"\\ No newline at end of file",
"",
].join("\n")
const second = 'diff --git "a/tab\\tname.txt" "b/tab\\tname.txt"\nold mode 100644\nnew mode 100755\n'
const changedType =
"diff --git a/first.txt b/first.txt\nnew file mode 120000\nindex 0000000..3333333\n--- /dev/null\n+++ b/first.txt\n@@ -0,0 +1 @@\n+target\n"
const patch = first + second + changedType
const expected = new Map([
["first.txt", first + changedType],
["tab\tname.txt", second],
])
test("preserves patches across every two-chunk split", () => {
for (let index = 0; index <= patch.length; index++) {
const collector = VcsPatch.collectGitPatch()
collector.write(patch.slice(0, index))
collector.write(patch.slice(index))
expect(collector.end()).toEqual(expected)
}
})
test("handles small chunks, CRLF content, Unicode, and multiple chunks per file", () => {
for (const size of [1, 2, 7, 12, 64]) {
const collector = VcsPatch.collectGitPatch()
for (let offset = 0; offset < patch.length; offset += size) collector.write(patch.slice(offset, offset + size))
expect(collector.end()).toEqual(expected)
}
})
test("accepts empty output and a final patch without a newline", () => {
expect(VcsPatch.collectGitPatch().end()).toEqual(new Map())
const collector = VcsPatch.collectGitPatch()
collector.write(second.trimEnd())
expect(collector.end()).toEqual(new Map([["tab\tname.txt", second.trimEnd()]]))
})
})
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
"enum": ["disable", "notify"]
},
"share": {
"type": "string",
+2 -2
View File
@@ -34,8 +34,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
default_agent: Schema.String.pipe(optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
description: "Disable updates, notify when one is available, or install automatically",
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
description: "Disable updates or notify when one is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
+1 -8
View File
@@ -1,11 +1,9 @@
export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { Bus } from "@opencode-ai/core/bus"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import {
HttpMiddleware,
@@ -116,12 +114,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
)
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
return {
address: bound.http.address,
shutdown: shutdown.await,
updateAvailable: (version: string) =>
Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
}
return { address: bound.http.address, shutdown: shutdown.await }
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
+3 -15
View File
@@ -1,5 +1,4 @@
import { expect } from "bun:test"
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
@@ -100,12 +99,9 @@ it.live("allows browser preflight requests without credentials", () =>
)
expect(event.status).toBe(200)
expect(event.headers.get("content-encoding")).toBeNull()
if (!event.body) return yield* Effect.die(new Error("Event response has no body"))
const reader = event.body.getReader()
yield* Effect.promise(() => readUntil(reader, "server.connected"))
yield* server.updateAvailable("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
yield* Effect.promise(() => reader.cancel())
const body = event.body
if (!body) return yield* Effect.die(new Error("Event response has no body"))
yield* Effect.promise(() => body.cancel())
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
@@ -130,11 +126,3 @@ it.live("allows browser preflight requests without credentials", () =>
)
}),
)
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
while (true) {
const next = await reader.read()
if (next.done) throw new Error(`Event stream ended before ${expected}`)
if (new TextDecoder().decode(next.value).includes(expected)) return
}
}
@@ -494,7 +494,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
gap: 8px;
padding: 24px;
padding-bottom: 160px;
text-align: center;
@@ -502,25 +502,26 @@
[data-slot="session-review-v2-empty-changes"] [data-slot="icon-svg"] {
flex: none;
margin-bottom: 8px;
color: var(--v2-icon-icon-muted);
}
[data-slot="session-review-v2-empty-changes-title"] {
flex: none;
margin-top: 4px;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
}
[data-slot="session-review-v2-empty-changes-description"] {
flex: none;
height: 20px;
max-width: 282px;
font-size: 13px;
font-weight: 440;
line-height: 20px;
line-height: var(--line-height-base);
text-align: center;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
+31 -16
View File
@@ -186,6 +186,7 @@ export type TuiInput = {
args: Args
config: Config.Interface
updater?: {
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
apply: (version: string) => Promise<void>
}
packages: PackageSource
@@ -220,9 +221,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const service = managed
? {
reconnect: async (signal: AbortSignal) => {
// Give the server a chance to respawn itself before starting client-side recovery.
await new Promise((resolve) => setTimeout(resolve, 50))
if (signal.aborted) throw signal.reason ?? new Error("Server reconnect cancelled")
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next), url: endpoint.url }
@@ -507,6 +505,36 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
"update-notifications",
{ initial: { versions: [] } },
)
const showUpdate = (version: string) => {
const updater = props.updater
if (!updater || updateNotifications.versions.includes(version)) return
void markUpdateNotification((draft) => {
draft.versions = [...draft.versions, version].slice(-100)
}).catch((error) => log.error("failed to persist update notification", { error }))
const key = `update:${version}`
dialog.replace(
() => (
<DialogUpdate
dialogKey={key}
version={version}
install={() => updater.apply(version)}
restart={client.restart}
/>
),
undefined,
{ key },
)
dialog.setCentered(true)
}
onMount(() => {
const updater = props.updater
if (!updater) return
const controller = new AbortController()
onCleanup(() => controller.abort())
void updater.monitor(showUpdate, controller.signal).catch((error) => {
if (!controller.signal.aborted) log.error("update monitor failed", { error })
})
})
const tabsResize = createPaneResize({
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
defaultValue: () => SESSION_SIDEBAR_WIDTH,
@@ -1215,19 +1243,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
})
})
event.on("installation.update-available", (evt) => {
const updater = props.updater
const restart = client.restart
if (!updater || !restart) return
const version = evt.data.version
if (updateNotifications.versions.includes(version)) return
void markUpdateNotification((draft) => {
draft.versions = [...draft.versions, version].slice(-100)
}).catch((error) => log.error("failed to persist update notification", { error }))
dialog.replace(() => <DialogUpdate version={version} install={() => updater.apply(version)} restart={restart} />)
dialog.setCentered(true)
})
event.on("tui.session.select", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
route.navigate({
+31 -18
View File
@@ -8,22 +8,32 @@ import { useDialog } from "../ui/dialog"
import { Spinner } from "./spinner"
type State =
| { type: "ready"; active: "update" | "ignore" }
| { type: "ready"; active: "update" | "skip" }
| { type: "installing" }
| { type: "restarting" }
| { type: "failed"; message: string }
export function DialogUpdate(props: { version: string; install: () => Promise<void>; restart: () => Promise<void> }) {
export function DialogUpdate(props: {
dialogKey: string
version: string
install: () => Promise<void>
restart?: () => Promise<void>
}) {
const dialog = useDialog()
const theme = useTheme("elevated")
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
const close = () => {
if (dialog.key === props.dialogKey) dialog.clear()
}
const install = async () => {
setState({ type: "installing" })
await props.install()
setState({ type: "restarting" })
await props.restart()
dialog.clear()
if (props.restart) {
setState({ type: "restarting" })
await props.restart()
}
close()
}
const beginInstall = () => {
@@ -34,16 +44,16 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
const run = () => {
const current = state()
if (current.type !== "ready") return
if (current.active === "ignore") return dialog.clear()
if (current.active === "skip") return close()
beginInstall()
}
const toggle = () =>
setState((current) =>
current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current,
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
)
const selected = (action: "update" | "ignore") => {
const selected = (action: "update" | "skip") => {
const current = state()
return current.type === "ready" && current.active === action
}
@@ -60,7 +70,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
bind: "return",
title: "Confirm update action",
group: "Dialog",
run: () => (state().type === "failed" ? dialog.clear() : run()),
run: () => (state().type === "failed" ? close() : run()),
},
{
bind: "left",
@@ -81,9 +91,9 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Update
Update available
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={close}>
esc
</text>
</box>
@@ -91,14 +101,17 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
<Switch>
<Match when={state().type === "ready"}>
<text fg={theme.text.subdued}>
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
An update is available. Applying will
{props.restart
? " restart the server and active sessions will be resumed."
: " install the update but you will need to manually restart."}
</text>
</Match>
<Match when={state().type === "installing"}>
<Spinner>Installing OpenCode {props.version}…</Spinner>
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}…</Spinner>
</Match>
<Match when={state().type === "restarting"}>
<Spinner>Restarting the background service…</Spinner>
<Spinner shimmer={theme.text.default}>Restarting the background service…</Spinner>
</Match>
<Match when={state().type === "failed"}>
<text fg={theme.text.feedback.error.default}>{failure()}</text>
@@ -114,7 +127,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => dialog.clear()}
onMouseUp={close}
>
<text fg={theme.text.action.primary.focused}>close</text>
</box>
@@ -123,19 +136,19 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
}
>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["ignore", "update"] as const}>
<For each={["skip", "update"] as const}>
{(action) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (action === "ignore") return dialog.clear()
if (action === "skip") return close()
beginInstall()
}}
>
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
{action === "update" ? "Update" : "Ignore"}
{action === "update" ? "Update" : "Skip"}
</text>
</box>
)}
+104
View File
@@ -0,0 +1,104 @@
import {
OptimizedBuffer,
RGBA,
TargetChannel,
TextRenderable,
type RenderContext,
type TextOptions,
} from "@opentui/core"
import { extend, type JSX } from "@opentui/solid"
import { splitProps } from "solid-js"
import { coast, intensityAt } from "./tab-pulse"
type ShimmerTextOptions = TextOptions & {
shimmer: RGBA
}
const DURATION = 1200
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
const CONTINUATION = 0xc0000000 | 0
class ShimmerTextRenderable extends TextRenderable {
private _shimmer = RGBA.defaultForeground()
private elapsed = 0
private scratch: OptimizedBuffer | undefined
private mask = new Float32Array(0)
private matrix = new Float32Array(16)
constructor(ctx: RenderContext, options: ShimmerTextOptions) {
super(ctx, options)
this.matrix[3] = this._shimmer.r
this.matrix[7] = this._shimmer.g
this.matrix[11] = this._shimmer.b
this.matrix[15] = 1
if (options.shimmer) this.shimmer = options.shimmer
this.live = true
}
set shimmer(value: RGBA) {
if (value.equals(this._shimmer)) return
this._shimmer = value
this.matrix[3] = value.r
this.matrix[7] = value.g
this.matrix[11] = value.b
this.requestRender()
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
this.elapsed = (this.elapsed + deltaTime) % DURATION
if (!this.scratch)
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
this.scratch.resize(this.width, this.height)
this.scratch.clear(TRANSPARENT)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
const characters = this.scratch.buffers.char
let end = 0
for (let row = 0; row < this.height; row++) {
let column = this.width
while (
column > 0 &&
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
)
column--
end = Math.max(end, column)
}
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
let strength = 0
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensityAt(column, front, 4, 18)
this.mask[cell * 3] = column
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
this.mask[cell * 3 + 2] = strength
}
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
this.markClean()
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
}
override destroy() {
this.scratch?.destroy()
this.scratch = undefined
super.destroy()
}
}
extend({ shimmer_text: ShimmerTextRenderable })
declare module "@opentui/solid" {
interface OpenTUIComponents {
shimmer_text: typeof ShimmerTextRenderable
}
}
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & { shimmer: RGBA }
export function ShimmerText(props: Props) {
const [local, text] = splitProps(props, ["shimmer"])
return <shimmer_text {...text} shimmer={local.shimmer} />
}
+26 -8
View File
@@ -1,30 +1,48 @@
import { Show } from "solid-js"
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useConfig } from "../config"
import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { registerOpencodeSpinner } from "./register-spinner"
import { SPINNER_FRAMES } from "./spinner-frames"
import { ShimmerText } from "./shimmer-text"
export { SPINNER_FRAMES } from "./spinner-frames"
registerOpencodeSpinner()
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?: RGBA }) {
const theme = useTheme()
const config = useConfig().data
const color = () => props.color ?? theme.text.subdued
const [frame, setFrame] = createSignal(0)
createEffect(() => {
if (!(config.animations ?? true) || !props.shimmer) return
const timer = setInterval(() => setFrame((value) => (value + 1) % SPINNER_FRAMES.length), 80)
onCleanup(() => clearInterval(timer))
})
return (
<Show
when={config.animations ?? true}
fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
</box>
<Show
when={props.shimmer}
fallback={
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
</box>
}
>
{(shimmer) => (
<ShimmerText fg={color()} shimmer={shimmer()}>
{SPINNER_FRAMES[frame()]} {props.children}
</ShimmerText>
)}
</Show>
</Show>
)
}
+1
View File
@@ -57,6 +57,7 @@
gap: 8px;
height: 28px;
padding: 0 12px;
padding-inline-end: 6px;
background: transparent;
border-radius: 4px;
outline: none;
+14 -3
View File
@@ -552,6 +552,17 @@
}
}
&[data-value="open-file"],
&[data-value^="file://"]
{
gap: 8px;
padding-inline-end: 4px;
[data-slot="tabs-trigger-close-button"] {
margin-inline-start: 0;
}
}
&:has([data-selected]) {
background-color: var(--v2-background-bg-layer-02);
box-shadow: none;
@@ -569,19 +580,19 @@
font-variation-settings: "slnt" 0;
font-variant-numeric: tabular-nums;
&[data-selected] {
&[data-selected]:not([data-value="open-file"]) {
color: var(--v2-text-text-base);
}
.tab-fileicon-color,
.tab-fileicon-mono,
[data-slot="icon-svg"] {
.tab-fileicon-mono {
margin-inline-start: -2px;
}
.italic {
font-style: italic !important;
font-variation-settings: "slnt" -10 !important;
padding-inline-end: 2px;
}
}
}
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
"enum": ["disable", "notify"]
},
"share": {
"type": "string",
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
"enum": ["disable", "notify"]
},
"share": {
"type": "string",
+3 -5
View File
@@ -129,15 +129,13 @@ agents.
### Updates
Control updates from the global config. Set `update` to `"disable"` to skip
updates, `"notify"` to report available updates without installing them, or
`"auto"` to automatically install compatible non-major updates.
Major updates are reported but never installed automatically.
Control update checks from the global config. Set `update` to `"disable"` to
skip them or `"notify"` to show available updates before installing them.
Project-level values are ignored.
```jsonc
{
"update": "auto",
"update": "notify",
}
```
+2 -1
View File
@@ -409,7 +409,8 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
- `disabled_providers` becomes internal deny policies for the listed providers.
- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` remains `"notify"`, and `true` maps to `"auto"`.
- `autoupdate` becomes `update`: `false` maps to `"disable"`, while `"notify"` and `true` map to `"notify"`.
- The previous V2 value `update: "auto"` is treated as `update: "notify"`.
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
`agents.title.model` instead.