mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc82a446b9 | |||
| c4bea9d558 | |||
| 1190ef3818 | |||
| 055a380e66 | |||
| 456c26a012 | |||
| 6072fc5c1b | |||
| 20df2fdba6 | |||
| f1399dcb65 | |||
| 3507bd10b6 | |||
| 492ae1fdfa | |||
| b00d8d65fe | |||
| f1366d80c8 | |||
| 549a8f826b | |||
| 59970699d0 | |||
| b35c5fc985 | |||
| 627e67323c | |||
| b97d964e5c | |||
| 3fd3e28690 | |||
| b4abeb4788 | |||
| 14deb6baf7 | |||
| ab6e18e4ff | |||
| 2b6dd2559c | |||
| 2b7bd84867 | |||
| baa54a5150 | |||
| c5704d3e3b | |||
| e3336725db | |||
| ff3ecefd24 |
@@ -124,12 +124,66 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist/cli-*
|
||||
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
sign-cli-macos:
|
||||
needs: build-cli
|
||||
runs-on: macos-26
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
|
||||
with:
|
||||
keychain: build
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist
|
||||
|
||||
- name: Sign macOS CLI binaries
|
||||
run: |
|
||||
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
|
||||
if [ -z "$identity" ]; then
|
||||
echo "Developer ID Application identity not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
found=0
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
found=1
|
||||
codesign \
|
||||
--force \
|
||||
--timestamp \
|
||||
--options runtime \
|
||||
--entitlements packages/cli/script/entitlements.plist \
|
||||
--sign "$identity" \
|
||||
"$file"
|
||||
codesign --verify --deep --strict --verbose=4 "$file"
|
||||
codesign --display --requirements - "$file"
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "No macOS CLI binaries found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
@@ -468,6 +522,7 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: ideal-pseudocode
|
||||
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
|
||||
---
|
||||
|
||||
# Ideal Pseudocode
|
||||
|
||||
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
|
||||
|
||||
## Loop
|
||||
|
||||
One function per round. Never touch code before the user picks a direction.
|
||||
|
||||
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
|
||||
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
|
||||
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
|
||||
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
|
||||
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
|
||||
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
|
||||
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
|
||||
8. **Offer the next target** and stop.
|
||||
|
||||
## Style
|
||||
|
||||
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
|
||||
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
|
||||
- Prefer early return and `if` chains for control flow; matchers are for producing values.
|
||||
- Compare names against the question each caller is actually asking; rename toward the domain noun.
|
||||
|
||||
## Example round
|
||||
|
||||
Every round should read like this (abridged from a real one, on an Effect step-runner):
|
||||
|
||||
> `runStep` it is. The ideal pseudocode:
|
||||
>
|
||||
> ```ts
|
||||
> // One logical step: one settled model call, however many attempts it takes.
|
||||
> // An attempt can end without settling in two ways:
|
||||
> // transient provider failure -> retry the same call, same assistant message
|
||||
> // compaction rewrote history -> rebuild the request and call again
|
||||
> function runStep(session, promotable, step) {
|
||||
> while (true) {
|
||||
> const result = callModel(session, promotable, step)
|
||||
>
|
||||
> if (result.completed) return result
|
||||
>
|
||||
> if (result.retryable) {
|
||||
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
|
||||
> promotable = none // never re-promote on a second attempt
|
||||
> continue
|
||||
> }
|
||||
>
|
||||
> // compaction restarted the step: fresh request from rewritten history
|
||||
> promotable = none
|
||||
> step = result.step
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Comparing against the real thing, three deltas:
|
||||
>
|
||||
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
|
||||
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
|
||||
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
|
||||
>
|
||||
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
|
||||
>
|
||||
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
|
||||
@@ -3,6 +3,7 @@ import type { UpdaterState } from "@/updater"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
export function updaterAction(state: UpdaterState | undefined) {
|
||||
if (!state) return { label: "settings.updates.action.checkNow" as const }
|
||||
@@ -31,7 +32,14 @@ export function useUpdaterAction() {
|
||||
action,
|
||||
async run() {
|
||||
const run = action().run
|
||||
if (run === "install") return platform.updater?.install()
|
||||
if (run === "install") {
|
||||
return platform.updater?.install().catch((error) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(error, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
})
|
||||
}
|
||||
if (run !== "check") return
|
||||
|
||||
const state = await platform.updater?.check()
|
||||
|
||||
@@ -31,6 +31,9 @@ export default [
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["@shikijs/stream", "katex", "marked", "marked-shiki", "remend"],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { $ } from "bun"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { brotliCompressSync, constants } from "node:zlib"
|
||||
|
||||
export async function buildAppArchive(channel: string) {
|
||||
const root = path.resolve(import.meta.dirname, "../../app")
|
||||
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
|
||||
const assets = Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await files(path.join(root, "dist")))
|
||||
.filter((key) => !key.endsWith(".map"))
|
||||
.map(async (key) => {
|
||||
const source = path.join(root, "dist", key)
|
||||
const body = Buffer.from(await Bun.file(source).arrayBuffer())
|
||||
const encoding = isText(key) ? "utf8" : "base64"
|
||||
return [key, { encoding, content: body.toString(encoding) }] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
return brotliCompressSync(JSON.stringify(assets), {
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
|
||||
}).toString("base64")
|
||||
}
|
||||
|
||||
function isText(key: string) {
|
||||
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
|
||||
}),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.toSorted()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { modelsData } from "./generate"
|
||||
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
|
||||
import { mainConfig } from "../vite.node.config"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
|
||||
const NODE_VERSION = "26.4.0"
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -55,13 +56,21 @@ const builder =
|
||||
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
|
||||
? await resolveHostNode()
|
||||
: undefined
|
||||
const appArchive = await buildAppArchive(Script.channel)
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`building cli-node-${targetName(target)}`)
|
||||
const assets = await collectNodeAssets(target)
|
||||
await rm("dist-node", { recursive: true, force: true })
|
||||
const assetHash = await hashNodeAssets(assets)
|
||||
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||
const input = {
|
||||
version: Script.version,
|
||||
channel: Script.channel,
|
||||
models: modelsData,
|
||||
assetHash,
|
||||
target,
|
||||
appArchive,
|
||||
}
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -54,6 +55,20 @@ const targets = singleFlag
|
||||
: allTargets
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
const appArchive = await buildAppArchive(Script.channel)
|
||||
const appAssetsPlugin: BunPlugin = {
|
||||
name: "opencode-app-assets",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({
|
||||
path: "opencode-app-assets",
|
||||
namespace: "opencode",
|
||||
}))
|
||||
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
|
||||
loader: "js",
|
||||
contents: `export default ${JSON.stringify(appArchive)}`,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||
@@ -80,7 +95,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [solidPlugin, parcelWatcherPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import path from "node:path"
|
||||
import { brotliDecompressSync } from "node:zlib"
|
||||
import { OPENCODE_LOCAL } from "./version"
|
||||
|
||||
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
|
||||
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
|
||||
|
||||
export const load = Effect.fn("cli.app-assets.load")(function* () {
|
||||
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
|
||||
if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
|
||||
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
|
||||
return decode(yield* sourceAssets())
|
||||
})
|
||||
|
||||
function decodeArchive(archive: string) {
|
||||
const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
|
||||
return decode(JSON.parse(body) as EncodedAssetMap)
|
||||
}
|
||||
|
||||
const sourceAssets = Effect.fnUntraced(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = path.resolve(import.meta.dirname, "../../app/dist")
|
||||
const files = yield* fs.readDirectory(root, { recursive: true })
|
||||
return Object.fromEntries(
|
||||
(yield* Effect.forEach(
|
||||
files.filter((file) => !file.endsWith(".map")),
|
||||
Effect.fnUntraced(function* (file) {
|
||||
const target = path.join(root, file)
|
||||
if ((yield* fs.stat(target)).type === "Directory") return
|
||||
const body = Buffer.from(yield* fs.readFile(target))
|
||||
const encoding = isText(file) ? "utf8" : "base64"
|
||||
return [file, { encoding, content: body.toString(encoding) }] as const
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)).filter((asset) => asset !== undefined),
|
||||
)
|
||||
})
|
||||
|
||||
function decode(assets: EncodedAssetMap): AssetMap {
|
||||
return Object.fromEntries(
|
||||
Object.entries(assets).map(([key, asset]) => [
|
||||
key,
|
||||
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function isText(file: string) {
|
||||
return file === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(file)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
}),
|
||||
Spec.make("pair", { description: "Show server pairing information" }),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
description: "Start the v2 API and web server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
|
||||
@@ -43,16 +44,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const foreground = options.mode === "default"
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||
if (
|
||||
serviceOptions !== undefined &&
|
||||
port !== undefined &&
|
||||
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
|
||||
)
|
||||
return
|
||||
const incumbent =
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
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.
|
||||
@@ -68,6 +69,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
: randomBytes(32).toString("base64url")
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const instanceID = randomUUID()
|
||||
const transform = yield* WebUi.handler()
|
||||
const server = yield* start(
|
||||
{
|
||||
app: {
|
||||
@@ -122,6 +124,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
@@ -143,7 +146,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (server === undefined) return
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
|
||||
return yield* options.mode === "service"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createHash } from "node:crypto"
|
||||
import { load, type AssetMap } from "../app-assets"
|
||||
|
||||
export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { readonly assets?: AssetMap }) {
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const assets = options?.assets
|
||||
? Effect.succeed(options.assets)
|
||||
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
|
||||
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
||||
api.pipe(
|
||||
Effect.catchIf(isRouteNotFound, () =>
|
||||
HttpServerRequest.HttpServerRequest.pipe(
|
||||
Effect.flatMap((request) => {
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
||||
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
|
||||
const key = url.pathname.replace(/^\//, "")
|
||||
const name = assets[key] !== undefined ? key : "index.html"
|
||||
const file = assets[name]
|
||||
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
||||
if (request.method !== "GET" && request.method !== "HEAD")
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
|
||||
const html = name === "index.html"
|
||||
const headers = {
|
||||
"content-type": FSUtil.mimeType(name),
|
||||
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
"content-security-policy": html
|
||||
? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString())
|
||||
: csp(),
|
||||
"x-content-type-options": "nosniff",
|
||||
}
|
||||
return Effect.succeed(
|
||||
request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }),
|
||||
)
|
||||
}
|
||||
|
||||
function isRouteNotFound(error: unknown) {
|
||||
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
|
||||
}
|
||||
|
||||
function csp(hash = "") {
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
|
||||
}
|
||||
|
||||
function cspForHtml(body: string) {
|
||||
const match = body.match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
|
||||
}
|
||||
|
||||
export * as WebUi from "./web-ui"
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "virtual:opencode-app-assets" {
|
||||
const archive: string
|
||||
export default archive
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { WebUi } from "../src/services/web-ui"
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
|
||||
afterAll(() => rm(root, { recursive: true, force: true }))
|
||||
|
||||
describe("web UI", () => {
|
||||
test("falls back from API routes to assets and the SPA index", async () => {
|
||||
const index = path.join(root, "index.html")
|
||||
const asset = path.join(root, "app.js")
|
||||
await writeFile(index, "<html><body>embedded</body></html>")
|
||||
await writeFile(asset, "console.log('embedded')")
|
||||
const assets = {
|
||||
"index.html": await Bun.file(index).text(),
|
||||
"app.js": await Bun.file(asset).text(),
|
||||
"font.woff2": new Uint8Array([0, 1, 2, 255]),
|
||||
}
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transform = yield* WebUi.handler({ assets })
|
||||
const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
|
||||
yield* http.serve(
|
||||
transform(
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const pathname = new URL(request.url, "http://localhost").pathname
|
||||
if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
|
||||
return yield* Effect.fail(
|
||||
new HttpServerError.HttpServerError({
|
||||
reason: new HttpServerError.RouteNotFound({ request }),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const origin = HttpServer.formatAddress(http.address)
|
||||
|
||||
const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
|
||||
expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
|
||||
|
||||
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
|
||||
expect(missing.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => missing.text())).toBe("")
|
||||
|
||||
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
|
||||
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
|
||||
|
||||
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
|
||||
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(
|
||||
new Uint8Array([0, 1, 2, 255]),
|
||||
)
|
||||
|
||||
const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`))
|
||||
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
|
||||
}),
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,19 @@ function rawTextPlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function appAssetsPlugin(archive: string): Plugin {
|
||||
return {
|
||||
name: "opencode:app-assets",
|
||||
resolveId(id) {
|
||||
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
|
||||
},
|
||||
load(id) {
|
||||
if (id !== "\0virtual:opencode-app-assets") return
|
||||
return `export default ${JSON.stringify(archive)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeRequirePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:runtime-require",
|
||||
@@ -212,12 +225,14 @@ export type NodeBuildInput = {
|
||||
readonly models: string
|
||||
readonly assetHash: string
|
||||
readonly target: NodeTarget
|
||||
readonly appArchive: string
|
||||
}
|
||||
|
||||
export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
return defineConfig({
|
||||
root: dir,
|
||||
plugins: [
|
||||
appAssetsPlugin(input.appArchive),
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
@@ -259,4 +274,5 @@ export default mainConfig({
|
||||
models: "undefined",
|
||||
assetHash: "local",
|
||||
target: nodeTarget(process.platform, process.arch),
|
||||
appArchive: "",
|
||||
})
|
||||
|
||||
@@ -1740,7 +1740,7 @@ export function make(options: ClientOptions) {
|
||||
request<ProjectCopyCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
successStatus: 200,
|
||||
@@ -1753,7 +1753,7 @@ export function make(options: ClientOptions) {
|
||||
request<ProjectCopyRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
@@ -1766,7 +1766,7 @@ export function make(options: ClientOptions) {
|
||||
request<ProjectCopyRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
|
||||
@@ -9310,7 +9310,7 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/experimental/project/{projectID}/copy": {
|
||||
"/api/experimental/project/{projectID}/copy": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.create",
|
||||
@@ -9536,7 +9536,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/experimental/project/{projectID}/copy/refresh": {
|
||||
"/api/experimental/project/{projectID}/copy/refresh": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.refresh",
|
||||
|
||||
@@ -25,9 +25,10 @@
|
||||
},
|
||||
"imports": {
|
||||
"#sqlite": {
|
||||
"workerd": "./src/database/sqlite.workerd.ts",
|
||||
"bun": "./src/database/sqlite.bun.ts",
|
||||
"node": "./src/database/sqlite.node.ts",
|
||||
"default": "./src/database/sqlite.bun.ts"
|
||||
"default": "./src/database/sqlite.node.ts"
|
||||
},
|
||||
"#pty": {
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
|
||||
@@ -51,6 +51,27 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/google-vertex/messages",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...(isRecord(input.settings.thinking) || typeof input.settings.effort === "string"
|
||||
? {
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
|
||||
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(input.settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
@@ -88,9 +109,13 @@ function mapBedrockSettings(
|
||||
: typeof settings.bearerToken === "string"
|
||||
? settings.bearerToken
|
||||
: undefined
|
||||
const credentials = mapBedrockCredentials(settings)
|
||||
const region = bedrockRegion(settings)
|
||||
const credentials = mapBedrockCredentials(settings, region)
|
||||
return {
|
||||
...baseSettings,
|
||||
...(typeof baseSettings.baseURL === "string" && region !== undefined
|
||||
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
|
||||
: {}),
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
@@ -155,14 +180,8 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
const region =
|
||||
typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
if (
|
||||
region === undefined ||
|
||||
typeof credentials.accessKeyId !== "string" ||
|
||||
@@ -177,6 +196,15 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
|
||||
}
|
||||
}
|
||||
|
||||
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
return typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
}
|
||||
|
||||
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
|
||||
@@ -204,13 +204,13 @@ export const layer = (options?: Options) =>
|
||||
const claude = [
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export * as Database from "./database"
|
||||
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { sqliteLayer } from "#sqlite"
|
||||
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
@@ -27,12 +28,15 @@ const databaseLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
if (supportsTuningPragmas) {
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
}
|
||||
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
|
||||
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
return { db }
|
||||
@@ -42,7 +46,7 @@ const databaseLayer = Layer.effect(
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
@@ -51,6 +55,12 @@ export function layer(options: Options = { path: ":memory:" }) {
|
||||
)
|
||||
}
|
||||
|
||||
// The database service over an injected SqlClient, for runtimes that receive
|
||||
// database storage instead of opening a filesystem path. Any client provided
|
||||
// here still goes through the pragma guards and migrations; Global is required
|
||||
// because migrations may read it (the v1 import).
|
||||
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as DatabaseMigration from "./migration"
|
||||
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import { supportsForeignKeyToggle } from "#sqlite"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
@@ -20,8 +21,10 @@ export type Migration = {
|
||||
export function apply(db: Database) {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// OpenCode owns the unprefixed table namespace. Embedders sharing this
|
||||
// database may own underscore-prefixed tables, which bootstrap ignores.
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
@@ -103,9 +106,15 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
})
|
||||
continue
|
||||
}
|
||||
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
// Durable Object SQLite rejects the foreign_keys toggle; the closest
|
||||
// allowlisted relaxation is deferring enforcement to transaction commit.
|
||||
const relaxForeignKeys = supportsForeignKeyToggle
|
||||
? db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
|
||||
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
|
||||
yield* relaxForeignKeys
|
||||
yield* apply.pipe(
|
||||
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
|
||||
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
|
||||
@@ -8,6 +8,11 @@ import { Sqlite } from "./sqlite"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
|
||||
|
||||
export const supportsTuningPragmas = true
|
||||
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
|
||||
@@ -8,6 +8,11 @@ import { Sqlite } from "./sqlite"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
|
||||
|
||||
export const supportsTuningPragmas = true
|
||||
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { drizzle } from "drizzle-orm/durable-sqlite"
|
||||
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
|
||||
// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
|
||||
// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
|
||||
export const supportsTuningPragmas = false
|
||||
|
||||
// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
|
||||
// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
|
||||
// allowlisted for migrations that must relax checking inside a transaction.
|
||||
export const supportsForeignKeyToggle = false
|
||||
|
||||
// Minimal structural types for the Durable Object storage API so this adapter
|
||||
// does not depend on @cloudflare/workers-types (whose ambient globals conflict
|
||||
// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
|
||||
type SqlStorageValue = ArrayBuffer | string | number | null
|
||||
|
||||
interface SqlStorageCursor {
|
||||
readonly columnNames: Array<string>
|
||||
raw(): IterableIterator<Array<SqlStorageValue>>
|
||||
toArray(): Array<Record<string, SqlStorageValue>>
|
||||
}
|
||||
|
||||
export interface SqlStorage {
|
||||
exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
|
||||
}
|
||||
|
||||
export interface DurableObjectStorage {
|
||||
readonly sql: SqlStorage
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
|
||||
transactionSync<T>(closure: () => T): T
|
||||
}
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
interface Config {
|
||||
readonly storage: DurableObjectStorage
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
|
||||
// transaction SQL can never run. withTransaction is replaced below with a
|
||||
// DurableObjectStorage.transaction-backed implementation; this service only
|
||||
// tracks the active transaction connection for statements and nesting checks.
|
||||
const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
|
||||
"@opencode-ai/core/database/SqliteWorkerdTransaction",
|
||||
)
|
||||
|
||||
const transactionError = (message: string) =>
|
||||
new SqlError({
|
||||
reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
|
||||
})
|
||||
|
||||
const makeWithTransaction =
|
||||
(
|
||||
storage: DurableObjectStorage,
|
||||
connection: Connection,
|
||||
semaphore: Semaphore.Semaphore,
|
||||
): SqlClient.SqlClient["withTransaction"] =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
|
||||
Effect.withFiber((fiber) => {
|
||||
const services = fiber.context
|
||||
if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
|
||||
return Effect.fail(
|
||||
transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
|
||||
)
|
||||
const effectWithTxn = Effect.provideContext(
|
||||
effect,
|
||||
Context.add(services, WorkerdTransaction, [connection, 0] as const),
|
||||
)
|
||||
return semaphore.withPermits(1)(
|
||||
Effect.callback((resume) => {
|
||||
let interrupted = false
|
||||
const promise = storage
|
||||
.transaction(
|
||||
(txn) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (interrupted) return resolve()
|
||||
resume(
|
||||
Effect.onExit(effectWithTxn, (exit) => {
|
||||
if (Exit.isFailure(exit)) txn.rollback()
|
||||
resolve()
|
||||
// wait for the transaction to complete
|
||||
return Effect.promise(() => promise)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.catch((cause) =>
|
||||
resume(
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Effect.suspend(() => {
|
||||
interrupted = true
|
||||
return Effect.promise(() => promise)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DurableObjectStorage
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
// SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
|
||||
// mode and always returns integers as numbers. Blobs come back as
|
||||
// ArrayBuffer and are normalized to Uint8Array to match the other adapters.
|
||||
function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
|
||||
const cursor = native.sql.exec(query, ...params)
|
||||
const columns = cursor.columnNames
|
||||
for (const row of cursor.raw()) {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const value = row[i]
|
||||
record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
|
||||
}
|
||||
yield record
|
||||
}
|
||||
}
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.try({
|
||||
try: () => Array.from(runIterator(query, params)),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.try({
|
||||
try: () =>
|
||||
Array.from(native.sql.exec(query, ...params).raw(), (row) =>
|
||||
row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
|
||||
),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const connection = identity<Connection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
transactionService: WorkerdTransaction,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
withTransaction: makeWithTransaction(native, connection, semaphore),
|
||||
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
|
||||
// as the drizzle session must route through withTransaction instead.
|
||||
transactionStatements: false,
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
})
|
||||
|
||||
// Defends against the shared path-based Database.layer, which passes a
|
||||
// filename instead of storage when resolved under the workerd condition.
|
||||
const nativeLayer = (config: Config) =>
|
||||
config.storage
|
||||
? Layer.succeed(Sqlite.Native, config.storage)
|
||||
: Layer.effect(
|
||||
Sqlite.Native,
|
||||
Effect.die(
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
||||
),
|
||||
)
|
||||
|
||||
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
|
||||
|
||||
const drizzleLayer = Layer.effect(
|
||||
Sqlite.Drizzle,
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DurableObjectStorage
|
||||
return drizzle(native) as unknown as Sqlite.DrizzleClient
|
||||
}),
|
||||
)
|
||||
|
||||
export const sqliteLayer = (config: Config) => {
|
||||
const native = nativeLayer(config)
|
||||
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
||||
Layer.provide(Reactivity.layer),
|
||||
)
|
||||
}
|
||||
@@ -578,10 +578,18 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
)
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx.run(sql`
|
||||
INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
|
||||
VALUES (${message.id}, ${message.session_id}, ${message.type}, ${message.seq}, ${message.time_created}, ${message.time_updated}, ${JSON.stringify(message.data)})
|
||||
`),
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${JSON.stringify(message.data)}`,
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.update(SessionTable)
|
||||
@@ -739,13 +747,18 @@ function importNextDatabase(
|
||||
)
|
||||
`)
|
||||
yield* Effect.forEach(messages, (message) =>
|
||||
tx.run(sql`
|
||||
INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
|
||||
VALUES (
|
||||
${message.id}, ${message.session_id}, ${message.type}, ${message.seq},
|
||||
${message.time_created}, ${message.time_updated}, ${message.data}
|
||||
)
|
||||
`),
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type as SessionMessage.Type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${message.data}`,
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
|
||||
@@ -126,6 +126,12 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
|
||||
const image =
|
||||
(remote.capabilities.supports.vision ?? false) ||
|
||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||
const pdf =
|
||||
(remote.capabilities.supports.vision ?? false) &&
|
||||
(remote.capabilities.limits.vision?.supported_media_types.includes("application/pdf") ?? false)
|
||||
const input = ["text"]
|
||||
if (image) input.push("image")
|
||||
if (pdf) input.push("pdf")
|
||||
const prices = remote.billing?.token_prices
|
||||
// Copilot reports AIC per billing batch; OpenCode stores USD per million tokens.
|
||||
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
|
||||
@@ -150,7 +156,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
|
||||
body: previous?.body,
|
||||
capabilities: {
|
||||
tools: remote.capabilities.supports.tool_calls,
|
||||
input: image ? ["text", "image"] : ["text"],
|
||||
input,
|
||||
output: ["text"],
|
||||
},
|
||||
variants: variants(remote, messages),
|
||||
|
||||
@@ -158,45 +158,42 @@ export const fromCatalogModel = (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
|
||||
const prepared = prepareRuntimeModel(model, credential)
|
||||
if (prepared.unresolved.length > 0)
|
||||
return Effect.fail(
|
||||
new UnresolvedProviderVariablesError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variables: prepared.unresolved,
|
||||
}),
|
||||
)
|
||||
const resolved = prepared.model
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
|
||||
resolveCatalogModel(model, credential, dependencies).pipe(
|
||||
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
const resolved = prepareRuntimeModel(model, credential)
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
}
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
}
|
||||
if (
|
||||
Provider.isAISDK(resolved.package) &&
|
||||
packageName === "@ai-sdk/openai-compatible" &&
|
||||
typeof resolved.settings?.baseURL === "string"
|
||||
) {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
@@ -208,64 +205,107 @@ export const fromCatalogModel = (
|
||||
: undefined
|
||||
const native = mapping?.package ?? resolved.package
|
||||
if (Provider.isAISDK(resolved.package) && !mapping) {
|
||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
const loadAISDK = dependencies?.loadAISDK
|
||||
if (!loadAISDK) return yield* unsupported(resolved)
|
||||
const settings = yield* prepareProviderSettings(
|
||||
resolved,
|
||||
Provider.mergeOverlay(resolved.settings, {
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
})
|
||||
}) ?? {},
|
||||
)
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = settings
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!native) return Effect.fail(unsupported(resolved))
|
||||
if (!native) return yield* unsupported(resolved)
|
||||
|
||||
const specifier = native
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const mapped = mapping?.settings ?? configured
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
const prepared = produce(model, (draft) => {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
if (typeof draft.settings?.baseURL !== "string") return
|
||||
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
|
||||
return process.env[name] ?? placeholder
|
||||
})
|
||||
})
|
||||
const baseURL = prepared.settings?.baseURL
|
||||
const unresolved =
|
||||
typeof baseURL === "string"
|
||||
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
|
||||
(name, index, names) => names.indexOf(name) === index,
|
||||
)
|
||||
: []
|
||||
return { model: prepared, unresolved }
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
model: Info,
|
||||
resolved: LanguageModel,
|
||||
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
|
||||
const baseURL = resolved.route.endpoint.baseURL
|
||||
if (typeof baseURL !== "string") return Effect.succeed(resolved)
|
||||
const failure = unresolvedProviderVariables(model, baseURL)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
|
||||
}
|
||||
|
||||
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
|
||||
if (!model.settings) return Effect.succeed(model)
|
||||
return prepareProviderSettings(model, model.settings).pipe(
|
||||
Effect.map((settings) =>
|
||||
settings === model.settings
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
draft.settings = settings
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderSettings(
|
||||
model: Info,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
|
||||
const baseURL = settings.baseURL
|
||||
if (typeof baseURL !== "string") return Effect.succeed(settings)
|
||||
return prepareProviderURL(model, baseURL).pipe(
|
||||
Effect.map((prepared) => (prepared === baseURL ? settings : { ...settings, baseURL: prepared })),
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
|
||||
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
|
||||
const failure = unresolvedProviderVariables(model, prepared)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
|
||||
}
|
||||
|
||||
function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
|
||||
if (variables.size === 0) return
|
||||
return new UnresolvedProviderVariablesError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variables: Array.from(variables),
|
||||
})
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
|
||||
@@ -52,7 +52,11 @@ export const Plugin = define({
|
||||
text,
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to inject Plan mode reminder", { sessionID: event.data.sessionID, cause }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Credential } from "../../credential"
|
||||
import { Bus } from "../../bus"
|
||||
import { CopilotModels } from "../../github-copilot/models"
|
||||
import { App } from "../../app"
|
||||
import { Agent } from "../../agent"
|
||||
import { Integration } from "../../integration"
|
||||
import { Model } from "../../model"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -242,6 +243,10 @@ export const GithubCopilotPlugin = define({
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
if (evt.agent === Agent.ID.make("title"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-background")
|
||||
if (evt.agent === Agent.ID.make("compaction"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
|
||||
const token = evt.request.headers.get("x-api-key")
|
||||
if (!token) return
|
||||
const text = yield* Effect.promise(() => evt.request.clone().text())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -11,14 +12,17 @@ import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import type { SessionMessage } from "./message"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionModelHttp } from "./model-http"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key"
|
||||
import { App } from "../app"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { toSessionError } from "./to-session-error"
|
||||
import { Token } from "../util/token"
|
||||
import type { Info } from "../model"
|
||||
import type { Info, Ref } from "../model"
|
||||
import { SessionUsage } from "./usage"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { Agent } from "../agent"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
@@ -66,16 +70,18 @@ type Dependencies = {
|
||||
readonly app: App.Info
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly config: Settings
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly model: LanguageModel
|
||||
readonly ref: Ref
|
||||
readonly cost: Info["cost"]
|
||||
}
|
||||
|
||||
@@ -85,9 +91,12 @@ export type ManualInput = {
|
||||
readonly inputID: SessionMessage.ID
|
||||
}
|
||||
|
||||
type RequiredInput = Omit<AutoInput, "ref">
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly model: LanguageModel
|
||||
readonly ref: Ref
|
||||
readonly cost: Info["cost"]
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
@@ -100,7 +109,7 @@ export type Outcome =
|
||||
| Pick<SessionMessage.CompactionFailed, "status" | "error">
|
||||
|
||||
export interface Interface {
|
||||
readonly required: (input: AutoInput) => boolean
|
||||
readonly required: (input: RequiredInput) => boolean
|
||||
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
|
||||
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
|
||||
}
|
||||
@@ -265,6 +274,13 @@ const make = (dependencies: Dependencies) => {
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
}),
|
||||
{
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.ref,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
@@ -331,6 +347,7 @@ const make = (dependencies: Dependencies) => {
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
model: input.model,
|
||||
ref: input.ref,
|
||||
cost: input.cost,
|
||||
reason: "auto",
|
||||
...content,
|
||||
@@ -342,7 +359,7 @@ const make = (dependencies: Dependencies) => {
|
||||
error,
|
||||
})
|
||||
})
|
||||
const required = (input: AutoInput) => {
|
||||
const required = (input: RequiredInput) => {
|
||||
if (!config.auto) return false
|
||||
const context = input.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
@@ -385,6 +402,7 @@ const make = (dependencies: Dependencies) => {
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
model: resolved.model,
|
||||
ref: resolved.ref,
|
||||
cost: resolved.cost,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
@@ -406,12 +424,13 @@ export const layer = Layer.effect(
|
||||
const config = yield* Config.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const app = yield* App.Metadata
|
||||
return make({ bus, llm, models, config: settings(yield* config.entries()), app })
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
|
||||
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { InstructionState } from "./instruction-state"
|
||||
@@ -11,6 +10,8 @@ import { SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
@@ -49,7 +50,7 @@ const messageRows = Effect.fnUntraced(function* (
|
||||
})
|
||||
|
||||
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
SessionMessageRow.decode(row).pipe(
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export * as SessionMessageRow from "./message-row"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { SessionMessage } from "./message"
|
||||
import type { SessionMessageTable } from "./sql"
|
||||
|
||||
export type Representation = Pick<typeof SessionMessageTable.$inferSelect, "id" | "type" | "data">
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const decodeMessageSync = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
export const decode = (row: Representation) => decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
|
||||
export const decodeSync = (row: Representation) => decodeMessageSync({ ...row.data, id: row.id, type: row.type })
|
||||
|
||||
export function encode(message: SessionMessage.Info): Representation {
|
||||
const { id, type, ...data } = encodeMessage(message)
|
||||
return { id: SessionMessage.ID.make(id), type, data }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export * as SessionModelHttp from "./model-http"
|
||||
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
|
||||
export const middleware =
|
||||
(
|
||||
hooks: PluginHooks.Interface,
|
||||
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
|
||||
): NonNullable<StreamOptions["http"]> =>
|
||||
(request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
...input,
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
...input,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
|
||||
@@ -4,8 +4,7 @@ import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { Model } from "../model"
|
||||
@@ -15,6 +14,7 @@ import { QuestionTool } from "../tool/plugin/question"
|
||||
import { Tool } from "../tool"
|
||||
import { SessionContext } from "./context"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionModelHttp } from "./model-http"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
||||
@@ -227,36 +227,11 @@ export const layer = Layer.effect(
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
http: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
|
||||
@@ -17,7 +17,6 @@ import { Bus } from "../bus"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable, SessionPendingTable } from "./sql"
|
||||
|
||||
@@ -36,6 +35,7 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
||||
const encodeUser = Schema.encodeSync(UserData)
|
||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
|
||||
|
||||
@@ -113,7 +113,7 @@ const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(func
|
||||
if (row === undefined) return undefined
|
||||
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
const message = SessionMessageRow.decodeSync(row)
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const base = { id, sessionID, timeCreated: message.time.created, delivery }
|
||||
if (message.type === "user")
|
||||
return User.make({
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Agent } from "../agent"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionPending } from "./pending"
|
||||
import { Workspace } from "../workspace"
|
||||
@@ -23,6 +22,9 @@ type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
type Usage = {
|
||||
@@ -208,15 +210,22 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
|
||||
function run(db: DatabaseService, event: MessageEvent) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => SessionMessageRow.decodeSync(row)
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const updateMessage = (message: SessionMessage.Info) => {
|
||||
if (event.durable === undefined)
|
||||
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const row = SessionMessageRow.encode(message)
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return db
|
||||
.update(SessionMessageTable)
|
||||
.set({ type: row.type, time_created: DateTime.toEpochMillis(message.time.created), data: row.data })
|
||||
.where(and(eq(SessionMessageTable.id, row.id), eq(SessionMessageTable.session_id, event.data.sessionID)))
|
||||
.set({ type, time_created: DateTime.toEpochMillis(message.time.created), data })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
@@ -334,16 +343,17 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
|
||||
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const row = SessionMessageRow.encode(message)
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return db
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: row.id,
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type: row.type,
|
||||
type,
|
||||
seq: event.durable.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data: row.data,
|
||||
data,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -8,7 +8,6 @@ import { RelativePath } from "../schema"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
|
||||
@@ -47,9 +46,10 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
const message = yield* SessionMessageRow.decode(row).pipe(Effect.orDie)
|
||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
if (message.type !== "assistant" || !message.snapshot?.start) continue
|
||||
for (const file of message.snapshot.files ?? [])
|
||||
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
|
||||
|
||||
@@ -244,7 +244,7 @@ const layer = Layer.effect(
|
||||
const model = resolved.model
|
||||
// Make room: history must fit the context window before the call. A pending manual
|
||||
// compaction owns this instead; the runner executes it between steps.
|
||||
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
|
||||
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
|
||||
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status === "completed")
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export * as SessionStore from "./store"
|
||||
|
||||
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionHistory } from "./history"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
import { fromRow } from "./info"
|
||||
@@ -52,6 +51,8 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
@@ -70,7 +71,7 @@ const layer = Layer.effect(
|
||||
return row
|
||||
? {
|
||||
sessionID: Session.ID.make(row.session_id),
|
||||
message: yield* SessionMessageRow.decode(row).pipe(Effect.orDie),
|
||||
message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
|
||||
}
|
||||
: undefined
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
@@ -9,9 +10,11 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { App } from "../app"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionModelHttp } from "./model-http"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionUsage } from "./usage"
|
||||
@@ -24,11 +27,12 @@ type Dependencies = {
|
||||
readonly app: App.Info
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly agents: Agent.Interface
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly store: SessionStore.Interface
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -85,6 +89,13 @@ const make = (dependencies: Dependencies) => {
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
}),
|
||||
{
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
@@ -135,7 +146,8 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const database = yield* Database.Service
|
||||
const app = yield* App.Metadata
|
||||
const title = make({ bus, llm, agents, models, store, app })
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const title = make({ bus, llm, agents, models, store, app, hooks })
|
||||
return Service.of({
|
||||
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
|
||||
})
|
||||
@@ -145,5 +157,14 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
|
||||
deps: [
|
||||
Bus.node,
|
||||
llmClient,
|
||||
Agent.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
Database.node,
|
||||
App.node,
|
||||
PluginHooks.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Session } from "../session"
|
||||
import { Slug } from "../util/slug"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionProjector } from "./projector"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
|
||||
@@ -48,6 +47,8 @@ const layer = Layer.effect(
|
||||
const { db } = yield* Database.Service
|
||||
const projects = yield* Project.Service
|
||||
const sessions = yield* Session.Service
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
|
||||
return Service.of({
|
||||
@@ -70,14 +71,15 @@ const layer = Layer.effect(
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* persistProject(project)
|
||||
const messages = input.data.messages.map((message, index) => {
|
||||
const row = SessionMessageRow.encode(message)
|
||||
const encoded = encodeMessage(message)
|
||||
const { id: _, type, ...data } = encoded
|
||||
return {
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type: row.type,
|
||||
type,
|
||||
seq: index + 1,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data: row.data,
|
||||
data,
|
||||
}
|
||||
})
|
||||
yield* bus
|
||||
|
||||
@@ -123,6 +123,16 @@ describe("AISDKNative", () => {
|
||||
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
|
||||
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
|
||||
)
|
||||
expect(
|
||||
map(
|
||||
"@ai-sdk/amazon-bedrock/mantle",
|
||||
{
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
"openai.gpt-5.5",
|
||||
),
|
||||
).toMatchObject({ settings: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" } })
|
||||
})
|
||||
|
||||
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
|
||||
@@ -134,8 +144,9 @@ describe("AISDKNative", () => {
|
||||
accessKeyId: "key",
|
||||
secretAccessKey: "secret",
|
||||
sessionToken: "session",
|
||||
region: "eu-west-1",
|
||||
},
|
||||
region: "eu-west-1",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
|
||||
profile: "ignored",
|
||||
credentialProvider: "ignored",
|
||||
fetch: "ignored",
|
||||
@@ -152,7 +163,7 @@ describe("AISDKNative", () => {
|
||||
sessionToken: "session",
|
||||
region: "eu-west-1",
|
||||
},
|
||||
region: "eu-west-1",
|
||||
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
|
||||
providerOptions: { openai: { store: false } },
|
||||
},
|
||||
})
|
||||
@@ -262,6 +273,35 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Vertex Anthropic settings to native Messages", () => {
|
||||
expect(
|
||||
map("@ai-sdk/google-vertex/anthropic", {
|
||||
accessToken: "vertex-token",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
headers: { "x-test": "value" },
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/google-vertex/messages",
|
||||
settings: {
|
||||
accessToken: "vertex-token",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: { "x-test": "value" },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps supported xAI settings", () => {
|
||||
expect(
|
||||
map("@ai-sdk/xai", {
|
||||
|
||||
@@ -1464,13 +1464,13 @@ describe("Config", () => {
|
||||
])
|
||||
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(globalAgents),
|
||||
AbsolutePath.make(path.join(directory, ".agents")),
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
AbsolutePath.make(path.join(directory, ".agents")),
|
||||
])
|
||||
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(globalClaude),
|
||||
AbsolutePath.make(path.join(directory, ".claude")),
|
||||
AbsolutePath.make(path.join(root, ".claude")),
|
||||
AbsolutePath.make(path.join(directory, ".claude")),
|
||||
])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual([
|
||||
"global",
|
||||
@@ -1483,11 +1483,11 @@ describe("Config", () => {
|
||||
])
|
||||
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
|
||||
AbsolutePath.make(globalClaude),
|
||||
AbsolutePath.make(path.join(directory, ".claude")),
|
||||
AbsolutePath.make(path.join(root, ".claude")),
|
||||
AbsolutePath.make(path.join(directory, ".claude")),
|
||||
AbsolutePath.make(globalAgents),
|
||||
AbsolutePath.make(path.join(directory, ".agents")),
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
AbsolutePath.make(path.join(directory, ".agents")),
|
||||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"outside",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -23,6 +24,8 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -91,6 +94,25 @@ const start = (skills: string[], directory: string) =>
|
||||
directory,
|
||||
)
|
||||
|
||||
const discover = (directory: string, global: string) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
return yield* config.entries()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
[Watcher.node, Watcher.testLayer],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
@@ -218,6 +240,37 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("prefers a worktree skill over the parent checkout copy", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const checkout = path.join(tmp.path, "repo")
|
||||
const worktree = path.join(checkout, ".worktrees", "feature")
|
||||
const parentSkills = path.join(checkout, ".agents", "skills")
|
||||
const worktreeSkills = path.join(worktree, ".agents", "skills")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(checkout, ".git"), { recursive: true })
|
||||
await fs.mkdir(path.join(parentSkills, "review"), { recursive: true })
|
||||
await fs.mkdir(path.join(worktreeSkills, "review"), { recursive: true })
|
||||
await fs.writeFile(path.join(worktree, ".git"), "gitdir: ../../../.git/worktrees/feature\n")
|
||||
await write(parentSkills, "review", "Parent checkout")
|
||||
await write(worktreeSkills, "review", "Worktree")
|
||||
})
|
||||
|
||||
const entries = yield* discover(worktree, path.join(tmp.path, "global"))
|
||||
const skill = yield* startEntries(entries, worktree)
|
||||
const review = (yield* skill.list()).find((item) => item.id === "review")
|
||||
|
||||
expect(review?.description).toBe("Worktree")
|
||||
expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps directory skills when a URL source fails", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -84,6 +84,19 @@ describe("DatabaseMigration", () => {
|
||||
).rejects.toThrow("Database is not empty and has no session table")
|
||||
})
|
||||
|
||||
test("bootstraps alongside underscore-prefixed embedder tables", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE _embedder_state (id text PRIMARY KEY)`)
|
||||
yield* DatabaseMigration.apply(db)
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual(
|
||||
{ name: "session_v2" },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("applies generic migrations once and records their order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
|
||||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
|
||||
async function job() {
|
||||
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
||||
|
||||
@@ -27,8 +27,32 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
max_context_window_tokens: 200000,
|
||||
max_output_tokens: 16384,
|
||||
max_prompt_tokens: 180000,
|
||||
vision: {
|
||||
max_prompt_image_size: 10000000,
|
||||
max_prompt_images: 10,
|
||||
supported_media_types: ["image/png", "application/pdf"],
|
||||
},
|
||||
},
|
||||
supports: { tool_calls: true, reasoning_effort: ["low", "high"] },
|
||||
supports: { tool_calls: true, vision: true, reasoning_effort: ["low", "high"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
model_picker_enabled: true,
|
||||
id: "vision-only",
|
||||
name: "Vision only",
|
||||
version: "vision-only-2026-06-01",
|
||||
capabilities: {
|
||||
family: "vision",
|
||||
limits: {
|
||||
max_output_tokens: 16384,
|
||||
max_prompt_tokens: 180000,
|
||||
vision: {
|
||||
max_prompt_image_size: 10000000,
|
||||
max_prompt_images: 10,
|
||||
supported_media_types: ["image/png"],
|
||||
},
|
||||
},
|
||||
supports: { tool_calls: true, vision: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -67,6 +91,8 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
Model.VariantID.make("low"),
|
||||
Model.VariantID.make("high"),
|
||||
])
|
||||
expect(model?.capabilities.input).toEqual(["text", "image", "pdf"])
|
||||
expect(models.get(Model.ID.make("vision-only"))?.capabilities.input).toEqual(["text", "image"])
|
||||
expect(models.get(Model.ID.make("utility"))?.enabled).toBe(false)
|
||||
expect(models.has(Model.ID.make("stale"))).toBe(false)
|
||||
expect(models.has(Model.ID.make("incomplete"))).toBe(false)
|
||||
|
||||
@@ -123,6 +123,24 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves environment templates before native providers inspect endpoints", () =>
|
||||
withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
settings: { baseURL: "https://${AZURE_HOST}/openai" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.endpoint).toMatchObject({
|
||||
baseURL: "https://resource.openai.azure.com/openai/v1",
|
||||
query: { "api-version": "v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "secret" })
|
||||
@@ -152,6 +170,46 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () =>
|
||||
withEnv({ AWS_REGION: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
providerID: Provider.ID.amazonBedrock,
|
||||
modelID: "openai.gpt-5.5",
|
||||
settings: {
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "bedrock-mantle-responses",
|
||||
endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" },
|
||||
})
|
||||
expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers the configured Mantle region over the environment", () =>
|
||||
withEnv({ AWS_REGION: "us-east-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-5.5",
|
||||
settings: {
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
@@ -265,7 +323,7 @@ describe("ModelResolver", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved provider URL variables before route construction", () =>
|
||||
it.effect("rejects unresolved variables in constructed provider routes", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -705,6 +763,57 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
|
||||
const credential = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "vertex-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
})
|
||||
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google-vertex/anthropic"), {
|
||||
modelID: "claude-sonnet-4-6",
|
||||
settings: {
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
}),
|
||||
credential,
|
||||
{
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
|
||||
return Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("claude-sonnet-4-6")
|
||||
expect(settings).toMatchObject({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
},
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
|
||||
},
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({ id: "claude-sonnet-4-6", provider: "test-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
|
||||
@@ -823,6 +932,63 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved variables before loading opaque AISDK packages", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["REQUIRED_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects placeholders introduced by environment expansion before loading providers", () =>
|
||||
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["MISSING_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved variables before loading native provider packages", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadPackage: () => Effect.die("Native package loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["REQUIRED_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects AISDK packages without an available loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -35,7 +34,7 @@ const npmLayer = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
export const PluginTestLayer = AppNodeBuilder.build(
|
||||
export const PluginTestLayer = LayerNode.compile(
|
||||
LayerNode.group([
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
|
||||
@@ -141,6 +141,32 @@ describe("GithubCopilotPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies title generation as a background interaction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_title"),
|
||||
agent: Agent.ID.make("title"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
|
||||
request: new Request("https://api.githubcopilot.com/chat/completions"),
|
||||
})
|
||||
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_compaction"),
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
|
||||
request: new Request("https://api.githubcopilot.com/responses"),
|
||||
})
|
||||
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionMessageRow } from "@opencode-ai/core/session/message-row"
|
||||
|
||||
const message = SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_row"),
|
||||
type: "synthetic",
|
||||
text: "hello",
|
||||
time: { created: DateTime.makeUnsafe(1_000) },
|
||||
})
|
||||
|
||||
test("round trips the persisted message representation", async () => {
|
||||
const row = SessionMessageRow.encode(message)
|
||||
|
||||
expect(row.id).toBe(message.id)
|
||||
expect(row.type).toBe(message.type)
|
||||
expect(row.data).toHaveProperty("text", message.text)
|
||||
expect(row.data).toHaveProperty("time.created", 1_000)
|
||||
expect(await Effect.runPromise(SessionMessageRow.decode(row))).toEqual(message)
|
||||
expect(SessionMessageRow.decodeSync(row)).toEqual(message)
|
||||
})
|
||||
|
||||
test("canonical columns override stale values in message data", () => {
|
||||
const row = SessionMessageRow.encode(message)
|
||||
const data = {
|
||||
...row.data,
|
||||
id: SessionMessage.ID.make("msg_stale"),
|
||||
type: "system" as const,
|
||||
}
|
||||
|
||||
expect(SessionMessageRow.decodeSync({ ...row, data })).toEqual(message)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { BunPlugin } from "bun"
|
||||
import { join } from "path"
|
||||
|
||||
// Pins the #sqlite subpath-import condition gate: bundling database.ts for a
|
||||
// non-Bun runtime must never reach the static `import "bun:sqlite"` in
|
||||
// sqlite.bun.ts, which crashes workerd (and any other non-Bun bundle) at
|
||||
// module load. Bare imports are externalized so only core-relative modules —
|
||||
// including the #sqlite resolution under test — end up in the bundle.
|
||||
const externalizeBare: BunPlugin = {
|
||||
name: "externalize-bare",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^[^.#/]/ }, (args) => ({ path: args.path, external: true }))
|
||||
},
|
||||
}
|
||||
|
||||
const bundle = async (conditions: Array<string>) => {
|
||||
const result = await Bun.build({
|
||||
// join over URL.pathname: on Windows the latter yields "/C:/..." which
|
||||
// module resolution rejects.
|
||||
entrypoints: [join(import.meta.dir, "../src/database/database.ts")],
|
||||
target: "browser",
|
||||
conditions,
|
||||
plugins: [externalizeBare],
|
||||
throw: false,
|
||||
})
|
||||
expect(result.logs).toEqual([])
|
||||
expect(result.success).toBe(true)
|
||||
return result.outputs[0].text()
|
||||
}
|
||||
|
||||
// Skipped on Windows: Bun.build inside `bun test` reliably panics Bun 1.3.14
|
||||
// there ("Internal assertion failure", twice in a row on CI, always at this
|
||||
// file). The assertions pin platform-independent package.json condition
|
||||
// resolution, so Linux coverage loses nothing.
|
||||
describe.skipIf(process.platform === "win32")("sqlite bundle conditions", () => {
|
||||
test("workerd conditions select the Durable Object driver and never bun:sqlite", async () => {
|
||||
const output = await bundle(["workerd"])
|
||||
expect(output).not.toContain("bun:sqlite")
|
||||
expect(output).not.toContain("node:sqlite")
|
||||
expect(output).toContain("SqliteWorkerd")
|
||||
})
|
||||
|
||||
test("default conditions fall back to node:sqlite, not bun:sqlite", async () => {
|
||||
const output = await bundle([])
|
||||
expect(output).not.toContain("bun:sqlite")
|
||||
expect(output).toContain("SqliteNode")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
|
||||
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
|
||||
// be verified without workerd or Cloudflare runtime dependencies.
|
||||
const makeFakeStorage = () => {
|
||||
const native = new Database(":memory:")
|
||||
const toSqlStorageValue = (value: unknown) => {
|
||||
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
|
||||
const buffer = new ArrayBuffer(value.byteLength)
|
||||
new Uint8Array(buffer).set(value)
|
||||
return buffer
|
||||
}
|
||||
const storage: DurableObjectStorage = {
|
||||
sql: {
|
||||
exec(query: string, ...bindings: Array<unknown>) {
|
||||
const statement = native.query(query)
|
||||
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
|
||||
const columnNames = statement.columnNames
|
||||
return {
|
||||
columnNames,
|
||||
raw: () => rows[Symbol.iterator](),
|
||||
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
|
||||
}
|
||||
},
|
||||
},
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
|
||||
native.run("BEGIN")
|
||||
let rolledBack = false
|
||||
return closure({ rollback: () => (rolledBack = true) }).then(
|
||||
(result) => {
|
||||
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
|
||||
return result
|
||||
},
|
||||
(error) => {
|
||||
native.run("ROLLBACK")
|
||||
throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
transactionSync<T>(closure: () => T): T {
|
||||
return native.transaction(closure)()
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
|
||||
|
||||
describe("sqlite.workerd", () => {
|
||||
test("executes statements with bindings and maps rows to records", async () => {
|
||||
const rows = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
|
||||
yield* sql`INSERT INTO item (id, name) VALUES (${1}, ${"one"}), (${2}, ${"two"})`
|
||||
return yield* sql<{ id: number; name: string }>`SELECT id, name FROM item ORDER BY id`
|
||||
}),
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ id: 1, name: "one" },
|
||||
{ id: 2, name: "two" },
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
|
||||
const rows = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
|
||||
yield* sql`INSERT INTO blob (data) VALUES (${new Uint8Array([1, 2, 3])})`
|
||||
return yield* sql<{ data: Uint8Array }>`SELECT data FROM blob`
|
||||
}),
|
||||
)
|
||||
expect(rows[0].data).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(rows[0].data)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("withTransaction commits on success and rolls back on failure", async () => {
|
||||
const storage = makeFakeStorage()
|
||||
const count = await run(
|
||||
storage,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
yield* sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"kept"})`)
|
||||
yield* sql
|
||||
.withTransaction(
|
||||
Effect.gen(function* () {
|
||||
yield* sql`INSERT INTO t (value) VALUES (${"discarded"})`
|
||||
return yield* Effect.fail("rollback")
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.ignore)
|
||||
return yield* sql<{ count: number }>`SELECT count(*) AS count FROM t`
|
||||
}),
|
||||
)
|
||||
expect(count[0].count).toBe(1)
|
||||
})
|
||||
|
||||
test("nested withTransaction fails with SqlError", async () => {
|
||||
const error = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
return yield* sql
|
||||
.withTransaction(sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"nested"})`))
|
||||
.pipe(Effect.flip)
|
||||
}),
|
||||
)
|
||||
expect(error).toBeInstanceOf(SqlError)
|
||||
})
|
||||
|
||||
test("boots the full database layer with migrations over injected storage", async () => {
|
||||
const storage = makeFakeStorage()
|
||||
const core = await import("@opencode-ai/core/database/database")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Layer.build(
|
||||
core.Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })), Layer.provide(tempGlobalLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
const names = storage.sql
|
||||
.exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.toArray()
|
||||
.map((row) => row.name)
|
||||
expect(names).toContain("migration")
|
||||
expect(names).toContain("session_v2")
|
||||
})
|
||||
})
|
||||
@@ -3,17 +3,20 @@ import { realpathSync } from "node:fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -36,7 +39,7 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = Session.ID.make("ses_shell_tool_test")
|
||||
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
|
||||
@@ -124,27 +127,42 @@ const executionNode = makeGlobalNode({
|
||||
deps: [Bus.node, SessionStore.node],
|
||||
})
|
||||
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
filesystem,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
]),
|
||||
[
|
||||
[SessionExecution.node, executionNode],
|
||||
[Permission.node, permission],
|
||||
[Global.node, tempGlobalLayer],
|
||||
const shellPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [
|
||||
Config.node,
|
||||
Environment.node,
|
||||
LocationMutation.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Shell.node,
|
||||
Tool.node,
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
const it = testEffect(layer)
|
||||
const nodes = LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
filesystem,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
])
|
||||
const replacements = [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Permission.node, permission],
|
||||
[Global.node, tempGlobalLayer],
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
|
||||
|
||||
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
|
||||
sessionID,
|
||||
@@ -165,9 +183,6 @@ const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
|
||||
const timeoutOutputCommand = isWindows
|
||||
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
|
||||
: "printf 'before timeout'; sleep 60"
|
||||
const steadyProgressCommand = isWindows
|
||||
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
|
||||
: "printf steady; sleep 3.4"
|
||||
const bodyExitCommand = isWindows
|
||||
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
|
||||
: "printf body && exit 7"
|
||||
@@ -203,42 +218,49 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
|
||||
})
|
||||
|
||||
describe("ShellTool", () => {
|
||||
it.live("registers and returns real successful output from the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const definition = definitions.find((tool) => tool.name === "shell")
|
||||
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
||||
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
||||
// Code Mode receives the declared output schema, including the command output text.
|
||||
expect(definition?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("shell")
|
||||
productionIt.live(
|
||||
"registers and returns real successful output from the active Location",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const definition = definitions.find((tool) => tool.name === "shell")
|
||||
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
||||
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
||||
// Code Mode receives the declared output schema, including the command output text.
|
||||
expect(definition?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("shell")
|
||||
|
||||
const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
|
||||
])
|
||||
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "shell",
|
||||
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
|
||||
},
|
||||
])
|
||||
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("resolves a relative workdir from the active Location", () =>
|
||||
@@ -577,7 +599,7 @@ describe("ShellTool", () => {
|
||||
)
|
||||
|
||||
it.live(
|
||||
"does not repeat shell ID progress",
|
||||
"reports shell ID progress once",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -587,7 +609,7 @@ describe("ShellTool", () => {
|
||||
Effect.gen(function* () {
|
||||
const updates: Tool.Metadata[] = []
|
||||
yield* executeTool(registry, {
|
||||
...call({ command: steadyProgressCommand }, "call-steady-progress"),
|
||||
...call({ command: helloCommand }, "call-shell-id-progress"),
|
||||
progress: (update) => Effect.sync(() => updates.push(update)),
|
||||
})
|
||||
expect(updates).toHaveLength(1)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -24,12 +25,13 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
const childText = "child final response"
|
||||
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
|
||||
@@ -92,23 +94,30 @@ const executionNode = makeGlobalNode({
|
||||
deps: [Bus.node, SessionStore.node],
|
||||
})
|
||||
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
[
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
)
|
||||
const subagentPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
})
|
||||
|
||||
const it = testEffect(layer)
|
||||
const nodes = LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
])
|
||||
const replacements = [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
|
||||
|
||||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -136,7 +145,7 @@ const withSubagent = (location: Location.Ref) =>
|
||||
})
|
||||
|
||||
describe("SubagentTool", () => {
|
||||
it.live("registers globally while resolving agents from the caller location", () =>
|
||||
productionIt.live("registers globally while resolving agents from the caller location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -150,7 +159,6 @@ describe("SubagentTool", () => {
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -186,7 +194,6 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -229,7 +236,6 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
@@ -270,7 +276,6 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
const progress: Tool.Metadata[] = []
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
@@ -333,7 +338,6 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -371,7 +375,6 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
const bus = yield* Bus.Service
|
||||
const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "path"
|
||||
import os from "os"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -110,7 +109,7 @@ const testGlobal = Global.layerWith({
|
||||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
|
||||
@@ -828,7 +828,7 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("imports previous V2 sessions and messages as part of the migration", async () => {
|
||||
test("imports previous V2 sessions and messages containing apostrophes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode-next.db")
|
||||
const sqlite = await import("bun:sqlite")
|
||||
@@ -864,7 +864,7 @@ describe("V1Migration database workflow", () => {
|
||||
('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21),
|
||||
('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22);
|
||||
INSERT INTO session_message VALUES
|
||||
('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next","time":{"created":12}}'),
|
||||
('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'),
|
||||
('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'),
|
||||
('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}');
|
||||
`)
|
||||
@@ -909,7 +909,7 @@ describe("V1Migration database workflow", () => {
|
||||
{
|
||||
id: "msg_next",
|
||||
seq: 4,
|
||||
data: '{"text":"from next","time":{"created":12}}',
|
||||
data: '{"text":"from next\'s history","time":{"created":12}}',
|
||||
},
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_next'`)).toEqual({
|
||||
@@ -989,7 +989,7 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces projections, updates sessions, preserves V1 rows, and checkpoints completion", async () => {
|
||||
test("replaces projections containing apostrophes and checkpoints completion", async () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
@@ -1006,7 +1006,7 @@ describe("V1Migration database workflow", () => {
|
||||
1, 2, 3, 4
|
||||
)`)
|
||||
const source = user("msg_000000000040aaaaaaaaaaaaaa")
|
||||
const sourcePart = part("prt_1", source.id, { type: "text", text: "hello" })
|
||||
const sourcePart = part("prt_1", source.id, { type: "text", text: "don't stop" })
|
||||
yield* db.run(
|
||||
sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (${source.id}, 'ses_test', 10, 11, ${source.data})`,
|
||||
)
|
||||
@@ -1029,7 +1029,7 @@ describe("V1Migration database workflow", () => {
|
||||
seq: 0,
|
||||
time_created: 10,
|
||||
time_updated: 11,
|
||||
data: '{"text":"hello","time":{"created":10}}',
|
||||
data: '{"text":"don\'t stop","time":{"created":10}}',
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
"predev": "bun ./scripts/predev.ts",
|
||||
"dev": "electron-vite dev",
|
||||
"dev": "bun ./scripts/dev.ts",
|
||||
"prebuild": "bun ./scripts/prebuild.ts",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { $ } from "bun"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { buildCliToResources, downloadCliToResources, windowsify } from "./utils"
|
||||
|
||||
type ServerSource = { type: "build" } | { type: "download"; version: string }
|
||||
type DevOptions = { server: ServerSource; electron: string[] }
|
||||
|
||||
async function main() {
|
||||
const options = selectOptions()
|
||||
await prepareDesktop()
|
||||
await prepareServer(options.server)
|
||||
await startDesktop(options.electron)
|
||||
}
|
||||
|
||||
async function prepareDesktop() {
|
||||
await $`bun run install-electron`
|
||||
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
||||
}
|
||||
|
||||
function selectOptions(): DevOptions {
|
||||
const args = process.argv.slice(2)
|
||||
const build = args.indexOf("--build-server")
|
||||
const download = args.indexOf("--download-server")
|
||||
if (build >= 0 && download >= 0) {
|
||||
throw new Error("--build-server and --download-server cannot be used together")
|
||||
}
|
||||
if (download >= 0 && !args[download + 1]) throw new Error("--download-server requires a version")
|
||||
const consumed = new Set([build, download, download >= 0 ? download + 1 : -1])
|
||||
return {
|
||||
server: download >= 0 ? { type: "download", version: args[download + 1] } : { type: "build" },
|
||||
electron: args.filter((_, index) => !consumed.has(index)),
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareServer(source: ServerSource) {
|
||||
const destination = windowsify("resources/opencode-cli-dev")
|
||||
if (source.type === "download") return downloadCliToResources(source.version, destination)
|
||||
return buildCliToResources(destination, developmentStateHome())
|
||||
}
|
||||
|
||||
function developmentStateHome() {
|
||||
const appData = (() => {
|
||||
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
|
||||
if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")
|
||||
return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
|
||||
})()
|
||||
return join(appData, "ai.opencode.desktop.dev")
|
||||
}
|
||||
|
||||
async function startDesktop(args: string[]) {
|
||||
process.env.OPENCODE_DESKTOP_ISOLATED_SERVER = "1"
|
||||
await $`electron-vite dev ${args}`
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -1,8 +0,0 @@
|
||||
import { $ } from "bun"
|
||||
import { downloadCliToResources } from "./utils"
|
||||
|
||||
await $`bun run install-electron`
|
||||
|
||||
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
||||
|
||||
await downloadCliToResources()
|
||||
@@ -69,10 +69,9 @@ export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
|
||||
return binaryConfig
|
||||
}
|
||||
|
||||
export async function downloadCliToResources(version = CLI_VERSION) {
|
||||
export async function downloadCliToResources(version = CLI_VERSION, dest = windowsify("resources/opencode-cli")) {
|
||||
const cli = getCurrentCli()
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||
const dest = windowsify("resources/opencode-cli")
|
||||
try {
|
||||
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||
await copyFile(
|
||||
@@ -82,13 +81,45 @@ export async function downloadCliToResources(version = CLI_VERSION) {
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
await prepareCli(dest)
|
||||
|
||||
console.log(`Copied ${cli.package}@${version} to ${dest}`)
|
||||
}
|
||||
|
||||
export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
|
||||
try {
|
||||
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --outdir=${directory}`.env(
|
||||
{
|
||||
...process.env,
|
||||
OPENCODE_VERSION: `0.0.0-local-${Date.now()}`,
|
||||
},
|
||||
)
|
||||
if (stateHome && (await Bun.file(dest).exists())) {
|
||||
const child = Bun.spawn([dest, "service", "stop"], {
|
||||
env: { ...process.env, XDG_STATE_HOME: stateHome },
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
const exitCode = await child.exited
|
||||
if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`)
|
||||
}
|
||||
await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
await prepareCli(dest)
|
||||
|
||||
console.log(`Built local CLI at ${dest}`)
|
||||
}
|
||||
|
||||
async function prepareCli(dest: string) {
|
||||
if (process.platform !== "win32") await chmod(dest, 0o755)
|
||||
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
|
||||
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
|
||||
}
|
||||
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
|
||||
|
||||
console.log(`Copied ${cli.package}@${version} to ${dest}`)
|
||||
}
|
||||
|
||||
export function windowsify(path: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Service } from "@opencode-ai/client/service"
|
||||
import { execFile } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
|
||||
import { chmod, copyFile, mkdir, readdir, rename, rm } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { promisify } from "node:util"
|
||||
@@ -16,12 +16,14 @@ type Logger = {
|
||||
}
|
||||
|
||||
export async function startBackgroundCli(logger: Logger) {
|
||||
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
|
||||
const bundled = app.isPackaged
|
||||
? join(process.resourcesPath, executableName())
|
||||
: join(root, "../../resources", executableName())
|
||||
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
|
||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseVersion(await run(bundled, ["--version"], logger))
|
||||
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
|
||||
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
|
||||
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
|
||||
const service = await Service.ensure({
|
||||
version,
|
||||
command: [binary, "serve", "--service"],
|
||||
@@ -33,6 +35,7 @@ export async function startBackgroundCli(logger: Logger) {
|
||||
version,
|
||||
...endpoint(service.url),
|
||||
})
|
||||
if (isolated) await cleanCliStages(binary, logger)
|
||||
return {
|
||||
url: service.url,
|
||||
username: service.auth.username,
|
||||
@@ -40,6 +43,20 @@ export async function startBackgroundCli(logger: Logger) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanCliStages(binary: string, logger: Logger) {
|
||||
const current = dirname(binary)
|
||||
const root = dirname(current)
|
||||
await Promise.all(
|
||||
(await readdir(root, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory() && join(root, entry.name) !== current)
|
||||
.map((entry) =>
|
||||
rm(join(root, entry.name), { recursive: true, force: true }).catch((error) =>
|
||||
logger.error("failed to clean staged v2 CLI", { path: join(root, entry.name), error }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function installCli(source: string, version: string, logger: Logger) {
|
||||
const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
|
||||
const destination = join(directory, executableName())
|
||||
@@ -98,3 +115,7 @@ function endpoint(url: string | undefined) {
|
||||
function executableName() {
|
||||
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
|
||||
}
|
||||
|
||||
function developmentExecutableName() {
|
||||
return process.platform === "win32" ? "opencode-cli-dev.exe" : "opencode-cli-dev"
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "./onboarding"
|
||||
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
|
||||
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import { registerUpdaterIpc } from "./updater-ipc"
|
||||
import { safeWebContentsURL } from "./window-state"
|
||||
import {
|
||||
getLastFocusedWindow,
|
||||
@@ -283,7 +284,6 @@ const main = Effect.gen(function* () {
|
||||
setDisplayBackend: async () => undefined,
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
updater,
|
||||
showUpdater: () => showUpdaterDialog(updater, true),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
exportDebugLogs: () => exportDebugLogs(),
|
||||
@@ -292,6 +292,7 @@ const main = Effect.gen(function* () {
|
||||
if (setNativeTranslations(bundle)) createMenu(menuDeps)
|
||||
},
|
||||
})
|
||||
registerUpdaterIpc(updater)
|
||||
registerWslIpcHandlers(wslServers)
|
||||
void updater.start()
|
||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
setTitlebar,
|
||||
updateTitlebar,
|
||||
} from "./windows"
|
||||
import type { UpdaterController } from "./updater-controller"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
import { createDesktopDraftStore } from "./draft-store"
|
||||
import { nativeT } from "./native-translations"
|
||||
|
||||
@@ -46,7 +44,6 @@ type Deps = {
|
||||
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
updater: UpdaterController
|
||||
showUpdater: () => Promise<void> | void
|
||||
setBackgroundColor: (color: string) => void
|
||||
exportDebugLogs: () => Promise<string>
|
||||
@@ -56,8 +53,6 @@ type Deps = {
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
|
||||
const updaterSubscriptions = createUpdaterSubscriptions()
|
||||
app.once("will-quit", updaterSubscriptions.clear)
|
||||
app.on("before-quit", () => drafts.flush())
|
||||
app.once("will-quit", () => drafts.close())
|
||||
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
|
||||
@@ -80,20 +75,6 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
)
|
||||
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
||||
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
||||
ipcMain.handle("updater-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
updaterSubscriptions.set(
|
||||
id,
|
||||
deps.updater.subscribe((state) => {
|
||||
if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
|
||||
event.sender.send("updater-state", state)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
|
||||
})
|
||||
ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
|
||||
ipcMain.handle("updater-check", () => deps.updater.check())
|
||||
ipcMain.handle("updater-install", () => deps.updater.install())
|
||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
||||
ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) =>
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { createUpdaterController, type UpdaterPlatform, type UpdaterReadyRecord } from "./updater-controller"
|
||||
|
||||
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
|
||||
const calls: string[] = []
|
||||
const backend: UpdaterBackend = {
|
||||
async checkForUpdates() {
|
||||
const platform: UpdaterPlatform = {
|
||||
async checkForUpdate() {
|
||||
calls.push("check")
|
||||
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
|
||||
return "2.0.0"
|
||||
},
|
||||
async downloadUpdate() {
|
||||
async stageUpdate() {
|
||||
calls.push("download")
|
||||
},
|
||||
quitAndInstall() {
|
||||
installAndRestart() {
|
||||
calls.push("install")
|
||||
return new Promise<never>(() => {})
|
||||
},
|
||||
}
|
||||
let ready = input?.ready
|
||||
const controller = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: input?.currentVersion ?? "1.0.0",
|
||||
backend,
|
||||
platform,
|
||||
lifecycle: {
|
||||
async prepareToRestart() {
|
||||
calls.push("prepare")
|
||||
},
|
||||
},
|
||||
persistence: {
|
||||
get: () => ready,
|
||||
set: (value) => {
|
||||
@@ -29,9 +35,6 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord })
|
||||
ready = undefined
|
||||
},
|
||||
},
|
||||
stop: async () => {
|
||||
calls.push("stop")
|
||||
},
|
||||
})
|
||||
return { controller, calls, getReady: () => ready }
|
||||
}
|
||||
@@ -76,36 +79,81 @@ describe("updater controller", () => {
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
})
|
||||
|
||||
test("returns to ready when quitAndInstall returns without exiting", async () => {
|
||||
test("starts installing synchronously and coalesces restart requests", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
|
||||
await app.controller.install()
|
||||
const first = app.controller.install()
|
||||
const second = app.controller.install()
|
||||
|
||||
expect(app.calls).toEqual(["check", "download", "stop", "install"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
expect(first).toBe(second)
|
||||
await Promise.resolve()
|
||||
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("returns to ready when installation cannot start", async () => {
|
||||
test("does not check for updates while installation is in progress", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
void app.controller.install()
|
||||
|
||||
await app.controller.check()
|
||||
|
||||
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("returns to ready when installation fails", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
const error = new Error("install failed")
|
||||
const failed = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: "1.0.0",
|
||||
backend: {
|
||||
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
|
||||
downloadUpdate: async () => {},
|
||||
quitAndInstall() {},
|
||||
platform: {
|
||||
checkForUpdate: async () => "2.0.0",
|
||||
stageUpdate: async () => {},
|
||||
installAndRestart: () => Promise.reject(error),
|
||||
},
|
||||
lifecycle: { prepareToRestart: async () => {} },
|
||||
persistence: { get: () => undefined, set() {}, clear() {} },
|
||||
stop: async () => {
|
||||
throw new Error("stop failed")
|
||||
},
|
||||
})
|
||||
await failed.start()
|
||||
|
||||
await expect(failed.install()).rejects.toThrow("stop failed")
|
||||
await expect(failed.install()).rejects.toThrow("install failed")
|
||||
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("allows a state subscriber to retry after installation fails", async () => {
|
||||
let attempts = 0
|
||||
let sawInstalling = false
|
||||
let retry: Promise<void> | undefined
|
||||
const failed = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: "1.0.0",
|
||||
platform: {
|
||||
checkForUpdate: async () => "2.0.0",
|
||||
stageUpdate: async () => {},
|
||||
installAndRestart() {
|
||||
attempts++
|
||||
if (attempts === 1) return Promise.reject(new Error("install failed"))
|
||||
return new Promise<never>(() => {})
|
||||
},
|
||||
},
|
||||
lifecycle: { prepareToRestart: async () => {} },
|
||||
persistence: { get: () => undefined, set() {}, clear() {} },
|
||||
})
|
||||
failed.subscribe((state) => {
|
||||
if (state.status === "installing") sawInstalling = true
|
||||
if (!sawInstalling || state.status !== "ready" || retry) return
|
||||
retry = failed.install()
|
||||
})
|
||||
await failed.start()
|
||||
|
||||
await expect(failed.install()).rejects.toThrow("install failed")
|
||||
|
||||
expect(retry).toBeDefined()
|
||||
expect(attempts).toBe(2)
|
||||
expect(failed.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,14 @@ export type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
|
||||
export type UpdaterReadyRecord = { version: string }
|
||||
|
||||
export type UpdaterBackend = {
|
||||
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
|
||||
downloadUpdate(): Promise<unknown>
|
||||
quitAndInstall(): void
|
||||
export type UpdaterPlatform = {
|
||||
checkForUpdate(): Promise<string | undefined>
|
||||
stageUpdate(): Promise<unknown>
|
||||
installAndRestart(): Promise<never>
|
||||
}
|
||||
|
||||
export type UpdaterLifecycle = {
|
||||
prepareToRestart(): Promise<void>
|
||||
}
|
||||
|
||||
type UpdaterPersistence = {
|
||||
@@ -19,13 +23,14 @@ type UpdaterPersistence = {
|
||||
export function createUpdaterController(input: {
|
||||
enabled: boolean
|
||||
currentVersion: string
|
||||
backend: UpdaterBackend
|
||||
platform?: UpdaterPlatform
|
||||
lifecycle: UpdaterLifecycle
|
||||
persistence: UpdaterPersistence
|
||||
stop: () => Promise<void>
|
||||
log?: (message: string, data?: object) => void
|
||||
}) {
|
||||
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
|
||||
let pending: Promise<UpdaterState> | undefined
|
||||
let installing: Promise<void> | undefined
|
||||
const listeners = new Set<(state: UpdaterState) => void>()
|
||||
|
||||
const transition = (next: UpdaterState) => {
|
||||
@@ -37,20 +42,21 @@ export function createUpdaterController(input: {
|
||||
|
||||
const check = () => {
|
||||
if (!input.enabled) return Promise.resolve(state)
|
||||
if (state.status === "ready") return Promise.resolve(state)
|
||||
const platform = input.platform
|
||||
if (!platform) return Promise.resolve(state)
|
||||
if (state.status === "ready" || state.status === "installing") return Promise.resolve(state)
|
||||
if (pending) return pending
|
||||
|
||||
pending = (async () => {
|
||||
transition({ status: "checking" })
|
||||
const result = await input.backend.checkForUpdates()
|
||||
const version = result?.updateInfo?.version
|
||||
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
|
||||
const version = await platform.checkForUpdate()
|
||||
if (!version || version === input.currentVersion) {
|
||||
await input.persistence.clear()
|
||||
return transition({ status: "up-to-date" })
|
||||
}
|
||||
|
||||
transition({ status: "downloading", version })
|
||||
await input.backend.downloadUpdate()
|
||||
await platform.stageUpdate()
|
||||
await input.persistence.set({ version })
|
||||
return transition({ status: "ready", version })
|
||||
})()
|
||||
@@ -63,6 +69,33 @@ export function createUpdaterController(input: {
|
||||
return pending
|
||||
}
|
||||
|
||||
const install = () => {
|
||||
if (installing) return installing
|
||||
if (state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
|
||||
|
||||
const version = startInstalling(state.version)
|
||||
installing = restartWithUpdate(version)
|
||||
return installing
|
||||
}
|
||||
|
||||
const startInstalling = (version: string) => {
|
||||
transition({ status: "installing", version })
|
||||
return version
|
||||
}
|
||||
|
||||
const restartWithUpdate = (version: string) =>
|
||||
prepareAndRestart().catch((error) => {
|
||||
installing = undefined
|
||||
transition({ status: "ready", version })
|
||||
throw error
|
||||
})
|
||||
|
||||
const prepareAndRestart = async () => {
|
||||
if (!input.platform) throw new Error("Updater is disabled")
|
||||
await input.lifecycle.prepareToRestart()
|
||||
await input.platform.installAndRestart()
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener: (state: UpdaterState) => void) {
|
||||
@@ -76,21 +109,7 @@ export function createUpdaterController(input: {
|
||||
return check()
|
||||
},
|
||||
check,
|
||||
async install() {
|
||||
if (state.status !== "ready") throw new Error("Update is not ready to install")
|
||||
const version = state.version
|
||||
transition({ status: "installing", version })
|
||||
await input
|
||||
.stop()
|
||||
.then(() => {
|
||||
input.backend.quitAndInstall()
|
||||
transition({ status: "ready", version })
|
||||
})
|
||||
.catch((error) => {
|
||||
transition({ status: "ready", version })
|
||||
throw error
|
||||
})
|
||||
},
|
||||
install,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { app, ipcMain } from "electron"
|
||||
import type { UpdaterController } from "./updater-controller"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
|
||||
export function registerUpdaterIpc(controller: UpdaterController) {
|
||||
const subscriptions = createUpdaterSubscriptions()
|
||||
app.once("will-quit", subscriptions.clear)
|
||||
|
||||
ipcMain.handle("updater-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
subscriptions.set(
|
||||
id,
|
||||
controller.subscribe((state) => {
|
||||
if (event.sender.isDestroyed()) return subscriptions.delete(id)
|
||||
event.sender.send("updater-state", state)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => subscriptions.delete(id))
|
||||
})
|
||||
ipcMain.handle("updater-unsubscribe", (event) => subscriptions.delete(event.sender.id))
|
||||
ipcMain.handle("updater-check", () => controller.check())
|
||||
ipcMain.handle("updater-install", () => controller.install())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { app, autoUpdater } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { getLogger } from "./logging"
|
||||
import { setAppQuitting } from "./windows"
|
||||
import type { UpdaterPlatform } from "./updater-controller"
|
||||
|
||||
const updateClient = pkg.autoUpdater
|
||||
const restartTimeout = 10_000
|
||||
|
||||
export function createUpdaterPlatform(logger: ReturnType<typeof getLogger>): UpdaterPlatform {
|
||||
configureUpdater(logger)
|
||||
autoUpdater.on("before-quit-for-update", () => setAppQuitting())
|
||||
|
||||
return {
|
||||
async checkForUpdate() {
|
||||
const result = await updateClient.checkForUpdates()
|
||||
if (!result?.isUpdateAvailable) return
|
||||
return result.updateInfo.version
|
||||
},
|
||||
stageUpdate,
|
||||
installAndRestart: () => installAndRestart(logger),
|
||||
}
|
||||
}
|
||||
|
||||
function configureUpdater(logger: ReturnType<typeof getLogger>) {
|
||||
updateClient.logger = logger
|
||||
updateClient.channel = "latest"
|
||||
updateClient.allowPrerelease = false
|
||||
updateClient.allowDowngrade = true
|
||||
updateClient.autoDownload = false
|
||||
updateClient.autoInstallOnAppQuit = process.platform === "darwin"
|
||||
logger.log("auto updater configured", {
|
||||
channel: updateClient.channel,
|
||||
allowPrerelease: updateClient.allowPrerelease,
|
||||
allowDowngrade: updateClient.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
function stageUpdate() {
|
||||
if (process.platform !== "darwin") return updateClient.downloadUpdate()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
autoUpdater.removeListener("update-downloaded", complete)
|
||||
updateClient.removeListener("error", fail)
|
||||
}
|
||||
const complete = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const fail = (error: Error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
autoUpdater.once("update-downloaded", complete)
|
||||
updateClient.once("error", fail)
|
||||
void updateClient.downloadUpdate().catch(fail)
|
||||
})
|
||||
}
|
||||
|
||||
function installAndRestart(logger: ReturnType<typeof getLogger>) {
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logger.error("update restart did not start")
|
||||
fail(new Error())
|
||||
}, restartTimeout)
|
||||
const started = () => {
|
||||
clearTimeout(timeout)
|
||||
autoUpdater.removeListener("before-quit-for-update", started)
|
||||
}
|
||||
const fail = (error: Error) => {
|
||||
clearTimeout(timeout)
|
||||
autoUpdater.removeListener("before-quit-for-update", started)
|
||||
updateClient.removeListener("error", fail)
|
||||
setAppQuitting(false)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
autoUpdater.once("before-quit-for-update", started)
|
||||
updateClient.once("error", fail)
|
||||
try {
|
||||
updateClient.quitAndInstall()
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,51 +1,21 @@
|
||||
import { app, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { getLogger } from "./logging"
|
||||
import { getStore } from "./store"
|
||||
import { setAppQuitting } from "./windows"
|
||||
import { nativeT } from "./native-translations"
|
||||
import { createUpdaterPlatform } from "./updater-platform"
|
||||
|
||||
const { autoUpdater } = pkg
|
||||
const key = "ready"
|
||||
|
||||
export function setupAutoUpdater(stop: () => Promise<void>) {
|
||||
export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
|
||||
const logger = getLogger()
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.channel = "latest"
|
||||
autoUpdater.allowPrerelease = false
|
||||
autoUpdater.allowDowngrade = true
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
logger.log("auto updater configured", {
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
|
||||
const store = getStore("opencode.updater")
|
||||
return createUpdaterController({
|
||||
enabled: UPDATER_ENABLED,
|
||||
currentVersion: app.getVersion(),
|
||||
backend: {
|
||||
checkForUpdates: () => autoUpdater.checkForUpdates(),
|
||||
downloadUpdate: () => autoUpdater.downloadUpdate(),
|
||||
quitAndInstall: () => {
|
||||
// quitAndInstall closes all windows before emitting before-quit, so
|
||||
// flag the quit first to keep window ids persisted for restore.
|
||||
setAppQuitting()
|
||||
try {
|
||||
autoUpdater.quitAndInstall()
|
||||
} catch (error) {
|
||||
// The install failed and the app keeps running; clear the flag so
|
||||
// deliberate window closes prune ids again.
|
||||
setAppQuitting(false)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
|
||||
lifecycle: { prepareToRestart },
|
||||
persistence: {
|
||||
get() {
|
||||
const value = store.get(key)
|
||||
@@ -55,7 +25,6 @@ export function setupAutoUpdater(stop: () => Promise<void>) {
|
||||
set: (value) => store.set(key, value),
|
||||
clear: () => store.delete(key),
|
||||
},
|
||||
stop,
|
||||
log: (message, data) => logger.log(message, data),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,24 +150,89 @@ export interface Page {
|
||||
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
|
||||
}
|
||||
|
||||
type PromptFooterInput = { readonly sessionID?: string; readonly mode: "normal" | "shell" }
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
* may render around, inside, or take over. Paths are absolute and
|
||||
* dot-separated, and a path contains every path it prefixes — replacing
|
||||
* `prompt.footer` owns everything under `prompt.footer.*`.
|
||||
*
|
||||
* Each slot publishes an input: reactive props passed to every claim render
|
||||
* targeting it. Inputs carry only what the SDK cannot answer — instance
|
||||
* identity and client-local state. Paths and their inputs are documented
|
||||
* API: coarse, few, and kept stable across host refactors.
|
||||
*/
|
||||
export interface SlotMap {
|
||||
readonly app: Readonly<Record<string, never>>
|
||||
readonly "home.footer": Readonly<Record<string, never>>
|
||||
readonly "prompt.footer.end": {
|
||||
readonly sessionID?: string
|
||||
readonly mode: "normal" | "shell"
|
||||
}
|
||||
readonly "session.composer.top": {
|
||||
readonly sessionID: string
|
||||
}
|
||||
readonly "sidebar.content": {
|
||||
readonly sessionID: string
|
||||
}
|
||||
readonly "prompt.footer": PromptFooterInput
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
|
||||
export type SlotName = keyof SlotMap
|
||||
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
|
||||
/**
|
||||
* One contribution to the slot tree. Exactly one placement key names an
|
||||
* absolute target path:
|
||||
* - `prepend` / `append`: first/last inside the target's boundary
|
||||
* - `before` / `after`: siblings adjacent to the target, outside its boundary
|
||||
* - `replace`: take over the target. The boundary itself survives — siblings
|
||||
* anchored `before`/`after` it still compose — but the original content and
|
||||
* every claim inside the boundary are suppressed and recorded, never
|
||||
* silently dropped. At the same target the last-enabled claim wins; an
|
||||
* ancestor replacement beats a descendant one regardless of enable order.
|
||||
*
|
||||
* A claim aimed at a path the host no longer publishes degrades: additive
|
||||
* claims append to the nearest surviving ancestor, replacements are
|
||||
* suppressed. Several claims at one anchor coexist in plugin enable order.
|
||||
*
|
||||
* `render` receives the target slot's input, reactively. The `?: never`
|
||||
* fields make the variants mutually exclusive: a claim with two placement
|
||||
* keys is a type error, not a silent priority pick.
|
||||
*/
|
||||
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
|
||||
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (
|
||||
| {
|
||||
readonly prepend: Path
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly append: Path
|
||||
readonly prepend?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly before: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly after: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly replace: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
}
|
||||
)
|
||||
: never
|
||||
|
||||
export interface App {
|
||||
readonly version: string
|
||||
@@ -394,7 +459,8 @@ export interface UI {
|
||||
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
|
||||
close(sessionID?: string): boolean
|
||||
}
|
||||
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
|
||||
/** Claims a place in the slot tree; see SlotClaim. */
|
||||
readonly slot: (claim: SlotClaim) => () => void
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
|
||||
@@ -10930,7 +10930,7 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/experimental/project/{projectID}/copy": {
|
||||
"/api/experimental/project/{projectID}/copy": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.create",
|
||||
@@ -11156,7 +11156,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/experimental/project/{projectID}/copy/refresh": {
|
||||
"/api/experimental/project/{projectID}/copy/refresh": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.refresh",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const root = "/experimental/project/:projectID/copy"
|
||||
const root = "/api/experimental/project/:projectID/copy"
|
||||
|
||||
export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
|
||||
{
|
||||
|
||||
@@ -31,6 +31,8 @@ type App = Effect.Effect<
|
||||
HttpServerRequest.HttpServerRequest | Scope.Scope
|
||||
>
|
||||
|
||||
export type Transform = (app: App) => App
|
||||
|
||||
const errorResponseLogger = HttpMiddleware.make((app) =>
|
||||
HttpMiddleware.logger(
|
||||
Effect.tap(app, (response) =>
|
||||
@@ -42,6 +44,7 @@ const errorResponseLogger = HttpMiddleware.make((app) =>
|
||||
export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
options: ServerOptions,
|
||||
lifecycle?: Lifecycle<E, R>,
|
||||
transform?: Transform,
|
||||
) {
|
||||
const password = options.password
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
@@ -101,7 +104,8 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
Effect.provideService(Scope.Scope, applicationScope),
|
||||
)
|
||||
}
|
||||
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
|
||||
const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
|
||||
}).pipe(
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("allows browser preflight requests without credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
})
|
||||
const server = yield* ServerProcess.start<never, never>(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
},
|
||||
undefined,
|
||||
(api) =>
|
||||
api.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound",
|
||||
() => Effect.succeed(HttpServerResponse.text("fallback")),
|
||||
),
|
||||
),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
|
||||
method: "OPTIONS",
|
||||
@@ -40,5 +50,13 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
expect(health.status).toBe(200)
|
||||
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}),
|
||||
)
|
||||
expect(missing.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => missing.text())).toBe("fallback")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import MarkdownWorkerUrl from "./markdown.worker.ts?worker&url"
|
||||
import {
|
||||
applyMarkdownWorkerResponse,
|
||||
shouldReleaseMarkdownWorkerState,
|
||||
@@ -117,7 +116,7 @@ function getWorker() {
|
||||
if (worker) return worker
|
||||
if (disabled) throw new MarkdownWorkerUnavailableError(disabled.message)
|
||||
try {
|
||||
worker = new Worker(MarkdownWorkerUrl, { type: "module" })
|
||||
worker = new Worker(new URL("./markdown.worker.ts", import.meta.url), { type: "module" })
|
||||
} catch (error) {
|
||||
disabled = error instanceof Error ? error : new Error(String(error))
|
||||
throw new MarkdownWorkerUnavailableError(disabled.message)
|
||||
|
||||
@@ -87,7 +87,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { tuiPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, PluginSlot } from "./plugin/render"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
|
||||
@@ -882,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
{
|
||||
name: "server.pair",
|
||||
title: "Pair device",
|
||||
slash: { name: "pair" },
|
||||
slash: { name: "pair", aliases: ["web"] },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogPair credentials={props.pair} />)
|
||||
},
|
||||
@@ -1240,7 +1240,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<PluginSlot name="app" input={{}} mode="all" />
|
||||
<Slot path="app" />
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -39,6 +39,13 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
password: props.credentials?.password ?? "",
|
||||
}
|
||||
})
|
||||
const localhost = createMemo(() => {
|
||||
const value = info()?.urls[0]
|
||||
if (!value) return ""
|
||||
const url = new URL(value)
|
||||
url.hostname = "localhost"
|
||||
return url.toString().replace(/\/$/, "")
|
||||
})
|
||||
const horizontal = createMemo(() => dimensions().width >= 96)
|
||||
const content = () => {
|
||||
const value = info()
|
||||
@@ -46,6 +53,10 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
return (
|
||||
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text.subdued}>This device</text>
|
||||
<text fg={theme.text.default}>{localhost()}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.text.subdued}>URLs</text>
|
||||
<For each={value.urls}>{(url) => <text fg={theme.text.default}>{url}</text>}</For>
|
||||
|
||||
@@ -58,7 +58,7 @@ import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import {
|
||||
deduplicatePromptImages,
|
||||
@@ -1469,6 +1469,7 @@ export function Prompt(props: PromptProps) {
|
||||
animationsEnabled,
|
||||
)
|
||||
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
@@ -1778,77 +1779,78 @@ export function Prompt(props: PromptProps) {
|
||||
/>
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
|
||||
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
<Slot path="prompt.footer" input={footerInput()}>
|
||||
<Slot path="prompt.footer.status" input={footerInput()}>
|
||||
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<text
|
||||
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
>
|
||||
esc{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<Spinner color={theme.hue.accent[500]}>
|
||||
{progress()}
|
||||
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Slot>
|
||||
<Slot path="prompt.footer.file" input={footerInput()}>
|
||||
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||
{(file) => (
|
||||
<text
|
||||
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
|
||||
>
|
||||
esc{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
{file()}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<Spinner color={theme.hue.accent[500]}>
|
||||
{progress()}
|
||||
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||
{(file) => (
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
|
||||
>
|
||||
{file()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<PluginSlot
|
||||
name="prompt.footer.end"
|
||||
input={{ sessionID: props.sessionID, mode: store.mode }}
|
||||
mode="replace"
|
||||
/>
|
||||
</Show>
|
||||
</Slot>
|
||||
</Slot>
|
||||
</box>
|
||||
</box>
|
||||
<Autocomplete
|
||||
|
||||
@@ -62,6 +62,10 @@ function View(props: { context: Plugin.Context }) {
|
||||
export default Plugin.define({
|
||||
id: "opencode.home-footer",
|
||||
setup(context) {
|
||||
context.ui.slot("home.footer", () => <View context={context} />)
|
||||
// Root takeover: an external plugin replacing home.footer wins (last-
|
||||
// enabled) and this builtin shows as suppressed, not silently gone.
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({ append: "home.footer", render: () => <View context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,8 +85,9 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
export default Plugin.define({
|
||||
id: "opencode.prompt-footer",
|
||||
setup(context) {
|
||||
context.ui.slot("prompt.footer.end", (props) => (
|
||||
<PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />
|
||||
))
|
||||
context.ui.slot({
|
||||
append: "prompt.footer",
|
||||
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,6 +44,9 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-context",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
render: (props) => <SidebarContext context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -19,6 +19,8 @@ function View(props: { context: Plugin.Context }) {
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar-footer",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.footer", () => <View context={context} />)
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -73,6 +73,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-mcp",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
render: (props) => <View context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1090,6 +1090,6 @@ export default Plugin.define({
|
||||
name: ROUTE,
|
||||
render: () => <DiffViewer context={context} />,
|
||||
})
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,6 +85,6 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
export default Plugin.define({
|
||||
id,
|
||||
setup(context) {
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -137,6 +137,6 @@ export default Plugin.define({
|
||||
return <StorybookIndex context={context} />
|
||||
},
|
||||
})
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Placement, PlacementKind } from "./structure"
|
||||
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClient } from "../context/client"
|
||||
@@ -23,13 +24,25 @@ import { abbreviateHome } from "../util/path-format"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
|
||||
// Slot inputs erased to their union: the registry stores one render shape
|
||||
// regardless of which path a claim targets.
|
||||
export type SlotRender = (input: SlotMap[SlotPath]) => JSX.Element
|
||||
|
||||
// A registered claim as stored by the plugin provider's registry.
|
||||
export type RegisteredSlot = {
|
||||
readonly placement: Placement
|
||||
readonly render: SlotRender
|
||||
}
|
||||
|
||||
const placements = ["prepend", "append", "before", "after", "replace"] as const satisfies readonly PlacementKind[]
|
||||
|
||||
// The provider's registration store, narrowed to what a plugin context needs:
|
||||
// route/slot registration lands there, but ordering and lifecycle stay owned
|
||||
// by the provider.
|
||||
export type Registry = {
|
||||
has(kind: "routes" | "slots" | "markdown", name: string): boolean
|
||||
set(kind: "routes", name: string, page: Page): void
|
||||
set(kind: "slots", name: string, slot: Slot): void
|
||||
set(kind: "slots", name: string, claim: RegisteredSlot): void
|
||||
set(kind: "markdown", name: string, render: MarkdownCodeBlockRenderer): void
|
||||
remove(kind: "routes" | "slots" | "markdown", name: string): void
|
||||
active(): boolean
|
||||
@@ -70,6 +83,7 @@ export function createPluginContext(input: {
|
||||
}): Context {
|
||||
const host = input.host
|
||||
let context: Context
|
||||
let claims = 0
|
||||
// Every dialog and registered render is wrapped so plugin components can
|
||||
// reach their own context through usePlugin().
|
||||
const provide = (render: () => JSX.Element) => (
|
||||
@@ -184,12 +198,20 @@ export function createPluginContext(input: {
|
||||
return true
|
||||
},
|
||||
},
|
||||
slot(name, render) {
|
||||
if (input.registry.has("slots", name)) throw new Error(`Slot already registered: ${name}`)
|
||||
// The registration map erases the slot-specific input type.
|
||||
input.registry.set("slots", name, ((slotInput: SlotMap[typeof name]) =>
|
||||
provide(() => render(slotInput))) as Slot)
|
||||
return registration("slots", name)
|
||||
slot(value: SlotClaim) {
|
||||
// Keys are counter-suffixed so one plugin may claim several places;
|
||||
// order within the plugin is registration order.
|
||||
const key = `slot#${claims++}`
|
||||
// Exactly one placement kind, enforced at runtime for untyped plugins.
|
||||
const kinds = placements.filter((item) => value[item] !== undefined)
|
||||
if (kinds.length !== 1) throw new Error("Slot claim requires exactly one placement key")
|
||||
const kind = kinds[0]
|
||||
input.registry.set("slots", key, {
|
||||
placement: { kind, target: value[kind] as string },
|
||||
// The registration map erases the path-specific input type.
|
||||
render: (slotInput) => provide(() => (value.render as SlotRender)(slotInput)),
|
||||
})
|
||||
return registration("slots", key)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ import {
|
||||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
import { useConfig } from "../config"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose } from "./api"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
|
||||
|
||||
@@ -46,9 +47,11 @@ type Value = {
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: <Name extends SlotName>(
|
||||
name: Name,
|
||||
) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
|
||||
readonly slots: {
|
||||
// A mounted <Slot> instance registers its path; the disposer unregisters.
|
||||
readonly register: (path: string) => () => void
|
||||
readonly resolved: () => ReturnType<typeof resolveSlots<SlotRender>>
|
||||
}
|
||||
readonly markdown: () => MarkdownOptions["renderNode"]
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
@@ -62,7 +65,7 @@ type Registration = {
|
||||
options?: Readonly<Record<string, any>>
|
||||
active: boolean
|
||||
routes: Record<string, Page>
|
||||
slots: Record<string, Slot>
|
||||
slots: Record<string, RegisteredSlot>
|
||||
markdown: Record<string, MarkdownCodeBlockRenderer>
|
||||
cleanups: Dispose[]
|
||||
}
|
||||
@@ -119,8 +122,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
owned,
|
||||
registry: {
|
||||
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
|
||||
set: (kind: "routes" | "slots" | "markdown", name: string, value: Page | Slot | MarkdownCodeBlockRenderer) =>
|
||||
setStore("registrations", id, kind, name, () => value),
|
||||
set: (
|
||||
kind: "routes" | "slots" | "markdown",
|
||||
name: string,
|
||||
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer,
|
||||
) => setStore("registrations", id, kind, name, () => value),
|
||||
remove: (kind, name) =>
|
||||
setStore(
|
||||
"registrations",
|
||||
@@ -387,7 +393,44 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
|
||||
setStore("states", reconcileStore(states))
|
||||
}
|
||||
const slotItems = new WeakMap<Slot, { readonly id: string; readonly render: Slot }>()
|
||||
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
|
||||
// The mounted slot tree: path -> live <Slot> instance count. Reference
|
||||
// counted because the same path can be mounted several times (one composer
|
||||
// footer per session tab); a path exists while any instance is mounted.
|
||||
const [mounted, setMounted] = createStore<Record<string, number>>({})
|
||||
const registerSlot = (slotPath: string) => {
|
||||
setMounted(slotPath, (count) => (count ?? 0) + 1)
|
||||
return () =>
|
||||
setMounted(
|
||||
produce((counts) => {
|
||||
const count = counts[slotPath]
|
||||
if (count && count > 1) counts[slotPath] = count - 1
|
||||
else delete counts[slotPath]
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Claims come back in enable order: registration-store key order across
|
||||
// plugins (generations preserve key positions in place), then registration
|
||||
// order within one plugin. The resolver's last-wins rules depend on it.
|
||||
const claims = createMemo(() =>
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) =>
|
||||
Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
|
||||
// Rows downstream diff by reference; a stable claim per render
|
||||
// function keeps untouched plugins' slot rows (and their state)
|
||||
// alive across other plugins' reloads.
|
||||
const cached = slotItems.get(slot.render)
|
||||
if (cached) return cached
|
||||
// Placements are immutable once registered; unwrap the store proxy
|
||||
// so resolver reads don't subscribe tracked scopes.
|
||||
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
|
||||
slotItems.set(slot.render, item)
|
||||
return item
|
||||
}),
|
||||
),
|
||||
)
|
||||
// Object.keys tracks the store's keys node only: refcount changes on an
|
||||
// already-mounted path (a second tab's composer) skip re-resolution.
|
||||
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(config.data.plugins ?? []),
|
||||
@@ -436,19 +479,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
active: plugin.active,
|
||||
})),
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
slot: (name) =>
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) => {
|
||||
const render = registration.active ? registration.slots[name] : undefined
|
||||
if (!render) return []
|
||||
// <For> diffs rows by reference; a stable wrapper per render
|
||||
// function keeps untouched plugins' slot rows (and their state)
|
||||
// alive across other plugins' reloads.
|
||||
const cached = slotItems.get(render)
|
||||
if (cached) return [cached]
|
||||
const item = { id, render }
|
||||
slotItems.set(render, item)
|
||||
return [item]
|
||||
}),
|
||||
slots: { register: registerSlot, resolved },
|
||||
markdown,
|
||||
// Manual dialog toggles join the same chain as reconciles so a
|
||||
// toggle mid-reload cannot mix registrations across generations.
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import {
|
||||
createComponent,
|
||||
createContext,
|
||||
createMemo,
|
||||
ErrorBoundary,
|
||||
For,
|
||||
mergeProps,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
useContext,
|
||||
type JSX,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import { isShallowEqual } from "remeda"
|
||||
import type { SlotMap, SlotPath } from "@opencode-ai/plugin/tui/context"
|
||||
import type { SlotRender } from "./api"
|
||||
import { contains, emptySlotted, type Claim } from "./structure"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -64,31 +70,75 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginSlot<Name extends SlotName>(props: {
|
||||
readonly name: Name
|
||||
readonly input: SlotMap[Name]
|
||||
readonly mode: "all" | "replace"
|
||||
}) {
|
||||
// The nearest enclosing slot's path. Root slots mount outside any provider.
|
||||
const SlotParent = createContext<string>()
|
||||
|
||||
// `input` is required exactly when the path publishes a non-empty input.
|
||||
type SlotProps<Path extends SlotPath> = ParentProps<{ readonly path: Path }> &
|
||||
({} extends SlotMap[Path] ? { readonly input?: SlotMap[Path] } : { readonly input: SlotMap[Path] })
|
||||
|
||||
// One named boundary of the host UI's slot tree. The host's own content are
|
||||
// the children; every active plugin claim targeting this path resolves into
|
||||
// siblings around it, contributions inside it, or one takeover of it.
|
||||
// Placement policy lives in resolveSlots; this component only renders its
|
||||
// own path's buckets.
|
||||
export function Slot<Path extends SlotPath>(props: SlotProps<Path>) {
|
||||
const plugins = usePlugin()
|
||||
const renderers = createMemo(() => {
|
||||
const items = plugins.slot(props.name)
|
||||
if (props.mode === "replace") return items.slice(-1)
|
||||
return items
|
||||
})
|
||||
// A slot's path is its identity for the whole mount; instances are
|
||||
// reference-counted so the same path may be mounted several times (one
|
||||
// composer footer per session tab).
|
||||
const path = props.path
|
||||
// Paths are declared, not inferred: nesting under the wrong parent would
|
||||
// silently publish a mislocated public path, so containment fails loudly
|
||||
// at mount. Only host code can trip this — plugins cannot mount slots.
|
||||
const parent = useContext(SlotParent)
|
||||
if (parent !== undefined && !contains(parent, path)) {
|
||||
throw new Error(`Slot "${path}" is mounted inside "${parent}" but its path does not extend it`)
|
||||
}
|
||||
onCleanup(plugins.slots.register(path))
|
||||
const input = () => (props as { readonly input?: SlotMap[Path] }).input ?? ({} as SlotMap[Path])
|
||||
const slotted = createMemo(
|
||||
() => plugins.slots.resolved().slotted.get(path) ?? emptySlotted<SlotRender>(),
|
||||
emptySlotted<SlotRender>(),
|
||||
// Claim objects are reference-stable across resolutions, so a bucketwise
|
||||
// comparison makes a claim change elsewhere in the tree a no-op here.
|
||||
{
|
||||
equals: (a, b) =>
|
||||
isShallowEqual(a.before, b.before) &&
|
||||
isShallowEqual(a.prepend, b.prepend) &&
|
||||
isShallowEqual(a.append, b.append) &&
|
||||
isShallowEqual(a.after, b.after) &&
|
||||
a.replace === b.replace,
|
||||
},
|
||||
)
|
||||
// Component semantics: the render body runs once and untracked, so state
|
||||
// created inside is stable while the slot input stays reactive through the
|
||||
// merged getter.
|
||||
const contribution = (claim: Claim<SlotRender>) => (
|
||||
<PluginBoundary id={claim.plugin} where={`slot ${path}`}>
|
||||
{createComponent(claim.render, mergeProps(input))}
|
||||
</PluginBoundary>
|
||||
)
|
||||
return (
|
||||
<For each={renderers()}>
|
||||
{(item) => (
|
||||
<PluginBoundary id={item.id} where={`slot ${props.name}`}>
|
||||
{
|
||||
// Component semantics: the render body runs once and untracked, so
|
||||
// signals and intervals created inside are stable, while props stay
|
||||
// reactive through the merged getter. A bare item.render(props.input)
|
||||
// call would run inside the host's tracked scope and re-execute the
|
||||
// whole body (resetting plugin state) on every tracked read.
|
||||
createComponent(item.render, mergeProps(() => props.input) as SlotMap[Name])
|
||||
<>
|
||||
<For each={slotted().before}>{contribution}</For>
|
||||
{/* before/after are siblings outside the boundary, so outside the provider. */}
|
||||
<SlotParent.Provider value={path}>
|
||||
<Show
|
||||
keyed
|
||||
when={slotted().replace}
|
||||
fallback={
|
||||
<>
|
||||
<For each={slotted().prepend}>{contribution}</For>
|
||||
{props.children}
|
||||
<For each={slotted().append}>{contribution}</For>
|
||||
</>
|
||||
}
|
||||
</PluginBoundary>
|
||||
)}
|
||||
</For>
|
||||
>
|
||||
{contribution}
|
||||
</Show>
|
||||
</SlotParent.Provider>
|
||||
<For each={slotted().after}>{contribution}</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Pure resolution of the slot tree: the mounted slot paths plus plugin claims
|
||||
// in, per-path placement buckets plus diagnostics out. No solid, no I/O —
|
||||
// every policy rule (replacement takeover, hierarchy-beats-timeline,
|
||||
// last-enabled-wins, missing-target degradation) is testable as a data
|
||||
// transform.
|
||||
|
||||
export type PlacementKind = "prepend" | "append" | "before" | "after" | "replace"
|
||||
|
||||
// Normalized from the public SlotClaim shape by the plugin API: exactly one
|
||||
// placement kind, the target path erased to a string so the resolver stays
|
||||
// independent of the slot map.
|
||||
export type Placement = { readonly kind: PlacementKind; readonly target: string }
|
||||
|
||||
// One plugin's registered slot claim, in enable order within the claims array.
|
||||
export type Claim<Render> = {
|
||||
readonly key: string
|
||||
readonly plugin: string
|
||||
readonly placement: Placement
|
||||
readonly render: Render
|
||||
}
|
||||
|
||||
// Everything one mounted slot renders besides its own children: siblings
|
||||
// around the boundary, contributions inside it, and at most one takeover.
|
||||
export type Slotted<Render> = {
|
||||
readonly before: ReadonlyArray<Claim<Render>>
|
||||
readonly prepend: ReadonlyArray<Claim<Render>>
|
||||
readonly append: ReadonlyArray<Claim<Render>>
|
||||
readonly after: ReadonlyArray<Claim<Render>>
|
||||
readonly replace?: Claim<Render>
|
||||
}
|
||||
|
||||
export type Suppressed<Render> = {
|
||||
readonly claim: Claim<Render>
|
||||
// The winning claim for a conflict or boundary suppression; absent when a
|
||||
// replacement's target no longer exists (missing replacements never degrade).
|
||||
readonly by?: Claim<Render>
|
||||
}
|
||||
|
||||
export type Degraded<Render> = {
|
||||
readonly claim: Claim<Render>
|
||||
// The surviving ancestor path the claim was appended to.
|
||||
readonly to: string
|
||||
}
|
||||
|
||||
const EMPTY: Slotted<never> = { before: [], prepend: [], append: [], after: [] }
|
||||
|
||||
export function emptySlotted<Render>(): Slotted<Render> {
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
// The tree's one containment rule: a path contains every path it prefixes.
|
||||
// Shared with the <Slot> mount assertion so the spellings cannot drift.
|
||||
export function contains(ancestor: string, path: string) {
|
||||
return path.startsWith(ancestor + ".")
|
||||
}
|
||||
|
||||
// `paths` is the set of currently mounted slot paths; `claims` is every
|
||||
// active claim in plugin enable order. The result maps each targeted path to
|
||||
// its placement buckets — untargeted paths are absent and render as empty.
|
||||
export function resolveSlots<Render>(input: {
|
||||
readonly paths: ReadonlySet<string>
|
||||
readonly claims: ReadonlyArray<Claim<Render>>
|
||||
}): {
|
||||
readonly slotted: ReadonlyMap<string, Slotted<Render>>
|
||||
readonly suppressed: ReadonlyArray<Suppressed<Render>>
|
||||
readonly degraded: ReadonlyArray<Degraded<Render>>
|
||||
} {
|
||||
const suppressed: Suppressed<Render>[] = []
|
||||
const degraded: Degraded<Render>[] = []
|
||||
|
||||
// Pass 1 — replacement boundaries. Per target the last-enabled claim wins;
|
||||
// then an accepted boundary swallows every replacement strictly inside it,
|
||||
// regardless of enable order (hierarchy beats timeline).
|
||||
const winners = new Map<string, Claim<Render>>()
|
||||
for (const claim of input.claims) {
|
||||
if (claim.placement.kind !== "replace") continue
|
||||
if (!input.paths.has(claim.placement.target)) {
|
||||
suppressed.push({ claim })
|
||||
continue
|
||||
}
|
||||
const prior = winners.get(claim.placement.target)
|
||||
if (prior) suppressed.push({ claim: prior, by: claim })
|
||||
winners.set(claim.placement.target, claim)
|
||||
}
|
||||
const boundaries = new Map<string, Claim<Render>>()
|
||||
const containing = (path: string) => {
|
||||
for (const [boundary, winner] of boundaries) if (contains(boundary, path)) return winner
|
||||
return undefined
|
||||
}
|
||||
// Shallow boundaries first, so a nested replacement meets its container.
|
||||
for (const [path, winner] of [...winners].sort((a, b) => depth(a[0]) - depth(b[0]))) {
|
||||
const outer = containing(path)
|
||||
if (outer) {
|
||||
suppressed.push({ claim: winner, by: outer })
|
||||
continue
|
||||
}
|
||||
boundaries.set(path, winner)
|
||||
}
|
||||
|
||||
// Pass 2 — additive claims, in enable order. A claim whose target sits
|
||||
// inside a replaced boundary is suppressed; a claim whose target is gone
|
||||
// degrades to appending on the nearest surviving ancestor.
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{ before: Claim<Render>[]; prepend: Claim<Render>[]; append: Claim<Render>[]; after: Claim<Render>[] }
|
||||
>()
|
||||
const bucket = (path: string) => {
|
||||
const existing = buckets.get(path)
|
||||
if (existing) return existing
|
||||
const fresh = { before: [], prepend: [], append: [], after: [] }
|
||||
buckets.set(path, fresh)
|
||||
return fresh
|
||||
}
|
||||
for (const claim of input.claims) {
|
||||
const kind = claim.placement.kind
|
||||
if (kind === "replace") continue
|
||||
const target = claim.placement.target
|
||||
// Inside placements targeting a replaced boundary are part of its
|
||||
// contents; sibling placements on the boundary itself stay outside it.
|
||||
const inside = kind === "prepend" || kind === "append" ? boundaries.get(target) : undefined
|
||||
const outer = inside ?? containing(target)
|
||||
if (outer) {
|
||||
suppressed.push({ claim, by: outer })
|
||||
continue
|
||||
}
|
||||
if (input.paths.has(target)) {
|
||||
bucket(target)[kind].push(claim)
|
||||
continue
|
||||
}
|
||||
const ancestor = survivingAncestor(target, input.paths)
|
||||
if (ancestor === undefined) {
|
||||
suppressed.push({ claim })
|
||||
continue
|
||||
}
|
||||
// The ancestor is never a replaced boundary (containing() caught that).
|
||||
// Appending is load-bearing: a parent slot registers before its children
|
||||
// and <Slot> renders append after them, so the transient degradation
|
||||
// during a nested mount never instantiates anything.
|
||||
degraded.push({ claim, to: ancestor })
|
||||
bucket(ancestor).append.push(claim)
|
||||
}
|
||||
|
||||
const slotted = new Map<string, Slotted<Render>>()
|
||||
for (const [path, lists] of buckets) slotted.set(path, lists)
|
||||
for (const [path, winner] of boundaries) slotted.set(path, { ...(buckets.get(path) ?? EMPTY), replace: winner })
|
||||
return { slotted, suppressed, degraded }
|
||||
}
|
||||
|
||||
function depth(path: string) {
|
||||
return path.split(".").length
|
||||
}
|
||||
|
||||
function survivingAncestor(path: string, paths: ReadonlySet<string>) {
|
||||
for (let index = path.lastIndexOf("."); index !== -1; index = path.lastIndexOf(".", index - 1)) {
|
||||
const ancestor = path.slice(0, index)
|
||||
if (paths.has(ancestor)) return ancestor
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useEditorContext } from "../context/editor"
|
||||
import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
import { FormPrompt } from "./session/form"
|
||||
import { PluginSlot } from "../plugin/render"
|
||||
import { Slot } from "../plugin/render"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
let once = false
|
||||
@@ -91,7 +91,7 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
||||
<Slot path="home.footer" />
|
||||
</box>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
|
||||
@@ -86,7 +86,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
@@ -1088,7 +1088,7 @@ export function Session() {
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Slot path="session.composer.top" input={{ sessionID: route.sessionID }} />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useData } from "../../context/data"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useConfig } from "../../config"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
@@ -52,12 +52,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
|
||||
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<PluginSlot name="sidebar.footer" input={{}} mode="replace" />
|
||||
<Slot path="sidebar.footer" />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
KeymapCommand,
|
||||
KeymapLayer,
|
||||
Page,
|
||||
SlotClaim,
|
||||
Route,
|
||||
Slot,
|
||||
} from "@opencode-ai/plugin/tui/context"
|
||||
import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
@@ -143,7 +143,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
const commands = new Map<string, KeymapCommand>()
|
||||
let current = initialRoute ?? startRoute
|
||||
let renderDiff: Page["render"] | undefined
|
||||
let renderCommands: Slot | undefined
|
||||
let renderCommands: SlotClaim<"app">["render"] | undefined
|
||||
let vcsDiffInput: unknown
|
||||
const config = createTuiResolvedConfig()
|
||||
const transport = createFetch((url) => {
|
||||
@@ -200,8 +200,8 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
},
|
||||
current: () => current,
|
||||
},
|
||||
slot(_name: string, render: Slot) {
|
||||
renderCommands = render
|
||||
slot(claim: SlotClaim<"app">) {
|
||||
renderCommands = claim.render
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -140,8 +140,11 @@ import { appendFile } from "node:fs/promises"
|
||||
export default {
|
||||
id: "test.crash",
|
||||
setup: async (context: any) => {
|
||||
context.ui.slot("home.footer", () => {
|
||||
throw new Error("boom")
|
||||
context.ui.slot({
|
||||
replace: "home.footer",
|
||||
render: () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
})
|
||||
await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
|
||||
},
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SlotClaim } from "@opencode-ai/plugin/tui/context"
|
||||
import { resolveSlots, type Claim, type PlacementKind } from "../src/plugin/structure"
|
||||
|
||||
// Type-level canaries, checked by `bun typecheck`: exactly one placement key,
|
||||
// absolute paths only, and the render input follows the targeted path.
|
||||
export const canaries = () => {
|
||||
const claims: SlotClaim[] = []
|
||||
claims.push({ append: "prompt.footer", render: (input) => (input.mode === "shell" ? null : null) })
|
||||
claims.push({ after: "prompt.footer.status", render: () => null })
|
||||
// @ts-expect-error two placement keys cannot coexist
|
||||
claims.push({ append: "prompt.footer", before: "prompt.footer.status", render: () => null })
|
||||
// @ts-expect-error replace does not combine with an anchor
|
||||
claims.push({ replace: "prompt.footer.status", after: "prompt.footer.file", render: () => null })
|
||||
// @ts-expect-error targets must be absolute published paths
|
||||
claims.push({ after: "status", render: () => null })
|
||||
// @ts-expect-error the render input is the targeted slot's input
|
||||
claims.push({ append: "prompt.footer", render: (input: { mode: number }) => null })
|
||||
return claims
|
||||
}
|
||||
|
||||
// The resolver is generic over render values; strings make layout assertions
|
||||
// read as layouts. Placements are written in the public claim shape and
|
||||
// normalized here, like the plugin API does.
|
||||
function claim(plugin: string, placement: Partial<Record<PlacementKind, string>>, render: string): Claim<string> {
|
||||
const kind = (["prepend", "append", "before", "after", "replace"] as const).find((item) => placement[item])!
|
||||
return { key: `${plugin}/${render}`, plugin, placement: { kind, target: placement[kind]! }, render }
|
||||
}
|
||||
|
||||
// A host slot tree for tests: a node's children are its child slots, a leaf's
|
||||
// content is its own name. Mirrors how nested <Slot> components mount.
|
||||
type Node = { readonly path: string; readonly children?: ReadonlyArray<Node> }
|
||||
|
||||
function paths(nodes: ReadonlyArray<Node>, into = new Set<string>()): Set<string> {
|
||||
for (const node of nodes) {
|
||||
into.add(node.path)
|
||||
paths(node.children ?? [], into)
|
||||
}
|
||||
return into
|
||||
}
|
||||
|
||||
// Fold the tree with a resolution into the flat render order, mirroring the
|
||||
// <Slot> component: before + (replace | prepend + own content + append) + after.
|
||||
function layout(nodes: ReadonlyArray<Node>, resolved: ReturnType<typeof resolveSlots<string>>): ReadonlyArray<string> {
|
||||
return nodes.flatMap((node) => {
|
||||
const slotted = resolved.slotted.get(node.path)
|
||||
const own = node.children ? layout(node.children, resolved) : [leafName(node.path)]
|
||||
const inside = slotted?.replace
|
||||
? [slotted.replace.render]
|
||||
: [
|
||||
...(slotted?.prepend ?? []).map((item) => item.render),
|
||||
...own,
|
||||
...(slotted?.append ?? []).map((item) => item.render),
|
||||
]
|
||||
return [
|
||||
...(slotted?.before ?? []).map((item) => item.render),
|
||||
...inside,
|
||||
...(slotted?.after ?? []).map((item) => item.render),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function leafName(path: string) {
|
||||
return path.slice(path.lastIndexOf(".") + 1)
|
||||
}
|
||||
|
||||
function resolve(tree: ReadonlyArray<Node>, claims: ReadonlyArray<Claim<string>>) {
|
||||
return resolveSlots({ paths: paths(tree), claims })
|
||||
}
|
||||
|
||||
const footer: Node[] = [
|
||||
{
|
||||
path: "prompt.footer",
|
||||
children: [{ path: "prompt.footer.status" }, { path: "prompt.footer.file" }],
|
||||
},
|
||||
]
|
||||
|
||||
const tree: Node[] = [
|
||||
{
|
||||
path: "prompt.footer",
|
||||
children: [
|
||||
{ path: "prompt.footer.left", children: [{ path: "prompt.footer.left.mode" }] },
|
||||
{
|
||||
path: "prompt.footer.right",
|
||||
children: [
|
||||
{ path: "prompt.footer.right.directory" },
|
||||
{ path: "prompt.footer.right.model" },
|
||||
{ path: "prompt.footer.right.tokens" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
test("no claims renders the host tree in order", () => {
|
||||
const result = resolve(footer, [])
|
||||
expect(layout(footer, result)).toEqual(["status", "file"])
|
||||
expect(result.suppressed).toEqual([])
|
||||
expect(result.degraded).toEqual([])
|
||||
})
|
||||
|
||||
test("prepend and append land inside a boundary's edges, several in enable order", () => {
|
||||
const result = resolve(footer, [
|
||||
claim("a", { append: "prompt.footer" }, "a1"),
|
||||
claim("b", { prepend: "prompt.footer" }, "b1"),
|
||||
claim("a", { append: "prompt.footer" }, "a2"),
|
||||
])
|
||||
expect(layout(footer, result)).toEqual(["b1", "status", "file", "a1", "a2"])
|
||||
})
|
||||
|
||||
test("before and after anchor to a slot, wherever the host keeps it", () => {
|
||||
const result = resolve(footer, [
|
||||
claim("a", { after: "prompt.footer.status" }, "chip"),
|
||||
claim("b", { before: "prompt.footer.status" }, "vim"),
|
||||
])
|
||||
expect(layout(footer, result)).toEqual(["vim", "status", "chip", "file"])
|
||||
})
|
||||
|
||||
test("a missing anchor degrades to the nearest surviving ancestor's end", () => {
|
||||
const result = resolve(footer, [claim("a", { after: "prompt.footer.tokens" }, "chip")])
|
||||
expect(layout(footer, result)).toEqual(["status", "file", "chip"])
|
||||
expect(result.degraded).toEqual([
|
||||
{ claim: claim("a", { after: "prompt.footer.tokens" }, "chip"), to: "prompt.footer" },
|
||||
])
|
||||
})
|
||||
|
||||
test("an additive claim with no surviving ancestor is suppressed", () => {
|
||||
const result = resolve(footer, [claim("a", { append: "session.composer.top" }, "chip")])
|
||||
expect(layout(footer, result)).toEqual(["status", "file"])
|
||||
expect(result.suppressed).toEqual([{ claim: claim("a", { append: "session.composer.top" }, "chip") }])
|
||||
})
|
||||
|
||||
test("a missing replacement is suppressed, never degraded into a widget", () => {
|
||||
const result = resolve(footer, [claim("a", { replace: "prompt.footer.tokens" }, "cost")])
|
||||
expect(layout(footer, result)).toEqual(["status", "file"])
|
||||
expect(result.suppressed).toEqual([{ claim: claim("a", { replace: "prompt.footer.tokens" }, "cost") }])
|
||||
expect(result.degraded).toEqual([])
|
||||
})
|
||||
|
||||
test("replacing a slot swaps content but keeps the boundary and its outside anchors", () => {
|
||||
const fancy = claim("a", { replace: "prompt.footer.status" }, "fancy-status")
|
||||
const result = resolve(footer, [fancy, claim("b", { after: "prompt.footer.status" }, "chip")])
|
||||
expect(layout(footer, result)).toEqual(["fancy-status", "chip", "file"])
|
||||
expect(result.suppressed).toEqual([])
|
||||
})
|
||||
|
||||
test("inside contributions to a replaced boundary are suppressed", () => {
|
||||
const takeover = claim("a", { replace: "prompt.footer" }, "powerline")
|
||||
const badge = claim("b", { append: "prompt.footer" }, "badge")
|
||||
const result = resolve(footer, [badge, takeover])
|
||||
expect(layout(footer, result)).toEqual(["powerline"])
|
||||
expect(result.suppressed).toEqual([{ claim: badge, by: takeover }])
|
||||
})
|
||||
|
||||
test("same target: the last-enabled replacement wins and the loser is recorded", () => {
|
||||
const first = claim("a", { replace: "prompt.footer.status" }, "first")
|
||||
const second = claim("b", { replace: "prompt.footer.status" }, "second")
|
||||
const result = resolve(footer, [first, second])
|
||||
expect(layout(footer, result)).toEqual(["second", "file"])
|
||||
expect(result.suppressed).toEqual([{ claim: first, by: second }])
|
||||
})
|
||||
|
||||
test("container takeover suppresses everything anchored in the subtree", () => {
|
||||
const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right")
|
||||
const chip = claim("pr", { after: "prompt.footer.right.model" }, "chip")
|
||||
const inner = claim("x", { replace: "prompt.footer.right.tokens" }, "cost")
|
||||
const result = resolve(tree, [takeover, chip, inner])
|
||||
expect(layout(tree, result)).toEqual(["mode", "my-right"])
|
||||
expect(result.suppressed).toEqual([
|
||||
{ claim: inner, by: takeover },
|
||||
{ claim: chip, by: takeover },
|
||||
])
|
||||
})
|
||||
|
||||
test("hierarchy beats timeline: an ancestor takeover wins over a later descendant claim", () => {
|
||||
// The descendant replace was enabled after the container takeover; the
|
||||
// container still wins because its path contains the descendant's.
|
||||
const inner = claim("x", { replace: "prompt.footer.right.model" }, "swap-model")
|
||||
const outer = claim("theme", { replace: "prompt.footer.right" }, "my-right")
|
||||
const result = resolve(tree, [outer, inner])
|
||||
expect(layout(tree, result)).toEqual(["mode", "my-right"])
|
||||
expect(result.suppressed).toEqual([{ claim: inner, by: outer }])
|
||||
})
|
||||
|
||||
test("root takeover: nothing original survives, all inside claims suppressed", () => {
|
||||
const theme = claim("powerline", { replace: "prompt.footer" }, "powerline")
|
||||
const chip = claim("pr", { append: "prompt.footer" }, "chip")
|
||||
const result = resolve(tree, [chip, theme])
|
||||
expect(layout(tree, result)).toEqual(["powerline"])
|
||||
expect(result.suppressed).toEqual([{ claim: chip, by: theme }])
|
||||
})
|
||||
|
||||
test("a degraded claim landing inside a replaced boundary is suppressed, not shown", () => {
|
||||
const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right")
|
||||
const stray = claim("pr", { after: "prompt.footer.right.gone" }, "chip")
|
||||
const result = resolve(tree, [takeover, stray])
|
||||
expect(layout(tree, result)).toEqual(["mode", "my-right"])
|
||||
expect(result.suppressed).toEqual([{ claim: stray, by: takeover }])
|
||||
expect(result.degraded).toEqual([])
|
||||
})
|
||||
|
||||
test("anchors on a container wrap its whole span", () => {
|
||||
const result = resolve(tree, [
|
||||
claim("a", { before: "prompt.footer.right" }, "divider"),
|
||||
claim("b", { after: "prompt.footer.right" }, "clock"),
|
||||
])
|
||||
expect(layout(tree, result)).toEqual(["mode", "divider", "directory", "model", "tokens", "clock"])
|
||||
})
|
||||
@@ -22,6 +22,15 @@ export default defineConfig({
|
||||
branch: "dev",
|
||||
dir: "packages/www",
|
||||
},
|
||||
theme: {
|
||||
background: "#131010",
|
||||
fonts: {
|
||||
body: "ibm-plex-mono",
|
||||
display: "ibm-plex-mono",
|
||||
mono: "ibm-plex-mono",
|
||||
},
|
||||
mode: "dark",
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{ label: "Docs", path: "/" },
|
||||
@@ -34,6 +43,19 @@ export default defineConfig({
|
||||
route: "/api",
|
||||
spec: "./openapi.json",
|
||||
},
|
||||
seo: {
|
||||
og: {
|
||||
fonts: [{ name: "IBM Plex Mono", weight: [400, 600] }],
|
||||
logo: "public/assets/logo-dark.svg",
|
||||
palette: {
|
||||
accent: "#b7b1b1",
|
||||
background: "#131010",
|
||||
border: "#343030",
|
||||
foreground: "#f1ecec",
|
||||
muted: "#b7b1b1",
|
||||
},
|
||||
},
|
||||
},
|
||||
deployment: {
|
||||
adapter: "cloudflare",
|
||||
base: "/v2/",
|
||||
|
||||
@@ -161,8 +161,8 @@ Skills are keyed by ID. If several sources define the same ID, the later source
|
||||
wins. Sources are registered in this order, from lower to higher precedence:
|
||||
|
||||
1. Built-in skills
|
||||
2. `.claude/skills` sources, global first and then from the current directory upward
|
||||
3. `.agents/skills` sources, global first and then from the current directory upward
|
||||
2. `.claude/skills` sources, global first and then from the farthest ancestor toward the current directory
|
||||
3. `.agents/skills` sources, global first and then from the farthest ancestor toward the current directory
|
||||
4. `~/.config/opencode/skills`
|
||||
5. Project `.opencode/skills`, from the project root toward the current directory
|
||||
6. Explicit `skills` config entries, in config priority and array order
|
||||
|
||||
@@ -10930,7 +10930,7 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/experimental/project/{projectID}/copy": {
|
||||
"/api/experimental/project/{projectID}/copy": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.create",
|
||||
@@ -11156,7 +11156,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/experimental/project/{projectID}/copy/refresh": {
|
||||
"/api/experimental/project/{projectID}/copy/refresh": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.refresh",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user