mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 22:46:20 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bbf84372d | ||
|
|
b2a1063bab | ||
|
|
3560d7dbc0 | ||
|
|
3f7b12ed91 | ||
|
|
9bd30fc66b | ||
|
|
6f6886c0af | ||
|
|
c806503694 | ||
|
|
fd73a85de4 | ||
|
|
e2e82f18e2 | ||
|
|
91cdb182f0 | ||
|
|
316f7925d3 | ||
|
|
9f01e2b548 | ||
|
|
7856515140 | ||
|
|
818804e181 | ||
|
|
ce6247bd2f | ||
|
|
0465328297 | ||
|
|
43d09b9d75 | ||
|
|
7730cec123 | ||
|
|
367ee47d7e | ||
|
|
c596a8f11d | ||
|
|
30f998c5b9 | ||
|
|
d4b4dd17cc | ||
|
|
f330f3e02b | ||
|
|
a9c27209bb |
@@ -3,7 +3,7 @@
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
@@ -3213,8 +3213,6 @@
|
||||
|
||||
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
|
||||
|
||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
@@ -5453,8 +5451,6 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
@@ -5669,8 +5665,6 @@
|
||||
|
||||
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
|
||||
|
||||
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
@@ -5819,9 +5813,7 @@
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
@@ -5853,8 +5845,6 @@
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
|
||||
@@ -6609,8 +6599,6 @@
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
|
||||
@@ -400,7 +400,8 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: Ski
|
||||
|
||||
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
// Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
|
||||
await client.plugin.awaitActivation({ location })
|
||||
// Some providers discover models in the background after activation has settled.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
@@ -199,6 +199,24 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("check", {
|
||||
description: "Check package plugins for updates",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Configured package target"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("update", {
|
||||
description: "Update package plugins",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("remove", {
|
||||
description: "Remove a plugin from global configuration",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { format, inspect } from "./inventory"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.check,
|
||||
Effect.fn("cli.plugin.check")(function* (input) {
|
||||
const result = yield* inspect(Option.getOrUndefined(input.target))
|
||||
process.stdout.write((format(result.items) || "No package plugins found") + EOL)
|
||||
if (result.items.some((item) => item.error)) process.exitCode = 1
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Config } from "../../../config"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export interface Item {
|
||||
readonly runtime: "Server" | "TUI"
|
||||
readonly target: string
|
||||
readonly name: string
|
||||
readonly version?: string
|
||||
readonly outdated: boolean
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const location = { directory: process.cwd() }
|
||||
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
|
||||
const serverTargets = new Set(
|
||||
listed.data.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
|
||||
)
|
||||
const server =
|
||||
selected === undefined || serverTargets.has(selected)
|
||||
? yield* Effect.promise(() => client.plugin.check({ location, ...(selected ? { target: selected } : {}) }))
|
||||
: listed
|
||||
const serverItems = server.data.flatMap((plugin): Item[] => {
|
||||
if (plugin.source.type !== "package") return []
|
||||
if (selected !== undefined && plugin.source.target !== selected) return []
|
||||
return [
|
||||
{
|
||||
runtime: "Server",
|
||||
target: plugin.source.target,
|
||||
name: plugin.id ?? plugin.source.target,
|
||||
...(plugin.source.version ? { version: displayVersion(plugin.source.version) } : {}),
|
||||
outdated: plugin.source.outdated === true,
|
||||
...(plugin.state.status === "failed" ? { error: plugin.state.error } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const config = yield* Config.Service
|
||||
const info = yield* config.get()
|
||||
const configured = [
|
||||
...new Set(
|
||||
(info.plugins ?? []).flatMap((entry) => {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) return []
|
||||
return [target]
|
||||
}),
|
||||
),
|
||||
]
|
||||
const tuiTargets = yield* Effect.promise(async () => {
|
||||
const installable = await Promise.all(configured.map((target) => Npm.isInstallablePackage(target)))
|
||||
return configured.filter((target, index) => installable[index] && (selected === undefined || target === selected))
|
||||
})
|
||||
const npm = yield* Npm.Service
|
||||
const tuiItems = yield* Effect.forEach(
|
||||
tuiTargets,
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
const outdated = yield* npm.check(target).pipe(Effect.exit)
|
||||
return {
|
||||
runtime: "TUI" as const,
|
||||
target,
|
||||
name: target,
|
||||
...(installed.version ? { version: displayVersion(installed.version) } : {}),
|
||||
outdated: Exit.isSuccess(outdated) && outdated.value,
|
||||
...(Exit.isFailure(outdated) ? { error: Cause.pretty(outdated.cause) } : {}),
|
||||
}
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const items = [...serverItems, ...tuiItems]
|
||||
if (selected !== undefined && !items.length) return yield* Effect.fail(new Error(`Plugin is not configured: ${selected}`))
|
||||
return { client, location, items }
|
||||
})
|
||||
|
||||
export function displayVersion(version: string) {
|
||||
return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(version) ? version.slice(0, 7) : version
|
||||
}
|
||||
|
||||
export function format(items: readonly Item[]) {
|
||||
return ["Server", "TUI"]
|
||||
.flatMap((runtime) => {
|
||||
const rows = items.filter((item) => item.runtime === runtime)
|
||||
if (!rows.length) return []
|
||||
return [
|
||||
runtime,
|
||||
...rows.map(
|
||||
(item) =>
|
||||
` ${item.name}${item.version ? ` ${item.version}` : ""} (${item.error ? "check failed" : item.outdated ? "update available" : "current"})`,
|
||||
),
|
||||
]
|
||||
})
|
||||
.join(EOL)
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
@@ -22,15 +24,23 @@ export default Runtime.handler(
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
|
||||
return []
|
||||
const local = localSource(target, path.dirname(config.path))
|
||||
if (local) return [{ target: fileURLToPath(local), version: "local" }]
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
if (!installed.entrypoint) return []
|
||||
return [{ target, version: installed.version }]
|
||||
}),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
[
|
||||
...(info.plugins ?? []).flatMap((entry) => {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
|
||||
}),
|
||||
...discovered.map((target) => ({ target, source: "discovered" as const })),
|
||||
],
|
||||
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
|
||||
input.builtin,
|
||||
)
|
||||
if (!output) {
|
||||
@@ -43,37 +53,50 @@ export default Runtime.handler(
|
||||
|
||||
export function format(
|
||||
plugins: readonly PluginInfo[],
|
||||
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
|
||||
tui: ReadonlyArray<{ readonly target: string; readonly version?: string }>,
|
||||
builtin = false,
|
||||
) {
|
||||
const server = plugins
|
||||
.filter((plugin) => builtin || plugin.source.type !== "builtin")
|
||||
.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
|
||||
const advertised = plugins.flatMap((plugin) =>
|
||||
plugin.state.status !== "active" || !plugin.features.tui
|
||||
? []
|
||||
: plugin.source.type === "package"
|
||||
? [{ target: plugin.source.target, source: "advertised" as const }]
|
||||
: plugin.source.type === "local"
|
||||
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
|
||||
: [],
|
||||
)
|
||||
const targets = [...tui, ...advertised]
|
||||
.map((plugin) => ({
|
||||
id: plugin.id ?? "-",
|
||||
version:
|
||||
plugin.source.type === "package"
|
||||
? (plugin.source.version ?? "-")
|
||||
: plugin.source.type === "local"
|
||||
? "local"
|
||||
: "-",
|
||||
target:
|
||||
plugin.source.type === "package"
|
||||
? plugin.source.target
|
||||
: plugin.source.type === "local"
|
||||
? plugin.source.path
|
||||
: plugin.source.type,
|
||||
}))
|
||||
const targets = tui
|
||||
.filter(
|
||||
(item) =>
|
||||
!plugins.some((plugin) =>
|
||||
plugin.source.type === "package"
|
||||
? plugin.source.target === item.target
|
||||
: plugin.source.type === "local" &&
|
||||
(plugin.source.path === item.target ||
|
||||
(plugin.features.tui &&
|
||||
path.dirname(plugin.source.path) ===
|
||||
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
|
||||
),
|
||||
)
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
.toSorted((a, b) => a.target.localeCompare(b.target))
|
||||
.map((plugin) => `${plugin.target} (${plugin.source})`)
|
||||
return [
|
||||
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
|
||||
server.length ? ["Server", ...server].join(EOL) : undefined,
|
||||
]
|
||||
.filter((section) => section !== undefined)
|
||||
.join(EOL + EOL)
|
||||
}
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.target
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
.map((plugin) => ({ id: "-", version: plugin.version ?? "-", target: plugin.target }))
|
||||
const rows = [...server, ...targets]
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id) || a.target.localeCompare(b.target))
|
||||
.map((item) => [
|
||||
item.id,
|
||||
/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(item.version) ? item.version.slice(0, 7) : item.version,
|
||||
item.target,
|
||||
])
|
||||
if (!rows.length) return ""
|
||||
const table = [["ID", "VERSION", "SOURCE"], ...rows]
|
||||
const widths = [0, 1].map((index) => Math.max(...table.map((row) => row[index].length)))
|
||||
return table.map((row) => `${row[0].padEnd(widths[0])} ${row[1].padEnd(widths[1])} ${row[2]}`).join(EOL)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Cause, Effect, Exit, Option } from "effect"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { inspect } from "./inventory"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.update,
|
||||
Effect.fn("cli.plugin.update")(function* (input) {
|
||||
const result = yield* inspect(Option.getOrUndefined(input.target))
|
||||
for (const item of result.items) {
|
||||
if (!item.error) continue
|
||||
process.stderr.write(`Failed to check ${item.runtime} plugin "${item.name}": ${item.error}${EOL}`)
|
||||
}
|
||||
const selected = result.items.filter((item) => item.outdated)
|
||||
if (!selected.length) {
|
||||
process.stdout.write("No plugin updates available" + EOL)
|
||||
if (result.items.some((item) => item.error)) process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const npm = yield* Npm.Service
|
||||
const updated = yield* Effect.forEach(
|
||||
selected,
|
||||
(item) =>
|
||||
(item.runtime === "Server"
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
|
||||
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
|
||||
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
for (const item of updated) {
|
||||
if (Exit.isSuccess(item.result)) {
|
||||
process.stdout.write(`Updated ${item.item.runtime} plugin "${item.item.name}"${EOL}`)
|
||||
continue
|
||||
}
|
||||
process.stderr.write(
|
||||
`Failed to update ${item.item.runtime} plugin "${item.item.name}": ${Cause.pretty(item.result.cause)}${EOL}`,
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
@@ -91,7 +93,7 @@ type RenderOptions = {
|
||||
const colors = terminalPalette()
|
||||
|
||||
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
|
||||
const totalTokens = tokenTotal(stats.tokens)
|
||||
const totalTokens = TokenUsage.total(stats.tokens)
|
||||
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
|
||||
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
|
||||
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
|
||||
@@ -167,43 +169,23 @@ function renderActivity(
|
||||
color: boolean,
|
||||
width: number,
|
||||
) {
|
||||
const values = new Map(activity.map((day) => [day.date, day.steps]))
|
||||
const rangeStart = dateOrdinal(new Date(from))
|
||||
const rangeEnd = dateOrdinal(new Date(to - 1))
|
||||
const end = new Date(to - 1)
|
||||
end.setHours(12, 0, 0, 0)
|
||||
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
|
||||
const start = new Date(from)
|
||||
start.setHours(12, 0, 0, 0)
|
||||
start.setDate(start.getDate() - mondayIndex(start))
|
||||
const maxWeeks = Math.max(1, Math.min(53, width - 4))
|
||||
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
|
||||
const latest = new Date(end)
|
||||
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
|
||||
if (start < latest) start.setTime(latest.getTime())
|
||||
|
||||
const active = [...values.values()].filter((value) => value > 0)
|
||||
const levels = [...new Set(active)].sort((a, b) => a - b)
|
||||
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
|
||||
const date = new Date(start)
|
||||
date.setDate(date.getDate() + week * 7)
|
||||
return date
|
||||
})
|
||||
const weeks = weekStarts.map((week) =>
|
||||
Array.from({ length: 7 }, (_, day) => {
|
||||
const date = new Date(week)
|
||||
date.setDate(date.getDate() + day)
|
||||
const ordinal = dateOrdinal(date)
|
||||
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
|
||||
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
|
||||
}),
|
||||
)
|
||||
const calendar = activityCalendar({ activity, from, to, maxWeeks: width - 4 })
|
||||
const months = calendar.months
|
||||
.map((month, index) =>
|
||||
(index === calendar.months.length - 1 || month.label.length <= month.span ? month.label : "").padEnd(month.span),
|
||||
)
|
||||
.join("")
|
||||
.trimEnd()
|
||||
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
||||
return [
|
||||
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
|
||||
` ${style(monthLabels(weekStarts), "2", color)}`,
|
||||
style(
|
||||
calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity",
|
||||
`1;${colors.primary}`,
|
||||
color,
|
||||
),
|
||||
` ${style(months, "2", color)}`,
|
||||
...weekdays.flatMap((label, day) => [
|
||||
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
|
||||
`${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`,
|
||||
...(day === weekdays.length - 1 ? [] : [""]),
|
||||
]),
|
||||
"",
|
||||
@@ -238,7 +220,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
|
||||
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
|
||||
width,
|
||||
),
|
||||
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
|
||||
` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
|
||||
]),
|
||||
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
|
||||
]
|
||||
@@ -248,7 +230,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
|
||||
...models.map((item) =>
|
||||
tableRow(
|
||||
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
|
||||
formatNumber(tokenTotal(item.tokens)),
|
||||
formatNumber(TokenUsage.total(item.tokens)),
|
||||
formatNumber(item.steps),
|
||||
`$${item.cost.toFixed(2)}`,
|
||||
),
|
||||
@@ -310,10 +292,6 @@ function truncate(value: string, width: number) {
|
||||
return value.length <= width ? value : value.slice(0, width - 1) + "…"
|
||||
}
|
||||
|
||||
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
|
||||
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
|
||||
@@ -342,13 +320,6 @@ function style(value: string, code: string, color: boolean) {
|
||||
return color ? `\x1b[${code}m${value}\x1b[0m` : value
|
||||
}
|
||||
|
||||
function activityGlyph(value: number, levels: number[], color: boolean) {
|
||||
if (value === 0) return paintActivity(0, color)
|
||||
const index = levels.indexOf(value)
|
||||
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
|
||||
return paintActivity(level, color)
|
||||
}
|
||||
|
||||
function paintActivity(level: number, color: boolean) {
|
||||
const glyph = ["·", "░", "▒", "▓", "█"][level]
|
||||
if (!color) return glyph
|
||||
@@ -370,39 +341,3 @@ function terminalPalette() {
|
||||
}
|
||||
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
|
||||
}
|
||||
|
||||
function monthLabels(weeks: Date[]) {
|
||||
const line: string[] = []
|
||||
weeks.reduce((previous, week, index) => {
|
||||
const middle = new Date(week)
|
||||
middle.setDate(middle.getDate() + 3)
|
||||
const month = middle.getMonth()
|
||||
if (month === previous) return previous
|
||||
Intl.DateTimeFormat("en-US", { month: "short" })
|
||||
.format(middle)
|
||||
.split("")
|
||||
.forEach((character, offset) => {
|
||||
line[index + offset] = character
|
||||
})
|
||||
return month
|
||||
}, -1)
|
||||
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
|
||||
.join("")
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function mondayIndex(date: Date) {
|
||||
return (date.getDay() + 6) % 7
|
||||
}
|
||||
|
||||
function dateKey(date: Date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("-")
|
||||
}
|
||||
|
||||
function dateOrdinal(date: Date) {
|
||||
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
add: () => import("./commands/handlers/plugin/add"),
|
||||
check: () => import("./commands/handlers/plugin/check"),
|
||||
update: () => import("./commands/handlers/plugin/update"),
|
||||
remove: () => import("./commands/handlers/plugin/remove"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
|
||||
@@ -1,8 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
|
||||
|
||||
describe("acp service directory behavior", () => {
|
||||
test("does not cache an available model before plugin activation settles", async () => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
let ready = false
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
requested.resolve()
|
||||
if (request.path === "/api/plugin/await-activation") {
|
||||
return release.promise.then(() => {
|
||||
ready = true
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
}
|
||||
if (!ready && request.path === "/api/model") {
|
||||
return Response.json({ data: [{ ...testModel, providerID: "ambient" }] })
|
||||
}
|
||||
if (!ready && request.path === "/api/model/default") {
|
||||
return Response.json({ data: { ...testModel, providerID: "ambient" } })
|
||||
}
|
||||
if (request.path === "/api/session" && request.method === "POST") {
|
||||
return Response.json({ data: { ...makeSession("ses_ready"), model: undefined } })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const pending = fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
try {
|
||||
await requested.promise
|
||||
expect(fixture.requests.map((request) => request.path)).toEqual(["/api/plugin/await-activation"])
|
||||
expect(fixture.requests[0]?.query["location[directory]"]).toBe("/workspace")
|
||||
release.resolve()
|
||||
expect(currentValue(await pending, "model")).toBe("test/test-model")
|
||||
expect(
|
||||
fixture.requests.find((request) => request.path === "/api/session" && request.method === "POST")?.body,
|
||||
).toMatchObject({ model: { providerID: "test", id: "test-model" } })
|
||||
} finally {
|
||||
release.resolve()
|
||||
await pending.catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
let created = 0
|
||||
await using fixture = makeACPFixture({
|
||||
@@ -26,7 +67,14 @@ describe("acp service directory behavior", () => {
|
||||
expect(currentValue(first[0], "model")).toBe("test/test-model")
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
["/api/model", "/api/model/default", "/api/agent", "/api/command", "/api/skill"].map((path) =>
|
||||
[
|
||||
"/api/plugin/await-activation",
|
||||
"/api/model",
|
||||
"/api/model/default",
|
||||
"/api/agent",
|
||||
"/api/command",
|
||||
"/api/skill",
|
||||
].map((path) =>
|
||||
fixture.requests
|
||||
.filter((request) => request.path === path)
|
||||
.map((request) => request.query["location[directory]"]),
|
||||
@@ -37,6 +85,7 @@ describe("acp service directory behavior", () => {
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
|
||||
@@ -152,6 +152,7 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
|
||||
const directory = request.query["location[directory]"] ?? "/workspace"
|
||||
const location = { directory, project: { id: "global", directory } }
|
||||
if (request.path === "/api/plugin/await-activation") return new Response(null, { status: 204 })
|
||||
if (request.path === "/api/event") {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
return new Response(
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("acp service", () => {
|
||||
body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
|
||||
})
|
||||
const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
|
||||
if (url.pathname === "/api/plugin/await-activation") return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
|
||||
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
|
||||
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { EOL } from "node:os"
|
||||
import { expect, test } from "bun:test"
|
||||
import { displayVersion, format, type Item } from "../src/commands/handlers/plugin/inventory"
|
||||
|
||||
test("formats server and TUI package update status", () => {
|
||||
const items: Item[] = [
|
||||
{ runtime: "Server", target: "server", name: "server.plugin", version: "1.2.3", outdated: true },
|
||||
{ runtime: "TUI", target: "tui", name: "tui", version: "2.0.0", outdated: false },
|
||||
]
|
||||
|
||||
expect(format(items)).toBe(
|
||||
["Server", " server.plugin 1.2.3 (update available)", "TUI", " tui 2.0.0 (current)"].join(EOL),
|
||||
)
|
||||
})
|
||||
|
||||
test("shortens Git revisions", () => {
|
||||
expect(displayVersion("dadba138b7088d61f937869bbc1ef34b1f91188d")).toBe("dadba13")
|
||||
})
|
||||
@@ -2,14 +2,14 @@ import { expect, test } from "bun:test"
|
||||
import { EOL } from "node:os"
|
||||
import { format } from "../src/commands/handlers/plugin/list"
|
||||
|
||||
test("formats server and TUI plugins in sections without builtins", () => {
|
||||
test("lists plugin IDs, installed versions, and sources without runtime sections", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id: "acme.dual",
|
||||
source: { type: "package", target: "acme-plugin@1.0.0" },
|
||||
source: { type: "package", target: "acme-plugin@1.0.0", version: "1.0.0" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, tui: true },
|
||||
},
|
||||
@@ -26,24 +26,22 @@ test("formats server and TUI plugins in sections without builtins", () => {
|
||||
},
|
||||
],
|
||||
[
|
||||
{ target: "tui-only", source: "configured" },
|
||||
{ target: "/tmp/local.ts", source: "discovered" },
|
||||
{ target: "tui-only", version: "2.0.0" },
|
||||
{ target: "/tmp/local.ts", version: "local" },
|
||||
{ target: "acme-plugin@1.0.0", version: "1.0.0" },
|
||||
{ target: "/tmp/local/tui.ts", version: "local" },
|
||||
],
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"TUI",
|
||||
"/tmp/local (advertised)",
|
||||
"/tmp/local.ts (discovered)",
|
||||
"acme-plugin@1.0.0 (advertised)",
|
||||
"tui-only (configured)",
|
||||
"",
|
||||
"Server",
|
||||
"acme.dual (active)",
|
||||
"broken-plugin (failed)",
|
||||
"local.dual (active)",
|
||||
].join(EOL),
|
||||
)
|
||||
)
|
||||
.split(EOL)
|
||||
.map((line) => line.split(/\s{2,}/)),
|
||||
).toEqual([
|
||||
["ID", "VERSION", "SOURCE"],
|
||||
["-", "local", "/tmp/local.ts"],
|
||||
["-", "-", "broken-plugin"],
|
||||
["-", "2.0.0", "tui-only"],
|
||||
["acme.dual", "1.0.0", "acme-plugin@1.0.0"],
|
||||
["local.dual", "local", "/tmp/local/index.ts"],
|
||||
])
|
||||
})
|
||||
|
||||
test("includes builtins when requested", () => {
|
||||
@@ -59,6 +57,29 @@ test("includes builtins when requested", () => {
|
||||
],
|
||||
[],
|
||||
true,
|
||||
),
|
||||
).toBe(["Server", "opencode.agent (active)"].join(EOL))
|
||||
)
|
||||
.split(EOL)
|
||||
.map((line) => line.split(/\s{2,}/)),
|
||||
).toEqual([
|
||||
["ID", "VERSION", "SOURCE"],
|
||||
["opencode.agent", "-", "builtin"],
|
||||
])
|
||||
})
|
||||
|
||||
test("shortens Git commits and leaves absent IDs unknown", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
{
|
||||
source: { type: "package", target: "github:acme/plugin", version: "a".repeat(40) },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
.split(EOL)[1]
|
||||
.split(/\s{2,}/),
|
||||
).toEqual(["-", "aaaaaaa", "github:acme/plugin"])
|
||||
expect(format([], [])).toBe("")
|
||||
})
|
||||
|
||||
@@ -48,6 +48,20 @@ describe("stats rendering", () => {
|
||||
expect(output).not.toContain("$12.34")
|
||||
})
|
||||
|
||||
test("renders shared intensity levels and partial month labels", () => {
|
||||
const output = renderStats(
|
||||
{
|
||||
...stats,
|
||||
range: { from: new Date(2026, 3, 29).getTime(), to: new Date(2026, 4, 20).getTime() },
|
||||
activity: [{ date: "2026-04-29", steps: 1 }],
|
||||
},
|
||||
options(),
|
||||
)
|
||||
expect(output).toContain(" May")
|
||||
expect(output).not.toContain("Apr")
|
||||
expect(output.split(/\r?\n/).find((line) => line.startsWith("We "))).toContain("\u2588")
|
||||
})
|
||||
|
||||
test("renders only requested detail tables", () => {
|
||||
const output = renderStats(stats, options({ tools: true, cost: true }))
|
||||
expect(output).toContain("COST & TOKENS")
|
||||
|
||||
@@ -88,6 +88,21 @@ export type PluginListInput = {
|
||||
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
|
||||
|
||||
export type PluginAwaitActivationInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type PluginAwaitActivationOutput = void
|
||||
export type PluginAwaitActivationOperation<E = never> = (
|
||||
input?: PluginAwaitActivationInput,
|
||||
) => Effect.Effect<PluginAwaitActivationOutput, E>
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target?: string | undefined
|
||||
}
|
||||
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target: string
|
||||
@@ -97,6 +112,8 @@ export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Eff
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly awaitActivation: PluginAwaitActivationOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
readonly update: PluginUpdateOperation<E>
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginAwaitActivationInput,
|
||||
PluginAwaitActivationOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
SessionListInput,
|
||||
@@ -324,6 +328,18 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginAwaitActivation = (raw: RawClient["server.plugin"]) => (input?: PluginAwaitActivationInput) =>
|
||||
preserveEffect<PluginAwaitActivationOutput>()(
|
||||
raw["plugin.awaitActivation"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
@@ -333,6 +349,8 @@ const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: Plugin
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
awaitActivation: EndpointPluginAwaitActivation(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
})
|
||||
|
||||
|
||||
@@ -103,16 +103,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state === "ready")
|
||||
yield* Effect.tryPromise(() =>
|
||||
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
|
||||
)
|
||||
else {
|
||||
if (!service.legacy)
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
}
|
||||
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
|
||||
if (!service.legacy && service.state !== "ready")
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* stop({
|
||||
file: options.file,
|
||||
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
@@ -145,8 +141,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
const info = yield* read(options.file)
|
||||
if (options.pty === "handoff" && info !== undefined)
|
||||
yield* Effect.tryPromise(() =>
|
||||
PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout),
|
||||
)
|
||||
else yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginAwaitActivationInput,
|
||||
PluginAwaitActivationOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
SessionListInput,
|
||||
@@ -468,6 +472,31 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
awaitActivation: (input?: PluginAwaitActivationInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginAwaitActivationOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/await-activation`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/check`,
|
||||
query: { location: input?.["location"] },
|
||||
body: { target: input?.["target"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateOutput>(
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ export type PluginSource =
|
||||
|
||||
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
|
||||
|
||||
export type PluginState = { status: "active" } | { status: "failed"; error: string }
|
||||
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
@@ -2585,6 +2585,26 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginAwaitActivationInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginAwaitActivationOutput = void
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target?: { readonly target?: string | undefined }["target"]
|
||||
}
|
||||
|
||||
export type PluginCheckOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -84,14 +84,12 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state === "ready")
|
||||
await PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
|
||||
else {
|
||||
if (!service.legacy)
|
||||
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
}
|
||||
await terminate(service.info, options, timing).catch(() => undefined)
|
||||
if (!service.legacy && service.state !== "ready")
|
||||
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
await stop({
|
||||
file: options.file,
|
||||
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
|
||||
}).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
@@ -119,8 +117,10 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
const info = await read(options.file)
|
||||
if (options.pty === "handoff" && info !== undefined)
|
||||
await PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
|
||||
else await PtyHandoff.clear(options.file ?? fallback())
|
||||
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ export type EnsureOptions = DiscoverOptions & {
|
||||
export type StopOptions = {
|
||||
/** Absolute registration file path. Defaults to the XDG state directory. */
|
||||
readonly file?: string
|
||||
/** How to handle persistent terminals before stopping the service. */
|
||||
readonly pty?: "clear" | "handoff"
|
||||
}
|
||||
|
||||
/** Contents of the local service registration file. */
|
||||
|
||||
@@ -147,7 +147,7 @@ function createSync() {
|
||||
state.set(key, entry)
|
||||
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
|
||||
.then(() => {
|
||||
if (state.get(key) === entry) state.set(key, true)
|
||||
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.get(key) === entry) state.delete(key)
|
||||
@@ -493,6 +493,33 @@ export function createData(config: CreateDataInput) {
|
||||
)
|
||||
}
|
||||
|
||||
function evictSession(sessionID: string) {
|
||||
if (sessionOutbox.has(sessionID)) return
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
messageLoads.delete(sessionID)
|
||||
// Keep unacknowledged submissions until their echo or rollback settles them.
|
||||
const pending = store.session.pending[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
|
||||
const messages = store.session.message[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
|
||||
messageIndex.delete(sessionID)
|
||||
if (messages.length) messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
delete draft.message[sessionID]
|
||||
delete draft.messageCursor[sessionID]
|
||||
delete draft.messageLoading[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
if (messages.length) draft.message[sessionID] = messages
|
||||
if (pending.length) {
|
||||
draft.pending[sessionID] = pending
|
||||
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
activeUpdates?.set(sessionID, undefined)
|
||||
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
|
||||
@@ -1260,9 +1287,11 @@ export function createData(config: CreateDataInput) {
|
||||
return creating.has(sessionID)
|
||||
},
|
||||
remember(info: SessionInfo) {
|
||||
setStore("session", "info", info.id, reconcile(info))
|
||||
sync.complete(`session:${info.id}`)
|
||||
registerSession(info.id)
|
||||
batch(() => {
|
||||
setStore("session", "info", info.id, reconcile(info))
|
||||
sync.complete(`session:${info.id}`)
|
||||
registerSession(info.id)
|
||||
})
|
||||
},
|
||||
setStatus(sessionID: string, status: DataSessionStatus) {
|
||||
setSessionActive(sessionID, status)
|
||||
@@ -1273,6 +1302,13 @@ export function createData(config: CreateDataInput) {
|
||||
family(sessionID: string) {
|
||||
return store.session.family[resolveRoot(sessionID)] ?? []
|
||||
},
|
||||
/** Clear heavy cached data for the root and all known descendants. */
|
||||
evict(sessionID: string) {
|
||||
const root = resolveRoot(sessionID)
|
||||
batch(() => {
|
||||
for (const id of new Set([root, sessionID, ...(store.session.family[root] ?? [])])) evictSession(id)
|
||||
})
|
||||
},
|
||||
cost(sessionID: string) {
|
||||
const session = store.session.info[sessionID]
|
||||
if (!session) return 0
|
||||
@@ -1484,17 +1520,19 @@ export function createData(config: CreateDataInput) {
|
||||
: [],
|
||||
])
|
||||
const sessions = [info, ...children]
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of sessions) draft[session.id] = session
|
||||
}),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
sync.complete(`session:${session.id}`)
|
||||
registerSession(session.id)
|
||||
}
|
||||
batch(() => {
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of sessions) draft[session.id] = session
|
||||
}),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
sync.complete(`session:${session.id}`)
|
||||
registerSession(session.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
test("evicts a whole family while preserving metadata and attention", async () => {
|
||||
const setup = fixture()
|
||||
try {
|
||||
for (const id of ["ses_parent", "ses_child", "ses_grandchild", "ses_other"]) {
|
||||
await setup.data.session.message.sync(id)
|
||||
await setup.data.session.pending.sync(id)
|
||||
}
|
||||
setup.data.session.setStatus("ses_child", "running")
|
||||
setup.emit({
|
||||
id: "evt_permission",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
data: { id: "per_test", sessionID: "ses_child", action: "bash", resources: ["bun test"] },
|
||||
})
|
||||
|
||||
setup.data.session.evict("ses_parent")
|
||||
|
||||
for (const id of ["ses_parent", "ses_child", "ses_grandchild"]) {
|
||||
expect(setup.data.session.message.list(id)).toEqual([])
|
||||
expect(setup.data.session.message.get(id, "msg_page")).toBeUndefined()
|
||||
expect(setup.data.session.message.more(id)).toBe(false)
|
||||
expect(setup.data.session.message.loading(id)).toBe(false)
|
||||
expect(setup.data.session.pending.list(id)).toEqual([])
|
||||
expect(setup.data.session.input.list(id)).toEqual([])
|
||||
expect(setup.data.session.get(id)?.id).toBe(id)
|
||||
}
|
||||
expect(setup.data.session.family("ses_parent")).toEqual(["ses_parent", "ses_child", "ses_grandchild"])
|
||||
expect(setup.data.session.status("ses_child")).toBe("running")
|
||||
expect(setup.data.session.permission.list("ses_child")?.[0]?.id).toBe("per_test")
|
||||
expect(setup.data.session.message.list("ses_other")).toHaveLength(2)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_agent",
|
||||
created: 1,
|
||||
type: "session.agent.selected",
|
||||
durable: { aggregateID: "ses_parent", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_parent", agent: "build" },
|
||||
})
|
||||
expect(setup.data.session.message.list("ses_parent")).toMatchObject([{ type: "agent-switched" }])
|
||||
await setup.data.session.message.sync("ses_parent")
|
||||
expect(setup.data.session.message.list("ses_parent").map((item) => item.id)).toEqual(["msg_page"])
|
||||
await setup.data.session.message.sync("ses_grandchild")
|
||||
await setup.data.session.pending.sync("ses_grandchild")
|
||||
expect(setup.data.session.message.list("ses_grandchild")).toHaveLength(2)
|
||||
expect(setup.data.session.message.list("ses_child")).toEqual([])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("evicting an in-flight read leaves the next sync invalidated", async () => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
let requests = 0
|
||||
const setup = fixture(async () => {
|
||||
if (++requests !== 1) return
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
})
|
||||
try {
|
||||
const initial = setup.data.session.message.sync("ses_child")
|
||||
await requested.promise
|
||||
setup.data.session.evict("ses_parent")
|
||||
release.resolve()
|
||||
await initial
|
||||
await setup.data.session.message.sync("ses_child")
|
||||
expect(requests).toBe(2)
|
||||
expect(setup.data.session.message.list("ses_child")).toHaveLength(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["echo", "reject"] as const)("eviction preserves optimistic submissions until %s", async (mode) => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const setup = fixture(async (url) => {
|
||||
if (!url.pathname.endsWith("/prompt")) return undefined
|
||||
await release.promise
|
||||
return mode === "reject"
|
||||
? Response.json({ message: "rejected" }, { status: 400 })
|
||||
: Response.json({ id: "msg_local" })
|
||||
})
|
||||
try {
|
||||
const request = setup.data.session.prompt({ sessionID: "ses_child", id: "msg_local", text: "local" })
|
||||
const settled = Promise.allSettled([request])
|
||||
setup.data.session.evict("ses_parent")
|
||||
expect(setup.data.session.message.list("ses_child")).toMatchObject([{ id: "msg_local", text: "local" }])
|
||||
if (mode === "echo") setup.emit(enqueued("ses_child", "msg_local"))
|
||||
release.resolve()
|
||||
await settled
|
||||
expect(setup.data.session.message.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
|
||||
expect(setup.data.session.pending.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function info(id: string, parentID?: string): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
parentID,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
location: { directory: "/project" },
|
||||
}
|
||||
}
|
||||
|
||||
function enqueued(sessionID: string, inboxID = "msg_pending"): OpenCodeEvent {
|
||||
return {
|
||||
id: `evt_${inboxID}`,
|
||||
created: 1,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, inboxID, item: { type: "user", delivery: "steer", payload: { text: "pending" } } },
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(read?: (url: URL) => Promise<Response | void>) {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
const response = await read?.(url)
|
||||
if (response) return response
|
||||
const sessionID = url.pathname.split("/")[3]
|
||||
if (url.pathname.endsWith("/inbox"))
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_pending",
|
||||
sessionID,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "pending" },
|
||||
timeCreated: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
return Response.json({
|
||||
data: [{ id: "msg_page", type: "user", text: "page", time: { created: 0 } }],
|
||||
cursor: url.searchParams.has("cursor") ? {} : { next: "older" },
|
||||
})
|
||||
},
|
||||
})
|
||||
return createRoot((dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
})
|
||||
data.session.remember(info("ses_parent"))
|
||||
data.session.remember(info("ses_child", "ses_parent"))
|
||||
data.session.remember(info("ses_grandchild", "ses_child"))
|
||||
data.session.remember(info("ses_other"))
|
||||
return {
|
||||
data,
|
||||
dispose,
|
||||
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -87,7 +87,7 @@ const layer = Layer.effect(
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const selectable = (agent: Info | undefined) =>
|
||||
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
||||
|
||||
@@ -135,7 +135,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("Catalog.finalize")(function* () {
|
||||
notify: Effect.fn("Catalog.notify")(function* () {
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
|
||||
draft: (draft) => ({
|
||||
add: (definition) => draft.set(definition.name, definition),
|
||||
}),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const info = (definition: Definition) =>
|
||||
Info.make({
|
||||
|
||||
@@ -25,7 +25,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let current: readonly string[] = []
|
||||
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "location-watcher-policy",
|
||||
@@ -34,11 +33,9 @@ const layer = Layer.effect(
|
||||
add: (ignore) => draft.ignore.push(...ignore),
|
||||
list: () => draft.ignore,
|
||||
}),
|
||||
finalize: (draft) =>
|
||||
Effect.sync(() => {
|
||||
current = [...draft.list()]
|
||||
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
|
||||
notify: () => Effect.forEach(listeners, (listener) => listener(current()), { discard: true }),
|
||||
})
|
||||
const current = (): readonly string[] => state.get().ignore
|
||||
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
|
||||
listener: (ignore: readonly string[]) => Effect.Effect<void>,
|
||||
) {
|
||||
@@ -56,7 +53,7 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
current: () => current,
|
||||
current,
|
||||
observe,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -93,7 +93,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
yield* policy.observe(reconcile)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
yield* reconcile(policy.current())
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
|
||||
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
|
||||
@@ -328,7 +328,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
|
||||
@@ -400,21 +400,17 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.exit,
|
||||
)
|
||||
const persistence = yield* Effect.suspend(() => {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
return createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label ?? implementation?.label?.(exit.value),
|
||||
value: exit.value,
|
||||
})
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
@@ -432,7 +428,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
yield* persistence
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}, Effect.uninterruptible)
|
||||
|
||||
|
||||
@@ -6,7 +6,21 @@ import { ephemeral } from "@opencode-ai/schema/event"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
import {
|
||||
Cause,
|
||||
Context,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
FiberSet,
|
||||
Latch,
|
||||
Layer,
|
||||
Schema,
|
||||
Scope,
|
||||
Semaphore,
|
||||
Stream,
|
||||
Types,
|
||||
} from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -615,8 +629,10 @@ export const layer = (options?: Options) =>
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
|
||||
const reconcile = Effect.fnUntraced(function* (next: Draft) {
|
||||
const servers = new Map(next.list())
|
||||
const reconcileLock = Semaphore.makeUnsafe(1)
|
||||
const reconcile = Effect.fnUntraced(function* () {
|
||||
// Draft updates mutate configs in place; keep the applied snapshot stable across reconciliation awaits.
|
||||
const servers = structuredClone(state.get().servers)
|
||||
if (!applied && entries.size === 0) {
|
||||
for (const [name, server] of servers) {
|
||||
entries.set(name, {
|
||||
@@ -677,7 +693,7 @@ export const layer = (options?: Options) =>
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
),
|
||||
)
|
||||
const state = State.create<Data, Draft>({
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "mcp",
|
||||
initial: () => ({
|
||||
servers: new Map(
|
||||
@@ -702,7 +718,13 @@ export const layer = (options?: Options) =>
|
||||
},
|
||||
remove: (server) => draft.servers.delete(ServerName.make(server)),
|
||||
}),
|
||||
finalize: reconcile,
|
||||
// The layer owns both active reconciliation and work queued behind the lock.
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
|
||||
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
yield* exit
|
||||
}),
|
||||
})
|
||||
|
||||
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||
|
||||
@@ -3,8 +3,7 @@ export * as ModelResolver from "./model-resolver.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { Auth } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { Context, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { AISDKNative } from "./aisdk-native.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
@@ -95,11 +94,12 @@ export const withVariant = (
|
||||
)
|
||||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, variant.settings)
|
||||
draft.headers = Provider.mergeHeaders(draft.headers, variant.headers)
|
||||
draft.body = Provider.mergeOverlay(draft.body, variant.body)
|
||||
})
|
||||
? {
|
||||
...model,
|
||||
settings: Provider.mergeOverlay(model.settings, variant.settings),
|
||||
headers: Provider.mergeHeaders(model.headers, variant.headers),
|
||||
body: Provider.mergeOverlay(model.body, variant.body),
|
||||
}
|
||||
: model,
|
||||
)
|
||||
}
|
||||
@@ -147,10 +147,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
)
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = settings
|
||||
})
|
||||
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
return yield* loadAISDK({ ...resolved, settings }).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!native) return yield* unsupported(resolved)
|
||||
|
||||
@@ -160,7 +157,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
|
||||
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
@@ -182,11 +179,13 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
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)
|
||||
})
|
||||
return {
|
||||
...model,
|
||||
...(model.settings?.apiKey === "" ? { settings: Struct.omit(model.settings, ["apiKey"]) } : {}),
|
||||
...(credential?.type === "key" && credential.metadata !== undefined
|
||||
? { body: Provider.mergeOverlay(model.body, credential.metadata) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
@@ -243,11 +242,6 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
|
||||
return rest
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
|
||||
@@ -26,6 +26,11 @@ const Definition = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.LoadError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
options?: { readonly install?: boolean },
|
||||
@@ -39,13 +44,21 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const entrypoint = installed.entrypoint
|
||||
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
|
||||
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new LoadError({
|
||||
message: "Plugin must export a default definition with an id and an effect or setup function.",
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
const features = local
|
||||
? yield* localFeatures(operation.target)
|
||||
@@ -77,11 +90,11 @@ function localFeatures(entrypoint: string) {
|
||||
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
|
||||
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
|
||||
Effect.map((entries) => {
|
||||
const names = new Set(entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name))
|
||||
const names = new Set(
|
||||
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
|
||||
)
|
||||
const has = (name: string) =>
|
||||
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) =>
|
||||
names.has(`${name}.${extension}`),
|
||||
)
|
||||
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
|
||||
return {
|
||||
...(has("tui") ? { tui: true as const } : {}),
|
||||
...(has("rpc") ? { rpc: true as const } : {}),
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -20,9 +17,6 @@ const cognitiveScope = "https://cognitiveservices.azure.com/.default"
|
||||
const foundryScope = "https://ai.azure.com/.default"
|
||||
const methodID = Integration.MethodID.make("azure-cli")
|
||||
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
|
||||
const decodeProfile = Schema.decodeUnknownEffect(
|
||||
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.Array(Schema.Unknown) })),
|
||||
)
|
||||
const decodeToken = Schema.decodeUnknownEffect(
|
||||
Schema.Struct({
|
||||
accessToken: Schema.NonEmptyString,
|
||||
@@ -30,20 +24,6 @@ const decodeToken = Schema.decodeUnknownEffect(
|
||||
expiresOn: Schema.optional(Schema.NonEmptyString),
|
||||
}),
|
||||
)
|
||||
const decodeAccounts = Schema.decodeUnknownEffect(
|
||||
Schema.Array(Schema.Struct({ name: Schema.NonEmptyString, resourceGroup: Schema.NonEmptyString })),
|
||||
)
|
||||
const Deployments = Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
properties: Schema.Struct({
|
||||
model: Schema.Struct({ name: Schema.NonEmptyString }),
|
||||
provisioningState: Schema.NonEmptyString,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const decodeDeployments = Schema.decodeUnknownEffect(Deployments)
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
if (useChat && sdk.chat) return sdk.chat(modelID)
|
||||
if (sdk.responses) return sdk.responses(modelID)
|
||||
@@ -60,7 +40,7 @@ export const AzurePlugin = define({
|
||||
const bus = yield* Bus.Service
|
||||
const tokens = new Map<string, { access: string; expires: number }>()
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: { resource?: string; deployments?: typeof Deployments.Type } = {}
|
||||
const loaded: { resource?: string } = {}
|
||||
|
||||
const command = (args: string[]) =>
|
||||
processes
|
||||
@@ -86,28 +66,7 @@ export const AzurePlugin = define({
|
||||
})
|
||||
|
||||
const available = Boolean(which("az"))
|
||||
// Installing Azure CLI does not mean the user has signed in. Avoid spawning it for unrelated CLI commands.
|
||||
const signedIn = available
|
||||
? yield* Effect.tryPromise(() =>
|
||||
readFile(join(process.env.AZURE_CONFIG_DIR ?? join(homedir(), ".azure"), "azureProfile.json"), "utf8"),
|
||||
).pipe(
|
||||
Effect.flatMap((text) => decodeProfile(text.replace(/^\uFEFF/, ""))),
|
||||
Effect.map((profile) => profile.subscriptions.length > 0),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
: false
|
||||
const accounts =
|
||||
!resolveResourceName(configured) &&
|
||||
typeof configured?.baseURL !== "string" &&
|
||||
!process.env.AZURE_RESOURCE_GROUP &&
|
||||
signedIn
|
||||
? yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
|
||||
Effect.flatMap(decodeAccounts),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
: []
|
||||
|
||||
const form = (select = false) =>
|
||||
const form = () =>
|
||||
iife(() => {
|
||||
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
|
||||
return Form.Fields.make([
|
||||
@@ -117,16 +76,6 @@ export const AzurePlugin = define({
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
...(select && accounts.length > 0
|
||||
? {
|
||||
options: accounts.map((account) => ({
|
||||
value: account.name,
|
||||
label: account.name,
|
||||
description: account.resourceGroup,
|
||||
})),
|
||||
custom: true,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -143,7 +92,7 @@ export const AzurePlugin = define({
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
form: form(true),
|
||||
form: form(),
|
||||
},
|
||||
authorize: (answer) =>
|
||||
Effect.succeed({
|
||||
@@ -156,7 +105,6 @@ export const AzurePlugin = define({
|
||||
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
|
||||
const current = yield* token(cognitiveScope)
|
||||
loaded.resource = resourceName
|
||||
loaded.deployments = yield* discover(resourceName)
|
||||
yield* ctx.catalog.reload()
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
@@ -177,42 +125,6 @@ export const AzurePlugin = define({
|
||||
})
|
||||
})
|
||||
|
||||
const discover = Effect.fn("AzurePlugin.discover")(function* (resource: string) {
|
||||
return yield* Effect.gen(function* () {
|
||||
const group = process.env.AZURE_RESOURCE_GROUP
|
||||
const account = group
|
||||
? { name: resource, resourceGroup: group }
|
||||
: (accounts.length > 0
|
||||
? accounts
|
||||
: yield* command(["cognitiveservices", "account", "list", "--output", "json", "--only-show-errors"]).pipe(
|
||||
Effect.flatMap(decodeAccounts),
|
||||
)
|
||||
).find((item) => item.name.toLowerCase() === resource.toLowerCase())
|
||||
if (!account)
|
||||
return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found in the active subscription`))
|
||||
return yield* command([
|
||||
"cognitiveservices",
|
||||
"account",
|
||||
"deployment",
|
||||
"list",
|
||||
"--name",
|
||||
account.name,
|
||||
"--resource-group",
|
||||
account.resourceGroup,
|
||||
"--output",
|
||||
"json",
|
||||
"--only-show-errors",
|
||||
]).pipe(Effect.flatMap(decodeDeployments))
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("Azure model discovery failed", {
|
||||
resource,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const load = Effect.fn("AzurePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
@@ -220,13 +132,11 @@ export const AzurePlugin = define({
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
|
||||
loaded.resource = undefined
|
||||
loaded.deployments = undefined
|
||||
return
|
||||
}
|
||||
const resource =
|
||||
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
|
||||
loaded.resource = resource
|
||||
loaded.deployments = resource ? yield* discover(resource) : undefined
|
||||
})
|
||||
|
||||
yield* load()
|
||||
@@ -245,31 +155,6 @@ export const AzurePlugin = define({
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
if (item.provider.id === Provider.ID.azure && loaded.deployments) {
|
||||
// Startup batches catalog transforms, so match against the current draft rather than a pre-startup snapshot.
|
||||
const existing = Array.from(item.models.values())
|
||||
const found = new Map<Model.ID, Model.Info>()
|
||||
loaded.deployments.forEach((deployment) => {
|
||||
if (deployment.properties.provisioningState !== "Succeeded") return
|
||||
const model = existing.find(
|
||||
(model) => model.id.toLowerCase() === deployment.properties.model.name.toLowerCase(),
|
||||
)
|
||||
if (!model) return
|
||||
const id = found.has(model.id) ? Model.ID.make(deployment.name) : model.id
|
||||
found.set(id, {
|
||||
...model,
|
||||
id,
|
||||
name: id === model.id ? model.name : `${model.name} (${deployment.name})`,
|
||||
modelID: Model.ID.make(deployment.name),
|
||||
})
|
||||
})
|
||||
for (const id of Array.from(item.models.keys())) {
|
||||
if (!found.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
|
||||
}
|
||||
for (const [id, model] of found) {
|
||||
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
}
|
||||
}
|
||||
for (const model of item.models.values()) {
|
||||
evt.model.update(item.provider.id, model.id, (draft) => {
|
||||
if (resourceName && typeof draft.settings?.baseURL === "string")
|
||||
|
||||
@@ -8,10 +8,12 @@ import { Context, Effect } from "effect"
|
||||
*/
|
||||
export interface Interface {
|
||||
/**
|
||||
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
|
||||
* UI startup, or other unrelated work on plugin boot should be avoided.
|
||||
* Wait for configured plugin activation to settle, including missing-package installs.
|
||||
* Completion does not imply every plugin succeeded.
|
||||
* Interrupting this wait does not cancel activation. Use rarely: avoid blocking reads,
|
||||
* UI startup, or unrelated work on plugin boot.
|
||||
*/
|
||||
readonly flush: Effect.Effect<void>
|
||||
readonly awaitActivation: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
@@ -55,11 +55,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
}
|
||||
|
||||
const plugin = yield* PluginModule.load(operation, { install }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
|
||||
Effect.as({ error: Cause.pretty(cause) }),
|
||||
),
|
||||
),
|
||||
Effect.catchCause((cause) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.logWarning("failed to load plugin", { target: operation.target, ref, cause }).pipe(
|
||||
Effect.as({ error: error instanceof PluginModule.LoadError ? error.message : "Plugin failed to load", ref }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if ("pending" in plugin) {
|
||||
pending.add(operation.target)
|
||||
@@ -68,7 +70,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
state: { status: "failed", error: plugin.error },
|
||||
state: { status: "failed", error: plugin.error, ref: plugin.ref },
|
||||
features: { server: true },
|
||||
})
|
||||
continue
|
||||
@@ -127,9 +129,7 @@ export const layer = Layer.effect(
|
||||
// Activate everything available locally before waiting on missing package installs.
|
||||
const immediate = yield* resolve(pre, post, operations, false)
|
||||
const source = (source: Plugin.Source) =>
|
||||
source.type === "package" && outdated.has(source.target)
|
||||
? { ...source, outdated: true as const }
|
||||
: source
|
||||
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
|
||||
const apply = (resolved: typeof immediate) =>
|
||||
registry.activate(
|
||||
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
|
||||
@@ -162,12 +162,14 @@ export const layer = Layer.effect(
|
||||
const reloads = Stream.merge(
|
||||
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
|
||||
updates.changes().pipe(
|
||||
Stream.filter((target) => packages.has(target)),
|
||||
Stream.tap((target) => Effect.sync(() => outdated.delete(target))),
|
||||
Stream.filter((update) => packages.has(update.target)),
|
||||
Stream.tap((update) =>
|
||||
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
// Make accepted work visible to awaitActivation before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
@@ -189,7 +191,7 @@ export const layer = Layer.effect(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
|
||||
return Service.of({ flush: ready.await })
|
||||
return Service.of({ awaitActivation: ready.await })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
const interval = 24 * 60 * 60 * 1_000
|
||||
|
||||
export interface Interface {
|
||||
readonly check: (target: string) => Effect.Effect<boolean>
|
||||
readonly check: (target: string, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
|
||||
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
|
||||
readonly changes: () => Stream.Stream<string>
|
||||
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
|
||||
@@ -22,28 +22,29 @@ const layer = Layer.effect(
|
||||
const npm = yield* Npm.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
|
||||
const changes = yield* PubSub.unbounded<string>()
|
||||
const changes = yield* PubSub.unbounded<{ readonly target: string; readonly outdated: boolean }>()
|
||||
|
||||
return Service.of({
|
||||
check: (target) =>
|
||||
check: (target, options) =>
|
||||
locks.withLock(target)(
|
||||
Effect.gen(function* () {
|
||||
const checkedAt = yield* Clock.currentTimeMillis
|
||||
const current = status.get(target)
|
||||
if (current && checkedAt - current.checkedAt < interval) return current.outdated
|
||||
if (!options?.refresh && current && checkedAt - current.checkedAt < interval) return current.outdated
|
||||
const outdated = yield* npm.check(target).pipe(
|
||||
Effect.tapCause((cause) => Effect.logWarning("failed to check plugin update", { target, cause })),
|
||||
Effect.option,
|
||||
)
|
||||
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
|
||||
status.set(target, { outdated: value, checkedAt })
|
||||
if ((current?.outdated ?? false) !== value) yield* PubSub.publish(changes, { target, outdated: value })
|
||||
return value
|
||||
}),
|
||||
),
|
||||
update: (target) =>
|
||||
npm.update(target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => status.delete(target))),
|
||||
Effect.tap(() => PubSub.publish(changes, target)),
|
||||
Effect.tap(() => PubSub.publish(changes, { target, outdated: false })),
|
||||
Effect.asVoid,
|
||||
),
|
||||
changes: () => Stream.fromPubSub(changes),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Reference from "./reference.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Scope, Types } from "effect"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -25,7 +25,7 @@ export const Info = Reference.Info
|
||||
export type Info = Reference.Info
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Types.DeepMutable<Source>>
|
||||
sources: Map<string, Source>
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
@@ -49,10 +49,34 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const list = (): Info[] =>
|
||||
Array.from(state.get().sources).flatMap(([name, source]) => {
|
||||
const info = {
|
||||
name,
|
||||
source,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
}
|
||||
if (source.type === "local") return [Info.make({ ...info, path: source.path })]
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) return []
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return [
|
||||
Info.make({
|
||||
...info,
|
||||
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
|
||||
}),
|
||||
]
|
||||
})
|
||||
const refresh = Effect.fn("Reference.refresh")(function* () {
|
||||
yield* Effect.forEach(
|
||||
Array.from(materialized.values()),
|
||||
list(),
|
||||
(reference) =>
|
||||
Effect.gen(function* () {
|
||||
if (reference.source.type !== "git") return
|
||||
@@ -73,41 +97,12 @@ const layer = Layer.effect(
|
||||
name: "reference",
|
||||
initial: () => ({ sources: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
|
||||
add: (name, source) => draft.sources.set(name, source),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
list: () => Array.from(draft.sources),
|
||||
}),
|
||||
finalize: (draft) =>
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
for (const [name, source] of draft.list()) {
|
||||
const info = {
|
||||
name,
|
||||
source,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
}
|
||||
if (source.type === "local") {
|
||||
materialized.set(name, Info.make({ ...info, path: source.path }))
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
...info,
|
||||
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
yield* refresh()
|
||||
yield* bus.publish(Reference.Event.Updated, {})
|
||||
}),
|
||||
@@ -118,7 +113,7 @@ const layer = Layer.effect(
|
||||
reload: state.reload,
|
||||
refresh,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
return list()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -407,7 +407,7 @@ const layer = Layer.effect(
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const commands = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
return yield* Command.Service
|
||||
}).pipe(instances.provide(session))
|
||||
const delivery = input.delivery ?? "steer"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -10,18 +10,21 @@ import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { State } from "../state.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const IMAGE_TOKEN_ESTIMATE = 1_500
|
||||
const PDF_TOKEN_ESTIMATE = 2_000
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
<template>
|
||||
## Objective
|
||||
@@ -71,7 +74,9 @@ export type AutoInput = {
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
|
||||
readonly context: SessionContext.Loaded
|
||||
}
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
@@ -107,6 +112,67 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
|
||||
|
||||
export const estimateTokens = (input: RequiredInput) => {
|
||||
const index = input.messages.findLastIndex(
|
||||
(message) =>
|
||||
message.type === "assistant" &&
|
||||
!message.error &&
|
||||
message.tokens !== undefined &&
|
||||
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
|
||||
)
|
||||
const last = input.messages[index]
|
||||
// Keep the anchor's local tool results: they are not covered by its provider usage.
|
||||
const added = SessionModelRequest.unsupportedParts(
|
||||
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref),
|
||||
input.resolved.capabilities,
|
||||
)
|
||||
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
|
||||
.reduce((sum, message) => sum + message.content.reduce((sum, part) => sum + estimatePart(part), 0), 0)
|
||||
if (last?.type === "assistant" && last.tokens)
|
||||
return (
|
||||
added +
|
||||
last.tokens.input +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning
|
||||
)
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: input.context.agent.info,
|
||||
model: input.resolved,
|
||||
tools: input.context.tools,
|
||||
initial: input.context.initial,
|
||||
messages: [],
|
||||
})
|
||||
return (
|
||||
added +
|
||||
transcript.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
|
||||
input.context.tools.definitions.reduce(
|
||||
(sum, tool) => sum + Token.estimate(tool.name + tool.description + JSON.stringify(tool.inputSchema)),
|
||||
0,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const estimateMedia = (mime: string) => {
|
||||
const type = mime.toLowerCase()
|
||||
return type.startsWith("image/") ? IMAGE_TOKEN_ESTIMATE : type === "application/pdf" ? PDF_TOKEN_ESTIMATE : 0
|
||||
}
|
||||
|
||||
const estimatePart = (part: ContentPart): number => {
|
||||
if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text)
|
||||
if (part.type === "media") return estimateMedia(part.mediaType)
|
||||
if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? ""))
|
||||
if (part.result.type === "content")
|
||||
return part.result.value.reduce(
|
||||
(sum, content) => sum + (content.type === "text" ? Token.estimate(content.text) : estimateMedia(content.mime)),
|
||||
0,
|
||||
)
|
||||
return Token.estimate(
|
||||
typeof part.result.value === "string" ? part.result.value : (JSON.stringify(part.result.value) ?? ""),
|
||||
)
|
||||
}
|
||||
|
||||
export const truncateToolOutput = (value: string) => {
|
||||
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
|
||||
let end = 0
|
||||
@@ -364,27 +430,18 @@ export const layer = Layer.effect(
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
// Run the completed checkpoint before considering another automatic compaction.
|
||||
const last = input.messages.at(-1)
|
||||
if (last?.type === "compaction" && last.status === "completed") return false
|
||||
const limit = input.resolved.limit
|
||||
const context = limit.context
|
||||
if (context <= 0) return false
|
||||
const last = input.messages.findLast(
|
||||
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const used =
|
||||
last.tokens.input +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
return estimateTokens(input) >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
|
||||
@@ -125,7 +125,7 @@ const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
|
||||
@@ -37,7 +37,7 @@ export const make = Effect.fn("SessionPrompt.make")(function* () {
|
||||
messageID: SessionMessage.ID
|
||||
input: Input
|
||||
}) {
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
const event = yield* hooks.trigger("session", "prompt", {
|
||||
sessionID: request.sessionID,
|
||||
messageID: request.messageID,
|
||||
|
||||
@@ -38,7 +38,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
|
||||
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
const original = input.session.revert?.snapshot
|
||||
? Snapshot.ID.make(input.session.revert.snapshot)
|
||||
: yield* snapshot.capture()
|
||||
@@ -66,7 +66,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
|
||||
})
|
||||
|
||||
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
if (!session.revert) return
|
||||
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||
if (original)
|
||||
|
||||
@@ -57,7 +57,7 @@ const layer = Layer.effect(
|
||||
const control = pending.type === "compaction" || pending.type === "move"
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
|
||||
}
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
|
||||
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
|
||||
@@ -185,7 +185,7 @@ const layer = Layer.effect(
|
||||
resolved: loaded.model,
|
||||
prepare: context.prepare,
|
||||
}
|
||||
if (compaction.required(compactionInput)) {
|
||||
if (compaction.required({ ...compactionInput, context: loaded })) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
|
||||
@@ -196,7 +196,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
// Resolve shell services here without pinning Session events to this Location after a move.
|
||||
const shell = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
return yield* Shell.Service
|
||||
}).pipe(instances.provide(session))
|
||||
const started = yield* shell
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { ToolMode } from "@opencode-ai/schema/session-stats"
|
||||
import { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import { Database } from "../database/database.js"
|
||||
import { EventTable } from "../event/sql.js"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
@@ -341,7 +342,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
streak: longestStreak(days.map(([date]) => date)),
|
||||
activity: days.map(([date, steps]) => ({ date, steps })),
|
||||
models: [...models.values()]
|
||||
.sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens))
|
||||
.sort((a, b) => TokenUsage.total(b.tokens) - TokenUsage.total(a.tokens))
|
||||
.map((model) => ({ ...model, cost: Money.USD.make(model.cost) })),
|
||||
}
|
||||
})
|
||||
@@ -378,10 +379,6 @@ function addTokens(target: Tokens, source: Tokens) {
|
||||
target.cache.write += source.cache.write
|
||||
}
|
||||
|
||||
function tokenTotal(tokens: Tokens) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
function addToolStatus(
|
||||
target: { calls: number; succeeded: number; failed: number; unfinished: number },
|
||||
status: string | null,
|
||||
|
||||
@@ -109,7 +109,7 @@ const layer = Layer.effect(
|
||||
draft.skills.delete(ID.make(id))
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
||||
+97
-99
@@ -1,9 +1,9 @@
|
||||
export * as State from "./state.js"
|
||||
|
||||
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
|
||||
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
|
||||
|
||||
/**
|
||||
* A replayable transform applied to a draft during reload.
|
||||
* A synchronous, replayable edit to the current domain state.
|
||||
*
|
||||
* Domain drafts expose readable and writable state while preserving concise
|
||||
* plugin/config code. Transforms synchronously rebuild derived state.
|
||||
@@ -16,13 +16,14 @@ export interface Registration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers and applies a scoped transform. Closing the owning Scope removes
|
||||
* the transform and reloads the materialized state.
|
||||
* Registers a scoped transform. Reads apply pending transforms in order.
|
||||
* Closing the owning Scope removes the transform and invalidates accumulated state.
|
||||
*/
|
||||
export type Transform<DraftApi> = (
|
||||
transform: TransformCallback<DraftApi>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
/** Invalidates accumulated state after captured inputs change and coalesces notifications. */
|
||||
export type Reload = () => Effect.Effect<void>
|
||||
|
||||
export interface Transformable<DraftApi> {
|
||||
@@ -33,7 +34,7 @@ export interface Transformable<DraftApi> {
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly flush: boolean
|
||||
readonly reloads: Set<Reload>
|
||||
readonly notifications: Set<Reload>
|
||||
}
|
||||
|
||||
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
||||
@@ -41,17 +42,23 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
|
||||
})
|
||||
const reloadDebounce = 500
|
||||
|
||||
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
|
||||
/** Batches notifications, not read visibility or rollback. flush: false is terminal teardown. */
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active && options.flush !== false) return yield* effect
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
|
||||
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
|
||||
batch.active = false
|
||||
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
|
||||
return yield* exit
|
||||
})
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active && options.flush !== false) return yield* restore(effect)
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
|
||||
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
|
||||
batch.active = false
|
||||
const notifications = batch.flush
|
||||
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
|
||||
: []
|
||||
// Aggregate ordinary failures across domains, while allowing cancellation to stop observer work.
|
||||
yield* Exit.asVoidAll([exit, ...notifications])
|
||||
return yield* exit
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const inherit = Effect.fnUntraced(function* () {
|
||||
@@ -61,128 +68,119 @@ export const inherit = Effect.fnUntraced(function* () {
|
||||
|
||||
export interface Options<State, DraftApi> {
|
||||
readonly name?: string
|
||||
/** Creates the base value for initial state and every scoped-transform reload. */
|
||||
/** Creates the base value initially and after removal, reload, or failed replay. */
|
||||
readonly initial: () => State
|
||||
/** Wraps mutable state in a domain-specific draft API. */
|
||||
readonly draft: MakeDraft<State, DraftApi>
|
||||
/**
|
||||
* Runs after the rebuilt state becomes visible. Update events published here
|
||||
* act as read barriers: subscribers refetching on the event observe the
|
||||
* committed state.
|
||||
* Observes current state outside the read path. Batched changes notify at
|
||||
* batch completion; reloads debounce notifications. Resource reconciliation
|
||||
* owns its execution scope and coordination.
|
||||
*/
|
||||
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
|
||||
readonly notify?: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
||||
/** Applies pending edits synchronously. The returned value is a live view, not a retained snapshot. */
|
||||
readonly get: () => State
|
||||
}
|
||||
|
||||
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
|
||||
let state = options.initial()
|
||||
let transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||
let generation = 0
|
||||
const transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||
let prefix: { draft: DraftApi; applied: number } | undefined = { draft: options.draft(state), applied: 0 }
|
||||
let requestedAt = 0
|
||||
let running = false
|
||||
let closed = false
|
||||
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
let pending: Deferred.Deferred<void> | undefined
|
||||
|
||||
const commit = Effect.fn("State.commit")(function* (next: State) {
|
||||
state = next
|
||||
if (options.finalize) yield* options.finalize(options.draft(next))
|
||||
})
|
||||
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms) {
|
||||
yield* Effect.sync(() => {
|
||||
transform.run(api)
|
||||
})
|
||||
const get = () => {
|
||||
if (closed || prefix?.applied === transforms.length) return state
|
||||
const cached = prefix
|
||||
// A callback can throw after mutating the accumulator. Retry from a fresh base, never that partial prefix.
|
||||
prefix = undefined
|
||||
const next = cached ? state : options.initial()
|
||||
const draft = cached ? cached.draft : options.draft(next)
|
||||
for (let index = cached?.applied ?? 0; index < transforms.length; index++) {
|
||||
transforms[index].run(draft)
|
||||
}
|
||||
yield* commit(next)
|
||||
state = next
|
||||
prefix = cached ?? { draft, applied: 0 }
|
||||
prefix.applied = transforms.length
|
||||
return state
|
||||
}
|
||||
|
||||
const notify = Effect.fn("State.notify")(function* () {
|
||||
if (closed) return
|
||||
get()
|
||||
if (options.notify) yield* options.notify()
|
||||
})
|
||||
|
||||
const materializeReload = () => semaphore.withPermit(materialize())
|
||||
|
||||
const rebuild = (): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const clock = yield* Clock.Clock
|
||||
const publish = Effect.fnUntraced(function* (done: Deferred.Deferred<void>) {
|
||||
const clock = yield* Clock.Clock
|
||||
do {
|
||||
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
|
||||
if (remaining > 0) yield* Effect.sleep(remaining)
|
||||
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
|
||||
} while (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce)
|
||||
|
||||
const target = generation
|
||||
const exit = yield* materializeReload().pipe(Effect.exit)
|
||||
const completed = waiters.filter((waiter) => waiter.generation <= target)
|
||||
waiters = waiters.filter((waiter) => waiter.generation > target)
|
||||
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
if (generation > target) return yield* rebuild()
|
||||
running = false
|
||||
})
|
||||
|
||||
const reload = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const done = Deferred.makeUnsafe<void>()
|
||||
const clock = yield* Clock.Clock
|
||||
generation++
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
waiters.push({ generation, done })
|
||||
if (!running) {
|
||||
running = true
|
||||
yield* rebuild().pipe(Effect.forkDetach)
|
||||
}
|
||||
yield* Deferred.await(done)
|
||||
// Observers can request and await another reload without joining their own notification.
|
||||
pending = undefined
|
||||
yield* notify().pipe(Deferred.into(done))
|
||||
})
|
||||
|
||||
const changed = (debounce: boolean) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
if (debounce) prefix = undefined
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.notifications.add(notify)
|
||||
return
|
||||
}
|
||||
if (!debounce) {
|
||||
yield* restore(notify())
|
||||
return
|
||||
}
|
||||
|
||||
const clock = yield* Clock.Clock
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
const done = pending ?? Deferred.makeUnsafe<void>()
|
||||
if (!pending) {
|
||||
pending = done
|
||||
yield* publish(done).pipe(Effect.forkDetach)
|
||||
}
|
||||
yield* restore(Deferred.await(done))
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
get: () => state,
|
||||
get,
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
||||
const scope = yield* Scope.Scope
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { run: update }
|
||||
let active = true
|
||||
const dispose = Effect.uninterruptible(
|
||||
semaphore.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (!active) return Effect.void
|
||||
active = false
|
||||
transforms = transforms.filter((item) => item !== transform)
|
||||
return Effect.gen(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
// Detached debounced reloads must also stay quiet after teardown.
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.reloads.add(materializeReload)
|
||||
return
|
||||
}
|
||||
yield* materialize()
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
transforms = [...transforms, transform]
|
||||
Effect.suspend(() => {
|
||||
const index = transforms.indexOf(transform)
|
||||
if (index < 0) return Effect.void
|
||||
transforms.splice(index, 1)
|
||||
prefix = undefined
|
||||
return changed(false)
|
||||
}),
|
||||
)
|
||||
transforms.push(transform)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) batch.reloads.add(materializeReload)
|
||||
else yield* materializeReload()
|
||||
yield* changed(false)
|
||||
return { dispose }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
reload,
|
||||
reload: () => changed(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ const layer = Layer.effect(
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () =>
|
||||
notify: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ kind, name, namespace, error }) =>
|
||||
@@ -222,7 +222,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const namespaces = state.get().namespaces
|
||||
const namespaces = new Map(state.get().namespaces)
|
||||
const codeModeInventory = { tools: codeModeTools, namespaces }
|
||||
const codeModeEnabled = !whollyDisabled("execute", rules)
|
||||
const codeModeTool = codeModeEnabled
|
||||
|
||||
+35
-10
@@ -1,7 +1,7 @@
|
||||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import path from "path"
|
||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
@@ -55,8 +55,11 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const root = yield* Effect.scope
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const vcs = location.vcs
|
||||
const current: { info: Info } = { info: { branch: {} } }
|
||||
const refreshLock = Semaphore.makeUnsafe(1)
|
||||
const scope = {
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
@@ -78,7 +81,13 @@ const layer = Layer.effect(
|
||||
set: (selection) => (draft.selection = selection),
|
||||
},
|
||||
}),
|
||||
finalize: () => refresh(),
|
||||
// The layer owns both active refreshes and work queued behind the lock.
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fork(refresh()))
|
||||
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
yield* exit
|
||||
}),
|
||||
})
|
||||
const selected = () => {
|
||||
const value = state.get()
|
||||
@@ -117,13 +126,23 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
const refresh = Effect.fn("Vcs.refresh")(function* () {
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
|
||||
: { branch: {} }
|
||||
const changed = current.info.branch.current !== next.branch.current
|
||||
current.info = next
|
||||
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
const changed = yield* Effect.gen(function* () {
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
|
||||
: { branch: {} }
|
||||
const changed = current.info.branch.current !== next.branch.current
|
||||
current.info = next
|
||||
return changed
|
||||
}).pipe(Semaphore.withPermit(refreshLock))
|
||||
if (!changed) return
|
||||
// Legacy listeners can publish nested updates before streams and SSE receive
|
||||
// this event. Re-announce the latest branch if publication was overtaken.
|
||||
while (true) {
|
||||
const branch = current.info.branch.current
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
|
||||
if (branch === current.info.branch.current) return
|
||||
}
|
||||
})
|
||||
|
||||
if (vcs) {
|
||||
@@ -135,7 +154,13 @@ const layer = Layer.effect(
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.filter((event) => isBranchMetadata(event.data.file)),
|
||||
Stream.runForEach((event) =>
|
||||
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
|
||||
refresh().pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) => Effect.logWarning("vcs refresh failed", { file: event.data.file, cause }),
|
||||
),
|
||||
Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } }),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ const layer = Layer.effect(
|
||||
set: (selection) => (draft.selection = selection),
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -9,6 +11,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
@@ -30,6 +33,52 @@ const catalogLayer = AppNodeBuilder.build(
|
||||
const it = testEffect(catalogLayer)
|
||||
|
||||
describe("Catalog", () => {
|
||||
;["variant", "empty-key", "metadata", "aisdk"].forEach((path) =>
|
||||
it.effect(`keeps nested catalog values editable after ${path} model resolution`, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("resolve-fixture")
|
||||
const modelID = Model.ID.make("fixture-model")
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.model.update(providerID, modelID, (model) => {
|
||||
model.package = path === "aisdk" ? Provider.aisdk("@ai-sdk/fixture") : "@opencode-ai/ai/providers/openai"
|
||||
model.settings = {
|
||||
apiKey: path === "empty-key" ? "" : "fixture-key",
|
||||
baseURL: "https://fixture.example/v1",
|
||||
}
|
||||
model.variants = [{ id: Model.VariantID.make("high"), body: { reasoning: { effort: "high" } } }]
|
||||
}),
|
||||
)
|
||||
const selected = required(yield* catalog.model.get(providerID, modelID))
|
||||
if (path === "variant") yield* ModelResolver.withVariant(selected, Model.VariantID.make("high"))
|
||||
if (path !== "variant")
|
||||
yield* ModelResolver.fromCatalogModel(
|
||||
selected,
|
||||
path === "metadata"
|
||||
? Credential.Key.make({ type: "key", key: "fixture-key", metadata: { tenant: "fixture" } })
|
||||
: undefined,
|
||||
{
|
||||
loadAISDK: () =>
|
||||
Effect.succeed(LanguageModel.make({ id: modelID, provider: providerID, route: OpenAIChat.route })),
|
||||
},
|
||||
)
|
||||
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.model.update(providerID, modelID, (model) => {
|
||||
model.limit.context = 100_000
|
||||
model.capabilities.tools = false
|
||||
model.variants.push({ id: Model.VariantID.make("other") })
|
||||
}),
|
||||
)
|
||||
expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({
|
||||
limit: { context: 100_000 },
|
||||
capabilities: { tools: false },
|
||||
variants: [{ id: "high" }, { id: "other" }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -145,10 +145,8 @@ const session = Session.Info.make({
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
|
||||
})
|
||||
const input = (tokens: number) => ({
|
||||
session,
|
||||
resolved,
|
||||
messages: [
|
||||
const input = (tokens: number) => {
|
||||
const messages = [
|
||||
Schema.decodeUnknownSync(SessionMessage.Assistant)({
|
||||
id: SessionMessage.ID.make("msg_compaction_config"),
|
||||
type: "assistant",
|
||||
@@ -158,7 +156,20 @@ const input = (tokens: number) => ({
|
||||
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, completed: 0 },
|
||||
}),
|
||||
],
|
||||
})
|
||||
]
|
||||
return {
|
||||
session,
|
||||
resolved,
|
||||
messages,
|
||||
context: {
|
||||
session,
|
||||
model: resolved,
|
||||
messages,
|
||||
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
|
||||
initial: "",
|
||||
tools: { definitions: [], execute: () => Effect.die("unused") },
|
||||
},
|
||||
}
|
||||
}
|
||||
const bufferedInput = input(85_000)
|
||||
const nearInput = input(95_000)
|
||||
|
||||
@@ -20,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
|
||||
import { Cause, Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -217,12 +217,15 @@ describe("PluginSupervisor config", () => {
|
||||
)
|
||||
|
||||
it.live("logs invalid packages and continues loading", () => {
|
||||
const output: string[] = []
|
||||
const output: Array<{ target: string; ref: string; diagnostic: string }> = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
|
||||
const details = entry.message[1]
|
||||
if (typeof details !== "object" || details === null || !("target" in details)) return
|
||||
if (typeof details.target === "string") output.push(details.target)
|
||||
if (typeof details !== "object" || details === null) return
|
||||
if (!("target" in details) || typeof details.target !== "string") return
|
||||
if (!("ref" in details) || typeof details.ref !== "string") return
|
||||
if (!("cause" in details) || !Cause.isCause(details.cause)) return
|
||||
output.push({ target: details.target, ref: details.ref, diagnostic: Cause.pretty(details.cause) })
|
||||
})
|
||||
return withLocation(
|
||||
{
|
||||
@@ -230,6 +233,7 @@ describe("PluginSupervisor config", () => {
|
||||
"-*",
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/load-failure"),
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
|
||||
options: { description: "Loaded after invalid plugins" },
|
||||
@@ -243,16 +247,30 @@ describe("PluginSupervisor config", () => {
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded after invalid plugins",
|
||||
})
|
||||
expect(output).toEqual([
|
||||
expect(output.map((entry) => entry.target)).toEqual([
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts"),
|
||||
])
|
||||
expect(
|
||||
(yield* plugins.list()).filter((plugin) => plugin.state.status === "failed").map((plugin) => plugin.source),
|
||||
).toEqual([
|
||||
const failed = (yield* plugins.list()).filter((plugin) => plugin.state.status === "failed")
|
||||
expect(failed.map((plugin) => plugin.source)).toEqual([
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts") },
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/load-failure/index.ts") },
|
||||
])
|
||||
expect(failed.map((plugin) => plugin.state)).toEqual([
|
||||
{ status: "failed", error: "Plugin failed to load", ref: output[0]?.ref },
|
||||
{
|
||||
status: "failed",
|
||||
error: "Plugin must export a default definition with an id and an effect or setup function.",
|
||||
ref: output[1]?.ref,
|
||||
},
|
||||
{ status: "failed", error: "Plugin failed to load", ref: output[2]?.ref },
|
||||
])
|
||||
output.forEach((entry) => expect(entry.ref).toMatch(/^err_[0-9a-f]{8}$/))
|
||||
expect(new Set(output.map((entry) => entry.ref)).size).toBe(3)
|
||||
expect(output[2]?.diagnostic).toContain("private plugin loader details")
|
||||
expect(JSON.stringify(failed)).not.toContain("private plugin loader details")
|
||||
}),
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
@@ -490,7 +508,7 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("unblocks flush when plugin activation fails", () =>
|
||||
it.live("unblocks awaitActivation when plugin activation fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
@@ -530,19 +548,18 @@ describe("PluginSupervisor config", () => {
|
||||
const plugins = yield* Plugin.Service
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
|
||||
expect(Option.isNone(yield* supervisor.awaitActivation.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
|
||||
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
|
||||
yield* supervisor.flush.pipe(Effect.timeout("2 seconds"))
|
||||
yield* supervisor.awaitActivation.pipe(Effect.timeout("2 seconds"))
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
})
|
||||
|
||||
const waitForFile = (file: string) =>
|
||||
@@ -610,12 +627,10 @@ function discoveredPlugin(id: string) {
|
||||
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
|
||||
}
|
||||
|
||||
async function writeDiscoveredPackage(
|
||||
directory: string,
|
||||
name: string,
|
||||
files: Record<string, string>,
|
||||
) {
|
||||
async function writeDiscoveredPackage(directory: string, name: string, files: Record<string, string>) {
|
||||
const plugin = path.join(directory, ".opencode", "plugins", name)
|
||||
await fs.mkdir(plugin, { recursive: true })
|
||||
await Promise.all(Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))))
|
||||
await Promise.all(
|
||||
Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
|
||||
|
||||
describe("LocationWatcherPolicy", () => {
|
||||
it.effect("reads batched registrations and disposals before notifying observers", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
expect(policy.current()).toEqual(ignore)
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* policy.transform((draft) => draft.add(["node_modules"]))
|
||||
expect(policy.current()).toEqual(["node_modules"])
|
||||
const overlay = yield* policy.transform((draft) => draft.add([".git"]))
|
||||
expect(policy.current()).toEqual(["node_modules", ".git"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* overlay.dispose
|
||||
expect(policy.current()).toEqual(["node_modules"])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["node_modules"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
const config = { ignore: [".git"] }
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
yield* policy.transform((draft) => draft.add(config.ignore))
|
||||
expect(policy.current()).toEqual([".git"])
|
||||
observed.length = 0
|
||||
|
||||
config.ignore = [".hg"]
|
||||
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual([".hg"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual([[".hg"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const observed: string[][] = []
|
||||
let reentered = false
|
||||
yield* policy.observe(() =>
|
||||
Effect.gen(function* () {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
yield* policy.transform((draft) => draft.add([".git"])).pipe(Scope.provide(scope))
|
||||
expect(policy.current()).toEqual(["node_modules", ".git"])
|
||||
}),
|
||||
)
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
expect(policy.current()).toEqual(ignore)
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
yield* policy.transform((draft) => draft.add(["node_modules"]))
|
||||
|
||||
expect(policy.current()).toEqual(["node_modules", ".git"])
|
||||
expect(observed).toEqual([
|
||||
["node_modules", ".git"],
|
||||
["node_modules", ".git"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets an observer await a reload and keeps later observers current", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
const config = { ignore: [".git"] }
|
||||
let reentered = false
|
||||
yield* policy.observe(() =>
|
||||
Effect.gen(function* () {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
config.ignore = [".hg"]
|
||||
yield* policy.reload()
|
||||
}),
|
||||
)
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
expect(policy.current()).toEqual(ignore)
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
const writer = yield* policy
|
||||
.transform((draft) => draft.add(config.ignore))
|
||||
.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual([".hg"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(writer)
|
||||
expect(observed).toEqual([[".hg"], [".hg"]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -31,7 +31,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
|
||||
const configLayer = Config.testLayer()
|
||||
const pluginNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
@@ -313,7 +313,7 @@ describe("LocationWatcher subscriptions", () => {
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* policy.transform((draft) => draft.add([".git"]))
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void })
|
||||
return PluginSupervisor.Service.of({ awaitActivation: Effect.void })
|
||||
}),
|
||||
),
|
||||
deps: [LocationWatcherPolicy.node],
|
||||
|
||||
@@ -31,7 +31,7 @@ export const promptLocationNode = makeGlobalNode({
|
||||
}),
|
||||
Layer.succeed(FSUtil.Service, fs),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.void,
|
||||
awaitActivation: Effect.void,
|
||||
}),
|
||||
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
|
||||
),
|
||||
|
||||
@@ -44,7 +44,7 @@ function withFormatter<A, E, R>(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
return yield* body(yield* Formatter.Service, directory)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("InstancePlugins", () => {
|
||||
const agents = (ref: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
const service = yield* Agent.Service
|
||||
return {
|
||||
bound: yield* service.get(Agent.ID.make("thread-a-agent")),
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("Instance vanilla", () => {
|
||||
const read = (ref: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
const config = yield* Config.Service
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
@@ -145,7 +145,7 @@ describe("Instance vanilla", () => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
const config = yield* Config.Service
|
||||
// Only the pathless host-injected document; nothing file-backed.
|
||||
const entries = yield* config.entries()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
|
||||
@@ -429,6 +430,62 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails and closes OAuth attempts when a pending transform throws during persistence", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("replay-fixture")
|
||||
const methodID = Integration.MethodID.make("code")
|
||||
let closed = false
|
||||
yield* integrations.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Fixture" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||
Effect.as({
|
||||
mode: "code" as const,
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Enter the fixture code",
|
||||
callback: () =>
|
||||
Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "fixture-access",
|
||||
refresh: "fixture-refresh",
|
||||
expires: 1,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID })
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const failure = new Error("integration transform failed")
|
||||
yield* integrations.transform(() => {
|
||||
throw failure
|
||||
})
|
||||
const exit = yield* integrations.oauth
|
||||
.complete({ integrationID, attemptID: attempt.attemptID, code: "fixture-code" })
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
|
||||
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
|
||||
status: "failed",
|
||||
message: failure.message,
|
||||
time: attempt.time,
|
||||
})
|
||||
expect(closed).toBe(true)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("expires abandoned OAuth attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -199,7 +199,7 @@ describe("LocationServiceMap", () => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const read = Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
const agents = yield* Agent.Service
|
||||
return yield* agents.get(id)
|
||||
})
|
||||
@@ -233,14 +233,14 @@ describe("LocationServiceMap", () => {
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
yield* Fiber.join(activationFiber)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
@@ -281,7 +281,7 @@ describe("LocationServiceMap", () => {
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
@@ -296,10 +296,10 @@ describe("LocationServiceMap", () => {
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* Fiber.join(activationFiber)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -352,22 +352,22 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
yield* Fiber.join(updated)
|
||||
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* Fiber.join(activationFiber)
|
||||
expect(activations.count).toBe(2)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("keeps flush pending while startup updates continue", () =>
|
||||
itWithSdk.live("keeps awaitActivation pending while startup updates continue", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -376,7 +376,7 @@ describe("LocationServiceMap", () => {
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
@@ -387,8 +387,8 @@ describe("LocationServiceMap", () => {
|
||||
() => bus.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
|
||||
{ discard: true },
|
||||
)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Fiber.join(flushFiber)
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Fiber.join(activationFiber)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -412,7 +412,7 @@ describe("LocationServiceMap", () => {
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
|
||||
expect(activations.count).toBe(1)
|
||||
|
||||
yield* Bus.Service.use((bus) => bus.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
|
||||
@@ -424,7 +424,7 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("keeps flush open while later hot reload runs", () =>
|
||||
itWithSdk.live("keeps awaitActivation pending while later hot reload runs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -433,7 +433,7 @@ describe("LocationServiceMap", () => {
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
|
||||
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
@@ -451,20 +451,20 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* Fiber.join(activationFiber)
|
||||
yield* Deferred.await(completed)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
|
||||
itWithSdk.live("does not cancel activation when an awaitActivation waiter is interrupted", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -489,15 +489,15 @@ describe("LocationServiceMap", () => {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Fiber.interrupt(flushFiber)
|
||||
yield* Fiber.interrupt(activationFiber)
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Deferred.await(completed)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("500 millis"),
|
||||
)
|
||||
@@ -519,7 +519,7 @@ describe("LocationServiceMap", () => {
|
||||
const plugins = yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
@@ -546,7 +546,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
||||
@@ -710,7 +710,7 @@ describe("LocationServiceMap", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
const registry = yield* Tool.Service
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
@@ -992,7 +992,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
expect(observed.example).toBe(false)
|
||||
yield* mcp.add("dynamic", {
|
||||
type: "remote",
|
||||
|
||||
@@ -34,9 +34,24 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import {
|
||||
Context,
|
||||
Deferred,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
PubSub,
|
||||
Ref,
|
||||
Schedule,
|
||||
Schema,
|
||||
Scope,
|
||||
Sink,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
@@ -69,7 +84,7 @@ function resourceServer(
|
||||
listChanged?: boolean
|
||||
emptyElicitation?: boolean
|
||||
urlElicitation?: boolean
|
||||
respond?: (request: Request) => Response | undefined
|
||||
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
|
||||
} = {},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
@@ -178,7 +193,7 @@ function resourceServer(
|
||||
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
|
||||
state.initializations += 1
|
||||
}
|
||||
return input.respond?.(request) ?? transport.handleRequest(request)
|
||||
return (await input.respond?.(request)) ?? transport.handleRequest(request)
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1331,6 +1346,9 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
|
||||
yield* service.transform((draft) => draft.update("dynamic", (server) => (server.codemode = false)))
|
||||
expect((yield* service.tools()).map((tool) => tool.codemode)).toEqual([false, false])
|
||||
|
||||
const settings = { disabled: true }
|
||||
yield* service.transform((draft) => {
|
||||
draft.update("dynamic", (server) => {
|
||||
@@ -1415,7 +1433,7 @@ test("isolates nested configured MCP mutations and reconciles them", async () =>
|
||||
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("resources", (server) => {
|
||||
if (server.type === "remote") server.headers = { Authorization: "transformed" }
|
||||
if (server.type === "remote" && server.headers) server.headers.Authorization = "transformed"
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1426,6 +1444,41 @@ test("isolates nested configured MCP mutations and reconciles them", async () =>
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("batches MCP transforms without connecting intermediate configurations", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer()
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
const registrations = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const added = yield* service.transform((draft) =>
|
||||
draft.set("dynamic", {
|
||||
type: "remote",
|
||||
url: server.url,
|
||||
oauth: false,
|
||||
}),
|
||||
)
|
||||
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
||||
const disabled = yield* service.transform((draft) =>
|
||||
draft.update("dynamic", (config) => (config.disabled = true)),
|
||||
)
|
||||
return [added, disabled]
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||
status: "disabled",
|
||||
})
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
yield* State.batch(Effect.forEach(registrations, (registration) => registration.dispose))
|
||||
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
||||
expect(server.state.initializations).toBe(0)
|
||||
}).pipe(
|
||||
Effect.provide(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
test("reconciles only changed MCP server config", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
@@ -1516,6 +1569,133 @@ test("reconciles only changed MCP server config", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("keeps MCP config snapshots stable during an in-flight replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const accepted = yield* Deferred.make<void>()
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) =>
|
||||
request.method !== "POST"
|
||||
? undefined
|
||||
: Effect.runPromise(
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
|
||||
),
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
const replacing = yield* service
|
||||
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const restoring = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(accepted)
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(replacing)
|
||||
yield* Fiber.join(restoring)
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
expect(server.state.initializations).toBe(1)
|
||||
}).pipe(
|
||||
Effect.ensuring(Deferred.succeed(release, undefined)),
|
||||
Effect.provide(
|
||||
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const shutdownIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
|
||||
[
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
),
|
||||
),
|
||||
Environment.node.replace(hostEnvironmentLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
for (const phase of ["active", "queued"]) {
|
||||
shutdownIt.effect(`discards ${phase} MCP notifications after its layer closes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
|
||||
const service = Context.get(context, Mcp.Service)
|
||||
const observed: string[] = []
|
||||
let block = false
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== McpEvent.StatusChanged.type) return
|
||||
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
|
||||
if (!block) return
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
const source = { url: "https://example.com/initial", added: false }
|
||||
yield* service
|
||||
.transform((draft) => {
|
||||
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
|
||||
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
|
||||
block = true
|
||||
source.url = "https://example.com/first"
|
||||
source.added = phase === "active"
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
source.url = "https://example.com/second"
|
||||
source.added = true
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
|
||||
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(first.pollUnsafe()).toBeDefined()
|
||||
expect(second.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
yield* Fiber.join(shutdown)
|
||||
observed.length = 0
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(observed).toEqual([])
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -233,35 +233,32 @@ describe("Npm.add", () => {
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
|
||||
test("installs and resolves named and unnamed Git packages with dependencies", async () => {
|
||||
test.each(["unnamed", "named"])("installs and resolves %s Git packages with dependencies", async (kind) => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const specs = [
|
||||
`git+file://${fixture.repository}#${fixture.commit}`,
|
||||
`fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`,
|
||||
]
|
||||
const spec =
|
||||
kind === "unnamed"
|
||||
? `git+file://${fixture.repository}#${fixture.commit}`
|
||||
: `fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`
|
||||
|
||||
for (const spec of specs) {
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
added: yield* npm.add(spec),
|
||||
cached: yield* npm.add(spec),
|
||||
resolved: yield* npm.resolve(spec),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
added: yield* npm.add(spec),
|
||||
cached: yield* npm.add(spec),
|
||||
resolved: yield* npm.resolve(spec),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
|
||||
expect(entries.added.version).toBe(fixture.commit)
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
|
||||
expect(entries.added.directory).toContain("node_modules")
|
||||
}
|
||||
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
|
||||
expect(entries.added.version).toBe(fixture.commit)
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
|
||||
expect(entries.added.directory).toContain("node_modules")
|
||||
})
|
||||
|
||||
test("installs a Git package from an npm ::path: subdirectory", async () => {
|
||||
@@ -354,23 +351,19 @@ describe("Npm.resolve", () => {
|
||||
})
|
||||
|
||||
describe("Npm.check and Npm.update", () => {
|
||||
test("checks registry targets without mutation and explicitly updates mutable targets", async () => {
|
||||
test("checks a mutable registry target without mutation and explicitly updates it", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const mutable = "@fixture/registry-plugin@latest"
|
||||
const pinned = "@fixture/registry-plugin@1.0.0"
|
||||
const root = await registry.configure(cache, mutable)
|
||||
await registry.configure(cache, pinned)
|
||||
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(mutable)
|
||||
yield* npm.add(pinned)
|
||||
const current = yield* npm.check(mutable)
|
||||
registry.state.latest = "1.1.0"
|
||||
const outdated = yield* npm.check(mutable)
|
||||
const pinnedOutdated = yield* npm.check(pinned)
|
||||
const before = yield* Effect.promise(() => Bun.file(path.join(installed.directory, "index.js")).text())
|
||||
yield* Effect.promise(() => Promise.all([fs.mkdir(path.join(root, "1")), fs.mkdir(path.join(root, "2"))]))
|
||||
const updated = yield* npm.update(mutable)
|
||||
@@ -378,7 +371,6 @@ describe("Npm.check and Npm.update", () => {
|
||||
return {
|
||||
current,
|
||||
outdated,
|
||||
pinnedOutdated,
|
||||
before,
|
||||
after: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
|
||||
version: updated.version,
|
||||
@@ -391,7 +383,6 @@ describe("Npm.check and Npm.update", () => {
|
||||
|
||||
expect(result.current).toBeFalse()
|
||||
expect(result.outdated).toBeTrue()
|
||||
expect(result.pinnedOutdated).toBeFalse()
|
||||
expect(result.before).toContain('version = "1.0.0"')
|
||||
expect(result.after).toContain('version = "1.1.0"')
|
||||
expect(result.version).toBe("1.1.0")
|
||||
@@ -401,4 +392,21 @@ describe("Npm.check and Npm.update", () => {
|
||||
expect(result.generations).not.toContain("2")
|
||||
expect(result.updated).toBeFalse()
|
||||
})
|
||||
|
||||
test("never reports a pinned registry target as outdated", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const pinned = "@fixture/registry-plugin@1.0.0"
|
||||
await registry.configure(cache, pinned)
|
||||
|
||||
const outdated = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
yield* npm.add(pinned)
|
||||
registry.state.latest = "1.1.0"
|
||||
return yield* npm.check(pinned)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
|
||||
expect(outdated).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Clock, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
@@ -224,6 +227,68 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("refreshes expired OAuth credentials through the context during activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const clock = yield* Clock.Clock
|
||||
const integrationID = Integration.ID.make("refresh-fixture")
|
||||
const methodID = Integration.MethodID.make("oauth")
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "expired-access",
|
||||
refresh: "fixture-refresh",
|
||||
expires: (yield* Clock.currentTimeMillis) + 60_000,
|
||||
})
|
||||
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
const refreshed = Credential.OAuth.make({
|
||||
...expired,
|
||||
access: "fresh-access",
|
||||
refresh: "rotated-refresh",
|
||||
expires: (yield* Clock.currentTimeMillis) + 3_600_000,
|
||||
})
|
||||
const refreshes: Credential.OAuth[] = []
|
||||
const resolved: Array<Credential.Value | undefined> = []
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "oauth-refresh",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.integration.transform((draft) =>
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Fixture" },
|
||||
authorize: () => Effect.die("unexpected authorization"),
|
||||
refresh: (value) =>
|
||||
Effect.sync(() => {
|
||||
refreshes.push(value)
|
||||
return refreshed
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
if (!connection) return yield* Effect.die("fixture connection not found")
|
||||
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
|
||||
}).pipe(
|
||||
// Plugin activation isolates ambient services, including the test clock.
|
||||
Effect.provideService(Clock.Clock, clock),
|
||||
),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(plugin)])
|
||||
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: "oauth-refresh", state: { status: "active" } }])
|
||||
expect(resolved).toEqual([refreshed])
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(refreshed)
|
||||
expect(
|
||||
yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: stored.label }),
|
||||
).toEqual(refreshed)
|
||||
expect(refreshes).toEqual([expired])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces plugins by ID and revision", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -358,9 +423,7 @@ describe("Plugin", () => {
|
||||
it.effect("reports activated and discovered plugin features", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.activate([
|
||||
{ id: "rpc-plugin", revision: "1", features: { rpc: true }, effect: () => Effect.void },
|
||||
])
|
||||
yield* plugins.activate([{ id: "rpc-plugin", revision: "1", features: { rpc: true }, effect: () => Effect.void }])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
throw new Error("private plugin loader details")
|
||||
@@ -14,7 +14,6 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { fakeSelectorSdk } from "../fixture/selector"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -54,12 +53,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Ef
|
||||
)
|
||||
}
|
||||
|
||||
function withAzureCommands<A, E, R>(
|
||||
run: (args: readonly string[]) => unknown,
|
||||
fx: () => Effect.Effect<A, E, R>,
|
||||
deploymentDelay = 0,
|
||||
signedIn = true,
|
||||
) {
|
||||
function withAzureCommands<A, E, R>(run: (args: readonly string[]) => unknown, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const processes = yield* AppProcess.Service
|
||||
const directory = (yield* Location.Service).directory
|
||||
@@ -68,9 +62,6 @@ function withAzureCommands<A, E, R>(
|
||||
Bun.write(executable, process.platform === "win32" ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"),
|
||||
)
|
||||
yield* Effect.promise(() => chmod(executable, 0o755))
|
||||
if (signedIn) {
|
||||
yield* Effect.promise(() => Bun.write(`${directory}/azure-cli/azureProfile.json`, '{"subscriptions":[{}]}'))
|
||||
}
|
||||
const fake = AppProcess.Service.of({
|
||||
...processes,
|
||||
run: (command) => {
|
||||
@@ -79,7 +70,7 @@ function withAzureCommands<A, E, R>(
|
||||
if (value instanceof Error) {
|
||||
return Effect.fail(new AppProcess.AppProcessError({ command: "az", cause: value }))
|
||||
}
|
||||
const result = Effect.succeed({
|
||||
return Effect.succeed({
|
||||
command: `az ${command.args.join(" ")}`,
|
||||
exitCode: 0,
|
||||
stdout: Buffer.from(JSON.stringify(value)),
|
||||
@@ -87,16 +78,11 @@ function withAzureCommands<A, E, R>(
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
})
|
||||
if (deploymentDelay > 0 && command.args.includes("deployment")) {
|
||||
return Effect.sleep(`${deploymentDelay} millis`).pipe(Effect.andThen(result))
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
return yield* withEnv(
|
||||
{
|
||||
PATH: `${directory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`,
|
||||
AZURE_CONFIG_DIR: `${directory}/azure-cli`,
|
||||
},
|
||||
() => fx().pipe(Effect.provideService(AppProcess.Service, fake)),
|
||||
)
|
||||
@@ -177,13 +163,12 @@ describe("AzurePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not invoke Azure CLI at startup without a cached Azure login", () => {
|
||||
it.live("does not invoke Azure CLI at startup without an Azure connection", () => {
|
||||
const commands: string[] = []
|
||||
return withEnv(
|
||||
{
|
||||
AZURE_RESOURCE_NAME: undefined,
|
||||
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined,
|
||||
AZURE_RESOURCE_GROUP: undefined,
|
||||
},
|
||||
() =>
|
||||
withAzureCommands(
|
||||
@@ -198,48 +183,20 @@ describe("AzurePlugin", () => {
|
||||
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
|
||||
expect(integration?.methods.some((method) => method.type === "oauth")).toBe(true)
|
||||
}),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("lists Azure CLI resources and keeps manual resource entry available", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
withAzureCommands(
|
||||
() => [
|
||||
{ name: "first-resource", resourceGroup: "first-group" },
|
||||
{ name: "second-resource", resourceGroup: "second-group" },
|
||||
],
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("azure"))
|
||||
const method = integration?.methods.find((item) => item.type === "oauth")
|
||||
expect(method?.form?.[0]).toMatchObject({
|
||||
title: "Enter Azure Resource Name",
|
||||
options: [
|
||||
{ value: "first-resource", label: "first-resource", description: "first-group" },
|
||||
{ value: "second-resource", label: "second-resource", description: "second-group" },
|
||||
],
|
||||
custom: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("connects with the Azure CLI and accepts legacy token expiration", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
it.live("connects with the Azure CLI and accepts legacy token expiration", () => {
|
||||
const commands: string[][] = []
|
||||
return withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
if (args.includes("get-access-token")) {
|
||||
return {
|
||||
accessToken: "legacy-cli-token",
|
||||
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
}
|
||||
commands.push([...args])
|
||||
return {
|
||||
accessToken: "legacy-cli-token",
|
||||
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
}
|
||||
return []
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
@@ -262,130 +219,28 @@ describe("AzurePlugin", () => {
|
||||
access: "legacy-cli-token",
|
||||
metadata: { resourceName: "test-resource" },
|
||||
})
|
||||
expect(commands).toEqual([
|
||||
[
|
||||
"account",
|
||||
"get-access-token",
|
||||
"--scope",
|
||||
"https://cognitiveservices.azure.com/.default",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("finishes deployment discovery before Azure authorization completes", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
if (args.includes("get-access-token")) {
|
||||
return {
|
||||
accessToken: "azure-token",
|
||||
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
}
|
||||
}
|
||||
if (args.includes("deployment")) {
|
||||
return [
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{ name: "test-resource", resourceGroup: "test-group" }]
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make("azure")
|
||||
const attempt = yield* integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
answer: { resourceName: "test-resource" },
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const status = yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })
|
||||
if (status.status !== "complete") {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Azure CLI authorization ${status.status}${"message" in status ? `: ${status.message}` : ""}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}).pipe(Effect.retry({ times: 1500, schedule: Schedule.spaced("1 millis") }))
|
||||
|
||||
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
|
||||
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
|
||||
Model.ID.make("gpt-production"),
|
||||
)
|
||||
}),
|
||||
50,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
for (const batched of [false, true]) {
|
||||
it.effect(
|
||||
`discovers deployments with an existing connection (${batched ? "batched startup" : "ready catalog"})`,
|
||||
() =>
|
||||
withEnv({ AZURE_RESOURCE_GROUP: undefined }, () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
if (args.includes("deployment")) {
|
||||
return [
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "gpt-staging",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "gpt-pending",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Creating" },
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{ name: "test-resource", resourceGroup: "test-group" }]
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* azureCredential
|
||||
const startup = Effect.gen(function* () {
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), (model) => {
|
||||
model.name = "GPT-5 Mini"
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
})
|
||||
yield* batched ? State.batch(startup) : startup
|
||||
|
||||
expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
|
||||
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
|
||||
Model.ID.make("gpt-production"),
|
||||
)
|
||||
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-staging")))?.modelID).toBe(
|
||||
Model.ID.make("gpt-staging"),
|
||||
)
|
||||
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it.effect("keeps existing models when Azure deployment discovery is unavailable", () =>
|
||||
withAzureCommands(
|
||||
() => new Error("management access denied"),
|
||||
it.live("does not invoke Azure CLI at startup with an existing connection", () => {
|
||||
const commands: string[][] = []
|
||||
return withAzureCommands(
|
||||
(args) => {
|
||||
commands.push([...args])
|
||||
return []
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -394,46 +249,18 @@ describe("AzurePlugin", () => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
|
||||
})
|
||||
yield* azureCredential
|
||||
yield* addPlugin()
|
||||
|
||||
expect(commands).toEqual([])
|
||||
expect((yield* catalog.provider.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
|
||||
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
|
||||
expect(yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("skips subscription discovery when the resource group is configured", () =>
|
||||
withEnv({ AZURE_RESOURCE_GROUP: "restricted-group" }, () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
expect(args).toContain("deployment")
|
||||
expect(args).toContain("restricted-group")
|
||||
return [
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
]
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/azure")
|
||||
})
|
||||
draft.model.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
|
||||
})
|
||||
yield* azureCredential
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.model.get(Provider.ID.azure, Model.ID.make("gpt-5-mini")))?.modelID).toBe(
|
||||
Model.ID.make("gpt-production"),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("uses the correct bearer token audience for Azure and Foundry requests", () =>
|
||||
withAzureCommands(
|
||||
|
||||
@@ -46,6 +46,6 @@ it.effect("publishes successful package updates", () =>
|
||||
|
||||
yield* updates.update("fixture")
|
||||
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toBe("fixture")
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(changed))).toEqual({ target: "fixture", outdated: false })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
@@ -11,9 +14,140 @@ import { it } from "./lib/effect"
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: () => Effect.die("unexpected Git materialization"),
|
||||
})
|
||||
const referenceLayer = AppNodeBuilder.build(Reference.node, [RepositoryCache.node.replace(cache)])
|
||||
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
|
||||
RepositoryCache.node.replace(cache),
|
||||
])
|
||||
|
||||
describe("Reference", () => {
|
||||
it.effect("reads batched references before cache work and update events", () => {
|
||||
const operations: RepositoryCache.EnsureInput[] = []
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const release = Deferred.makeUnsafe<void>()
|
||||
const cache = Layer.succeed(RepositoryCache.Service, {
|
||||
ensure: (input) =>
|
||||
Effect.gen(function* () {
|
||||
operations.push(input)
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return {
|
||||
repository: input.reference.label,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath: Repository.cachePath(Global.Path.repos, input.reference, input.branch),
|
||||
status: "cached",
|
||||
} satisfies RepositoryCache.Result
|
||||
}),
|
||||
})
|
||||
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
|
||||
RepositoryCache.node.replace(cache),
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const bus = yield* Bus.Service
|
||||
const observed: string[][] = []
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
event.type === Reference.Event.Updated.type
|
||||
? references.list().pipe(
|
||||
Effect.map((infos) => {
|
||||
observed.push(infos.map((info) => info.name))
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* references.transform((draft) =>
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") })),
|
||||
)
|
||||
expect((yield* references.list()).map((info) => info.name)).toEqual(["docs"])
|
||||
|
||||
yield* references.transform((draft) => {
|
||||
draft.add(
|
||||
"sdk",
|
||||
Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: "owner/repo",
|
||||
branch: "feature/docs",
|
||||
description: "SDK documentation",
|
||||
hidden: true,
|
||||
}),
|
||||
)
|
||||
draft.add("invalid", Reference.GitSource.make({ type: "git", repository: "invalid" }))
|
||||
draft.add(
|
||||
"invalid-branch",
|
||||
Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "../escape" }),
|
||||
)
|
||||
draft.add("file", Reference.GitSource.make({ type: "git", repository: "file:///docs" }))
|
||||
})
|
||||
const infos = yield* references.list()
|
||||
expect(infos.map((info) => info.name)).toEqual(["docs", "sdk"])
|
||||
expect(infos[1]).toMatchObject({
|
||||
path: Repository.cachePath(Global.Path.repos, Repository.parseRemote("owner/repo"), "feature/docs"),
|
||||
description: "SDK documentation",
|
||||
hidden: true,
|
||||
})
|
||||
yield* Effect.yieldNow
|
||||
expect(operations).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["docs", "sdk"]])
|
||||
yield* Deferred.await(started)
|
||||
expect(operations).toEqual([
|
||||
{ reference: Repository.parseRemote("owner/repo"), branch: "feature/docs", refresh: "daily" },
|
||||
])
|
||||
expect((yield* references.list()).map((info) => info.name)).toEqual(["docs", "sdk"])
|
||||
yield* Effect.yieldNow
|
||||
expect(operations).toHaveLength(1)
|
||||
expect(observed).toHaveLength(1)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
}).pipe(Effect.scoped, Effect.provide(referenceLayer))
|
||||
})
|
||||
|
||||
it.effect("lets update listeners replace references and refetch current info", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const observed: string[][] = []
|
||||
let reentered = false
|
||||
const first = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== Reference.Event.Updated.type || reentered) return
|
||||
reentered = true
|
||||
yield* references
|
||||
.transform((draft) =>
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/new") })),
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
}),
|
||||
)
|
||||
const second = yield* bus.listen((event) =>
|
||||
event.type === Reference.Event.Updated.type
|
||||
? references.list().pipe(
|
||||
Effect.map((infos) => {
|
||||
observed.push(infos.map((info) => info.path))
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => first.pipe(Effect.andThen(second)))
|
||||
|
||||
yield* references.transform((draft) =>
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/old") })),
|
||||
)
|
||||
|
||||
expect((yield* references.list()).map((info) => info.path)).toEqual([AbsolutePath.make("/new")])
|
||||
expect(observed).toEqual([["/new"], ["/new"]])
|
||||
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
|
||||
)
|
||||
|
||||
it.effect("registers normalized sources for the owning scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LanguageModel, SystemPart, ToolDefinition, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -150,7 +150,7 @@ test("compaction points an existing summary to the following history", () => {
|
||||
expect(prompt).not.toContain("conversation history above")
|
||||
})
|
||||
|
||||
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
|
||||
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
|
||||
Effect.gen(function* () {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const session = Session.Info.make({
|
||||
@@ -161,25 +161,45 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
|
||||
})
|
||||
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => ({
|
||||
session,
|
||||
resolved: SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
const input = (tokens: number, limit: { context: number; input?: number; output: number }) => {
|
||||
const resolved = SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image", "pdf"], output: ["text"] },
|
||||
cost: [],
|
||||
limit,
|
||||
}),
|
||||
messages: [
|
||||
})
|
||||
const messages = [
|
||||
Schema.decodeUnknownSync(SessionMessage.Assistant)({
|
||||
id: SessionMessage.ID.make("msg_assistant"),
|
||||
type: "assistant",
|
||||
agent: Agent.defaultID,
|
||||
model: { id: "test-model", providerID: "test-provider" },
|
||||
content: [],
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, completed: 0 },
|
||||
}),
|
||||
],
|
||||
})
|
||||
]
|
||||
return {
|
||||
session,
|
||||
resolved,
|
||||
messages,
|
||||
context: {
|
||||
session,
|
||||
model: resolved,
|
||||
messages,
|
||||
agent: {
|
||||
id: Agent.defaultID,
|
||||
info: { ...Agent.Info.default(Agent.defaultID), system: "You are a helpful assistant." },
|
||||
},
|
||||
initial: "Project instructions.",
|
||||
tools: {
|
||||
definitions: [
|
||||
ToolDefinition.make({ name: "read", description: "Read files", inputSchema: { type: "object" } }),
|
||||
],
|
||||
execute: () => Effect.die("unused"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
|
||||
@@ -192,6 +212,69 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
|
||||
const outputLimited = { context: 100_000, output: 30_000 }
|
||||
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
|
||||
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
|
||||
|
||||
const assistant = input(79_000, contextLimited).messages[0]
|
||||
const tool = SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
state: { status: "completed", input: {}, content: [{ type: "text", text: "x".repeat(4_000) }] },
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
})
|
||||
const grown = { ...input(79_000, contextLimited), messages: [{ ...assistant, content: [tool] }] }
|
||||
expect(SessionCompaction.estimateTokens(grown)).toBe(80_000)
|
||||
expect(compaction.required(grown)).toBe(true)
|
||||
|
||||
const interrupted = { ...assistant, id: SessionMessage.ID.create(), tokens: undefined }
|
||||
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...grown.messages, interrupted] })).toBe(80_001)
|
||||
// Without provider usage, include 20 tokens for the system prompt, instructions, and tool definition.
|
||||
expect(SessionCompaction.estimateTokens({ ...grown, messages: [interrupted] })).toBe(21)
|
||||
expect(
|
||||
SessionCompaction.estimateTokens({
|
||||
...grown,
|
||||
messages: [{ ...interrupted, tokens: input(0, contextLimited).messages[0].tokens }],
|
||||
}),
|
||||
).toBe(21)
|
||||
|
||||
const media = [
|
||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${"a".repeat(100_000)}` },
|
||||
{ type: "file", mime: "application/pdf", uri: `data:application/pdf;base64,${"a".repeat(100_000)}` },
|
||||
] as const
|
||||
const messages = [
|
||||
{ ...assistant, content: [{ ...tool, state: { status: "completed" as const, input: {}, content: media } }] },
|
||||
]
|
||||
expect(SessionCompaction.estimateTokens({ ...grown, messages })).toBe(82_500)
|
||||
const user = Schema.decodeUnknownSync(SessionMessage.User)({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "",
|
||||
files: media.map((file) => ({ mime: file.mime, data: "a".repeat(100_000), source: { type: "inline" } })),
|
||||
time: { created: 0 },
|
||||
})
|
||||
expect(SessionCompaction.estimateTokens({ ...grown, messages: [...messages, user] })).toBe(86_000)
|
||||
for (const [modalities, tokens, fallback] of [
|
||||
[["text", "image"], 82_040, 1_520],
|
||||
[["text", "pdf"], 83_042, 2_021],
|
||||
[["text"], 79_082, 41],
|
||||
] as const) {
|
||||
const selected = {
|
||||
...grown,
|
||||
resolved: { ...grown.resolved, capabilities: { ...grown.resolved.capabilities, input: modalities } },
|
||||
}
|
||||
expect(SessionCompaction.estimateTokens({ ...selected, messages: [...messages, user] })).toBe(tokens)
|
||||
expect(SessionCompaction.estimateTokens({ ...selected, messages: [user] })).toBe(fallback + 20)
|
||||
}
|
||||
|
||||
const checkpoint = Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "x".repeat(400_000),
|
||||
recent: "",
|
||||
time: { created: 0, completed: 0 },
|
||||
})
|
||||
expect(compaction.required({ ...grown, messages: [checkpoint] })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
|
||||
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })
|
||||
const tools = Layer.mock(Tool.Service, {
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
|
||||
@@ -95,7 +95,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
)
|
||||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
|
||||
const locations: Location.Ref[] = []
|
||||
const flushes: Location.Ref[] = []
|
||||
const activationWaits: Location.Ref[] = []
|
||||
const resumes: SessionSchema.ID[] = []
|
||||
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
|
||||
const execution = SessionExecution.Service.of({
|
||||
@@ -143,8 +143,8 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
}),
|
||||
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.sync(() => {
|
||||
flushes.push(ref)
|
||||
awaitActivation: Effect.sync(() => {
|
||||
activationWaits.push(ref)
|
||||
}),
|
||||
}),
|
||||
),
|
||||
@@ -175,7 +175,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
}),
|
||||
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
|
||||
)
|
||||
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
|
||||
return { sessions, hooks, locations, activationWaits, resumes, wakes, db: database.db, bus, store }
|
||||
})
|
||||
|
||||
describe("Session-owned handles", () => {
|
||||
@@ -252,7 +252,7 @@ describe("Session-owned handles", () => {
|
||||
const calls: string[] = []
|
||||
yield* fixture.hooks.register("session", "prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(fixture.flushes).toEqual([source])
|
||||
expect(fixture.activationWaits).toEqual([source])
|
||||
calls.push(event.prompt.text)
|
||||
event.prompt.text += " prepared"
|
||||
}),
|
||||
@@ -277,7 +277,7 @@ describe("Session-owned handles", () => {
|
||||
)
|
||||
expect(calls).toEqual(["Original"])
|
||||
expect(fixture.locations).toEqual([source])
|
||||
expect(fixture.flushes).toEqual([source])
|
||||
expect(fixture.activationWaits).toEqual([source])
|
||||
expect(fixture.wakes).toEqual([
|
||||
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
|
||||
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
|
||||
@@ -363,7 +363,7 @@ describe("Session-owned handles", () => {
|
||||
expect(fixture.locations).toEqual([source])
|
||||
yield* prompt
|
||||
expect(fixture.locations).toEqual([source, destination])
|
||||
expect(fixture.flushes).toEqual([source, destination])
|
||||
expect(fixture.activationWaits).toEqual([source, destination])
|
||||
expect((yield* fixture.sessions.forSession(otherID).get()).location).toEqual(source)
|
||||
}),
|
||||
)
|
||||
@@ -408,7 +408,7 @@ describe("Session-owned handles", () => {
|
||||
expect(
|
||||
events.filter((event) => event.type === SessionEvent.Skill.Activated.type).map((event) => event.location),
|
||||
).toEqual([undefined, undefined, source])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.activationWaits).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
@@ -442,7 +442,7 @@ describe("Session-owned handles", () => {
|
||||
expect(yield* handle.get()).toEqual(before)
|
||||
expect(yield* handle.inbox()).toEqual([])
|
||||
expect(yield* fixture.store.context(sessionID)).toEqual([])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.activationWaits).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
@@ -776,7 +776,7 @@ describe("Session-owned handles", () => {
|
||||
|
||||
expect(captures).toEqual([source, destination])
|
||||
expect(fixture.locations).toEqual([source, destination, destination])
|
||||
expect(fixture.flushes).toEqual([source, destination, destination])
|
||||
expect(fixture.activationWaits).toEqual([source, destination, destination])
|
||||
expect((yield* handle.get()).revert).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
@@ -800,7 +800,7 @@ describe("SessionPrompt construction", () => {
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(PluginHooks.Service, fixture.hooks),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.sync(() => {
|
||||
awaitActivation: Effect.sync(() => {
|
||||
calls.push("ready")
|
||||
}),
|
||||
}),
|
||||
@@ -842,8 +842,8 @@ describe("SessionRevert construction", () => {
|
||||
Effect.provide(
|
||||
Layer.merge(
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.sync(() => {
|
||||
calls.push("flush")
|
||||
awaitActivation: Effect.sync(() => {
|
||||
calls.push("awaitActivation")
|
||||
}),
|
||||
}),
|
||||
Layer.mock(Snapshot.Service, {
|
||||
@@ -871,7 +871,7 @@ describe("SessionRevert construction", () => {
|
||||
yield* revert
|
||||
.stage({ session, messageID: boundary.id, files: false })
|
||||
.pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
|
||||
expect(calls).toEqual(["flush", "capture", "capture", "diff"])
|
||||
expect(calls).toEqual(["awaitActivation", "capture", "capture", "diff"])
|
||||
|
||||
const staged = yield* handle.get()
|
||||
expect(staged.revert?.snapshot).toBe(Snapshot.ID.make("captured-tree"))
|
||||
@@ -879,7 +879,15 @@ describe("SessionRevert construction", () => {
|
||||
const cleared = yield* handle.get()
|
||||
expect(cleared.revert).toBeUndefined()
|
||||
yield* revert.clear(cleared).pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
|
||||
expect(calls).toEqual(["flush", "capture", "capture", "diff", "flush", "restore", "flush"])
|
||||
expect(calls).toEqual([
|
||||
"awaitActivation",
|
||||
"capture",
|
||||
"capture",
|
||||
"diff",
|
||||
"awaitActivation",
|
||||
"restore",
|
||||
"awaitActivation",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ const setup = Effect.gen(function* () {
|
||||
const services = locations.get(session.location)
|
||||
const hooks = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
return yield* PluginHooks.Service
|
||||
}).pipe(Effect.provide(services))
|
||||
return { sessions, session, hooks, services }
|
||||
|
||||
@@ -115,7 +115,7 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
flush: Effect.sync(() => (ready = true)),
|
||||
awaitActivation: Effect.sync(() => (ready = true)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("Session.revert files", () => {
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Initial snapshot missing")
|
||||
|
||||
@@ -85,7 +85,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
})
|
||||
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Config.testLayer()
|
||||
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ awaitActivation: Effect.void }),
|
||||
)
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.undefined,
|
||||
|
||||
@@ -121,7 +121,7 @@ const compactModel = testModel("compact", { context: 4_000, output: 50 })
|
||||
const fullOutputModel = testModel("full-output", { context: 262_144, output: 262_144 })
|
||||
const unknownContextModel = testModel("unknown-context", { context: 0, output: 32_000 })
|
||||
const undersizedContextModel = testModel("undersized-context", { context: 1, output: 1_000 })
|
||||
const recoveryModel = testModel("recovery", { context: 20_000, output: 1_000 })
|
||||
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
|
||||
|
||||
test("calculates step cost using the matching context tier", () => {
|
||||
expect(
|
||||
@@ -206,7 +206,7 @@ const makeRunnerState = () => {
|
||||
systemUnavailable: false,
|
||||
systemLoadHook: Effect.void,
|
||||
skillBaselines: new Map<Agent.ID, string>(),
|
||||
pluginFlushHook: Effect.void,
|
||||
pluginActivationHook: Effect.void,
|
||||
authorizations: new Array<Tool.Context>(),
|
||||
executions: new Array<string>(),
|
||||
closedTransports: new Array<Session.ID>(),
|
||||
@@ -390,7 +390,7 @@ const layer = Layer.unwrap(
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
flush: Effect.suspend(() => state.pluginFlushHook),
|
||||
awaitActivation: Effect.suspend(() => state.pluginActivationHook),
|
||||
}),
|
||||
)
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
@@ -1840,7 +1840,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("waits for initial plugin readiness before constructing the model request", function* (s) {
|
||||
const release = yield* Deferred.make<void>()
|
||||
s.pluginFlushHook = Deferred.await(release)
|
||||
s.pluginActivationHook = Deferred.await(release)
|
||||
yield* s.session.prompt({ sessionID, text: "Wait for plugins", resume: false })
|
||||
|
||||
s.requests.length = 0
|
||||
@@ -2540,17 +2540,16 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
})
|
||||
|
||||
scenario("recovers from provider context overflow despite an undersized configured context limit", function* (s) {
|
||||
scenario("compacts before requesting an undersized configured context limit", function* (s) {
|
||||
yield* setupOverflowRecovery(s)
|
||||
s.currentModel = undersizedContextModel
|
||||
yield* s.llm.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||
TestLLM.text("Recovered", "text-final-undersized-limit"),
|
||||
)
|
||||
yield* s.runPrompt("Continue")
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(yield* s.context).toMatchObject([
|
||||
{ type: "compaction", summary: "## Objective\n- Recover undersized limit" },
|
||||
{ type: "assistant", finish: "stop" },
|
||||
|
||||
@@ -53,7 +53,7 @@ const locations = makeGlobalNode({
|
||||
list: () => Effect.succeed([info]),
|
||||
}),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.void,
|
||||
awaitActivation: Effect.void,
|
||||
}),
|
||||
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
|
||||
),
|
||||
|
||||
@@ -130,7 +130,7 @@ const it = testEffect(
|
||||
Catalog.node.replace(catalog),
|
||||
SessionRunnerModel.node.replace(models),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { flush: Effect.void })),
|
||||
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Queue, Scope, Sink, Stream } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { hostEnvironmentLayer } from "./fixture/environment"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
it.live("eviction makes progress past an already-removed shell", () =>
|
||||
Effect.gen(function* () {
|
||||
const completions = yield* Queue.unbounded<Effect.Effect<void>>()
|
||||
const environment = Layer.effect(
|
||||
Environment.Service,
|
||||
Effect.gen(function* () {
|
||||
const host = yield* Environment.Service
|
||||
const scope = yield* Scope.Scope
|
||||
return Environment.Service.of({
|
||||
...host,
|
||||
spawner: ChildProcessSpawner.make(() =>
|
||||
Effect.gen(function* () {
|
||||
const exited = yield* Deferred.make<ExitCode>()
|
||||
const observer = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>()
|
||||
yield* Queue.offer(
|
||||
completions,
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(exited, ExitCode(0))
|
||||
// Shell.wait resolves before retention runs; join the whole exit handler instead.
|
||||
const fiber = yield* Deferred.await(observer)
|
||||
yield* Fiber.join(fiber).pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
const output = Stream.succeed(Buffer.from("hello"))
|
||||
return makeHandle({
|
||||
pid: ProcessId(1),
|
||||
exitCode: Effect.withFiber((fiber) =>
|
||||
Scope.addFinalizer(scope, Fiber.interrupt(fiber)).pipe(
|
||||
Effect.andThen(Deferred.succeed(observer, fiber)),
|
||||
Effect.andThen(Deferred.await(exited)),
|
||||
),
|
||||
),
|
||||
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
|
||||
kill: () => Deferred.succeed(exited, ExitCode(0)).pipe(Effect.asVoid),
|
||||
stdin: Sink.drain,
|
||||
stdout: output,
|
||||
stderr: Stream.empty,
|
||||
all: output,
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty,
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(hostEnvironmentLayer))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
const removed = yield* shell.create({ shell: "sh", command: "removed", timeout: 0 })
|
||||
const finishRemoved = yield* Queue.take(completions)
|
||||
yield* shell.remove(removed.id)
|
||||
expect((yield* shell.result(removed)).capture).toBeUndefined()
|
||||
yield* finishRemoved
|
||||
|
||||
const complete = Effect.gen(function* () {
|
||||
const info = yield* shell.create({ shell: "sh", command: "hello", timeout: 0 })
|
||||
const finish = yield* Queue.take(completions)
|
||||
yield* finish
|
||||
return info
|
||||
})
|
||||
const oldest = yield* complete
|
||||
// Exceed the 25-entry retention cap with the removed ID at the head of exitOrder.
|
||||
yield* Effect.forEach(Array.from({ length: 25 }), () => complete, { discard: true })
|
||||
expect(yield* shell.get(oldest.id).pipe(Effect.flip)).toBeInstanceOf(Shell.NotFoundError)
|
||||
|
||||
const survivor = yield* complete
|
||||
expect(yield* shell.result(survivor)).toMatchObject({
|
||||
info: { status: "exited", exit: 0 },
|
||||
capture: { output: "hello", truncated: false },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Shell.node, [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Config.node.replace(Config.testLayer()),
|
||||
Environment.node.replace(environment),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Effect } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
|
||||
type Operation = { multiply: number; add: number }
|
||||
type Value = { value: number; order: number[] }
|
||||
|
||||
const operation = FastCheck.record({
|
||||
multiply: FastCheck.constantFrom(-3, -2, 2, 3),
|
||||
add: FastCheck.integer({ min: -9, max: 9 }),
|
||||
})
|
||||
const source = FastCheck.integer({ min: -100, max: 100 })
|
||||
const target = FastCheck.integer({ min: 0, max: 1 })
|
||||
const command = FastCheck.oneof(
|
||||
{
|
||||
weight: 4,
|
||||
arbitrary: FastCheck.record({ type: FastCheck.constant("append"), target, callback: FastCheck.nat(5) }),
|
||||
},
|
||||
{ weight: 3, arbitrary: FastCheck.record({ type: FastCheck.constant("read"), target }) },
|
||||
{
|
||||
weight: 2,
|
||||
arbitrary: FastCheck.record({ type: FastCheck.constant("dispose"), target, registration: FastCheck.nat(100) }),
|
||||
},
|
||||
{ weight: 2, arbitrary: FastCheck.record({ type: FastCheck.constant("reload"), target, source }) },
|
||||
)
|
||||
|
||||
// Affine transforms do not generally commute; the modulus keeps long traces exact.
|
||||
function apply(value: number, operation: Operation) {
|
||||
return (value * operation.multiply + operation.add) % 10_007
|
||||
}
|
||||
|
||||
// Replay a reported counterexample with STATE_REPLAY_SEED, STATE_REPLAY_PATH,
|
||||
// and --test-name-pattern. Raise STATE_REPLAY_RUNS for a larger corpus.
|
||||
const seeds = process.env.STATE_REPLAY_SEED ? [Number(process.env.STATE_REPLAY_SEED)] : [0x5eed, 0xc0ffee, 0xdecaf]
|
||||
|
||||
describe("State replay properties", () => {
|
||||
seeds.forEach((seed) => {
|
||||
const parameters = {
|
||||
seed,
|
||||
numRuns: Number(process.env.STATE_REPLAY_RUNS ?? 200),
|
||||
path: process.env.STATE_REPLAY_PATH,
|
||||
}
|
||||
|
||||
test(
|
||||
`matches a full fold across reads, registrations, removals and batched reloads (seed ${seed})`,
|
||||
() =>
|
||||
FastCheck.assert(
|
||||
FastCheck.property(
|
||||
FastCheck.tuple(source, source),
|
||||
FastCheck.array(operation, { minLength: 1, maxLength: 6 }),
|
||||
FastCheck.array(FastCheck.array(command, { maxLength: 48 }), { minLength: 1, maxLength: 8 }),
|
||||
(initial, operations, batches) =>
|
||||
Effect.gen(function* () {
|
||||
const sources = [...initial]
|
||||
const notifications = [0, 0]
|
||||
let calls = 0
|
||||
const states = sources.map((_, index) =>
|
||||
State.create({
|
||||
initial: (): Value => ({ value: sources[index], order: [] }),
|
||||
draft: (draft) => draft,
|
||||
notify: () => Effect.sync(() => void notifications[index]++),
|
||||
}),
|
||||
)
|
||||
const callbacks = operations.map((operation, index) => (draft: Value) => {
|
||||
calls++
|
||||
draft.value = apply(draft.value, operation)
|
||||
draft.order.push(index)
|
||||
})
|
||||
const registrations: { handle: State.Registration; callback: number; active: boolean }[][] = [[], []]
|
||||
const expected = (index: number): Value => {
|
||||
const order = registrations[index].filter((entry) => entry.active).map((entry) => entry.callback)
|
||||
return {
|
||||
value: order.reduce((value, callback) => apply(value, operations[callback]), sources[index]),
|
||||
order,
|
||||
}
|
||||
}
|
||||
|
||||
yield* Effect.forEach(
|
||||
batches,
|
||||
(commands) =>
|
||||
Effect.gen(function* () {
|
||||
const before = [...notifications]
|
||||
const dirty = new Set<number>()
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
commands,
|
||||
(command) =>
|
||||
Effect.gen(function* () {
|
||||
const state = states[command.target]
|
||||
switch (command.type) {
|
||||
case "append": {
|
||||
const callback = command.callback % callbacks.length
|
||||
const handle = yield* state.transform(callbacks[callback])
|
||||
registrations[command.target].push({ handle, callback, active: true })
|
||||
dirty.add(command.target)
|
||||
return
|
||||
}
|
||||
case "read":
|
||||
expect(state.get()).toEqual(expected(command.target))
|
||||
return
|
||||
case "dispose": {
|
||||
const entries = registrations[command.target]
|
||||
if (!entries.length) return
|
||||
const entry = entries[command.registration % entries.length]
|
||||
yield* entry.handle.dispose
|
||||
if (!entry.active) return
|
||||
entry.active = false
|
||||
dirty.add(command.target)
|
||||
return
|
||||
}
|
||||
case "reload":
|
||||
sources[command.target] = command.source
|
||||
yield* state.reload()
|
||||
dirty.add(command.target)
|
||||
}
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
expect(notifications).toEqual(before)
|
||||
}),
|
||||
)
|
||||
expect(notifications).toEqual(before.map((count, index) => count + Number(dirty.has(index))))
|
||||
const flushed = calls
|
||||
states.forEach((state, index) => expect(state.get()).toEqual(expected(index)))
|
||||
expect(calls).toBe(flushed)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.runSync),
|
||||
),
|
||||
parameters,
|
||||
),
|
||||
60_000,
|
||||
)
|
||||
|
||||
test(
|
||||
`applies each appended suffix once and replays survivors only after invalidation (seed ${seed})`,
|
||||
() =>
|
||||
FastCheck.assert(
|
||||
FastCheck.property(
|
||||
FastCheck.array(
|
||||
FastCheck.record({
|
||||
append: FastCheck.array(operation, { minLength: 1, maxLength: 8 }),
|
||||
reads: FastCheck.integer({ min: 1, max: 5 }),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 8 },
|
||||
),
|
||||
FastCheck.nat(100),
|
||||
(chunks, removal) =>
|
||||
Effect.gen(function* () {
|
||||
let calls = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ value: 1 }),
|
||||
draft: (draft) => draft,
|
||||
})
|
||||
const registrations: State.Registration[] = []
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
chunks,
|
||||
(chunk) =>
|
||||
Effect.gen(function* () {
|
||||
const before = calls
|
||||
const added = yield* Effect.forEach(chunk.append, (operation) =>
|
||||
state.transform((draft) => {
|
||||
calls++
|
||||
draft.value = apply(draft.value, operation)
|
||||
}),
|
||||
)
|
||||
registrations.push(...added)
|
||||
expect(calls).toBe(before)
|
||||
Array.from({ length: chunk.reads }).forEach(() => {
|
||||
state.get()
|
||||
expect(calls).toBe(registrations.length)
|
||||
})
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const removed = registrations[removal % registrations.length]
|
||||
yield* removed.dispose
|
||||
const before = calls
|
||||
state.get()
|
||||
expect(calls).toBe(before + registrations.length - 1)
|
||||
|
||||
const replayed = calls
|
||||
yield* removed.dispose
|
||||
state.get()
|
||||
expect(calls).toBe(replayed)
|
||||
|
||||
yield* state.reload()
|
||||
yield* state.reload()
|
||||
expect(calls).toBe(replayed)
|
||||
}),
|
||||
)
|
||||
const total = registrations.length + 2 * (registrations.length - 1)
|
||||
expect(calls).toBe(total)
|
||||
state.get()
|
||||
expect(calls).toBe(total)
|
||||
}).pipe(Effect.scoped, Effect.runSync),
|
||||
),
|
||||
parameters,
|
||||
),
|
||||
60_000,
|
||||
)
|
||||
|
||||
test(
|
||||
`keeps captured source changes out of an applied prefix until reload (seed ${seed})`,
|
||||
() =>
|
||||
FastCheck.assert(
|
||||
FastCheck.property(source, operation, operation, source, (initial, prefix, suffix, change) =>
|
||||
Effect.gen(function* () {
|
||||
let offset = prefix.add
|
||||
let calls = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ value: initial }),
|
||||
draft: (draft) => draft,
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
calls++
|
||||
draft.value = apply(draft.value, { multiply: prefix.multiply, add: offset })
|
||||
})
|
||||
const cached = apply(initial, prefix)
|
||||
expect(state.get().value).toBe(cached)
|
||||
|
||||
offset += change || 1
|
||||
expect(state.get().value).toBe(cached)
|
||||
expect(calls).toBe(1)
|
||||
yield* state.transform((draft) => {
|
||||
draft.value = apply(draft.value, suffix)
|
||||
})
|
||||
expect(state.get().value).toBe(apply(cached, suffix))
|
||||
expect(calls).toBe(1)
|
||||
|
||||
yield* state.reload()
|
||||
expect(calls).toBe(1)
|
||||
expect(state.get().value).toBe(
|
||||
[{ multiply: prefix.multiply, add: offset }, suffix].reduce(apply, initial),
|
||||
)
|
||||
expect(calls).toBe(2)
|
||||
}).pipe(State.batch, Effect.scoped, Effect.runSync),
|
||||
),
|
||||
parameters,
|
||||
),
|
||||
60_000,
|
||||
)
|
||||
|
||||
test(
|
||||
`retries a throwing callback from a fresh full fold instead of a partial draft (seed ${seed})`,
|
||||
() =>
|
||||
FastCheck.assert(
|
||||
FastCheck.property(
|
||||
source,
|
||||
FastCheck.array(operation, { maxLength: 12 }),
|
||||
operation,
|
||||
FastCheck.array(operation, { maxLength: 12 }),
|
||||
FastCheck.boolean(),
|
||||
(initial, prefix, failing, suffix, invalidate) =>
|
||||
Effect.gen(function* () {
|
||||
const operations = [...prefix, failing, ...suffix]
|
||||
const calls = operations.map(() => 0)
|
||||
let fresh = 0
|
||||
let fail = true
|
||||
const state = State.create({
|
||||
initial: (): Value => {
|
||||
fresh++
|
||||
return { value: initial, order: [] }
|
||||
},
|
||||
draft: (draft) => draft,
|
||||
})
|
||||
yield* Effect.forEach(
|
||||
prefix,
|
||||
(operation, index) =>
|
||||
state.transform((draft) => {
|
||||
calls[index]++
|
||||
draft.value = apply(draft.value, operation)
|
||||
draft.order.push(index)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
expect(state.get()).toEqual({ value: prefix.reduce(apply, initial), order: prefix.map((_, i) => i) })
|
||||
yield* Effect.forEach(
|
||||
[failing, ...suffix],
|
||||
(operation, index) =>
|
||||
state.transform((draft) => {
|
||||
calls[prefix.length + index]++
|
||||
draft.value = apply(draft.value, operation)
|
||||
draft.order.push(prefix.length + index)
|
||||
if (index === 0 && fail) {
|
||||
fail = false
|
||||
throw new Error("transient transform failure")
|
||||
}
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
if (invalidate) yield* state.reload()
|
||||
expect(() => state.get()).toThrow("transient transform failure")
|
||||
const before = [...calls]
|
||||
const failed = fresh
|
||||
expect(state.get()).toEqual({
|
||||
value: operations.reduce(apply, initial),
|
||||
order: operations.map((_, index) => index),
|
||||
})
|
||||
expect(fresh).toBe(failed + 1)
|
||||
expect(calls).toEqual(before.map((count) => count + 1))
|
||||
state.get()
|
||||
expect(calls).toEqual(before.map((count) => count + 1))
|
||||
}).pipe(State.batch, Effect.scoped, Effect.runSync),
|
||||
),
|
||||
parameters,
|
||||
),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("State", () => {
|
||||
it.effect("commits a transform atomically when its updater is interrupted", () =>
|
||||
@@ -15,7 +13,7 @@ describe("State", () => {
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
|
||||
finalize: () =>
|
||||
notify: () =>
|
||||
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
@@ -36,20 +34,20 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("commits rebuilt state before finalize runs", () =>
|
||||
it.effect("makes current state visible before notifying", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[][] = []
|
||||
const state: State.Interface<{ values: string[] }, { add: (item: string) => void }> = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
notify: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
})
|
||||
|
||||
yield* state.transform((draft) => {
|
||||
draft.add("value")
|
||||
})
|
||||
|
||||
// Update events publish from finalize, so consumers reading on the event
|
||||
// Update events publish from notify, so consumers reading on the event
|
||||
// must observe the rebuilt state, not the previous one.
|
||||
expect(observed).toEqual([["value"]])
|
||||
}),
|
||||
@@ -98,18 +96,18 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches automatic rebuilds", () =>
|
||||
it.effect("batches notifications across domains", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
const first = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
notify: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
const second = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
notify: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
|
||||
yield* State.batch(
|
||||
@@ -140,7 +138,7 @@ describe("State", () => {
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
notify: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.addFinalizer(
|
||||
@@ -170,12 +168,12 @@ describe("State", () => {
|
||||
const closing = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
finalize: () => Effect.sync(() => finalized.push("closing")),
|
||||
notify: () => Effect.sync(() => finalized.push("closing")),
|
||||
})
|
||||
const live = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
finalize: () => Effect.sync(() => finalized.push("live")),
|
||||
notify: () => Effect.sync(() => finalized.push("live")),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
|
||||
@@ -197,7 +195,7 @@ describe("State", () => {
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
notify: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
draft.add("value")
|
||||
@@ -217,3 +215,455 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("State cached prefix", () => {
|
||||
it.effect("retains the Draft handle across suffix reads and recreates it on invalidation", () =>
|
||||
Effect.gen(function* () {
|
||||
let drafts = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ values: new Array<number>() }),
|
||||
draft: (data) => {
|
||||
drafts++
|
||||
let sequence = 0
|
||||
return { add: () => data.values.push(++sequence) }
|
||||
},
|
||||
})
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => draft.add())
|
||||
expect(state.get().values).toEqual([1])
|
||||
yield* state.transform((draft) => draft.add())
|
||||
expect(state.get().values).toEqual([1, 2])
|
||||
expect(drafts).toBe(1)
|
||||
yield* state.reload()
|
||||
expect(state.get().values).toEqual([1, 2])
|
||||
expect(drafts).toBe(2)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lazily applies only the pending suffix and notifies even without a final read", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const notifications: string[][] = []
|
||||
const state: State.Interface<{ values: string[] }, { values: string[] }> = State.create({
|
||||
initial: () => ({ values: new Array<string>() }),
|
||||
draft: (data) => data,
|
||||
notify: () => Effect.sync(() => notifications.push([...state.get().values])),
|
||||
})
|
||||
expect(state.get().values).toEqual([])
|
||||
yield* State.batch(Effect.void)
|
||||
expect(notifications).toEqual([])
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => {
|
||||
calls.push("first")
|
||||
draft.values.push("first")
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
calls.push("second")
|
||||
draft.values.push("second")
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
const view = state.get()
|
||||
expect(view.values).toEqual(["first", "second"])
|
||||
expect(state.get()).toBe(view)
|
||||
expect(calls).toEqual(["first", "second"])
|
||||
yield* state.transform((draft) => {
|
||||
calls.push("third")
|
||||
draft.values.push("third")
|
||||
})
|
||||
expect(calls).toEqual(["first", "second"])
|
||||
expect(state.get()).toBe(view)
|
||||
expect(view.values).toEqual(["first", "second", "third"])
|
||||
expect(calls).toEqual(["first", "second", "third"])
|
||||
yield* state.transform((draft) => {
|
||||
calls.push("fourth")
|
||||
draft.values.push("fourth")
|
||||
})
|
||||
expect(notifications).toEqual([])
|
||||
}),
|
||||
)
|
||||
expect(calls).toEqual(["first", "second", "third", "fourth"])
|
||||
expect(notifications).toEqual([["first", "second", "third", "fourth"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("also applies only new callbacks outside a batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: number[] = []
|
||||
const state = State.create({ initial: () => ({ value: 2 }), draft: (data) => data })
|
||||
yield* state.transform((draft) => {
|
||||
calls.push(1)
|
||||
draft.value += 3
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
calls.push(2)
|
||||
draft.value *= 4
|
||||
})
|
||||
expect(state.get().value).toBe(20)
|
||||
expect(calls).toEqual([1, 2])
|
||||
}),
|
||||
)
|
||||
;[0, 1, 2].forEach((removed) =>
|
||||
it.effect(`rebuilds noncommutative edits after removing position ${removed}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: number[] = []
|
||||
const state = State.create({ initial: () => ({ value: 5 }), draft: (data) => data })
|
||||
const registrations = yield* State.batch(
|
||||
Effect.all([
|
||||
state.transform((draft) => {
|
||||
calls.push(0)
|
||||
draft.value += 1
|
||||
}),
|
||||
state.transform((draft) => {
|
||||
calls.push(1)
|
||||
draft.value *= 3
|
||||
}),
|
||||
state.transform((draft) => {
|
||||
calls.push(2)
|
||||
draft.value -= 4
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(state.get().value).toBe(14)
|
||||
const registration = registrations[removed]
|
||||
if (!registration) throw new Error("missing registration")
|
||||
calls.length = 0
|
||||
yield* registration.dispose
|
||||
expect(state.get().value).toBe([11, 2, 18][removed])
|
||||
expect(calls).toEqual([0, 1, 2].filter((index) => index !== removed))
|
||||
calls.length = 0
|
||||
yield* registration.dispose
|
||||
expect(calls).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps equal callback registrations independently disposable", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = State.create({ initial: () => ({ value: 0 }), draft: (data) => data })
|
||||
const callback = (draft: { value: number }) => draft.value++
|
||||
const first = yield* state.transform(callback)
|
||||
const second = yield* state.transform(callback)
|
||||
expect(state.get().value).toBe(2)
|
||||
yield* first.dispose
|
||||
expect(state.get().value).toBe(1)
|
||||
yield* first.dispose
|
||||
expect(state.get().value).toBe(1)
|
||||
yield* second.dispose
|
||||
expect(state.get().value).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not evaluate a pending callback removed before the first read", () =>
|
||||
Effect.gen(function* () {
|
||||
let calls = 0
|
||||
const state = State.create({ initial: () => ({ value: 0 }), draft: (data) => data })
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const registration = yield* state.transform(() => calls++)
|
||||
yield* registration.dispose
|
||||
expect(state.get().value).toBe(0)
|
||||
}),
|
||||
)
|
||||
expect(calls).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("invalidates the prefix on reload and reads new inputs before notification", () =>
|
||||
Effect.gen(function* () {
|
||||
let source = 1
|
||||
let calls = 0
|
||||
let notifications = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ value: 0 }),
|
||||
draft: (data) => data,
|
||||
notify: () => Effect.sync(() => notifications++),
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
calls++
|
||||
draft.value += source
|
||||
})
|
||||
notifications = 0
|
||||
source = 2
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(state.get().value).toBe(2)
|
||||
expect(calls).toBe(2)
|
||||
expect(notifications).toBe(0)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(calls).toBe(2)
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
source = 3
|
||||
yield* state.reload()
|
||||
expect(state.get().value).toBe(3)
|
||||
yield* state.transform((draft) => (draft.value *= 10))
|
||||
expect(state.get().value).toBe(30)
|
||||
expect(calls).toBe(3)
|
||||
expect(notifications).toBe(1)
|
||||
}),
|
||||
)
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resamples a changing initial value even when there are no transforms", () =>
|
||||
Effect.gen(function* () {
|
||||
let source = 1
|
||||
const state = State.create({ initial: () => ({ value: source }), draft: (data) => data })
|
||||
expect(state.get().value).toBe(1)
|
||||
source = 2
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.reload()
|
||||
expect(state.get().value).toBe(2)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries a partially failed suffix from a fresh base", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = true
|
||||
let initializations = 0
|
||||
const state = State.create({
|
||||
initial: () => {
|
||||
initializations++
|
||||
return { values: new Array<string>() }
|
||||
},
|
||||
draft: (data) => data,
|
||||
})
|
||||
yield* state.transform((draft) => draft.values.push("first"))
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => {
|
||||
draft.values.push("second")
|
||||
if (fail) throw new Error("failed edit")
|
||||
})
|
||||
expect(() => state.get()).toThrow("failed edit")
|
||||
expect(initializations).toBe(1)
|
||||
expect(() => state.get()).toThrow("failed edit")
|
||||
expect(initializations).toBe(2)
|
||||
fail = false
|
||||
expect(state.get().values).toEqual(["first", "second"])
|
||||
expect(initializations).toBe(3)
|
||||
yield* state.transform((draft) => draft.values.push("third"))
|
||||
expect(state.get().values).toEqual(["first", "second", "third"])
|
||||
expect(initializations).toBe(3)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers by disposing a failing callback without keeping its partial edits", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = State.create({ initial: () => ({ value: 2 }), draft: (data) => data })
|
||||
yield* state.transform((draft) => (draft.value *= 3))
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const failing = yield* state.transform((draft) => {
|
||||
draft.value += 100
|
||||
throw new Error("bad callback")
|
||||
})
|
||||
expect(() => state.get()).toThrow("bad callback")
|
||||
yield* failing.dispose
|
||||
expect(state.get().value).toBe(6)
|
||||
yield* state.transform((draft) => (draft.value += 1))
|
||||
expect(state.get().value).toBe(7)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("State notification boundaries", () => {
|
||||
;["body", "observer"].forEach((phase) =>
|
||||
it.effect(
|
||||
`cancels remaining observers when interrupted during the ${phase}, without rolling back registrations`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const observed: string[] = []
|
||||
let block = true
|
||||
const first = State.create({
|
||||
initial: () => ({ value: 0 }),
|
||||
draft: (data) => data,
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push("first")
|
||||
if (phase !== "observer" || !block) return
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Effect.never
|
||||
}),
|
||||
})
|
||||
const second = State.create({
|
||||
initial: () => ({ value: 0 }),
|
||||
draft: (data) => data,
|
||||
notify: () => Effect.sync(() => observed.push("second")),
|
||||
})
|
||||
const writer = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* first.transform((draft) => draft.value++)
|
||||
yield* second.transform((draft) => draft.value++)
|
||||
if (phase !== "body") return
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Effect.never
|
||||
}),
|
||||
).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
yield* Fiber.interrupt(writer)
|
||||
block = false
|
||||
expect(Exit.hasInterrupts(yield* Fiber.await(writer))).toBe(true)
|
||||
expect([first.get().value, second.get().value]).toEqual([1, 1])
|
||||
expect(observed).toEqual(phase === "body" ? [] : ["first"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("shares nested live batches and does not retain an escaped batch", () =>
|
||||
Effect.gen(function* () {
|
||||
let notifications = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ value: 0 }),
|
||||
draft: (data) => data,
|
||||
notify: () => Effect.sync(() => notifications++),
|
||||
})
|
||||
const inherit = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => draft.value++)
|
||||
yield* State.batch(state.transform((draft) => draft.value++))
|
||||
expect(state.get().value).toBe(2)
|
||||
expect(notifications).toBe(0)
|
||||
return yield* State.inherit()
|
||||
}),
|
||||
)
|
||||
expect(notifications).toBe(1)
|
||||
yield* inherit(state.transform((draft) => draft.value++))
|
||||
expect(state.get().value).toBe(3)
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets observers read other pending domains and register more edits", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const observed: number[] = []
|
||||
let added = false
|
||||
const other = State.create({ initial: () => ({ value: 0 }), draft: (data) => data })
|
||||
const state: State.Interface<object, object> = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (data) => data,
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push(other.get().value)
|
||||
if (added) return
|
||||
added = true
|
||||
yield* state.transform(() => {}).pipe(Scope.provide(scope))
|
||||
}),
|
||||
})
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform(() => {})
|
||||
yield* other.transform((draft) => (draft.value = 42))
|
||||
}),
|
||||
)
|
||||
expect(observed).toEqual([42, 42])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows a debounced observer to await another reload", () =>
|
||||
Effect.gen(function* () {
|
||||
let source = 1
|
||||
let reloadAgain = false
|
||||
const observed: number[] = []
|
||||
const state: State.Interface<{ value: number }, { value: number }> = State.create({
|
||||
initial: () => ({ value: 0 }),
|
||||
draft: (data) => data,
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push(state.get().value)
|
||||
if (!reloadAgain) return
|
||||
reloadAgain = false
|
||||
source = 3
|
||||
yield* state.reload()
|
||||
}),
|
||||
})
|
||||
yield* state.transform((draft) => (draft.value = source))
|
||||
source = 2
|
||||
reloadAgain = true
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual([1, 2, 3])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("attempts every domain notification and preserves both batch and observer failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[] = []
|
||||
let fail = true
|
||||
const first = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (data) => data,
|
||||
notify: () => (fail ? Effect.die("observer failed") : Effect.void),
|
||||
})
|
||||
const second = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (data) => data,
|
||||
notify: () => Effect.sync(() => observed.push("second")),
|
||||
})
|
||||
const exit = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* first.transform(() => {})
|
||||
yield* second.transform(() => {})
|
||||
return yield* Effect.fail("body failed")
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
fail = false
|
||||
expect(observed).toEqual(["second"])
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.pretty(exit.cause)).toContain("observer failed")
|
||||
expect(Cause.pretty(exit.cause)).toContain("body failed")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps cancelled reload callers independent and shares notification results", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
let notifications = 0
|
||||
const state = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (data) => data,
|
||||
notify: () =>
|
||||
Effect.sync(() => {
|
||||
notifications++
|
||||
if (fail) throw new Error("notification failed")
|
||||
}),
|
||||
})
|
||||
yield* state.transform(() => {})
|
||||
notifications = 0
|
||||
fail = true
|
||||
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Fiber.interrupt(cancelled)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
const exits = yield* Fiber.awaitAll([first, second])
|
||||
fail = false
|
||||
expect(exits.every(Exit.isFailure)).toBe(true)
|
||||
expect(notifications).toBe(1)
|
||||
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(recovered)
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -360,19 +360,19 @@ describe("Tool", () => {
|
||||
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
expect(runs).toEqual(["base", "base", "overlay"])
|
||||
expect(runs).toEqual(["base", "overlay"])
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" })
|
||||
|
||||
yield* overlay.dispose
|
||||
expect(runs).toEqual(["base", "base", "overlay", "base"])
|
||||
expect(runs).toEqual(["base", "overlay", "base"])
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "base" })
|
||||
yield* overlay.dispose
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(runs).toEqual(["base", "base", "overlay", "base"])
|
||||
expect(runs).toEqual(["base", "overlay", "base"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
|
||||
it.effect("reads pending tools inside a batch and suppresses terminal teardown replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const runs: string[] = []
|
||||
@@ -388,7 +388,8 @@ describe("Tool", () => {
|
||||
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
|
||||
})
|
||||
expect(runs).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
expect(runs).toEqual(["base", "overlay"])
|
||||
}).pipe(Scope.provide(scope)),
|
||||
)
|
||||
|
||||
@@ -624,6 +625,41 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains namespace descriptions in executable snapshots after appended transforms", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.namespace({ name: "acme", description: "Archival operations" })
|
||||
draft.add({ ...make(), options: { namespace: "acme" } })
|
||||
})
|
||||
const advertised = yield* service.snapshot()
|
||||
|
||||
yield* service.transform((draft) => {
|
||||
draft.namespace({ name: "acme", description: "Billing operations" })
|
||||
})
|
||||
const current = yield* service.snapshot()
|
||||
expect(advertised.codeModeCatalog?.tools).toMatchObject([{ name: "acme", description: "Archival operations" }])
|
||||
expect(current.codeModeCatalog?.tools).toMatchObject([{ name: "acme", description: "Billing operations" }])
|
||||
|
||||
const search = (snapshot: Tool.Snapshot, query: string) =>
|
||||
snapshot.execute({
|
||||
...call("execute"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `namespace-${query}`,
|
||||
name: "execute",
|
||||
input: {
|
||||
code: `return search({ query: ${JSON.stringify(query)} }).items.map(item => item.path).join(",")`,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect((yield* search(advertised, "archival")).output).toMatchObject({ output: "tools.acme.echo" })
|
||||
expect((yield* search(advertised, "billing")).output).toMatchObject({ output: "" })
|
||||
expect((yield* search(current, "archival")).output).toMatchObject({ output: "" })
|
||||
expect((yield* search(current, "billing")).output).toMatchObject({ output: "tools.acme.echo" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a top-level tool that also has child tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
|
||||
@@ -129,7 +129,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void }))),
|
||||
),
|
||||
deps: [
|
||||
Config.node,
|
||||
@@ -220,7 +220,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
|
||||
const locationLayer = locations.get(location)
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
const registry = yield* Tool.Service
|
||||
return yield* body(registry)
|
||||
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
|
||||
@@ -1561,35 +1561,6 @@ describe("ShellTool", () => {
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
it.live("does not retain removed running shells in exit order", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
withSession(tmp.path, () =>
|
||||
Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
yield* Effect.forEach(Array.from({ length: 26 }), () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* shell.create({ command: idleCommand, timeout: 0 })
|
||||
yield* shell.remove(info.id)
|
||||
expect((yield* shell.result(info)).capture).toBeUndefined()
|
||||
yield* Effect.sleep(Duration.millis(10))
|
||||
}),
|
||||
)
|
||||
|
||||
const info = yield* shell.create({ command: helloCommand, timeout: 0 })
|
||||
const settled = yield* shell.wait(info.id).pipe(Effect.timeoutOption(Duration.seconds(2)))
|
||||
expect(settled._tag).toBe("Some")
|
||||
expect(yield* shell.result(info)).toMatchObject({
|
||||
info: { status: "exited", exit: 0 },
|
||||
capture: { output: expect.stringContaining("hello"), truncated: false },
|
||||
})
|
||||
}),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
if (!isWindows) {
|
||||
it.live("settles a shell terminated by an external signal", () =>
|
||||
Effect.acquireUseRelease(
|
||||
|
||||
@@ -107,7 +107,9 @@ const subagentPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(
|
||||
Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
|
||||
),
|
||||
),
|
||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
})
|
||||
@@ -155,7 +157,9 @@ const completionIt = testEffect(
|
||||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location)))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
)
|
||||
yield* Agent.Service.use((agents) =>
|
||||
agents.transform((draft) => {
|
||||
// The caller identity used by executeTool; subagent permission asserts against it.
|
||||
|
||||
@@ -2,7 +2,8 @@ import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -10,6 +11,7 @@ import { Git } from "@opencode-ai/core/git"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { VcsGitPlugin } from "@opencode-ai/core/plugin/vcs/git"
|
||||
import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs"
|
||||
@@ -17,9 +19,23 @@ import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { it, testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const Done = Bus.ephemeral({ type: "test.vcs.done", schema: {} })
|
||||
const synthetic = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), {
|
||||
replacements: [
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const provide = (directory: string, input: { git?: boolean; worktree?: string } = {}) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node, FSUtil.node, Git.node]), {
|
||||
@@ -146,6 +162,44 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
|
||||
synthetic.effect("reads batched providers without refreshing intermediate selections", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const reads: string[] = []
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
info: () =>
|
||||
Effect.sync(() => {
|
||||
reads.push("intermediate")
|
||||
return { branch: { current: "intermediate" } }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
expect((yield* vcs.status())[0]?.file).toBe("file.txt")
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
info: () =>
|
||||
Effect.sync(() => {
|
||||
reads.push("final")
|
||||
return { branch: { current: "final" } }
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
expect(reads).toEqual(["final"])
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "final" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("passes location scope and bounded diff options to providers", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -210,8 +264,16 @@ describe("Vcs", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
let interrupt = false
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(provider({ status: () => Effect.never, diff: () => Effect.never, base: () => Effect.never }))
|
||||
draft.add(
|
||||
provider({
|
||||
info: () => (interrupt ? Effect.interrupt : Effect.succeed({ branch: { current: "feature" } })),
|
||||
status: () => Effect.never,
|
||||
diff: () => Effect.never,
|
||||
base: () => Effect.never,
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
@@ -227,10 +289,291 @@ describe("Vcs", () => {
|
||||
yield* Fiber.interrupt(base)
|
||||
const cancelled = yield* Fiber.await(base)
|
||||
expect(Exit.isFailure(cancelled) && Cause.hasInterrupts(cancelled.cause)).toBeTrue()
|
||||
|
||||
interrupt = true
|
||||
const refreshed = yield* vcs.reload().pipe(Effect.exit)
|
||||
expect(Exit.isFailure(refreshed) && Cause.hasInterruptsOnly(refreshed.cause)).toBeTrue()
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("stops in-flight and queued VCS reloads when its layer closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(
|
||||
LayerNode.compile(Vcs.node, {
|
||||
replacements: [
|
||||
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
root,
|
||||
)
|
||||
const vcs = Context.get(context, Vcs.Service)
|
||||
const reads: string[] = []
|
||||
const observed: (string | undefined)[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type !== VcsEvent.BranchUpdated.type) return
|
||||
observed.push(Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch)
|
||||
}),
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
let branch = "initial"
|
||||
let block = false
|
||||
yield* vcs
|
||||
.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
info: () =>
|
||||
Effect.gen(function* () {
|
||||
const value = branch
|
||||
reads.push(value)
|
||||
if (block) {
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return { branch: { current: value } }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
observed.length = 0
|
||||
|
||||
block = true
|
||||
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
branch = "late"
|
||||
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
const snapshot = yield* vcs.info()
|
||||
|
||||
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(first.pollUnsafe()).toBeDefined()
|
||||
expect(second.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
yield* Fiber.join(shutdown)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(observed).toEqual([])
|
||||
expect(yield* vcs.info()).toBe(snapshot)
|
||||
}).pipe(Effect.provide(LayerNode.compile(Bus.node))),
|
||||
)
|
||||
|
||||
it.live("keeps watching HEAD changes after a transform replay failure", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const replayed = yield* Deferred.make<void>()
|
||||
const faulty = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(faulty, Exit.void))
|
||||
let branch = "initial"
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(provider({ id: "git", info: () => Effect.sync(() => ({ branch: { current: branch } })) })),
|
||||
)
|
||||
const failure = new Error("fixture replay failed")
|
||||
let replays = 0
|
||||
const failed = yield* vcs
|
||||
.transform(() => {
|
||||
if (++replays === 2) Deferred.doneUnsafe(replayed, Exit.void)
|
||||
throw failure
|
||||
})
|
||||
.pipe(Scope.provide(faulty), Effect.exit)
|
||||
expect(Exit.isFailure(failed) && Cause.squash(failed.cause)).toBe(failure)
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
yield* Deferred.await(replayed).pipe(Effect.timeout("1 second"))
|
||||
yield* Effect.yieldNow
|
||||
const status = yield* vcs.status().pipe(Effect.exit)
|
||||
expect(Exit.isFailure(status) && Cause.squash(status.cause)).toBe(failure)
|
||||
expect((yield* vcs.info()).branch.current).toBe("initial")
|
||||
|
||||
branch = "recovered"
|
||||
yield* Scope.close(faulty, Exit.void)
|
||||
expect((yield* vcs.info()).branch.current).toBe("recovered")
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.runHead, Effect.timeout("1 second"), Effect.forkScoped({ startImmediately: true }))
|
||||
branch = "after-recovery"
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(updated))).toMatchObject({ data: { branch: "after-recovery" } })
|
||||
expect((yield* vcs.info()).branch.current).toBe("after-recovery")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes filesystem and config refreshes while reading the latest desired provider", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const accepted = yield* Deferred.make<void>()
|
||||
const reads: string[] = []
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
id: "git",
|
||||
info: () =>
|
||||
Effect.gen(function* () {
|
||||
reads.push(reads.length === 0 ? "initial" : "filesystem")
|
||||
if (reads.length === 1) return { branch: { current: "initial" } }
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return { branch: { current: "filesystem" } }
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const updates = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(2), Stream.runLast, Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
yield* Deferred.await(started)
|
||||
const configured = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
id: "git",
|
||||
info: () =>
|
||||
Effect.sync(() => {
|
||||
reads.push("config")
|
||||
return { branch: { current: "config" } }
|
||||
}),
|
||||
status: () => Effect.succeed([{ file: "config.txt", additions: 1, deletions: 0, status: "added" }]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(accepted)
|
||||
expect(reads).toEqual(["initial", "filesystem"])
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(configured)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
|
||||
expect(reads).toEqual(["initial", "filesystem", "config"])
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
synthetic.effect("keeps branch streams current when listeners change the selected provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Effect.scope
|
||||
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (
|
||||
event.type !== VcsEvent.BranchUpdated.type ||
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch !== "feature"
|
||||
)
|
||||
return Effect.void
|
||||
return vcs
|
||||
.transform((draft) =>
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "listener" } }) })),
|
||||
)
|
||||
.pipe(Scope.provide(scope), Effect.asVoid)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
}),
|
||||
)
|
||||
|
||||
synthetic.effect("does not roll back branch streams when an older listener finishes late", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === VcsEvent.BranchUpdated.type &&
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "older"
|
||||
? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const older = yield* vcs
|
||||
.transform((draft) => {
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "older" } }) }))
|
||||
draft.default.set("custom")
|
||||
})
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "newer" } }) })),
|
||||
)
|
||||
expect(older.pollUnsafe()).toBeUndefined()
|
||||
expect((yield* vcs.info()).branch.current).toBe("newer")
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(older)
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined).pipe(Effect.andThen(unsubscribe))))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("lists local branches by recent activity", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -45,7 +45,7 @@ yield *
|
||||
})
|
||||
```
|
||||
|
||||
OpenCode rebuilds the domain when a transform is registered or disposed. A rebuild starts from fresh domain state and runs every active transform in registration order.
|
||||
Registry reads apply pending transforms in registration order, reusing the already-applied prefix. Disposal and reload invalidate that prefix for a fresh replay. Notifications and resource reconciliation run separately from synchronous state materialization.
|
||||
|
||||
Available transform hooks are namespaced by domain:
|
||||
|
||||
|
||||
@@ -156,7 +156,11 @@ export interface Page {
|
||||
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
|
||||
}
|
||||
|
||||
type PromptFooterInput = { readonly sessionID?: string; readonly mode: "normal" | "shell" }
|
||||
type PromptFooterInput = {
|
||||
readonly sessionID?: string
|
||||
readonly mode: "normal" | "shell"
|
||||
readonly showDetails: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
|
||||
@@ -486,6 +486,208 @@
|
||||
"summary": "List plugins"
|
||||
}
|
||||
},
|
||||
"/api/plugin/await-activation": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.awaitActivation",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
|
||||
"summary": "Wait for plugin activation"
|
||||
}
|
||||
},
|
||||
"/api/plugin/check": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.check",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.InfoEncoded"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Plugin.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Check one or all package plugins for available updates.",
|
||||
"summary": "Check plugin updates",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/plugin/update": {
|
||||
"post": {
|
||||
"tags": ["plugin"],
|
||||
@@ -16772,6 +16974,9 @@
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "error"],
|
||||
|
||||
@@ -20,6 +20,37 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.awaitActivation", "/api/plugin/await-activation", {
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.awaitActivation",
|
||||
summary: "Wait for plugin activation",
|
||||
description:
|
||||
"Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({ target: Schema.String.pipe(Schema.optional) }),
|
||||
success: Location.response(Schema.Array(Plugin.Info)),
|
||||
error: InvalidRequestError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.check",
|
||||
summary: "Check plugin updates",
|
||||
description: "Check one or all package plugins for available updates.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -29,7 +29,7 @@ export type Features = typeof Features.Type
|
||||
|
||||
export const State = Schema.Union([
|
||||
Schema.Struct({ status: Schema.Literal("active") }),
|
||||
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }),
|
||||
Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String, ref: Schema.String.pipe(optional) }),
|
||||
]).annotate({ identifier: "Plugin.State" })
|
||||
export type State = typeof State.Type
|
||||
|
||||
|
||||
@@ -12,3 +12,7 @@ export const Info = Schema.Struct({
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}).annotate({ identifier: "TokenUsage.Info" })
|
||||
|
||||
export function total(tokens: Info) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
@@ -23,3 +23,17 @@ test("embeds plugin state with a status discriminator", () => {
|
||||
outdated: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("plugin failure references round trip and absent references stay omitted", () => {
|
||||
const codec = Schema.fromJsonString(Plugin.State)
|
||||
const failed = { status: "failed", error: "Plugin failed to load", ref: "err_a1b2c3d4" } as const
|
||||
expect(Schema.decodeUnknownSync(codec)(Schema.encodeSync(codec)(failed))).toEqual(failed)
|
||||
expect(Schema.encodeSync(Plugin.State)({ ...failed, ref: undefined })).toEqual({
|
||||
status: "failed",
|
||||
error: failed.error,
|
||||
})
|
||||
expect(Schema.decodeUnknownSync(Plugin.State)({ status: "failed", error: failed.error })).toMatchObject({
|
||||
status: "failed",
|
||||
error: failed.error,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { TokenUsage } from "../src/token-usage.js"
|
||||
|
||||
test("totals every token category", () => {
|
||||
expect(TokenUsage.total({ input: 1, output: 2, reasoning: 4, cache: { read: 8, write: 16 } })).toBe(31)
|
||||
})
|
||||
|
||||
test("supports zero and large usage totals without rounding", () => {
|
||||
expect(TokenUsage.total({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } })).toBe(0)
|
||||
expect(
|
||||
TokenUsage.total({
|
||||
input: 1_000_000_000,
|
||||
output: 2_000_000_000,
|
||||
reasoning: 3,
|
||||
cache: { read: 4_000_000_000, write: 5 },
|
||||
}),
|
||||
).toBe(7_000_000_008)
|
||||
})
|
||||
@@ -48,7 +48,7 @@ for (const selection of ["explicit", "default"] as const) {
|
||||
PluginSupervisor.Service,
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
|
||||
return { awaitActivation: release.open.pipe(Effect.andThen(plugins.awaitActivation)) }
|
||||
}),
|
||||
).pipe(Layer.provide(layer)),
|
||||
)
|
||||
|
||||
@@ -14,10 +14,56 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
|
||||
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
|
||||
}),
|
||||
)
|
||||
.handle("plugin.awaitActivation", () =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
}),
|
||||
)
|
||||
.handle("plugin.check", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
const plugins = yield* Plugin.Service
|
||||
const inventory = yield* plugins.list()
|
||||
const targets = [
|
||||
...new Set(inventory.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : []))),
|
||||
].filter((target) => ctx.payload.target === undefined || target === ctx.payload.target)
|
||||
if (ctx.payload.target !== undefined && !targets.length)
|
||||
return yield* new InvalidRequestError({
|
||||
message: `Plugin package is not in the current server inventory: ${ctx.payload.target}`,
|
||||
field: "target",
|
||||
})
|
||||
const updates = yield* PluginUpdate.Service
|
||||
const outdated = new Map(
|
||||
yield* Effect.forEach(
|
||||
targets,
|
||||
(target) => updates.check(target, { refresh: true }).pipe(Effect.map((value) => [target, value] as const)),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
)
|
||||
return yield* response(
|
||||
Effect.succeed(
|
||||
inventory.map((plugin) => {
|
||||
if (plugin.source.type !== "package" || !outdated.has(plugin.source.target)) return plugin
|
||||
return {
|
||||
...plugin,
|
||||
source: {
|
||||
type: "package" as const,
|
||||
target: plugin.source.target,
|
||||
...(plugin.source.version ? { version: plugin.source.version } : {}),
|
||||
...(outdated.get(plugin.source.target) ? { outdated: true as const } : {}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle("plugin.update", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
const plugins = yield* Plugin.Service
|
||||
if (
|
||||
!(yield* plugins.list()).some(
|
||||
|
||||
@@ -9,7 +9,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
handlers.handle("rpc.call", ({ params, payload }) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
yield* supervisor.awaitActivation
|
||||
const rpc = yield* Rpc.Service
|
||||
const output = yield* rpc.call(params.rpcID, params.method, payload.input)
|
||||
return output === undefined ? {} : { output }
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { expect } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Context, Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { createRoutes } from "../src/routes"
|
||||
|
||||
const fixture = Effect.fn(function* (plugin: Plugin.Plugin) {
|
||||
const tmp = yield* tmpdirScoped("opencode-plugin-activation-")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
const config = path.join(tmp.path, "config")
|
||||
yield* Effect.promise(() => Promise.all([first, second, config].map((directory) => mkdir(directory))))
|
||||
const context = yield* Layer.build(
|
||||
createRoutes({
|
||||
password: "secret",
|
||||
database: { path: ":memory:" },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
config: {
|
||||
directory: config,
|
||||
project: false,
|
||||
content: JSON.stringify({
|
||||
providers: {
|
||||
acme: {
|
||||
models: {
|
||||
reasoner: { name: "Configured Reasoner", limit: { context: 96_000, output: 8_000 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const sdk = Context.get(context, SdkPlugins.Service)
|
||||
yield* sdk.register(plugin)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
|
||||
return {
|
||||
first,
|
||||
second,
|
||||
request: (method: "GET" | "POST", route: string, directory = first, signal?: AbortSignal) =>
|
||||
Effect.promise((interruption) => {
|
||||
const url = new URL(route, "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", directory)
|
||||
return handler(
|
||||
new Request(url, {
|
||||
method,
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
signal: signal ?? interruption,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
it.live(
|
||||
"awaits activation only for the requested location without blocking model or plugin snapshots",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const server = yield* fixture(
|
||||
Plugin.define({
|
||||
id: "slow-plugin",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (path.basename(ctx.location.directory) !== "first") return
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const pending = yield* server.request("POST", "/api/plugin/await-activation").pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
expect(pending.pollUnsafe()).toBeUndefined()
|
||||
|
||||
const models = yield* server.request("GET", "/api/model")
|
||||
expect(models.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => models.json())).toMatchObject({
|
||||
location: { directory: server.first },
|
||||
data: expect.not.arrayContaining([expect.objectContaining({ providerID: "acme", id: "reasoner" })]),
|
||||
})
|
||||
const plugins = yield* server.request("GET", "/api/plugin")
|
||||
expect(plugins.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({ location: { directory: server.first } })
|
||||
|
||||
const second = yield* server.request("POST", "/api/plugin/await-activation", server.second)
|
||||
expect(second.status).toBe(204)
|
||||
expect(pending.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const response = yield* Fiber.join(pending)
|
||||
expect(response.status).toBe(204)
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("")
|
||||
const configured = yield* server.request("GET", "/api/model")
|
||||
expect(configured.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => configured.json())).toMatchObject({
|
||||
location: { directory: server.first },
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
providerID: "acme",
|
||||
id: "reasoner",
|
||||
name: "Configured Reasoner",
|
||||
limit: { context: 96_000, output: 8_000 },
|
||||
}),
|
||||
]),
|
||||
})
|
||||
const active = yield* server.request("GET", "/api/plugin")
|
||||
expect(active.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => active.json())).toMatchObject({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "slow-plugin", source: { type: "sdk" }, state: { status: "active" } }),
|
||||
]),
|
||||
})
|
||||
}).pipe(Effect.timeout("10 seconds")),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"aborting an activation wait does not cancel plugin setup",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const completed = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const server = yield* fixture(
|
||||
Plugin.define({
|
||||
id: "slow-plugin",
|
||||
effect: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
yield* Deferred.succeed(completed, undefined)
|
||||
}).pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))),
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
|
||||
const pending = yield* server
|
||||
.request("POST", "/api/plugin/await-activation", server.first, controller.signal)
|
||||
.pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
controller.abort()
|
||||
// HttpEffect resolves a cancelled Web request with 499 rather than rejecting its Promise.
|
||||
expect((yield* Fiber.join(pending)).status).toBe(499)
|
||||
expect(yield* Deferred.isDone(interrupted)).toBe(false)
|
||||
expect(yield* Deferred.isDone(completed)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect((yield* server.request("POST", "/api/plugin/await-activation")).status).toBe(204)
|
||||
expect(yield* Deferred.isDone(completed)).toBe(true)
|
||||
expect(yield* Deferred.isDone(interrupted)).toBe(false)
|
||||
const plugins = yield* server.request("GET", "/api/plugin")
|
||||
expect(plugins.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({
|
||||
data: expect.arrayContaining([expect.objectContaining({ id: "slow-plugin", state: { status: "active" } })]),
|
||||
})
|
||||
}).pipe(Effect.timeout("10 seconds")),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"settles activation when plugin setup fails and exposes the failure in the inventory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* fixture(
|
||||
Plugin.define({
|
||||
id: "failing-plugin",
|
||||
effect: () => Effect.die(new Error("fixture setup failed")),
|
||||
}),
|
||||
)
|
||||
expect((yield* server.request("POST", "/api/plugin/await-activation")).status).toBe(204)
|
||||
const plugins = yield* server.request("GET", "/api/plugin")
|
||||
expect(plugins.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({
|
||||
location: { directory: server.first },
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "failing-plugin",
|
||||
source: { type: "sdk" },
|
||||
state: { status: "failed", error: expect.stringContaining("fixture setup failed") },
|
||||
}),
|
||||
]),
|
||||
})
|
||||
}).pipe(Effect.timeout("10 seconds")),
|
||||
15_000,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user